on master help sign in
.cargo/config.tomlnew file+5
since splitsnapshots
unifiedsplit
@@ -0,0 +1,5 @@
1+# Pin the iOS/macOS deployment targets so the C deps (zstd-sys) and rustc agree
2+# on the minimum OS version — otherwise the linker errors on a version mismatch.
3+[env]
4+IPHONEOS_DEPLOYMENT_TARGET = "17.0"
5+MACOSX_DEPLOYMENT_TARGET = "11.0"
.github/workflows/build.yml+196−38
@@ -5,10 +5,66 @@ on:
5 branches: [master]
5 branches: [master]
6 pull_request:
6 pull_request:
7
7
8−env:
9− CARGO_TERM_COLOR: always
10−
11jobs:
8jobs:
9+ core:
10+ name: Core (test + clippy)
11+ runs-on: ubuntu-latest
12+ steps:
13+ - uses: actions/checkout@v4
14+
15+ - uses: dtolnay/rust-toolchain@stable
16+ with:
17+ components: clippy
18+
19+ - uses: Swatinem/rust-cache@v2
20+
21+ - name: Test
22+ run: cargo test -p skrepka_core
23+
24+ - name: Clippy
25+ run: cargo clippy -p skrepka_core --all-targets -- -D warnings
26+
27+ # The codegen bin (src/bin/codegen.rs) is behind the `codegen` feature, so
28+ # the default clippy run never compiles it — a typegen break would only
29+ # surface at `just generate` time.
30+ - name: Clippy (codegen feature)
31+ run: cargo clippy -p skrepka_core --features codegen -- -D warnings
32+
33+ ios:
34+ name: iOS shell
35+ runs-on: macos-14
36+ steps:
37+ - uses: actions/checkout@v4
38+
39+ - uses: dtolnay/rust-toolchain@stable
40+ with:
41+ targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios
42+
43+ - uses: Swatinem/rust-cache@v2
44+
45+ - uses: extractions/setup-just@v2
46+
47+ - name: Install tooling
48+ run: |
49+ cargo install boltffi_cli
50+ brew install xcodegen
51+
52+ # typegen + pack + project: regenerates the Swift types, builds the core
53+ # xcframework, and emits the xcodeproj the build step below compiles.
54+ - name: Generate
55+ run: just generate
56+
57+ # -skipPackagePluginValidation matches the Justfile's `build` recipe: the
58+ # generated SPM packages carry plugins, which xcodebuild otherwise blocks
59+ # on with an interactive trust prompt.
60+ - name: Build app
61+ run: |
62+ xcodebuild -project apple/SkrepkaApp.xcodeproj \
63+ -scheme SkrepkaApp-iOS \
64+ -destination 'generic/platform=iOS Simulator' \
65+ -skipPackagePluginValidation \
66+ build
67+
12 build:
68 build:
13 strategy:
69 strategy:
14 fail-fast: false
70 fail-fast: false
@@ -16,44 +72,31 @@ jobs:
16 include:
72 include:
17 - runner: ubuntu-latest
73 - runner: ubuntu-latest
18 name: linux-x86_64
74 name: linux-x86_64
75+ knot-platform: linux-x86_64
76+ - runner: ubuntu-24.04-arm
77+ name: linux-arm64
78+ knot-platform: linux-aarch64
79+ # macos-14 is the oldest GA arm64 image; the iOS job above already runs on
80+ # it. (`macos-26` is not a provisioned runner label — a job requesting it
81+ # queues until it times out rather than failing fast.)
19− - runner: macos-latest
82+ - runner: macos-14
20 name: macos-arm64
83 name: macos-arm64
21− - runner: macos-13
84+ knot-platform: macos-aarch64
22− name: macos-x86_64
23 runs-on: ${{ matrix.runner }}
85 runs-on: ${{ matrix.runner }}
24 steps:
86 steps:
25 - uses: actions/checkout@v4
87 - uses: actions/checkout@v4
26
88
27− - uses: actions/checkout@v4
89+ - name: Download knot compiler
28− with:
29− repository: ilyakooo0/knot
30− path: knot
31−
32− - uses: dtolnay/rust-toolchain@stable
33−
34− - name: Install musl toolchain
35− if: runner.os == 'Linux'
36 run: |
90 run: |
37− sudo apt-get update
91+ version="2026.6.26.1947"
38− sudo apt-get install -y musl-tools
92+ tarball="knot-${version}-${{ matrix.knot-platform }}.tar.gz"
39− rustup target add x86_64-unknown-linux-musl
93+ curl -fsSL "https://github.com/ilyakooo0/knot/releases/download/${version}/${tarball}" \
94+ | tar xz
95+ mv "knot-${version}-${{ matrix.knot-platform }}" knot-dist
40
96
41− - uses: actions/cache@v4
97+ - name: Install musl toolchain
42− with:
43− path: |
44− ~/.cargo/registry
45− ~/.cargo/git
46− knot/target
47− key: ${{ matrix.name }}-cargo-${{ hashFiles('knot/Cargo.lock') }}
48−
49− - name: Build knotc
50− working-directory: knot
51− run: cargo build --release -p knot-compiler
52−
53− - name: Build musl runtime
54 if: runner.os == 'Linux'
98 if: runner.os == 'Linux'
55− working-directory: knot
99+ run: sudo apt-get update && sudo apt-get install -y musl-tools
56− run: cargo build --release -p knot-runtime --target x86_64-unknown-linux-musl
57
100
58 - name: Compile server (Linux static)
101 - name: Compile server (Linux static)
59 if: runner.os == 'Linux'
102 if: runner.os == 'Linux'
@@ -61,23 +104,116 @@ jobs:
61 printf '#!/bin/sh\nexec musl-gcc -static "$@"\n' > /tmp/static-cc
104 printf '#!/bin/sh\nexec musl-gcc -static "$@"\n' > /tmp/static-cc
62 chmod +x /tmp/static-cc
105 chmod +x /tmp/static-cc
63 CC=/tmp/static-cc \
106 CC=/tmp/static-cc \
64− KNOT_RUNTIME_LIB=${{ github.workspace }}/knot/target/x86_64-unknown-linux-musl/release/libknot_runtime.a \
107+ KNOT_RUNTIME_LIB=${{ github.workspace }}/knot-dist/libknot_runtime.a \
65− ./knot/target/release/knotc build server.knot
108+ ./knot-dist/knot build server.knot
66
109
67 - name: Compile server (macOS)
110 - name: Compile server (macOS)
68 if: runner.os == 'macOS'
111 if: runner.os == 'macOS'
69− run: ./knot/target/release/knotc build server.knot
112+ run: ./knot-dist/knot build server.knot
70
113
71 - name: Verify binary
114 - name: Verify binary
72 run: file server
115 run: file server
73
116
117+ # The Linux release binary is what install.sh drops onto a bare host, so it
118+ # has to carry its own libc. A dynamically-linked build would still pass every
119+ # other step here and only fail on the operator's machine, at startup.
120+ - name: Verify static linking (Linux)
121+ if: runner.os == 'Linux'
122+ run: |
123+ file server | grep -q "statically linked" || {
124+ echo "::error::release binary is not statically linked"
125+ file server
126+ exit 1
127+ }
128+
129+ # End-to-end smoke test: the compile steps above only prove the server
130+ # typechecks, never that it serves. This boots the real binary and asserts the
131+ # status code of every route, so a regression that breaks auth, validation, or
132+ # the federation gate fails CI instead of shipping.
133+ #
134+ # The server binds 0.0.0.0:8080 and cannot be moved: the Knot runtime accepts
135+ # but ignores --<name>=<value> constant overrides at startup (they only apply
136+ # at `knot build` time), so --listenPort/--bindHost would be silently inert.
137+ # Everything below therefore runs against the compiled-in default port, and
138+ # against the compiled-in default federationEnabled=False.
139+ - name: Smoke test
140+ run: |
141+ set -euo pipefail
142+ ./server > /tmp/server.log 2>&1 &
143+ SERVER_PID=$!
144+ trap 'kill "$SERVER_PID" 2>/dev/null || true' EXIT
145+
146+ # Wait for the listener rather than sleeping a fixed amount.
147+ for i in $(seq 1 30); do
148+ if curl -sf -o /dev/null "http://127.0.0.1:8080/" 2>/dev/null \
149+ || curl -s -o /dev/null "http://127.0.0.1:8080/"; then
150+ break
151+ fi
152+ if ! kill -0 "$SERVER_PID" 2>/dev/null; then
153+ echo "::error::server exited during startup"; cat /tmp/server.log; exit 1
154+ fi
155+ sleep 1
156+ done
157+
158+ PK=$(printf '0%.0s' $(seq 1 64)) # 64 hex chars: a well-formed pubkey
159+ SIG=$(printf '0%.0s' $(seq 1 128)) # 128 hex chars: a well-formed signature
160+ BLOB=$(printf 'ab%.0s' $(seq 1 200)) # 400 hex chars: over the 346-char floor
161+
162+ # $1 = expected status, $2 = label, rest = curl args
163+ expect() {
164+ want="$1"; label="$2"; shift 2
165+ got=$(curl -s -o /tmp/body -w '%{http_code}' --max-time 10 "$@")
166+ if [ "$got" != "$want" ]; then
167+ echo "::error::${label}: expected HTTP ${want}, got ${got}"
168+ cat /tmp/body; echo; cat /tmp/server.log
169+ exit 1
170+ fi
171+ echo "ok ${label} -> ${got}"
172+ }
173+ J='Content-Type: application/json'
174+
175+ expect 200 "auth/challenge (valid pubkey)" \
176+ -X POST http://127.0.0.1:8080/auth/challenge -H "$J" -d "{\"pubkey\":\"$PK\"}"
177+ grep -q '"challenge"' /tmp/body || { echo "::error::no challenge in body"; exit 1; }
178+
179+ expect 400 "auth/challenge (malformed pubkey rejected)" \
180+ -X POST http://127.0.0.1:8080/auth/challenge -H "$J" -d '{"pubkey":"zz"}'
181+
182+ expect 401 "auth/verify (bad signature)" \
183+ -X POST http://127.0.0.1:8080/auth/verify -H "$J" \
184+ -d "{\"pubkey\":\"$PK\",\"challenge\":\"x\",\"signature\":\"$SIG\",\"revokeOthers\":false}"
185+
186+ expect 401 "poll (unauthenticated)" \
187+ -X POST http://127.0.0.1:8080/poll -H "$J" -H 'Authorization: Bearer nope' -d '{"cursor":0}'
188+
189+ expect 401 "messages (unauthenticated)" \
190+ -X POST http://127.0.0.1:8080/messages -H "$J" -H 'Authorization: Bearer nope' \
191+ -d "{\"messages\":[{\"to\":\"$PK\",\"encryptedBlob\":\"$BLOB\"}]}"
192+
193+ # federationEnabled defaults to False, so both peer endpoints must refuse.
194+ expect 403 "federation/gossip (federation disabled by default)" \
195+ -X POST http://127.0.0.1:8080/federation/gossip -H "$J" \
196+ -d '{"events":[],"fromServer":"relay.example.com"}'
197+
198+ expect 403 "federation/forward (federation disabled by default)" \
199+ -X POST http://127.0.0.1:8080/federation/forward -H "$J" \
200+ -d "{\"toKey\":\"$PK\",\"encryptedBlob\":\"$BLOB\"}"
201+
202+ # Below the 346-hex-char (173-byte) minimum envelope: rejected by BlobHex
203+ # at the route boundary, before the federation gate.
204+ expect 400 "federation/forward (undersized blob rejected)" \
205+ -X POST http://127.0.0.1:8080/federation/forward -H "$J" \
206+ -d "{\"toKey\":\"$PK\",\"encryptedBlob\":\"abcd\"}"
207+
208+ echo "smoke test passed"
209+
74 - uses: actions/upload-artifact@v4
210 - uses: actions/upload-artifact@v4
75 with:
211 with:
76 name: server-${{ matrix.name }}
212 name: server-${{ matrix.name }}
77 path: server
213 path: server
78
214
79 release:
215 release:
80− needs: build
216+ needs: [core, ios, build]
81 if: github.ref == 'refs/heads/master' && github.event_name == 'push'
217 if: github.ref == 'refs/heads/master' && github.event_name == 'push'
82 runs-on: ubuntu-latest
218 runs-on: ubuntu-latest
83 permissions:
219 permissions:
@@ -89,9 +225,17 @@ jobs:
89 with:
225 with:
90 path: repo
226 path: repo
91
227
228+ # Commit timestamp to the second, plus the short SHA. Minute resolution
229+ # alone collides when two commits land in the same minute (a rebase-push,
230+ # or a merge right after a direct push), and a duplicate tag makes the
231+ # release step fail — or worse, silently overwrite the previous release's
232+ # assets. The SHA makes the tag unique even at identical timestamps.
92 - name: Generate version
233 - name: Generate version
93 id: version
234 id: version
235+ run: |
94− run: echo "tag=$(git -C repo log -1 --format='%cd' --date='format:%Y.%m.%d.%H%M')" >> "$GITHUB_OUTPUT"
236+ ts=$(git -C repo log -1 --format='%cd' --date='format:%Y.%m.%d.%H%M%S')
237+ sha=$(git -C repo rev-parse --short=7 HEAD)
238+ echo "tag=${ts}-${sha}" >> "$GITHUB_OUTPUT"
95
239
96 - name: Prepare release assets
240 - name: Prepare release assets
97 run: |
241 run: |
@@ -103,6 +247,20 @@ jobs:
103 done
247 done
104 chmod +x server-*
248 chmod +x server-*
105
249
250+ # Publish a SHA-256 beside each binary. install.sh downloads
251+ # "<asset>.sha256" and refuses to install a binary that does not match it,
252+ # so this is the integrity link between what CI built and what lands on an
253+ # operator's host as root. Hashing the *final* asset names (rather than the
254+ # in-artifact "server") means a downloaded pair also verifies with a plain
255+ # `sha256sum -c server-linux-x86_64.sha256`.
256+ #
257+ # The glob is expanded before the loop body runs, so `bin` only ever
258+ # iterates the binaries, never the .sha256 files being created.
259+ for bin in server-*; do
260+ sha256sum "$bin" > "${bin}.sha256"
261+ done
262+ ls -l server-*
263+
106 - uses: softprops/action-gh-release@v2
264 - uses: softprops/action-gh-release@v2
107 with:
265 with:
108 tag_name: ${{ steps.version.outputs.tag }}
266 tag_name: ${{ steps.version.outputs.tag }}
.gitignore+14
@@ -4,3 +4,17 @@ server
4*.db-journal
4*.db-journal
5*.db-wal
5*.db-wal
6*.db-shm
6*.db-shm
7+.DS_Store
8+.claude/
9+
10+# Rust core
11+/target
12+
13+# iOS shell (xcodegen project + boltffi/typegen output + build products are generated)
14+apple/SkrepkaApp.xcodeproj
15+apple/build
16+apple/generated
17+apple/DerivedData
18+# xcodegen writes both of these from project.yml — edit that, not these.
19+apple/SkrepkaApp/Info-iOS.plist
20+apple/SkrepkaApp/SkrepkaApp.entitlements
.vscode/settings.jsonnew file+3
@@ -0,0 +1,3 @@
1+{
2+ "editor.formatOnSave": true
3+}
AGENTS.mdnew file+119
@@ -0,0 +1,119 @@
1+# CLAUDE.md
2+
3+Decentralized, end-to-end encrypted messenger. No accounts, no home servers — identity is an Ed25519 keypair. Messages route over plain HTTP through any relay in an open federated mesh.
4+
5+`PROTOCOL.md` is the normative spec (v0.1 draft) and should be read before touching crypto, the wire format, or the server. The relay sees only the recipient pubkey and an opaque blob; the sender's identity and signature live *inside* the AEAD ciphertext, so a server never learns who sent a message.
6+
7+## Tech Stack
8+
9+- **Shared core:** Rust (edition 2021, `rust-version` 1.90) built on [Crux](https://redbadger.github.io/crux/) — `crux_core` (MVU), `crux_http`, `crux_kv`, `crux_time`. All business logic lives here: identity, auth, the long-poll loop, the outbox, contacts, profiles, message ingest.
10+- **FFI:** `boltffi` exports `CoreFFI` (`core/src/ffi.rs`) as a bincode bridge; `facet` drives Swift type generation from the core's `Event` / `ViewModel` / `Effect` types.
11+- **Apple shell:** Swift + SwiftUI, **iOS 17+ only** (`include_macos = false` in `core/boltffi.toml`). The shell is a thin effect executor — it performs HTTP, key-value, and timer effects and renders the `ViewModel`. It contains no business logic.
12+- **Crypto:** `ed25519-dalek` + `x25519-dalek` + `curve25519-dalek`, `chacha20poly1305` (XChaCha20-Poly1305), `hkdf` + `sha2` (HKDF-SHA256), `zstd` (plaintext compression). **No libsodium** — `core/src/crypto.rs` hand-reimplements libsodium's `crypto_sign_ed25519_{pk,sk}_to_curve25519` so blobs stay wire-compatible with any spec-following client.
13+- **Server:** `server.knot`, written in [Knot](https://github.com/ilyakooo0/knot) — a custom functional language compiled to a native binary. Its runtime transparently persists every `*ref` to SQLite, so all server state (`*sessions`, `*messages`, `*presence`, …) survives restarts despite reading like in-memory lists.
14+- **Persistence (client):** `crux_kv` — JSON blobs under the keys `settings`, `profile`, `contacts`, `cursor`, `outbox`, `seen_ids`, and `messages:<peer_hex>`. The Swift shell backs this with one file per key under Application Support. The 64-byte Ed25519 secret key lives in the iOS Keychain, never in kv.
15+
16+## Project Layout
17+
18+```
19+core/ Rust shared core (crate `skrepka_core`)
20+ src/app.rs Crux App: Event / Model / ViewModel / Effect + update(). The whole state machine.
21+ src/model.rs Persisted state types (Settings, OwnProfile, Contact, StoredMessage, OutboxItem)
22+ and the flat ViewModel the shell renders.
23+ src/crypto.rs Identity, encrypt/decrypt, ed25519→x25519 derivation, auth-challenge signing.
24+ src/protocol.rs Plaintext payload (de)serialization (text / delivery.ack / profile) + Envelope.
25+ src/phonemic.rs Urbit-style @p syllable encoding of pubkeys; try_parse_pubkey accepts @p or hex.
26+ src/ffi.rs boltffi surface: update / resolve / view over bincode bytes.
27+ src/bin/codegen.rs Emits Swift bindings via facet typegen (feature `codegen`).
28+ boltffi.toml Apple packaging config (iOS 17 target, module `SkrepkaShared`).
29+
30+apple/
31+ project.yml xcodegen spec → SkrepkaApp.xcodeproj (bundle lol.skrepka.SkrepkaApp-iOS).
32+ SkrepkaApp/SkrepkaApp.swift @main entry point.
33+ SkrepkaApp/Core.swift Drives the core: serializes events in, dispatches effects out, resolves them back.
34+ SkrepkaApp/Effects.swift Effect handlers — Http (URLSession, 70s timeout), Time, KvStore (file per key),
35+ Keychain (loads or generates the 64-byte identity key).
36+ SkrepkaApp/Views.swift RootView, ConversationsView, ChatView, MessageBubble, Avatar.
37+ SkrepkaApp/Forms.swift AddContactView, SettingsView, EditProfileView.
38+ SkrepkaApp/QRView.swift QR generation + AVFoundation camera scanner.
39+ SkrepkaApp/PhotoPicker.swift PHPicker → downscaled 256px JPEG as base64.
40+ generated/ gitignored — Shared/ (xcframework + SPM package) and App/Skrepka (Swift types).
41+
42+server.knot The entire relay: config, types, routes, auth, poll, send, federation, background loops.
43+servers.json Public relay list (flat JSON array of URLs). A static suggestion, not a directory.
44+install.sh Linux deploy: downloads the release binary, writes a systemd unit, configures Caddy + TLS.
45+skrepka.service Reference systemd unit (install.sh writes its own, with --http-max-body-bytes=42M).
46+Justfile All build/run recipes.
47+.cargo/config.toml Pins IPHONEOS/MACOSX deployment targets (see Gotchas).
48+.github/workflows/build.yml CI: tests + clippy on the core, builds the iOS shell, compiles server.knot on
49+ linux-x86_64 / linux-arm64 / macos-arm64, cuts a release.
50+PROTOCOL.md Wire protocol specification.
51+PROTOCOL-REVIEW.md Audit of spec-vs-implementation gaps, cross-checked against the Rust core.
52+```
53+
54+## Build & Run
55+
56+One-time setup:
57+
58+```sh
59+rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
60+cargo install boltffi_cli
61+brew install xcodegen
62+```
63+
64+Recipes (`just <name>`; `just` alone runs `test`):
65+
66+```sh
67+just test # cargo test -p skrepka_core (crypto, protocol, @p, state machine)
68+just typegen # regenerate Swift types into apple/generated/App
69+just pack # build the core xcframework + Swift package into apple/generated/Shared
70+just project # xcodegen generate → apple/SkrepkaApp.xcodeproj
71+just generate # typegen + pack + project — run this after any core type change
72+just build SIM_ID=<udid> # xcodebuild for a booted simulator
73+just run SIM_ID=<udid> # install + launch on that simulator
74+just server # wipe server.db* and run the local ./server binary
75+```
76+
77+`just server` expects a **prebuilt `./server`** binary; there is no local recipe that compiles it. To build it you need the Knot compiler (`knot build server.knot`), which CI downloads from `ilyakooo0/knot` releases — on Linux it links statically against musl. `server` and `*.db*` are gitignored.
78+
79+Deploying a relay:
80+
81+```sh
82+curl -fsSL https://raw.githubusercontent.com/ilyakooo0/skrepka/master/install.sh | sudo DOMAIN=relay.example.com sh
83+```
84+
85+Every top-level scalar constant in `server.knot` is overridable at startup as `--name=value` (e.g. `--serverHost=relay.example.com`, `--federationEnabled=False`, `--trustForwardedFor=False`); `--help` lists them all.
86+
87+## Architecture
88+
89+The core is a pure MVU state machine. `Skrepka::update(Event, &mut Model) -> Command<Effect, Event>` is the only place state changes; `Skrepka::view(&Model) -> ViewModel` is the only thing the shell reads. The shell never decides anything — it turns `Effect`s into platform calls and resolves the results back in.
90+
91+Two kinds of `Event` variants, and the distinction matters:
92+
93+- **Shell-facing** (`IdentityLoaded`, `SendText`, `OpenChat`, `AddContact`, …) — cross the FFI, so they appear in the generated Swift `Event` enum.
94+- **Internal** (`LoadedContacts`, `PollResult`, `SendResult`, …) — carry non-FFI-safe payloads (`crux_http::Result`, `KeyValueError`) and are marked `#[serde(skip)] #[facet(skip)] #[facet(opaque)]` so they never reach Swift. Adding a new internal event means adding those attributes too, or typegen breaks.
95+
96+The `ViewModel` is deliberately flat and stringly-typed (`page: String`, `conn_status: String`) so the Swift side switches on plain strings rather than needing generated enums for UI state.
97+
98+Runtime flow: `IdentityLoaded` (from the Keychain, at boot) fans out six kv loads → `LoadedSettings` triggers `Connect` → `Authenticate` (challenge/verify, signature bound to the server's bare hostname under the `skrepka-auth-v1:` tag) → on a token, `Poll` (server long-polls ~25 s, client re-polls immediately) and `StartFlush` (drains the outbox one message at a time, encrypting at send time). Failures back off via `crux_time` — 3 s doubling to a 30 s cap.
99+
100+## Code Conventions
101+
102+- **Rust:** standard `rustfmt`; the code is written clippy-clean (note the targeted `#[allow]`s in `app.rs` / `ffi.rs`). Doc comments (`//!`, `///`) carry the design rationale and cite `PROTOCOL.md` sections — keep that habit; it's how the crypto stays auditable against the spec.
103+- **Swift:** SwiftUI, no business logic. `Core.swift` uses `try!` on bincode round-trips deliberately (a serialization mismatch is a build-time bug, not a runtime condition) with `swiftlint:disable` comments marking each.
104+- **Knot:** `server.knot` is written for clarity over performance — every collection is a flat list, every scan is O(n). The header comment says so explicitly. Its dense comments encode security reasoning (SSRF deny-list, rate-limit key choice, replay/dedup invariants); read them before changing behavior.
105+- Anything on the wire is **lowercase hex**; anything shown to a human can be **@p** (`ridler-binzod-…`). `try_parse_pubkey` accepts both.
106+
107+## Version Control
108+
109+Convention is **jj** (jujutsu), not git — but note the working copy is currently a **plain git checkout with no `.jj/`**. Run `jj git init --colocate` before using jj commands here.
110+
111+When reading diffs, pass `--git` to `jj diff` / `jj show` — the configured formatter is difftastic, whose ANSI output is meant for terminals, not models.
112+
113+## Gotchas
114+
115+- **Regenerate after core type changes.** Touching `Event`, `ViewModel`, or `Effect` without running `just generate` leaves the Swift side compiling against stale types.
116+- **`.cargo/config.toml` is load-bearing.** It pins `IPHONEOS_DEPLOYMENT_TARGET=17.0` / `MACOSX_DEPLOYMENT_TARGET=11.0` so `zstd-sys` (a C dep) and rustc agree on the minimum OS version. Without it the linker fails on a version mismatch.
117+- **Body-size cap on the relay.** `--http-max-body-bytes` must exceed `maxBlobLen` (40 MiB hex) plus the JSON envelope, or the runtime's default 16 MiB cap rejects a max-size blob with a bare `413` before the handler's `400 invalid_message` check runs. `install.sh` sets `42M`.
118+- **Refined types don't validate inside lists.** Knot's route-boundary checks only fire on top-level scalar body fields, so `handleSendMessages` and `handleRecvGossip` re-validate nested `to` / `pubkey` / `encryptedBlob` by hand. Preserve those checks.
119+- **Federation is unauthenticated by design.** `/federation/*` endpoints accept any caller; abuse is bounded by the SSRF deny-list, the `no_presence` gate, and per-IP rate limits — not by peer authentication. `PROTOCOL.md` §10 enumerates what that does and doesn't protect.
AUDIT-DEEP.mdnew file+230
@@ -0,0 +1,230 @@
1+# Skrepka Deep Audit — app.rs & model.rs (2026-07-16)
2+
3+**Scope:** Deep audit of `core/src/app.rs` and `core/src/model.rs`, cross-referenced against PROTOCOL.md §4, §6, §9, §10 and existing audits (AUDIT-NEW.md, AUDIT-FRESH.md, PROTOCOL-REVIEW.md). Findings below are **not** documented in any prior audit.
4+
5+**Legend:** 🔴 critical · 🟠 high · 🟡 medium · 🔵 low · ⚪ info
6+
7+---
8+
9+## 1. State Machine — Poll Loop / Cursor / Budget
10+
11+### 🟡 D1. `events_stored` doesn't count successfully-decrypted-but-non-stored events — livelock on budget exhaustion
12+
13+**Type:** State machine bug (livelock)
14+**File:** `core/src/app.rs:2127, 2188-2200, 2248-2293, 2316, 2342-2349`
15+
16+The `events_stored` counter drives the cursor-restore decision on budget/count exhaustion (lines 2145-2147, 2163-2165): if `events_stored == 0`, the cursor is restored to `prev_cursor`, and the relay re-serves the same page on the next poll.
17+
18+`events_stored` is only incremented for:
19+- Text messages that **pass** dedup (line 2264 — inside `if !id_set.contains`)
20+- All `delivery.ack` payloads (line 2316)
21+- Profiles that **pass** staleness (line 2348 — inside `if ts >= c.last_profile_ts`)
22+
23+It is **NOT** incremented for:
24+- **Duplicate text messages** (line 2248: the `if !id_set.contains` block is not entered)
25+- **Stale profiles** (line 2342: the `if ts >=` block is not entered)
26+- **Profiles from strangers** (line 2327: `continue` before any increment)
27+- **Self-messages** (line 2188: `continue` before any increment)
28+- **Blocked-sender messages** (line 2198: `continue` before any increment)
29+
30+All of these are **successfully decrypted** — they consume the full AEAD decrypt cost and the per-blob byte budget — but they don't increment `events_stored`. If a poll page consists entirely of such events and the byte budget is exhausted mid-page, `events_stored == 0`, the cursor is restored, and the client re-polls with the old cursor. The relay re-serves the same page. **Livelock.**
31+
32+**Attack scenario:**
33+1. Client receives 4 large blobs (each ~20 MiB decoded, total ~80 MiB > 64 MiB budget).
34+2. First encounter: all 4 are new text messages. `events_stored = 3` (budget exhausted on 4th). Cursor kept at `page.cursor`. Relay deletes events. No issue.
35+3. **Hostile relay re-serves the same 4 blobs** (it kept copies despite cursor advance). All 4 are now duplicates. Budget exhausted on 4th blob. `events_stored == 0`. Cursor restored to `prev_cursor`.
36+4. Client re-polls with `prev_cursor`. Relay re-serves same 4 blobs. Go to 3. **Permanent livelock** — the client can never advance past this page, wasting bandwidth and battery on every cycle.
37+
38+The same pattern applies to a page of large stale profile blobs, large profile-from-stranger blobs, or large self-message echoes — all decrypt successfully, all are dropped, none increment `events_stored`.
39+
40+**Note:** AUDIT-FRESH.md N9 identified the budget-exhaustion livelock for the case where blobs **fail decryption** (events_stored == 0, hostile junk). The fix applied (keep cursor if `events_stored > 0`) addresses that case. But the **duplicate/stale/stranger/self/blocked** cases are different: the blobs ARE decryptable, the client DID process them (and doesn't need them again), but `events_stored` doesn't reflect this. This is a new variant of the N9 livelock that the existing fix does not cover.
41+
42+**Suggested fix:** Increment `events_stored` after successful decryption and parse, regardless of whether the event is stored:
43+```rust
44+let Ok(dec) = crate::crypto::decrypt(&id, &blob) else { continue; };
45+let Some(parsed) = protocol::parse_payload(&dec.plaintext) else { continue; };
46+events_stored += 1; // successfully decrypted — cursor can advance past this
47+```
48+This ensures the cursor is kept whenever at least one event was successfully processed (even if it was a duplicate/stale/stranger), breaking the livelock. The unprocessed tail (budget exhaustion) is still lost, but that's the accepted N9 tradeoff.
49+
50+---
51+
52+### 🔵 D2. `SetServerUrl` resets `send_retries` but not `poll_retries` — new relay inherits stale poll backoff
53+
54+**Type:** State machine bug (stale state on relay switch)
55+**File:** `core/src/app.rs:960`
56+
57+When switching relays, `SetServerUrl` resets `send_retries = 0` (line 960) and `flush_paused_until = 0` (line 959), with the comment: "The old relay's rate limit was the old relay's opinion. A new one is entitled to take our sends immediately."
58+
59+The same logic applies to `poll_retries` — it's the old relay's poll failure count — but it is **not reset**. If the old relay was failing (e.g., 5xx storm), `poll_retries` could be at 4 (backoff = 30s). The first poll to the new relay fails (e.g., transient network issue), and `backoff_poll` uses `backoff_ms(5) = 30s` instead of starting at 3s. The new relay's poll loop is penalized for the old relay's failures.
60+
61+**Attack scenario:** No attack needed — this triggers on any relay switch after a period of poll failures. The user switches to a healthy relay but experiences unnecessarily long poll backoffs.
62+
63+**Suggested fix:** Add `model.poll_retries = 0;` alongside `model.send_retries = 0;` at line 960.
64+
65+---
66+
67+## 2. State Machine — Outbox / Flush
68+
69+### 🔵 D3. `SendResult` pops outbox items even when `kv_load_failed` is set — duplicate sends on relaunch
70+
71+**Type:** State machine bug (write guard gap)
72+**File:** `core/src/app.rs:1547-1560, 1728-1733`
73+
74+If `kv_load_failed` is set (by a `LoadedMessages` failure) while a send is in flight, `SendResult` still pops the batch from `model.outbox` (line 1557-1559) on a 200 or 400. The subsequent `persist_outbox` (line 1560) calls `refuse_write`, which checks `kv_load_failed` and refuses the write. The items are removed from the in-memory outbox but **not from the on-disk outbox**.
75+
76+On relaunch, the outbox is re-loaded from disk — the popped items are still there. They are re-sent:
77+- For a **200** (relay accepted): duplicate sends. The recipient's dedup handles it, but the sender wastes bandwidth and the recipient sees duplicate delivery attempts.
78+- For a **400** (relay rejected): the items are re-sent and re-rejected, burning retry budget.
79+
80+**Timeline:**
81+1. Startup loads complete. `Connect` → `Authenticate` → `VerifyResult` → `Poll` + `StartFlush`.
82+2. `StartFlush` sends a batch. `flushing = true`, `in_flight` is set.
83+3. `LoadedMessages` for some peer fails. `kv_load_failed = true`.
84+4. `SendResult` arrives (200). `flushing` cleared, batch popped (line 1557-1559). `persist_outbox` refused (line 1729-1730).
85+5. On relaunch: outbox loaded from disk. Items re-sent.
86+
87+**Note:** The window is narrow — `LoadedMessages` is a lazy load triggered by `LoadedContacts`, and `StartFlush` fires from `VerifyResult` which also fires after startup loads. But the ordering is: startup loads → `Connect` → `Authenticate` → `VerifyResult` → `StartFlush` + `Poll` → `LoadedMessages` (lazy). So the send can be in flight when `LoadedMessages` fails.
88+
89+**Suggested fix:** Check `kv_load_failed` before popping in `SendResult`. If set, don't pop — leave items in the outbox for relaunch to handle:
90+```rust
91+if ((200..300).contains(&status) || status == 400) && !model.kv_load_failed {
92+ // pop and persist
93+}
94+```
95+Alternatively, don't pop if `persist_outbox` was refused (check the return of `refuse_write`).
96+
97+---
98+
99+## 3. Model / Persistence
100+
101+### 🔵 D4. `now_ms()` returns 0 on system clock failure — outbox TTL disabled
102+
103+**Type:** Robustness gap
104+**File:** `core/src/app.rs:580-585`
105+
106+```rust
107+fn now_ms() -> i64 {
108+ SystemTime::now()
109+ .duration_since(UNIX_EPOCH)
110+ .map(|d| d.as_millis() as i64)
111+ .unwrap_or(0)
112+}
113+```
114+
115+If the system clock is before `UNIX_EPOCH` (misconfigured device, RTK bug, NTP step), `now_ms()` returns 0. Consequences:
116+
117+- **Outbox TTL disabled:** `first_attempt` is stamped as 0 (line 1990-1993). `OutboxItem::is_expired` checks `first_attempt > 0` (line 170) — 0 means "never attempted," so the TTL never applies. Stuck items are retried forever (only the retry counter bounds them, which at 10 retries with slow backoff could take days).
118+- **`flush_paused_until` bypassed:** `now_ms() < flush_paused_until` → `0 < 0` → false. Flushing proceeds immediately, ignoring a relay's `Retry-After`.
119+- **`last_ack_ts` / `last_profile_ts` clamped to 0:** `ts.min(now)` → `ts.min(0)` → 0 (if ts is positive). All future acks/profiles pass the staleness check (any ts >= 0). Dedup is weakened.
120+
121+**Attack scenario:** No attack — this is a device misconfiguration. But on iOS, a clock that briefly reads as pre-epoch during boot could trigger this transiently.
122+
123+**Suggested fix:** Use `UNIX_EPOCH` as the floor instead of 0:
124+```rust
125+fn now_ms() -> i64 {
126+ SystemTime::now()
127+ .duration_since(UNIX_EPOCH)
128+ .map(|d| d.as_millis() as i64)
129+ .unwrap_or(1) // 1, not 0 — 0 is a valid "never attempted" sentinel
130+}
131+```
132+Or better: propagate the error and have callers handle clock failure explicitly.
133+
134+---
135+
136+### 🔵 D5. `LoadedMessages` merge doesn't update `seen_ids` — weakened dedup after `seen_ids` load failure
137+
138+**Type:** Dedup gap
139+**File:** `core/src/app.rs:815-856`
140+
141+The `LoadedMessages` handler merges on-disk messages into the in-memory conversation (union by ID, re-sort, trim). But it does **not** add the on-disk message IDs to `model.seen_ids`.
142+
143+When `seen_ids` loads successfully from kv at startup, this is fine — the on-disk IDs are already in the set. But when `seen_ids` **fails to load** (line 807-811, non-fatal), the set is empty. The `ingest_poll` seeding at line 2244-2246 seeds from the conversation:
144+```rust
145+if id_set.is_empty() {
146+ id_set.extend(convo.iter().map(|m| m.id.clone()));
147+}
148+```
149+
150+If a poll arrives **before** `LoadedMessages` for a peer, the conversation is empty (not loaded yet). The seeding adds no IDs. The poll's messages are stored and their IDs are added to the set. Later, `LoadedMessages` arrives and merges the on-disk messages — but their IDs are **never** added to `seen_ids` (the merge doesn't touch `seen_ids`, and the seeding only runs when `id_set.is_empty()`, which is no longer true after the poll).
151+
152+**Result:** On-disk message IDs are missing from `seen_ids`. Future replays of those on-disk messages pass the dedup check (line 2248) and are re-inserted into the conversation. The `LoadedMessages` merge filters duplicates by ID (line 830), so the conversation doesn't grow unboundedly — but each replay is processed (decrypted, inserted, acked) before the merge catches it.
153+
154+**Attack scenario:**
155+1. `seen_ids` kv load fails (device locked, file corruption).
156+2. `LoadedContacts` triggers `LoadedMessages` for peer P.
157+3. A poll arrives before `LoadedMessages` for P. P's conversation is empty. `seen_ids[P]` is seeded as empty.
158+4. Poll stores new messages, adds their IDs to `seen_ids[P]`.
159+5. `LoadedMessages` for P arrives. On-disk messages are merged into the conversation. Their IDs are NOT added to `seen_ids[P]`.
160+6. A replay of an on-disk message arrives. Its ID is not in `seen_ids[P]`. It passes dedup. It's inserted and acked.
161+
162+**Impact:** Weakened dedup — replays of on-disk messages are re-processed (decrypted, stored, acked). No permanent data corruption (the merge prevents duplicate conversation entries), but the recipient sends spurious delivery acks for old messages, and the sender receives false "delivered" confirmations.
163+
164+**Suggested fix:** In the `LoadedMessages` merge, add the on-disk message IDs to `seen_ids`:
165+```rust
166+if let Some(id_set) = model.seen_ids.get_mut(&peer) {
167+ id_set.extend(list.iter().map(|m| m.id.clone()));
168+}
169+```
170+
171+---
172+
173+## 4. Protocol Deviations
174+
175+### ⚪ D6. `ingest_poll` doesn't send profile to stranger on first contact — protocol "SHOULD" not followed
176+
177+**Type:** Protocol deviation (UX)
178+**File:** `core/src/app.rs:2221-2230, 2400-2412`
179+
180+PROTOCOL.md §4: "Clients SHOULD send a `profile` message to a contact on first interaction and whenever the user updates their name, bio, or photo."
181+
182+When a stranger sends us a text, `ingest_poll` auto-creates a contact (line 2225-2229) and queues a `delivery.ack` (line 2400-2412). But it does **not** queue a `profile` broadcast back to the stranger. The stranger's client has our pubkey (they sent us a message) but no profile — they see our truncated @p but not our display name or avatar.
183+
184+`send_text` handles this correctly: on first contact (line 1858-1873), it queues a profile broadcast after the text. But `ingest_poll` doesn't.
185+
186+**Impact:** Minor UX issue — a stranger who initiates conversation doesn't see our profile until we manually send them a text or update our profile. Not a security vulnerability.
187+
188+**Suggested fix:** After auto-creating a contact in `ingest_poll`, queue a profile broadcast:
189+```rust
190+if !known {
191+ // ... auto-create contact ...
192+ let profile = Payload::Profile { /* ... */ };
193+ model.outbox.push_back(OutboxItem::profile(
194+ sender.clone(),
195+ Arc::new(protocol::serialize_payload(&profile, ts)),
196+ ));
197+ outbox_dirty = true;
198+}
199+```
200+
201+---
202+
203+## Summary of New Findings
204+
205+| # | Severity | Type | Title |
206+|---|----------|------|-------|
207+| D1 | 🟡 medium | Livelock | `events_stored` doesn't count decrypted-but-non-stored events — livelock on budget exhaustion |
208+| D2 | 🔵 low | Stale state | `SetServerUrl` doesn't reset `poll_retries` — new relay inherits old poll backoff |
209+| D3 | 🔵 low | Write guard gap | `SendResult` pops items when `kv_load_failed` — duplicate sends on relaunch |
210+| D4 | 🔵 low | Robustness | `now_ms()` returns 0 on clock failure — outbox TTL disabled |
211+| D5 | 🔵 low | Dedup gap | `LoadedMessages` merge doesn't update `seen_ids` — weakened dedup after load failure |
212+| D6 | ⚪ info | Protocol deviation | `ingest_poll` doesn't send profile to stranger on first contact |
213+
214+**Medium (1):** D1
215+**Low (4):** D2, D3, D4, D5
216+**Info (1):** D6
217+
218+---
219+
220+## Priorities
221+
222+1. **D1 (medium):** Fix `events_stored` to count all successfully decrypted events. This closes a livelock variant that the N9 fix doesn't cover — a hostile relay can permanently wedge the client by re-serving large duplicate/stale/stranger blobs.
223+
224+2. **D2 (low):** Add `model.poll_retries = 0;` to `SetServerUrl`. One-line fix, prevents stale backoff on relay switch.
225+
226+3. **D5 (low):** Update `seen_ids` in the `LoadedMessages` merge. Closes a dedup gap when `seen_ids` kv load fails.
227+
228+4. **D3 (low):** Guard `SendResult` pop with `kv_load_failed` check. Prevents duplicate sends on relaunch.
229+
230+5. **D4 (low):** Change `unwrap_or(0)` to `unwrap_or(1)` in `now_ms()`. Prevents TTL disablement on clock failure.
AUDIT-FFI-PHONEMIC-APPLE.mdnew file+470
@@ -0,0 +1,470 @@
1+# Skrepka Security Audit — FFI, Phonemic, Apple Shell (2026-07-16)
2+
3+**Scope:** Deep audit of `core/src/ffi.rs`, `core/src/phonemic.rs`, and all Apple shell files (`apple/SkrepkaApp/*.swift`). Cross-referenced against `PROTOCOL.md`, `AGENTS.md`, `AUDIT-NEW.md`, and `AUDIT-FRESH.md`. Findings below are **not** in prior audits.
4+
5+**Legend:** 🔴 critical · 🟠 high · 🟡 medium · 🔵 low · ⚪ info
6+
7+---
8+
9+## 1. FFI Boundary
10+
11+### 🟠 F1. `try!` in Core.swift crashes on empty bytes from `guard` — FFI panic safety is a lie
12+
13+**Type:** FFI boundary / panic safety
14+**Files:** `core/src/ffi.rs:42-44` (`guard`), `apple/SkrepkaApp/Core.swift:30,38,49,60,66,74,81`
15+
16+The `guard` function in `ffi.rs` catches panics with `catch_unwind` and returns an empty `Vec<u8>`. The module doc (lines 4-9) claims: *"the shell already tolerates an empty effect list (it just does nothing) and an empty view (it renders the previous one), so a bug degrades into a stalled UI rather than a corrupted process."*
17+
18+This is false. Every bincode deserialization in `Core.swift` uses `try!`:
19+
20+```swift
21+// Core.swift:30 (init)
22+self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
23+
24+// Core.swift:38 (update)
25+let effects = [UInt8](core.update(data: Data(try! event.bincodeSerialize())))
26+
27+// Core.swift:49 (dispatch)
28+let requests = try! Requests.bincodeDeserialize(input: effects).value
29+
30+// Core.swift:60 (render)
31+self.view = try! .bincodeDeserialize(input: [UInt8](core.view()))
32+```
33+
34+If `core.view()` or `core.update()` returns empty bytes (from a caught panic), `try! .bincodeDeserialize(input: [])` throws, and `try!` produces a fatal error — **crashing the app**, not degrading gracefully. The `init` case is the worst: there is no "previous view" to render, so the very first `core.view()` call would crash the app on launch if the core panics.
35+
36+**Attack scenario:** Any input that causes a panic inside `App::update` or `Skrepka::view` — an unforeseen edge case, a dependency bug, or an integer overflow in debug mode — would crash the app instantly via `try!` rather than degrading to a stalled UI as the FFI docs claim. On iOS, this is a clean crash with no data corruption, but it's a denial-of-service: the user can't use the app at all.
37+
38+**Exploitable in practice:** Requires triggering a panic in the Rust core. The core code is careful with `unwrap_or` / `unwrap_or_default`, but panics from dependencies (e.g., `serde_json` on malformed input, `zstd` on a crafted frame) or array indexing bugs are possible. A hostile relay crafting specific poll responses could potentially trigger a panic in the decrypt path.
39+
40+**Suggested fix:** Replace `try!` with `try?` or a `do/catch` block that logs the error and skips the deserialization. For `init`, provide a default empty `ViewModel` if `core.view()` returns empty bytes. Alternatively, have `guard` return a sentinel (e.g., a single zero byte) that the Swift side can detect as "panic occurred" and handle gracefully.
41+
42+---
43+
44+### 🟡 F2. `AssertUnwindSafe` is unsound — Model is read after a panic
45+
46+**Type:** FFI boundary / memory safety
47+**Files:** `core/src/ffi.rs:39-44` (`guard` + `AssertUnwindSafe`)
48+
49+The `guard` function uses `AssertUnwindSafe(f)` to bypass the `UnwindSafe` requirement. The doc comment (lines 39-41) justifies this: *"AssertUnwindSafe is sound here because a poisoned Bridge is never read again for correctness: the worst case is a dropped effect batch, and the next event starts a fresh update."*
50+
51+This is incorrect. The `Bridge` holds the `Core`, which holds the `Model`. `App::update` takes `&mut Model` and mutates it in place. If a panic occurs **mid-update** (after some model mutations but before the function returns), the `Model` is left in a partially-mutated, inconsistent state. The `catch_unwind` catches the panic, returns empty bytes, but the `Model` stays corrupted. The **next** `update` call operates on this corrupted model.
52+
53+The comment's claim that "the next event starts a fresh update" is misleading — a fresh update on a corrupted model is not fresh. The model's invariants (e.g., `contacts` is consistent with `messages`, `cursor` is valid, `outbox` items are well-formed) may be violated.
54+
55+**Attack scenario:** A panic mid-update corrupts the model. The next `update` call sees the corrupted state and may:
56+- Write corrupted state to kv (persisting the corruption across launches)
57+- Crash on a subsequent update (if the corruption violates an invariant checked by `update`)
58+- Produce incorrect behavior (e.g., sending messages to the wrong recipient)
59+
60+**Exploitable in practice:** Requires triggering a panic mid-update, which is the same bar as F1. The corruption is silent — the user sees a stalled UI (if F1 doesn't crash first), but the next interaction operates on corrupted state.
61+
62+**Suggested fix:** After a caught panic, reset the `Model` to a known-good state (e.g., re-load from kv) or mark the `Bridge` as poisoned and refuse further operations. Alternatively, snapshot the `Model` before `update` and restore it on panic. This is expensive but correct. At minimum, document that a panic may corrupt the model and recommend the shell re-initialize the core after a caught panic.
63+
64+---
65+
66+### 🔵 F3. `resolve` accepts arbitrary `EffectId` — no validation against pending effects
67+
68+**Type:** FFI boundary / input validation
69+**Files:** `core/src/ffi.rs:70-78` (`resolve`)
70+
71+The `resolve` method accepts any `u32` as an effect ID and any `&[u8]` as the resolution data. The `Bridge::resolve` call would fail with `Err` if the effect ID is unknown, which `guard` converts to an empty Vec. But the empty Vec would crash the Swift side via `try!` (see F1).
72+
73+More importantly, if the Swift side has a bug that resolves the same effect ID twice (e.g., from a race between a timer callback and a `.clear` call), the second resolution would produce effects for a non-existent or already-resolved effect. The `Bridge::resolve` would return `Err`, which becomes empty bytes, which crashes via `try!`.
74+
75+**Attack scenario:** Not directly exploitable by an attacker — the effect IDs are generated by the core and passed to the shell. But a shell bug (e.g., a race condition in timer handling) could trigger a double-resolve, crashing the app.
76+
77+**Suggested fix:** The shell should track which effect IDs are pending and guard against double-resolve. The FFI should return a distinguishable error for "unknown effect ID" vs "serialization failure" so the shell can handle it gracefully.
78+
79+---
80+
81+## 2. Phonemic
82+
83+### 🔵 P1. `to_ob` / `from_ob` round-trip asymmetry for empty input
84+
85+**Type:** Correctness asymmetry
86+**Files:** `core/src/phonemic.rs:98-109` (`to_ob`), `core/src/phonemic.rs:113-132` (`from_ob`)
87+
88+`to_ob(&[])` returns `Some(String::new())` (empty string), but `from_ob("")` returns `None` (because `split('-').filter(|p| !p.is_empty()).collect()` yields an empty Vec, which triggers `parts.is_empty() => None`).
89+
90+This means `to_ob` produces a string that `from_ob` can't decode. The round-trip property is broken for empty input.
91+
92+**Impact:** No caller passes empty bytes to `to_ob` for pubkey purposes (keys are always 32 bytes). The asymmetry is documented in the test `odd_length_input_has_no_spelling` (line 210). Harmless in practice.
93+
94+**Suggested fix:** Either make `to_ob(&[])` return `None` (consistent with odd-length rejection) or make `from_ob("")` return `Some(vec![])` (consistent with the encoding). The former is safer — an empty encoding is meaningless.
95+
96+---
97+
98+### 🔵 P2. `try_parse_pubkey` hex path lowercases redundantly — no bug, but misleading
99+
100+**Type:** Code clarity
101+**Files:** `core/src/phonemic.rs:154`
102+
103+`hex::decode(trimmed.to_lowercase())` — the `hex` crate's `decode` accepts both uppercase and lowercase hex digits. The `to_lowercase()` is redundant for the hex path. It doesn't cause a bug (the output of `valid_pubkey_hex` is always lowercase via `hex::encode`), but it suggests the case matters when it doesn't.
104+
105+**Suggested fix:** Remove the `to_lowercase()` or add a comment explaining it's for consistency, not correctness.
106+
107+---
108+
109+### ⚪ P3. No @p/hex collision for 32-byte pubkeys — confirmed safe
110+
111+**Type:** Correctness verification
112+**Files:** `core/src/phonemic.rs:148-158` (`try_parse_pubkey`)
113+
114+A 64-char hex string has no hyphens, so `from_ob` sees one part of 64 chars, fails the `part.len() != 6` check, returns `None`. The hex path then decodes it. No collision.
115+
116+A valid @p for a 32-byte key is 16 syllables (111 chars with hyphens). `hex::decode` fails on hyphens. No collision.
117+
118+A 6-char hex string (e.g., "abcdef") that happens to be a valid @p syllable: `from_ob` decodes it to 2 bytes, `valid_pubkey_hex` rejects (not 32 bytes). `hex::decode` gives 3 bytes, also rejected. No collision.
119+
120+**Verdict:** Safe. The @p-first ordering in `try_parse_pubkey` is correct.
121+
122+---
123+
124+### ⚪ P4. `from_ob` multibyte handling is correct — confirmed safe
125+
126+**Type:** Correctness verification
127+**Files:** `core/src/phonemic.rs:113-132`
128+
129+`to_lowercase()` is applied before `split('-')`. For non-ASCII input, `to_lowercase()` may change byte lengths, but the `part.len() != 6 || !part.is_ascii()` check on each part catches any non-ASCII characters that survive lowercasing. The test `multibyte_input_is_rejected_not_panicked_on` (line 279) confirms this.
130+
131+The `part[0..3]` and `part[3..6]` slices are safe because `part.len() == 6` and `part.is_ascii()` (ASCII = 1 byte per char, so byte indices == char indices).
132+
133+**Verdict:** Safe. No panic on multibyte input.
134+
135+---
136+
137+## 3. Apple Shell — HTTP Handling
138+
139+### 🟡 A1. `Http.perform` doesn't validate URL scheme — `file://` requests would be honored
140+
141+**Type:** Defense-in-depth gap
142+**Files:** `apple/SkrepkaApp/Effects.swift:104-106`
143+
144+```swift
145+guard let url = URL(string: req.url) else {
146+ return .err(.url(req.url))
147+}
148+var request = URLRequest(url: url)
149+```
150+
151+The shell accepts any URL scheme. `URLSession.data(for:)` handles `file://` URLs by reading local files. The core's `normalize_server_url` only accepts `http`/`https`, but if a bug in the core (or a future change) produces a `file://` or `data:` URL, the shell would honor it without question.
152+
153+**Attack scenario:** Not directly exploitable — the server URL is user-configured and validated by `normalize_server_url`. But if a bug in URL construction (e.g., `format!("{}/poll", model.settings.server_url)` where `server_url` is somehow `file:///etc/passwd`) reaches the shell, a local file could be read and its contents returned to the core as an HTTP response body. The core would try to parse it as JSON, fail, and treat it as an empty page.
154+
155+**Exploitable in practice:** Requires a core bug that produces a non-http URL. `normalize_server_url` prevents this for the server URL, but there's no belt-and-suspenders check in the shell.
156+
157+**Suggested fix:** Add a scheme check in `Http.perform`:
158+```swift
159+guard url.scheme == "http" || url.scheme == "https" else {
160+ return .err(.url(req.url))
161+}
162+```
163+
164+---
165+
166+### 🟡 A2. `HttpSessionDelegate` cancels same-host redirects — may break legitimate HTTPS upgrades
167+
168+**Type:** Protocol conformance / usability
169+**Files:** `apple/SkrepkaApp/Effects.swift:45-53`
170+
171+The redirect delegate cancels **all** redirects, including same-host redirects. A relay that redirects from `http://relay:80` to `https://relay:443` (a legitimate TLS upgrade) would have its redirect cancelled. The client would see the 3xx response and treat it as a failure.
172+
173+The protocol says "plain HTTP, served over TLS in production" (§5), and `normalize_server_url` accepts both `http` and `https`. So a relay that serves HTTP on port 80 and redirects to HTTPS on port 443 would be broken by this client.
174+
175+**Attack scenario:** Not an attack — a usability issue. A relay operator who sets up an HTTP-to-HTTPS redirect would find that Skrepka clients can't connect.
176+
177+**Exploitable in practice:** Depends on relay configuration. The `install.sh` deploys Caddy with TLS, so production relays serve HTTPS directly (no redirect). But a relay misconfigured to redirect would be unreachable.
178+
179+**Suggested fix:** Allow same-host redirects (compare the redirect target's host against the original request's host). Only cancel cross-host redirects, which are the token-exfiltration vector. Or, since the protocol says no redirects, document that relays must not redirect.
180+
181+---
182+
183+### 🔵 A3. `UInt16(clamping:)` silently truncates status codes > 65535
184+
185+**Type:** Input validation
186+**Files:** `apple/SkrepkaApp/Effects.swift:120`
187+
188+```swift
189+status: UInt16(clamping: http?.statusCode ?? 0),
190+```
191+
192+HTTP status codes are 3-digit integers (100-599), so this is fine in practice. But `UInt16(clamping:)` would clamp any value > 65535 to 65535, which is a valid `UInt16` but not a valid HTTP status. The core checks `status >= 200 && status < 300` for success, so 65535 would be treated as failure. No security issue.
193+
194+**Verdict:** Safe. HTTP status codes are bounded by the protocol.
195+
196+---
197+
198+### ⚪ A4. URLSession configuration is correct — confirmed safe
199+
200+**Type:** Implementation review
201+**Files:** `apple/SkrepkaApp/Effects.swift:91-101`
202+
203+The session configuration is:
204+- `ephemeral` — no cookies, no cache, no persistent storage ✓
205+- `httpCookieAcceptPolicy = .never` ✓
206+- `httpShouldSetCookies = false` ✓
207+- `httpCookieStorage = nil` ✓
208+- `urlCache = nil` ✓
209+- `requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData` ✓
210+- `timeoutIntervalForRequest = 70` — matches the server's ~25s long-poll ✓
211+- `tlsMinimumSupportedProtocolVersion = .TLSv12` ✓
212+
213+No sensitive data in logs. The `error.localizedDescription` in the catch block returns user-friendly strings like "The network connection was lost" — no relay-specific info beyond what the user already configured.
214+
215+**Verdict:** Safe. Good defense-in-depth.
216+
217+---
218+
219+### ⚪ A5. Response size limiting is correct — confirmed safe
220+
221+**Type:** Implementation review
222+**Files:** `apple/SkrepkaApp/Effects.swift:31-79`
223+
224+The `HttpSessionDelegate` tracks per-task byte counts in a thread-safe `NSLock`-guarded dictionary. When a task's accumulated body exceeds `maxResponseBytes` (64 MiB), the task is cancelled. The `didCompleteWithError` callback cleans up the dictionary entry. Every task eventually completes (success, failure, or cancel), so no dictionary leak.
225+
226+The 64 MiB cap matches the core's `MAX_POLL_RESPONSE_BYTES` (also 64 MiB). The shell's cap fires before the core's (the shell cancels the task mid-stream, the core never sees the full body), which is the correct ordering — the shell's cap prevents OOM before the core's check runs.
227+
228+**Verdict:** Safe. Well-designed.
229+
230+---
231+
232+## 4. Apple Shell — Keychain & KV Persistence
233+
234+### ⚪ A6. Keychain storage is correct — confirmed safe
235+
236+**Type:** Implementation review
237+**Files:** `apple/SkrepkaApp/Effects.swift:320-415`
238+
239+- `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` — key never rides backup ✓
240+- `SecItemUpdate` first, then `SecItemAdd` if not found — no delete-then-add window ✓
241+- `errSecItemNotFound` is the only status that triggers key generation ✓
242+- 64-byte key length is validated on load ✓
243+- `loadOrCreateIdentity` is called only on the main actor (singleton `Core.shared`), so no race ✓
244+
245+The `store` function's update-then-add sequence has a theoretical race (between `SecItemUpdate` returning `errSecItemNotFound` and `SecItemAdd`, another thread could add the item), but `bootIdentity` is only called on the `@MainActor` singleton, so there's no concurrency.
246+
247+**Verdict:** Safe. Good practice.
248+
249+---
250+
251+### ⚪ A7. KV persistence is correct — confirmed safe
252+
253+**Type:** Implementation review
254+**Files:** `apple/SkrepkaApp/Effects.swift:195-316`
255+
256+- Serial queue — all operations serialized ✓
257+- Atomic writes with `.completeFileProtectionUnlessOpen` ✓
258+- `isExcludedFromBackup = true` — plaintext history not in iCloud backup ✓
259+- Percent-encoding with `.alphanumerics` — injective key→filename mapping, no path traversal ✓
260+- Legacy migration runs before any read (same serial queue) ✓
261+- Missing file = empty key (`.none`), not an error — correct distinction from read failure ✓
262+
263+**Verdict:** Safe. Well-designed.
264+
265+---
266+
267+## 5. Apple Shell — Photo Picker & QR Scanning
268+
269+### 🔵 A8. `PhotoPicker` last-resort quality doesn't check size cap
270+
271+**Type:** Logic bug / UX
272+**Files:** `apple/SkrepkaApp/PhotoPicker.swift:80-81`
273+
274+```swift
275+// Last resort: the smallest quality that still produces something.
276+return image.jpegData(compressionQuality: 0.1)?.base64EncodedString()
277+```
278+
279+The loop (lines 71-79) reduces quality until the base64 fits within 64 KiB (`maxBase64Len`). But the last-resort return on line 81 doesn't check if the result fits. If a 256px JPEG at q0.1 still exceeds 64 KiB (possible for high-frequency noise images), the core's `SaveProfile` rejects it with "photo too large" and the user has no recourse.
280+
281+**Attack scenario:** A user picks a photo that, even at 256px and q0.1, produces > 64 KiB of base64. The profile save fails with no way to proceed. Not a security issue — a UX dead end.
282+
283+**Exploitable in practice:** Very unlikely for real photos (256px JPEG at q0.1 is typically 5-15 KiB). But a synthetic image (e.g., pure noise) could exceed the cap.
284+
285+**Suggested fix:** If the last-resort still exceeds the cap, return `nil` (triggering `onCancel`) or downscale further (e.g., 128px). Or document that the user should pick a different photo.
286+
287+---
288+
289+### 🔵 A9. `scannedKeyPayload` is business logic in the shell — portability concern
290+
291+**Type:** Protocol conformance / architecture
292+**Files:** `apple/SkrepkaApp/QRView.swift:24-35`
293+
294+`scannedKeyPayload` parses QR code content: strips URL schemes (`skrepka://`, `https://`, `http://`), extracts the last path component, and trims whitespace. This is parsing logic that determines what input the core receives.
295+
296+`AGENTS.md` says: *"The shell never decides anything — it turns Effects into platform calls and resolves the results back in."* This function decides how to interpret a QR code, which is arguably a business decision.
297+
298+An Android client would need to reimplement this logic. If the parsing differs (e.g., one client strips `skrepka://` and another doesn't), the same QR code would produce different inputs to the core.
299+
300+**Attack scenario:** Not a security vulnerability — the core's `try_parse_pubkey` validates the result regardless. But a portability bug: a QR code with a `skrepka://` prefix would work on iOS (stripped by `scannedKeyPayload`) but fail on an Android client that doesn't strip the scheme.
301+
302+**Suggested fix:** Move `scannedKeyPayload` into the core (e.g., `try_parse_pubkey_from_qr` or add scheme-stripping to `try_parse_pubkey`). The shell should pass the raw QR content to the core.
303+
304+---
305+
306+### 🔵 A10. `QRScannerView` doesn't check camera permission
307+
308+**Type:** UX gap
309+**Files:** `apple/SkrepkaApp/QRView.swift:75-80`
310+
311+`ScannerVC.viewDidLoad` calls `AVCaptureDevice.default(for: .video)`, which returns `nil` if camera permission hasn't been granted (or has been denied). The `guard` fails silently, and the scanner shows a black screen with no error message or permission prompt.
312+
313+**Attack scenario:** Not a security issue. A user who denied camera permission can't scan QR codes and sees a black screen with no explanation.
314+
315+**Suggested fix:** Check `AVCaptureDevice.authorizationStatus(for: .video)` and request permission if not determined. Show an error message if denied.
316+
317+---
318+
319+### ⚪ A11. `QRScannerView` camera lifecycle is correct — confirmed safe
320+
321+**Type:** Implementation review
322+**Files:** `apple/SkrepkaApp/QRView.swift:101-114`
323+
324+`startRunning()` and `stopRunning()` are dispatched to a background queue (`DispatchQueue.global(qos: .userInitiated)`) to avoid blocking the main thread. The `done` flag in `Coordinator` prevents multiple `onScan` callbacks. The camera session is properly stopped in `viewWillDisappear`.
325+
326+**Verdict:** Safe.
327+
328+---
329+
330+## 6. Apple Shell — Logic Leakage & Protocol Conformance
331+
332+### 🟡 A12. `BackgroundRefresh` polls `core.view` in a tight loop — main actor contention
333+
334+**Type:** Performance / architecture
335+**Files:** `apple/SkrepkaApp/SkrepkaApp.swift:156-163`
336+
337+The `settle` function polls `core.view` every 200ms, reading the `@Published` `ViewModel` on the main actor. While the overhead per poll is minimal (reading a published property), this runs for up to 25 seconds during a background refresh. If the core is processing a large poll response (which also runs on the main actor via `resolve`), the 200ms polls interleave with core processing.
338+
339+More importantly, `core.view` is a `@Published` property backed by `ViewModel`, which is a value type. Each read copies the entire `ViewModel` (including `contacts: Vec<ContactVM>` and `messages: Vec<MessageVM>`). For a user with 500 contacts and 1000 messages, each poll copies ~1 MB of data. Over 25 seconds at 200ms intervals, that's 125 copies = ~125 MB of transient allocations.
340+
341+**Attack scenario:** Not an attack — a performance issue. A user with many contacts and active conversations would experience increased memory pressure during background refresh.
342+
343+**Suggested fix:** Use a Combine subscription (`core.$view.sink`) instead of polling, or have the core expose a lightweight "status changed" flag that the shell can check without copying the full `ViewModel`.
344+
345+---
346+
347+### 🔵 A13. `Core.swift` `dispatch` silently drops effects if `try!` crashes — no recovery
348+
349+**Type:** Error handling
350+**Files:** `apple/SkrepkaApp/Core.swift:47-53`
351+
352+```swift
353+private func dispatch(_ effects: [UInt8]) {
354+ // swiftlint:disable:next force_try
355+ let requests = try! Requests.bincodeDeserialize(input: effects).value
356+ for request in requests {
357+ process(request)
358+ }
359+}
360+```
361+
362+If `effects` is valid bincode but contains an effect variant the Swift side doesn't know about (e.g., after a core update that adds a new effect without regenerating Swift types), `try!` would crash. The AGENTS.md warns about this: *"Touching Event, ViewModel, or Effect without running just generate leaves the Swift side compiling against stale types."*
363+
364+But there's no runtime recovery. The crash is immediate and unrecoverable. A safer approach would be to catch the deserialization error, log it, and skip the unknown effect — the core would see the effect as unresolved and its watchdog would handle it.
365+
366+**Suggested fix:** Replace `try!` with `try?` and log the error. Unknown effects are dropped; the core's watchdog handles the missing resolution.
367+
368+---
369+
370+### ⚪ A14. `Time.handle` timer lifecycle is correct — confirmed safe
371+
372+**Type:** Implementation review
373+**Files:** `apple/SkrepkaApp/Effects.swift:147-191`
374+
375+The `timers` dictionary is on the `@MainActor`. The `schedule` function removes the old work item before inserting a new one. The `DispatchWorkItem`'s callback runs on the main queue (via `MainActor.assumeIsolated`), so there's no interleaving between `schedule` and the callback. The `.clear` case removes and cancels the work item.
376+
377+No race condition: all operations are on the main actor / main queue, which is single-threaded.
378+
379+**Verdict:** Safe.
380+
381+---
382+
383+### ⚪ A15. `SkrepkaApp.swift` background refresh is correct — confirmed safe
384+
385+**Type:** Implementation review
386+**Files:** `apple/SkrepkaApp/SkrepkaApp.swift:64-164`
387+
388+- `isProtectedDataAvailable` guard prevents running while locked ✓
389+- `settle` polling respects `Task.isCancelled` and deadline ✓
390+- `setTaskCompleted(success: !Task.isCancelled)` correctly reports failure on cancellation ✓
391+- `schedule()` is called before `run()` — next refresh is always queued ✓
392+- `Core.shared` is only accessed after the `isProtectedDataAvailable` guard ✓
393+
394+**Verdict:** Safe. Well-designed.
395+
396+---
397+
398+## 7. Protocol & AGENTS.md Conformance
399+
400+### 🔵 A16. AGENTS.md says "five kv loads" but core does six — `seen_ids` added
401+
402+**Type:** Documentation drift
403+**Files:** `AGENTS.md:98` ("fans out five kv loads"), `core/src/app.rs:719-726` (six loads)
404+
405+AGENTS.md line 98 says: *"IdentityLoaded fans out five kv loads → LoadedSettings triggers Connect"*. But the core actually fans out **six** kv loads: `settings`, `profile`, `contacts`, `cursor`, `outbox`, and `seen_ids` (line 725: `KeyValue::get(K_SEEN_IDS)`). The `STARTUP_LOADS` constant is 6 (line 312).
406+
407+**Impact:** Documentation drift, not a bug. A developer reading AGENTS.md would expect five loads and be confused by the sixth.
408+
409+**Suggested fix:** Update AGENTS.md to say "six kv loads" and include `seen_ids` in the list.
410+
411+---
412+
413+### 🔵 A17. AGENTS.md lists kv keys without `seen_ids` — stale documentation
414+
415+**Type:** Documentation drift
416+**Files:** `AGENTS.md:14` ("keys `settings`, `profile`, `contacts`, `cursor`, `outbox`, and `messages:<peer_hex>`")
417+
418+The `seen_ids` key is not listed in AGENTS.md's kv key list. It was added as part of the dedup fix (AUDIT-NEW.md Q18) but the documentation wasn't updated.
419+
420+**Suggested fix:** Add `seen_ids` to the kv key list in AGENTS.md.
421+
422+---
423+
424+## Summary of New Findings
425+
426+| # | Severity | Type | Title |
427+|---|----------|------|-------|
428+| F1 | 🟠 high | FFI/panic | `try!` in Core.swift crashes on empty bytes from `guard` — FFI panic safety claim is false |
429+| F2 | 🟡 medium | FFI/memory | `AssertUnwindSafe` is unsound — Model is read after a panic, corruption persists |
430+| F3 | 🔵 low | FFI/input | `resolve` accepts arbitrary EffectId — double-resolve crashes via `try!` |
431+| P1 | 🔵 low | Correctness | `to_ob`/`from_ob` round-trip asymmetry for empty input |
432+| P2 | 🔵 low | Clarity | `try_parse_pubkey` hex path lowercases redundantly |
433+| P3 | ⚪ info | Safe | No @p/hex collision for 32-byte pubkeys — confirmed |
434+| P4 | ⚪ info | Safe | `from_ob` multibyte handling is correct — confirmed |
435+| A1 | 🟡 medium | HTTP/defense | `Http.perform` doesn't validate URL scheme — `file://` would be honored |
436+| A2 | 🟡 medium | HTTP/protocol | Redirect delegate cancels same-host redirects — breaks HTTPS upgrades |
437+| A3 | 🔵 low | HTTP/input | `UInt16(clamping:)` silently truncates status codes > 65535 |
438+| A4 | ⚪ info | Safe | URLSession configuration is correct |
439+| A5 | ⚪ info | Safe | Response size limiting is correct |
440+| A6 | ⚪ info | Safe | Keychain storage is correct |
441+| A7 | ⚪ info | Safe | KV persistence is correct |
442+| A8 | 🔵 low | Photo/UX | `PhotoPicker` last-resort quality doesn't check size cap |
443+| A9 | 🔵 low | Architecture | `scannedKeyPayload` is business logic in the shell — portability concern |
444+| A10 | 🔵 low | QR/UX | `QRScannerView` doesn't check camera permission |
445+| A11 | ⚪ info | Safe | QR scanner camera lifecycle is correct |
446+| A12 | 🟡 medium | Performance | `BackgroundRefresh` polls `core.view` — copies full ViewModel every 200ms |
447+| A13 | 🔵 low | Error handling | `Core.swift` `dispatch` crashes on unknown effect — no recovery |
448+| A14 | ⚪ info | Safe | Timer lifecycle is correct |
449+| A15 | ⚪ info | Safe | Background refresh is correct |
450+| A16 | 🔵 low | Docs | AGENTS.md says "five kv loads" but core does six |
451+| A17 | 🔵 low | Docs | AGENTS.md kv key list missing `seen_ids` |
452+
453+**High (1):** F1
454+**Medium (4):** F2, A1, A2, A12
455+**Low (8):** F3, P1, P2, A3, A8, A9, A10, A13, A16, A17
456+**Info (8):** P3, P4, A4, A5, A6, A7, A11, A14, A15
457+
458+---
459+
460+## Priorities
461+
462+1. **F1 (high):** Replace `try!` in Core.swift with graceful error handling. The FFI's panic safety design is undermined by `try!` — a caught panic crashes the app instead of degrading. This is the most impactful finding because it turns every potential core panic into an immediate app crash.
463+
464+2. **F2 (medium):** Address `AssertUnwindSafe` soundness. After a caught panic, the Model may be corrupted. At minimum, document the risk and consider resetting the Model from kv on panic recovery.
465+
466+3. **A1 (medium):** Add URL scheme validation in `Http.perform`. Defense-in-depth against a core bug producing non-http URLs.
467+
468+4. **A2 (medium):** Allow same-host redirects or document that relays must not redirect. The current all-redirects-cancelled policy breaks legitimate HTTPS upgrades.
469+
470+5. **A12 (medium):** Replace `settle`'s polling with a Combine subscription to avoid copying the full ViewModel every 200ms during background refresh.
AUDIT-FRESH.mdnew file+385
@@ -0,0 +1,385 @@
1+# Skrepka Security Audit — Fresh Pass (2026-07-15)
2+
3+**Scope:** Exhaustive multi-pass audit of PROTOCOL.md, server.knot, core/src/ (app.rs,
4+crypto.rs, protocol.rs, model.rs, phonemic.rs, ffi.rs), apple/SkrepkaApp/ (Effects.swift,
5+Core.swift, Views.swift, Forms.swift), install.sh. Findings below are **not** in
6+PROTOCOL-REVIEW.md or AUDIT-NEW.md, or are new angles on previously-noted areas.
7+
8+**Legend:** 🔴 critical · 🟠 high · 🟡 medium · 🔵 low · ⚪ info
9+
10+---
11+
12+## 1. Cryptographic Protocol
13+
14+### 🔴 N1. No AAD on AEAD — envelope `to` field is unauthenticated, enabling silent targeted censorship by any relay
15+
16+**Type:** Design gap (protocol + crypto.rs)
17+**Files:** `core/src/crypto.rs:384-387` (encrypt), `core/src/crypto.rs:434-438` (decrypt)
18+
19+AUDIT-NEW.md Q2 notes this but downgrades the impact to "equivalent to message loss, which is
20+an accepted limitation." That understates it. The real issue is **silent, targeted message loss
21+with zero detectability**:
22+
23+A relay holding a victim's messages can swap the `to` field on a *specific* message (from a
24+sender the relay wants to censor), while leaving other messages intact. The recipient never
25+sees the message and gets no error. The `delivery.ack` system doesn't help because the recipient
26+never received the message to ack it, and the sender's UI shows the message as "sent"
27+indefinitely — which is the same behavior as "the recipient is offline" or "the recipient read
28+it but hasn't acked yet." There is **no way for either party to distinguish "the relay ate
29+Alice's message" from "Alice didn't send anything" or "Bob is just slow to ack."**
30+
31+This is worse than the generic "relay can drop messages" case. Dropping is *eventually*
32+detectable — the sender sees their message stuck at "sent" and can investigate. But `to`-swapping
33+moves the message to a *different recipient's* mailbox, where it's undecryptable and gets
34+silently dropped on poll. The original recipient's cursor is unaffected (no events), so the
35+next poll is normal. The censorship is invisible.
36+
37+**Attack scenario:**
38+1. Alice sends a message to Bob via relay R.
39+2. R swaps `to` from Bob's pubkey to Charlie's.
40+3. Message stored under Charlie's mailbox. Charlie can't decrypt (wrong key), drops silently.
41+4. Bob polls, gets nothing. Normal poll, no error.
42+5. Alice's UI shows "sent" — same as if Bob were merely offline.
43+6. No delivery.ack ever comes. Alice can't tell if Bob received it or not.
44+
45+The relay can do this selectively — censor messages from specific senders, or suppress specific
46+content patterns (inferred from blob size), while delivering everything else normally. The
47+censorship is undetectable because the sender sees the same "sent" status they'd see for any
48+offline recipient, and the recipient sees nothing unusual.
49+
50+**Suggested fix:** Include `version_byte || ephemeral_public || nonce || recipient_ed25519_public`
51+as AAD in the AEAD. The `to` field must match `recipient_ed25519_public` in the AAD. A relay
52+swapping `to` causes the wrong recipient to fail AEAD decryption (before signature check), and
53+the blob is dropped before cursor advancement. The *original* recipient's message stays in the
54+mailbox. Wire-format change (v0x02).
55+
56+### ⚪ N2. HKDF info string and wire version byte are independent constants
57+
58+**Type:** Code hygiene (crypto.rs)
59+**Files:** `core/src/crypto.rs:28` (`HKDF_INFO = b"skrepka-v1"`), `crypto.rs:33` (`WIRE_VERSION = 1`)
60+
61+The spec says "A future revision that changes the crypto primitives should bump both the version
62+byte and the HKDF `info` string together." But there's no code-level binding between them. A
63+developer bumping `WIRE_VERSION` to `2` could forget to update `HKDF_INFO`, causing two different
64+crypto versions to derive keys with the same KDF label.
65+
66+**Suggested fix:** Derive the info string from the version byte or add a compile-time assertion
67+linking them.
68+
69+---
70+
71+## 2. Server Implementation
72+
73+### 🟡 N3. `appendMessage` dedup scan is O(n) — identical-blob replay amplifies to O(n × batch_size)
74+
75+**Type:** Implementation concern / DoS amplification (server.knot)
76+**Files:** `server.knot:919-937`
77+
78+AUDIT-NEW.md Q7 notes the O(n) dedup scan. The amplification is worse than described for
79+identical-blob replay: a batch of 100 identical blobs to the same recipient does 100 × O(|messages|)
80+scans. With |messages| = 10,000, that's 1M comparisons per request. At 60 requests/min, that's
81+60M comparisons/min. Each comparison is a string equality on 64-char hex pubkeys + 346+ char
82+hex blobs.
83+
84+**Attack:** An attacker authenticates (free keypair) and sends batches of 100 identical
85+min-size blobs to one recipient. Each request is ~35 KB but triggers 1M comparisons. Bounded
86+by the 60/min rate limit, but the CPU amplification is ~1000x per request.
87+
88+**Suggested fix:** Index `*messages` by `(toKey, encryptedBlob)` or use a hash-based dedup set.
89+
90+### 🟡 N4. `capForwards` eviction is non-deterministic when retry counts are equal
91+
92+**Type:** Implementation bug (server.knot)
93+**Files:** `server.knot:515-522` (`capForwards`)
94+
95+AUDIT-NEW.md Q9 notes this. The sort by `retries` only (no secondary sort key) means when many
96+entries share `retries: 0`, the sort is unstable and any retries-0 entry can be dropped —
97+including a fresh forward for a healthy peer that would have succeeded on the first try.
98+
99+**Impact:** Delivery delay, not permanent loss (the message stays in `*messages` and can be
100+re-forwarded on the next presence announce). But it's a real availability issue under
101+federation abuse.
102+
103+**Suggested fix:** Sort by `(retries, encryptedBlob)` for determinism, or skip entries for
104+peers in backoff before capping.
105+
106+### 🟡 N5. `handleRecvGossip` `fromServer` not verified against connecting peer's address
107+
108+**Type:** Design gap (server.knot)
109+**Files:** `server.knot:1533-1578`
110+
111+AUDIT-NEW.md Q29 notes this. The `fromServer` field is self-asserted and used to route forwarded
112+messages. An attacker can claim any `fromServer` hostname. The server writes presence rows with
113+that hostname and forwards queued messages there. This is the root cause of the gossip-redirect
114+attack and is already documented, but the spec's §10 should state more prominently that **the
115+server has no mechanism to verify that the connecting peer is who it claims to be**.
116+
117+The `fromServer` field is a `ServerName` (format-validated), but format validation only checks
118+length, lowercase, and no trailing dot — it does not verify the peer controls that hostname.
119+
120+**Suggested fix:** Document more prominently. Real fix needs reverse-DNS verification or peer
121+authentication (signed gossip).
122+
123+### 🔵 N6. `genToken` generates one hex digit per `randomInt 16` call — entropy waste
124+
125+**Type:** Implementation concern (server.knot)
126+**Files:** `server.knot:532-537`
127+
128+AUDIT-NEW.md Q10 notes the CSPRNG dependency. Additionally, generating one hex digit at a time
129+is inefficient — each `randomInt 16` call should produce at least 4 bits, but if the runtime's
130+`randomInt` draws from a wider source (e.g., 32-bit `getrandom`), it wastes 28 bits per call.
131+48 calls × 28 wasted bits = 1344 bits wasted per token. Not a security issue (entropy is
132+abundant) but inefficient.
133+
134+**Verdict:** Info.
135+
136+---
137+
138+## 3. Client Core Logic
139+
140+### 🟡 N7. `seen_ids` dedup set is unbounded — grows without limit per peer
141+
142+**Type:** Implementation concern (app.rs)
143+**Files:** `core/src/app.rs:325` (`seen_ids: BTreeMap<String, HashSet<String>>`)
144+
145+The `seen_ids` map (Q18 fix) is persisted to kv and loaded at startup. It's a `BTreeMap<String,
146+HashSet<String>>` — one entry per peer, each a set of message IDs. The set is never trimmed.
147+Over time, a peer who sends thousands of messages accumulates thousands of IDs. The kv blob
148+(`seen_ids`) grows without bound.
149+
150+With `MAX_ID_LEN` = 128 chars and UUIDs = 36 chars, 1000 messages per peer (the conversation cap)
151+= 36 KB per peer. With 500 contacts (`MAX_CONTACTS`), that's 18 MB. The `seen_ids` kv blob is
152+rewritten in full on every conversation touch (line 2316), so a large set makes every message
153+receipt slower. On iOS, a 18 MB JSON blob written to Application Support on every incoming
154+message is a real performance issue.
155+
156+**Suggested fix:** Cap the `seen_ids` set per peer (e.g., keep the last 2000 IDs) and trim
157+periodically. Or use a time-based expiry. Or a Bloom filter with a fixed size.
158+
159+### 🟡 N8. `ingest_poll` advances cursor before processing events — crash between advance and persist loses messages
160+
161+**Type:** Implementation concern (app.rs)
162+**Files:** `core/src/app.rs:2024-2089`
163+
164+The cursor is advanced to `page.cursor` on line 2038 (before the event loop), and restored to
165+`prev_cursor` on budget exhaustion (line 2086) or event count overflow (line 2069). The cursor
166+is persisted to kv AFTER `ingest_poll` returns (line 1369), via a `KeyValue::set` effect. This
167+means the cursor write only happens after the event loop completes successfully.
168+
169+However, there's a crash window: if the app crashes between `model.cursor = clamped_cursor`
170+(line 2038) and the cursor persist effect (line 1369), the in-memory cursor is advanced but
171+the persisted cursor is still `prev_cursor`. On relaunch, the client sends `prev_cursor` to
172+the relay, which re-serves the events. No message loss — the persisted cursor is the safe one.
173+
174+**Verdict:** Safe. The cursor is only authoritative when persisted, and the persist happens
175+after `ingest_poll` completes. A crash during `ingest_poll` loses no data because the persisted
176+cursor hasn't changed. Info.
177+
178+### 🟡 N9. `ingest_poll` budget exhaustion drops remaining events — client is stuck if the relay always serves an oversized page
179+
180+**Type:** Implementation concern (app.rs)
181+**Files:** `core/src/app.rs:2054-2089`
182+
183+When the `MAX_POLL_TOTAL_BYTES` budget is exhausted mid-page, the loop breaks, the cursor is
184+restored to `prev_cursor`, and an error is set. The next poll sends `prev_cursor` again, and
185+the relay re-serves the same events. If the page always exceeds the budget (e.g., the relay
186+serves 50 large blobs and the budget only covers 10), the client is stuck: poll, process 10,
187+drop 40, re-poll, relay serves the same 50, process the same 10 (deduped, fast), drop 40, etc.
188+
189+The dedup makes re-processing fast (already-seen IDs are O(1) drops), but the client never
190+advances past the oversized page. The relay's events are served in `receivedAt` order, so the
191+same 50 events come back every time. The cursor never moves. The client is stuck in a loop.
192+
193+This is a **livelock**: the client polls, processes some events, but can never ack them. The
194+relay keeps serving the same page. The client's error message ("relay sent an oversized poll
195+page") is set but may not be visible in the UI.
196+
197+**Attack scenario:** A malicious relay serves a page of 50 blobs, each just under `MAX_BLOB_LEN`
198+(42 MiB). The total page is ~2 GiB, far exceeding the 64 MiB budget. The client processes a few,
199+drops the rest, and is stuck. Or more subtly: the relay serves 50 blobs that are each ~1.3 MiB
200+(total ~65 MiB, just over the 64 MiB budget). The client processes 49, drops 1, and is stuck
201+because the cursor never advances past the 50th event.
202+
203+**Suggested fix:** When the budget is exhausted, advance the cursor to the last *successfully
204+processed* event's `receivedAt` (not `page.cursor` and not `prev_cursor`). This acks the
205+processed events and lets the client move forward, dropping only the unprocessed tail. The
206+relay re-serves only the unprocessed events on the next poll. This changes the behavior from
207+"all-or-nothing cursor advance" to "partial ack."
208+
209+### 🟡 N10. Profile `ts` staleness check uses `>=` — equal-timestamp replay is a no-op, but the check is per-contact, not per-field
210+
211+**Type:** Implementation concern (app.rs)
212+**Files:** `core/src/app.rs:2174-2179` (approximate)
213+
214+AUDIT-NEW.md Q19 analyzes this and concludes it's safe because the attacker can't modify the
215+content without breaking the signature. I agree — the `>=` is correct and a same-`ts` replay
216+is a true no-op (same ciphertext, same signature, same content). Info.
217+
218+---
219+
220+## 4. Wire Format / Parsing
221+
222+### 🟡 N11. `protocol.rs` `parse_payload` does not validate `ts` is positive — negative `ts` clamped to 0 is harmless but surprising
223+
224+**Type:** Implementation concern (protocol.rs)
225+**Files:** `core/src/protocol.rs:100`
226+
227+AUDIT-NEW.md Q24 notes this. A `ts` of 0 or negative is clamped to `max(0, ...)` in `ingest_poll`.
228+A message with `ts = 0` is inserted at the earliest chronological position and trimmed first.
229+This is harmless. Info.
230+
231+### 🟡 N12. `delivery.ack` parsing accepts non-string entries in `ack_ids` — filtered silently, no length check on individual IDs after filtering
232+
233+**Type:** Implementation concern (protocol.rs)
234+**Files:** `core/src/protocol.rs:116-126`
235+
236+AUDIT-NEW.md Q26 notes this. The double-check (raw count + post-filter count) is correct.
237+Non-string entries are filtered. Individual ID length is checked (`ack_ids.iter().any(|id|
238+id.len() > MAX_ID_LEN)`). This is correct. Info.
239+
240+---
241+
242+## 5. Federation
243+
244+### 🟠 N13. Gossip amplification: 100 online events trigger 100 mailbox scans + forward staging — O(100 × |messages|) per request
245+
246+**Type:** Design gap (server.knot)
247+**Files:** `server.knot:1544-1578` (`handleRecvGossip`)
248+
249+AUDIT-NEW.md Q27 notes this. A single gossip request with 100 `online` events for 100 different
250+keys triggers `forwardQueuedToServer` for each key, which scans the entire mailbox. With
251+|messages| = 10,000, that's 100 × 10,000 = 1M comparisons per request. At 60/min per IP, that's
252+60M comparisons/min. An attacker with 10 IPs can do 600M comparisons/min.
253+
254+Additionally, each `forwardQueuedToServer` call stages forward entries, which consume
255+`maxForwardsPerServer` slots and trigger HTTP POSTs to the attacker's server. The attacker
256+receives copies of the victim's ciphertext.
257+
258+**Suggested fix:** Limit the number of newly-online keys per gossip batch that trigger forwards
259+(e.g., cap at 10). Or batch the mailbox scan across all newly-online keys in one pass.
260+
261+### 🟡 N14. `offline` gossip events don't cancel pending forwards — wasted retries for up to 80 minutes
262+
263+**Type:** Design gap (server.knot)
264+**Files:** `server.knot:1535-1541`
265+
266+AUDIT-NEW.md Q28 notes this. When an `offline` event is received, pending forwards to that server
267+are not cancelled. They retry for up to `retryBackoffMax × maxForwardRetries` = 8 min × 10 = 80
268+minutes, wasting `maxForwardsPerServer` slots and generating failed HTTP requests.
269+
270+If the offline event was spoofed, the forwards continue — and if the key is actually still
271+online at that server, the forward succeeds on retry. The impact is minimal.
272+
273+**Suggested fix:** When processing an `offline` event, optionally drop pending forwards to that
274+server for the offline key. But this could cause message loss if the offline event is spoofed.
275+Better to leave as-is and document.
276+
277+---
278+
279+## 6. Deployment / install.sh
280+
281+### 🟡 N15. `install.sh` downloads binary over HTTPS but checksum is best-effort — missing checksum silently proceeds
282+
283+**Type:** Deployment concern (install.sh)
284+**Files:** `install.sh:150-181`
285+
286+The checksum verification is best-effort: if the `.sha256` file doesn't exist (line 162 fails),
287+the script warns and proceeds. If `sha256sum`/`shasum` is not available, it warns and proceeds.
288+A checksum that exists but doesn't match is fatal (line 169-174) — correct.
289+
290+The concern: a compromised release asset without a published `.sha256` file (e.g., a pre-checksum
291+release, or a supply-chain attack that removes the checksum file) would be installed without
292+verification. The script trusts GitHub's HTTPS, but HTTPS authenticates the CDN, not the
293+artifact. A compromised GitHub release would sail through.
294+
295+**Suggested fix:** Make the checksum mandatory for all new installs. Keep the best-effort
296+behavior only for upgrades from pre-checksum releases, with a prominent warning.
297+
298+### ⚪ N16. `install.sh` DOMAIN validation rejects uppercase but the Caddyfile interpolation is safe
299+
300+**Type:** Deployment review (install.sh)
301+**Files:** `install.sh:34-45`
302+
303+DOMAIN is lowercased and stripped of scheme/path/port before interpolation into the Caddyfile
304+and systemd unit. The validation rejects anything outside `[a-z0-9.-]`. This prevents config
305+injection (a stray quote or semicolon in the Caddyfile). Good.
306+
307+**Verdict:** Safe. Info.
308+
309+---
310+
311+## 7. Apple Shell
312+
313+### ⚪ N17. URLSession redirect cancellation is correct — bearer token exfiltration via 302 is prevented
314+
315+**Type:** Implementation review (Effects.swift)
316+**Files:** `apple/SkrepkaApp/Effects.swift:8-30`
317+
318+The `NoRedirectDelegate` cancels all HTTP redirects. This prevents a hostile relay from
319+answering `/poll` or `/messages` with a 302 to an attacker-controlled host, which would replay
320+the `Authorization: Bearer` header to the redirect target. The delegate returns `nil`, which
321+hands the 3xx response to the caller as a non-2xx status. The core treats it as a failed
322+request. Correct.
323+
324+**Verdict:** Safe. Good defense.
325+
326+### ⚪ N18. Keychain uses `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` — key never rides backup
327+
328+**Type:** Implementation review (Effects.swift)
329+**Files:** `apple/SkrepkaApp/Effects.swift:352`
330+
331+The identity key is stored with `ThisDeviceOnly` accessibility, meaning it never enters iCloud
332+or iTunes backups. The key is accessible after first unlock (not only when unlocked), so
333+background polling works. This is the correct accessibility class for a messaging identity key.
334+
335+**Verdict:** Safe. Good practice.
336+
337+---
338+
339+## Summary of New Findings
340+
341+| # | Severity | Type | Title |
342+|---|----------|------|-------|
343+| N1 | 🔴 critical | Design gap | No AAD — `to` field unauthenticated, silent targeted censorship |
344+| N2 | ⚪ info | Code hygiene | HKDF info and wire version byte are independent constants |
345+| N3 | 🟡 medium | DoS | appendMessage dedup O(n) — identical-blob replay amplifies to O(n × batch) |
346+| N4 | 🟡 medium | Impl bug | capForwards non-deterministic eviction when retry counts equal |
347+| N5 | 🟡 medium | Design gap | fromServer not verified against connecting peer address |
348+| N6 | 🔵 low | Info | genToken entropy waste (one digit per call) |
349+| N7 | 🟡 medium | Impl concern | seen_ids dedup set unbounded — grows without limit |
350+| N8 | ⚪ info | Safe | Cursor crash window — persisted cursor is safe |
351+| N9 | 🟡 medium | Impl concern | Budget exhaustion livelock — client stuck if relay always serves oversized page |
352+| N10 | ⚪ info | Safe | Profile ts >= replay is a no-op |
353+| N11 | ⚪ info | Safe | Negative ts clamped to 0 is harmless |
354+| N12 | ⚪ info | Safe | ack_ids non-string filtering is correct |
355+| N13 | 🟠 high | Design gap | Gossip amplification 100x — O(100 × |messages|) per request |
356+| N14 | 🟡 medium | Design gap | Offline events don't cancel pending forwards |
357+| N15 | 🟡 medium | Deploy | install.sh checksum is best-effort — missing checksum silently proceeds |
358+| N16 | ⚪ info | Safe | install.sh DOMAIN validation is correct |
359+| N17 | ⚪ info | Safe | Redirect cancellation prevents token exfiltration |
360+| N18 | ⚪ info | Safe | Keychain ThisDeviceOnly is correct |
361+
362+**Critical (1):** N1
363+**High (1):** N13
364+**Medium (6):** N3, N4, N5, N7, N9, N14, N15
365+**Low (1):** N6
366+**Info (7):** N2, N8, N10, N11, N12, N16, N17, N18
367+
368+---
369+
370+## Priorities
371+
372+1. **N1 (critical):** Add AAD to AEAD. This is the most impactful finding — it enables
373+ undetectable censorship by any relay. Requires wire-format change (v0x02).
374+
375+2. **N13 (high):** Document or limit gossip amplification. A single request triggers 100
376+ mailbox scans. Cap newly-online keys that trigger forwards per batch.
377+
378+3. **N9 (medium):** Fix budget exhaustion livelock. Advance cursor to last processed event
379+ instead of restoring to pre-page cursor. This prevents a malicious relay from permanently
380+ wedging the client with oversized poll pages.
381+
382+4. **N7 (medium):** Bound `seen_ids` set. An unbounded dedup set that's rewritten on every
383+ message receipt will degrade performance over time.
384+
385+5. **N3 (medium):** Index `*messages` for dedup. The O(n) scan is a CPU amplification vector.
AUDIT-NEW.mdnew file+816
@@ -0,0 +1,816 @@
1+# Skrepka Security Audit — New Findings (2026-07-15)
2+
3+**Scope:** Exhaustive multi-pass audit of PROTOCOL.md, server.knot, and core/src/ (app.rs, crypto.rs, protocol.rs, model.rs, phonemic.rs, ffi.rs). Findings below are **not** documented in PROTOCOL-REVIEW.md.
4+
5+**Legend:** 🔴 critical · 🟠 high · 🟡 medium · 🔵 low · ⚪ info
6+
7+---
8+
9+## 1. Cryptographic Protocol Weaknesses
10+
11+### 🔴 Q1. HKDF salt omits sender identity — KCI/UKS not prevented by construction
12+
13+**Type:** Design gap (protocol + crypto.rs)
14+**Files:** `core/src/crypto.rs:276-290` (`derive_key`), PROTOCOL.md §3 step 3
15+
16+The HKDF salt is `ephemeral_public || recipient_x25519_public` — both public values, neither identifying the sender. The encryption key is derived entirely from the ephemeral X25519 exchange and the recipient's public key. While the Ed25519 signature inside the AEAD binds the sender, the *key derivation* itself has no sender contribution.
17+
18+This means anyone who knows the recipient's private key can compute the same shared secret from a captured ephemeral public key — there's no sender contribution to the KDF. The signature is the only thing binding the sender, and it's verified *after* AEAD decryption. An attacker who obtains the recipient's private key can decrypt all captured blobs (already noted as "no forward secrecy" in §10), but additionally, because the KDF has no sender binding, an attacker can *replace* the sender identity and signature inside a captured ciphertext by re-encrypting with the same ephemeral key — wait, no, they can't re-encrypt without the shared key. However, if the attacker *has* the recipient's private key, they can decrypt, strip the original sender, forge a new inner buffer with their own sender key + signature over the same `recipient || compressed_len || compressed || padding`, re-encrypt with the derived key, and re-send. The recipient sees a valid message from a different sender. This is a *key compromise impersonation (KCI)* vector: compromising the recipient's key lets an attacker re-attribute captured messages.
19+
20+**Attack scenario:** An attacker records ciphertext between Alice and Bob. Later they compromise Bob's private key. They decrypt the blobs, replace Alice's identity with Mallory's (signing under Mallory's key), re-encrypt, and deliver to Bob. Bob sees messages apparently from Mallory.
21+
22+**Exploitable in practice:** Requires recipient key compromise, which the protocol already considers catastrophic. The KCI vector is an *additional* consequence of that compromise, not a standalone attack. Still, including the sender's public key in the HKDF salt or info would close it by construction.
23+
24+**Suggested fix:** Include `sender_ed25519_public` in the HKDF salt or info: `salt = ephemeral_public || recipient_x25519_public || sender_ed25519_public`. Since the sender pubkey is inside the AEAD, a decryptor already has it before verification, but the key derivation would need it — so the sender pubkey would need to be *outside* the AEAD (as associated data or in the blob header), which changes the wire format. Alternatively, accept this as a documented limitation of v0.1 (the signature already prevents forgery without key compromise).
25+
26+### 🟡 Q2. No associated data (AAD) on AEAD — ciphertext is malleable across contexts
27+
28+**Type:** Design gap (protocol + crypto.rs)
29+**Files:** `core/src/crypto.rs:384-387` (encrypt), `core/src/crypto.rs:434-438` (decrypt)
30+
31+XChaCha20-Poly1305 is used with **no associated data**: `cipher.encrypt(nonce, inner)` and `cipher.decrypt(nonce, ciphertext)`. The version byte, ephemeral public key, and nonce are all in the clear and not authenticated. An attacker who can modify the blob in transit (a malicious relay, or a network attacker without TLS) can flip the version byte or corrupt the ephemeral key without any AEAD failure — the AEAD only protects the ciphertext body.
32+
33+While corrupting these fields will cause decryption/verification to fail downstream (wrong key derivation, wrong version), there's no *cryptographic binding* between the outer envelope fields and the inner ciphertext. A relay could, for example, swap the ephemeral public key from one blob with that of another (keeping the same nonce + ciphertext), and the recipient would get a decryption failure rather than an authentication failure — a distinction that matters for error handling and oracle analysis.
34+
35+More importantly, the `to` field in the envelope is **not** authenticated by the AEAD. A relay can change the `to` field on a stored message, redirecting it to a different recipient. That recipient can't decrypt it (wrong key), but the *original* recipient's message is now gone from the mailbox. This is a denial-of-service vector that doesn't require breaking encryption.
36+
37+**Attack scenario:** A malicious relay swaps the `to` field on Alice's message to Bob, replacing it with Charlie's pubkey. Bob never receives the message. Charlie receives garbage (can't decrypt). Neither side gets an error from the protocol.
38+
39+**Exploitable in practice:** Yes — any relay can do this. However, a relay can already drop messages, so this is equivalent in impact to message loss, which is an accepted limitation.
40+
41+**Suggested fix:** Include `version_byte || ephemeral_public || nonce` as AAD in the AEAD. For the envelope-level `to` field, consider including `recipient_ed25519_public` as AAD (it's already in the signed inner buffer, but AAD would make the envelope integrity-checked too). This is a wire-format change (v0x02).
42+
43+### 🟡 Q3. compressed_len field is attacker-controllable and trusted for slicing
44+
45+**Type:** Implementation concern (crypto.rs)
46+**Files:** `core/src/crypto.rs:449-459`
47+
48+During decryption, `compressed_len` is read from the AEAD-decrypted inner buffer and used to slice the `compressed` region: `let compressed = &inner[100..100 + compressed_len]`. The AEAD protects the integrity of the inner buffer, so this field is authenticated — but only *after* decryption. The check `100 + compressed_len > inner.len()` prevents an out-of-bounds read, but a `compressed_len` that points into the padding region (i.e., `compressed_len < actual_compressed_data_len`) would silently feed truncated zstd data to the decompressor, which would fail. Conversely, `compressed_len > actual_compressed_len` would include padding bytes in the zstd frame, which the decompressor might or might not reject.
49+
50+Since the signature is over `recipient_pub || compressed_len_bytes || compressed || padding`, the signed region includes the `compressed_len` field. A tampered `compressed_len` would break the signature. So this is actually well-protected. The concern is that the signature is verified *after* the `compressed_len` is already used for slicing — but since the slice is only used for decompression *after* signature verification (`crypto.rs:472`), this is safe.
51+
52+**Verdict:** Not a bug after careful analysis — the signature covers `compressed_len` and the decompression happens after verification. Downgraded to info.
53+
54+### ⚪ Q4. zstd decompression bomb window before content-size cap
55+
56+**Type:** Implementation defense-in-depth (crypto.rs)
57+**Files:** `core/src/crypto.rs:303-322`
58+
59+The decompressor caps `window_log_max(21)` (2 MiB window) and output at `MAX_PLAINTEXT_LEN + 1` (1 MiB + 1 byte). This is solid. However, a crafted zstd frame with a small window but very long output (many small blocks) could still cause significant CPU consumption before the 1 MiB + 1 byte limit triggers. The `take(MAX_PLAINTEXT_LEN + 1)` read will read one byte past the cap, forcing a full decompression attempt up to that point. This is bounded at ~1 MiB of output, so CPU is bounded too. This is fine.
60+
61+### 🟡 Q5. CRIME/BREACH surface is wider than documented for profile messages
62+
63+**Type:** Spec gap / design weakness (PROTOCOL.md §3)
64+**Files:** PROTOCOL.md §3, §4; `core/src/protocol.rs:73-91`
65+
66+The spec warns about CRIME/BREACH but says "an attacker cannot make the client re-encrypt a chosen variant of a secret on demand." However, `profile` messages compress `display_name`, `bio`, and `photo` together in one zstd frame. If an attacker can influence part of the profile (e.g., the user sets their display name based on something an attacker controls, like a contact's name echoed back), and the profile is broadcast to contacts, the compressed size could leak information. The attacker would need to:
67+
68+1. Influence part of the plaintext that is compressed alongside a secret
69+2. Observe the resulting blob size (which is bucket-padded, so only the bucket boundary leaks)
70+
71+The padding buckets (256, 512, 1024, ...) are coarse enough that the leak is minimal in practice. But the spec's claim that "an attacker cannot make the client re-encrypt a chosen variant of a secret on demand" is too strong: if an attacker sends messages that cause the user to change their display name or bio (e.g., "change your name to match mine"), the re-broadcast compresses attacker-influenced text alongside the user's photo (which is base64 and thus high-entropy/incompressible). The photo being base64 limits the CRIME surface since it's already nearly random.
72+
73+**Exploitable in practice:** Very unlikely given bucket padding and the difficulty of controlling what text gets compressed alongside secrets.
74+
75+**Suggested fix:** Clarify in the spec that the CRIME/BREACH surface extends to profile broadcasts (where attacker-influenced text and user-controlled secrets are compressed together), not just text messages.
76+
77+---
78+
79+## 2. Server Implementation Bugs
80+
81+### 🟠 Q6. `ackPolledMessages` is called before `doPoll` — races with concurrent sends to the same mailbox
82+
83+**Type:** Implementation bug (server.knot)
84+**Files:** `server.knot:1258-1287` (`handlePoll`), `server.knot:1343-1351` (`ackPolledMessages`)
85+
86+In `handlePoll`, the cursor-ack (`ackPolledMessages`) runs inside the setup atomic block, *before* `doPoll` collects events. If a concurrent `/messages` request arrives between the ack and the collect, the new message is appended with a fresh `receivedAt` that is strictly greater than the acked cursor — so it will be picked up by `doPoll`'s collect. This is actually correct behavior.
87+
88+However, there's a subtler issue: the cursor is clamped to `max(0 (min (max t curSeq) cursor))` before the ack. If the client sends a cursor that is *ahead* of `curSeq` (e.g., from a different relay), the clamp to `max t curSeq` could *lower* the cursor, which means `ackPolledMessages` acks less than the client intended. But this is intentional (the comment explains it).
89+
90+After deeper analysis, the ordering here is actually correct — the ack happens first (deleting old messages), then the collect picks up what's left that's newer than the cursor. The race with concurrent sends is handled by STM: the new message gets a fresh `receivedAt > cursor`, so it's picked up.
91+
92+**Verdict:** Not a bug after careful analysis. Downgraded to info.
93+
94+### 🟡 Q7. `appendMessage` dedup is O(n) per message — quadratic under burst
95+
96+**Type:** Implementation performance issue (server.knot)
97+**Files:** `server.knot:919-937` (`appendMessage`)
98+
99+`appendMessage` checks `any (\\m -> m.toKey == toKey && m.encryptedBlob == encryptedBlob) msgs` — a full scan of `*messages` — on every call. For a batch of 100 messages to different recipients, this is 100 × |messages| comparisons. With `maxMessagesPerRecipient = 10000` and many recipients, this can be tens of thousands of comparisons per message. Under sustained throughput this is quadratic.
100+
101+This is documented in the file header ("Everything is intentionally O(n) over flat lists; performance is sacrificed for clarity"), so it's a known design choice, not a security finding per se. But it creates a **DoS amplification** vector: an authenticated sender can send 100 messages per request (up to 60 requests/min = 6000/min), each triggering a full scan of the mailbox. With 10,000 messages per recipient and many recipients, each scan is O(total_messages), making the CPU cost per request proportional to the entire mailbox size.
102+
103+**Attack scenario:** An attacker authenticates (free — just generate a keypair) and sends 100-message batches of junk to many recipients. Each message in the batch triggers a full `*messages` scan for dedup. With a large mailbox, this can consume significant CPU per request.
104+
105+**Exploitable in practice:** Bounded by rate limits (60/min per IP) and the fact that the attacker must be authenticated. The CPU amplification is real but modest.
106+
107+**Suggested fix:** Index `*messages` by `(toKey, encryptedBlob)` or use a hash-based dedup set.
108+
109+### 🟠 Q8. `maxMessagesPerRecipient` eviction destroys genuine mail under flood
110+
111+**Type:** Design gap (server.knot)
112+**Files:** `server.knot:156-182`, `server.knot:926-937`
113+
114+This is documented in the code comments as a "trade, not a fix," but it constitutes a real attack: an authenticated sender (pubkeys are free to mint) can flood a recipient's mailbox with 10,000 junk messages, pushing out all genuine undelivered messages. The recipient's client only sees junk. The comment says "at least the newest messages always land" — but the newest messages are the *attacker's junk*, not the genuine messages that were already queued.
115+
116+The `MAX_POLL_TOTAL_BYTES` client-side budget (64 MiB) means the client processes a bounded amount per poll, but the server has already destroyed the genuine messages by eviction. The client never sees them.
117+
118+**Attack scenario:**
119+1. Victim is offline for a day. Attacker sends 10,000 messages to victim's pubkey.
120+2. Each message is a valid encrypted blob (attacker generates a keypair and encrypts real messages to the victim — they don't need to be decryptable by the victim, just valid blobs).
121+3. Victim comes online, polls, and gets a page of junk. Their genuine messages were evicted.
122+
123+**Exploitable in practice:** Yes — the attacker needs to be authenticated (free) and send 10,000 messages. At 60 requests/min × 100 messages = 6,000/min, this takes ~2 minutes. The rate limit is per-IP, so the attacker needs multiple IPs or time.
124+
125+**Suggested fix:** This is an accepted design tradeoff in the current implementation. A real fix requires per-sender quotas or pricing. Document it in PROTOCOL.md §10 as a DoS vector, not just a capacity concern.
126+
127+### 🟡 Q9. `capForwards` evicts by retry count, not by age — fresh forwards can be dropped
128+
129+**Type:** Implementation bug (server.knot)
130+**Files:** `server.knot:515-522` (`capForwards`), `server.knot:109-110` (`maxForwardsTotal`)
131+
132+`capForwards` keeps the lowest-retry entries (`take maxForwardsTotal (sortBy (\\f -> f.retries) fwds)`). When the outbox exceeds `maxForwardsTotal = 50000`, the *highest-retry* entries are dropped. The comment says these are "closest to `maxForwardRetries` (about to be abandoned anyway)."
133+
134+But there's a subtle issue: a fresh forward (retries: 0) for a *healthy* peer could be dropped if the outbox is full of fresh forwards (all retries: 0) for *failing* peers. The sort is by `retries` only, not by `retries` then by age or destination health. When many entries share `retries: 0`, the `sortBy` is unstable (Knot's sort may not be stable), so *any* retries-0 entry could be dropped — including one for a healthy peer that would have succeeded on the first try.
135+
136+**Attack scenario:** An attacker floods the forwards outbox by gossipping many fake "online" events, causing `forwardQueuedToServer` to stage forwards for many fake servers. Each fails and increments retries. When `maxForwardsTotal` is reached, genuine forwards for healthy peers may be evicted alongside the junk.
137+
138+**Exploitable in practice:** Requires federation to be enabled and the attacker to be able to reach `/federation/gossip`. The genuine forward would be re-staged on the next presence announce, so the impact is a delivery delay, not permanent loss.
139+
140+**Suggested fix:** Sort by `(retries, age)` or skip entries for peers in backoff before capping.
141+
142+### 🟡 Q10. `genToken` entropy is 4 bits per call — collision-resistant but timing-leaky
143+
144+**Type:** Implementation concern (server.knot)
145+**Files:** `server.knot:532-537`
146+
147+`genToken` generates one hex character per recursive call, drawing from `randomInt 16`. Each call to `randomInt` produces 4 bits. For 48 chars (192 bits), this is 48 calls to `randomInt`. The concern is that `randomInt` on the Knot runtime may not be a CSPRNG — the function signature is `IO {random}` which suggests a runtime-provided random source, but the implementation is not visible here.
148+
149+If `randomInt` uses a non-cryptographic PRNG (e.g., a LCG), the 192-bit tokens would be predictable. The auth challenge tokens and session tokens both use `genToken`. If predictable, an attacker could predict future session tokens after observing some.
150+
151+**Exploitable in practice:** Depends on the Knot runtime's `randomInt` implementation, which is not in this codebase. If it uses `/dev/urandom` or equivalent, this is fine. If it uses a userspace PRNG seeded from a low-entropy source, this is critical.
152+
153+**Suggested fix:** Verify that the Knot runtime's `randomInt` uses a CSPRNG. Document the assumption.
154+
155+### 🟡 Q11. `handleVerifyAuth` challenge lookup is O(n) — timing varies with challenge pool size
156+
157+**Type:** Implementation concern (server.knot)
158+**Files:** `server.knot:1210-1214`
159+
160+The challenge verification does `any (\\c -> same c && c.expiresAt > t && matchIp clientIp c.ip) ch` — a linear scan of `*challenges`. With `maxChallenges = 10000`, this is up to 10,000 comparisons per verify. The timing varies with the position of the matching challenge in the list, which could be observable.
161+
162+Combined with the already-documented token comparison timing side-channel (finding B), this adds another timing channel: the time to find the matching challenge leaks its position in the list, which leaks when it was inserted (roughly when the `/auth/challenge` was called).
163+
164+**Exploitable in practice:** Very low impact — the challenge is consumed immediately after verification, and the timing difference is small relative to network jitter.
165+
166+**Suggested fix:** Index challenges by `(pubkey, challenge)` for O(1) lookup.
167+
168+### 🟡 Q12. `handleVerifyAuth` computes signature verification outside atomic — TOCTOU on challenge
169+
170+**Type:** Implementation gap (server.knot)
171+**Files:** `server.knot:1196-1214`
172+
173+The `sigValid` computation is done outside the atomic block (line 1204-1209), but the challenge lookup is inside the atomic (line 1213). Between computing `sigValid` and the atomic block, the challenge could be consumed by a concurrent `/auth/verify` with the same challenge. But the atomic block re-checks: `any (\\c -> same c && ... && c.expiresAt > t && matchIp clientIp c.ip) ch && sigValid`. The `sigValid` is pre-computed, so the check is: challenge exists AND signature is valid. If the challenge is consumed between the pre-compute and the atomic, the `any` returns false, and the verify fails. This is safe — no TOCTOU.
174+
175+But there's a subtler issue: the `clientIp` for the challenge (set at `/auth/challenge` time) and the `clientIp` for the verify (set at `/auth/verify` time) must match (`matchIp clientIp c.ip`). If the client's IP changes between challenge and verify (mobile network change), the verify fails. This is documented as expected behavior. But if `trustForwardedFor` is true and the client can spoof `X-Forwarded-For`, they could set a different IP on the challenge and verify requests — but they'd need to match, so they'd just set the same spoofed IP on both. This is already documented (finding 8).
176+
177+**Verdict:** Not a new bug. The pre-compute is safe because the atomic block re-validates the challenge's existence.
178+
179+### 🟠 Q13. `revokeOthers` race — concurrent sessions for same pubkey can race on revoke
180+
181+**Type:** Implementation bug (server.knot)
182+**Files:** `server.knot:1219-1231`
183+
184+When `revokeOthers: true`, the handler does:
185+```
186+let pruned = if revokeOthers
187+then filter (\\s -> s.pubkey != pubkey) ses
188+else capPerKey ses pubkey maxSessionsPerKey
189+```
190+
191+This drops *all* other sessions for this pubkey. But if a concurrent `/auth/verify` for the same pubkey is in flight (without `revokeOthers`), it could install a new session between the `filter` and the `union`:
192+
193+1. Verify A (revokeOthers=true) reads `*sessions`, filters out all pubkey sessions → `pruned`
194+2. Verify B (revokeOthers=false) reads `*sessions` (still has the old sessions), adds a new one → commits
195+3. Verify A commits `pruned + newSession` → Verify B's session is lost
196+
197+But wait — both are inside `atomic` blocks, and STM guarantees serializability. If Verify A's atomic block retries after Verify B commits, Verify A re-reads `*sessions` (now including B's session) and re-applies the filter — so B's session would be dropped. This is actually the *intended* behavior of `revokeOthers`: it should drop all other sessions. The "race" is really just STM serializing correctly.
198+
199+**Verdict:** Not a bug. STM handles this correctly. Downgraded to info.
200+
201+### 🟡 Q14. `pruneExpired` presence offline gossip may miss peers that expired in a prior sweep
202+
203+**Type:** Implementation concern (server.knot)
204+**Files:** `server.knot:1614-1633` (`backgroundPrune`)
205+
206+The prune loop snapshots peers *before* the sweep (`prePeers`) and unions with *post-sweep* peers. But `pruneExpired` returns only locally-expired keys that have no other live local session. If a key expired in a *previous* sweep (and was already removed from `*presence`), the current sweep has nothing to return for it. The offline gossip for that key was already sent in the previous sweep. This is correct — each key gets exactly one offline event.
207+
208+**Verdict:** Not a bug. The pre/post union handles the edge case correctly.
209+
210+### 🟡 Q15. `forwardQueuedToServer` budget check reads `*forwards` count but doesn't reserve atomically
211+
212+**Type:** Implementation concern (server.knot)
213+**Files:** `server.knot:1140-1151`
214+
215+`forwardQueuedToServer` computes `budget = max 0 (maxForwardsPerServer - existing)` where `existing = countWhere (\\f -> f.toServer == server) fwds`, then takes `take budget` candidates and unions them into `*forwards`. The whole thing is inside one `atomic` block, so it's serializable — two concurrent calls would serialize, and the second would see the first's additions. This is correct.
216+
217+**Verdict:** Not a bug. STM handles concurrency correctly.
218+
219+---
220+
221+## 3. Client Core Logic Bugs
222+
223+### 🟠 Q16. `ingest_poll` cursor clamping uses `now_ms() + 60_000` — relay can advance cursor beyond actual messages
224+
225+**Type:** Implementation concern (app.rs)
226+**Files:** `core/src/app.rs:1967-1975`
227+
228+The cursor is clamped to `page.cursor.min(now_ms().saturating_add(60_000))`. If a hostile relay sends `cursor = now_ms() + 59_000`, the client accepts it, writes it to disk, and the next poll sends this cursor to the relay. If the client later switches to a *different* relay, the cursor is reset to 0 (line 868) — so this is per-relay and not cross-relay. But on the *same* relay, a hostile relay can send an artificially high cursor, causing the client to ack (delete) all messages up to that cursor on the next poll — even messages the client hasn't seen yet.
229+
230+Wait — the cursor is what the *client* sends to the *server*. The server uses it to ack (delete) messages ≤ cursor. If a relay sends a high cursor in the poll response, the client adopts it and sends it back on the next poll. The relay then deletes everything ≤ that cursor. If there were messages the client never received (because the relay withheld them), they're now permanently lost.
231+
232+But the relay already controls what messages it sends to the client — it could simply withhold messages and never show them. The cursor doesn't give the relay any new power it doesn't already have. The relay can always just not send events. So this is not a new attack vector.
233+
234+However, there's a subtler issue: the cursor is persisted to disk. If the relay sends a high cursor and the client adopts it, then the client reconnects to the same relay after a network interruption, the cursor is still high. Any messages that arrived at the relay during the interruption (with `receivedAt` ≤ the poisoned cursor) are automatically acked and deleted.
235+
236+**Attack scenario:** A malicious relay sends `cursor = now_ms() + 59000` in an empty poll response. The client adopts it and persists it. Later, legitimate messages arrive at the relay with `receivedAt` values below the poisoned cursor. The client's next poll sends the poisoned cursor, and the relay deletes those messages.
237+
238+But wait — the relay assigns `receivedAt` using its own monotonic sequence. If the relay is malicious, it can just not deliver messages. If it's honest but was briefly compromised, the poisoned cursor could cause message loss after recovery. This is a real concern for the "briefly compromised relay" scenario.
239+
240+**Exploitable in practice:** A compromised relay can cause permanent message loss for messages that arrive during the compromise window. The 60-second clamp limits the window, but the cursor persists across reconnections.
241+
242+**Suggested fix:** Only adopt the cursor if the poll response contained events. If `events` is empty and `cursor > model.cursor`, don't advance the cursor — the relay has nothing to tell us, and a cursor advance from an empty page is suspicious.
243+
244+### 🟡 Q17. Delivery ack staleness check uses `ts` not `last_ack_ts` watermark for ordering — race with clock skew
245+
246+**Type:** Implementation concern (app.rs)
247+**Files:** `core/src/app.rs:2116-2134`
248+
249+The ack staleness check compares `ts < c.last_ack_ts`. If two acks arrive in the same poll page from the same sender, with `ts_A = 100` and `ts_B = 200`, and `last_ack_ts = 50`:
250+- Ack A (ts=100): not stale (100 >= 50), applies, sets `last_ack_ts = min(100, now)`
251+- Ack B (ts=200): not stale (200 >= 100), applies, sets `last_ack_ts = min(200, now)`
252+
253+This is correct. But if the page order is reversed (B before A), and `last_ack_ts = 50`:
254+- Ack B (ts=200): not stale, applies, sets `last_ack_ts = min(200, now)`
255+- Ack A (ts=100): stale (100 < min(200, now)), dropped!
256+
257+This means an older ack in the same page as a newer one is dropped if it's processed after the newer one. Since the events are processed in page order (not sorted), a relay could reorder events to cause an older ack to be dropped. The impact is minimal — the older ack would have acked fewer or the same messages.
258+
259+**Exploitable in practice:** A malicious relay could reorder events to suppress a specific ack, but it could also just drop the event entirely. Low impact.
260+
261+**Suggested fix:** Process acks in `ts` order within a page, or collect all acks from a sender and apply the highest-ts one.
262+
263+### 🟡 Q18. `seen_ids` dedup set is built lazily from conversation — misses messages trimmed by `MAX_MESSAGES_PER_PEER`
264+
265+**Type:** Implementation bug (app.rs)
266+**Files:** `core/src/app.rs:2086-2092`
267+
268+The dedup set is built from the current conversation: `convo.iter().map(|m| m.id.clone()).collect()`. But `trim_history` may have aged out older messages (keeping only the most recent 1000). A replayed message whose `id` matches an aged-out message would pass the dedup check and be re-inserted into the conversation. The message would appear as a new message (with its original `ts`), potentially at the top of the conversation list if `ts` is high.
269+
270+**Attack scenario:** An attacker captures a message blob (by being a relay, or by gossip redirect), waits until the recipient's conversation has been trimmed past that message (1000+ messages later), then replays the blob. The dedup check misses it (the `id` is no longer in the trimmed conversation), and the message is re-inserted. The message appears as a new old-dated message.
271+
272+**Exploitable in practice:** Requires the attacker to have captured the blob and to wait for 1000+ messages. The replayed message would be inserted at its original `ts` position, so it wouldn't appear at the bottom of the chat — it would appear in its chronological position. The user might not notice. The impact is minimal — it's an old message reappearing, not new content.
273+
274+**Suggested fix:** Maintain a persistent dedup set (not derived from the trimmed conversation), or store the set of seen IDs separately from the message history.
275+
276+### 🟡 Q19. Profile `ts` staleness check uses `>=` — equal-timestamp replay overwrites with identical content (harmless) but also overwrites with *different* content if sender crafts it
277+
278+**Type:** Implementation concern (app.rs)
279+**Files:** `core/src/app.rs:2174-2179`
280+
281+The profile staleness check uses `ts >= c.last_profile_ts`, meaning a replayed profile at the *same* `ts` is accepted. The comment says "a replayed profile at the same `ts` overwrites with identical content, which is a no-op in practice." But this assumes the replayed blob has the same content. An attacker who captures a profile blob can modify the *inner* content (display_name, bio, photo) and re-encrypt it with the same `ts` — but they'd need to sign it, and they can only sign with their own key. The recipient would see it coming from the same sender (the original profile sender), but the signature would be from the original sender's key... wait, no. The attacker can't re-sign with the original sender's key unless they have it.
282+
283+Actually, the attacker *replays the exact same blob* — same ciphertext, same inner content, same signature. The `ts` is the same, and the content is the same. So it's truly a no-op. The attacker can't modify the content without breaking the signature. This is safe.
284+
285+**Verdict:** Not a bug. The `>=` is correct and the replay is a true no-op.
286+
287+### 🟡 Q20. `flush_next` batch-building skips items for different recipients — outbox may have interleaved items stuck behind a large batch
288+
289+**Type:** Implementation concern (app.rs)
290+**Files:** `core/src/app.rs:1818-1853`
291+
292+The batch stops at the first recipient change (`if recipient.as_ref().is_some_and(|r| r != &item.recipient) { break }`). If the outbox has items for recipient A followed by items for recipient B, and A's batch is being built, B's items wait. This is by design (FIFO fairness). But if A's send fails repeatedly, B's items are stuck behind A's retries. The `retry_batch` function charges retries and eventually drops A's items (after `MAX_OUTBOX_RETRIES` or `OUTBOX_TTL_MS`), unblocking B.
293+
294+This is correct behavior, not a bug. The FIFO ordering ensures fairness at the cost of head-of-line blocking, which is the right tradeoff for a messaging app (you don't want a failed send to one peer to reorder sends to another).
295+
296+**Verdict:** Not a bug. Downgraded to info.
297+
298+### 🟡 Q21. `encrypt_for` uses `OsRng` — no deterministic test path, but also no RNG failure handling
299+
300+**Type:** Implementation concern (app.rs)
301+**Files:** `core/src/app.rs:2227-2231`
302+
303+`encrypt_for` uses `let mut rng = rand_core::OsRng;` and passes it to `crypto::encrypt`. If `OsRng` fails (e.g., the OS entropy pool is exhausted on a freshly booted device), `rng.fill_bytes` would panic or return an error. `crypto::encrypt` calls `rng.fill_bytes` in multiple places (`eph_priv`, `padding`, `nonce`), and these would propagate the error up as `CryptoError::Encrypt` (since `fill_bytes` on `OsRng` infallibly fills — it uses `/dev/urandom` which never blocks on modern systems).
304+
305+**Verdict:** `OsRng` is infallible on iOS/macOS (it uses `SecRandomCopyBytes` which always succeeds). Not a bug.
306+
307+---
308+
309+## 4. Wire Format / Parsing Issues
310+
311+### 🟠 Q22. `isValidHex` uses `chars` (set semantics) — deduplication hides length-based attacks
312+
313+**Type:** Implementation bug (server.knot)
314+**Files:** `server.knot:542-545`
315+
316+```knot
317+isValidHex = \s ->
318+ let n = length s in
319+ n > 0 && n / 2 * 2 == n && all (\c -> contains c "0123456789abcdef") (chars s)
320+```
321+
322+`chars s` returns the **distinct** character set (relations dedupe). So for a string like `"aaaa"`, `chars` returns `{"a"}`, and the `all` check passes. The `length` check (`n / 2 * 2 == n`) ensures even length. But `chars` deduplication means the `all` check is O(distinct chars) not O(string length), which is a performance optimization, not a correctness issue — the set membership test covers all characters.
323+
324+This is actually correct: if all *distinct* characters are in the hex alphabet, then all characters are in the hex alphabet. The dedup just makes it faster.
325+
326+**Verdict:** Not a bug. The set semantics are correct for membership checking.
327+
328+### 🟡 Q23. `PubkeyHex` type accepts non-curve-point 32-byte hex values at the route boundary
329+
330+**Type:** Implementation gap (server.knot)
331+**Files:** `server.knot:322` (`type PubkeyHex = Text where isValidExactHex 64`)
332+
333+The `PubkeyHex` type only checks that the string is 64 lowercase hex characters — it doesn't verify the decoded bytes form a valid Ed25519 public key (a valid curve point). The client's `try_parse_pubkey` (`phonemic.rs:140-144`) does check this via `VerifyingKey::from_bytes`, but the server accepts any 64-hex-char string as a valid `PubkeyHex`.
334+
335+This means a client can send a message to a `to` address that is 32 bytes of hex but not a valid Ed25519 public key. The server stores and forwards the blob. The "recipient" can never decrypt it because `ed25519_pk_to_x25519` would fail on the invalid key. The blob sits in the mailbox until TTL expiry, consuming storage.
336+
337+**Attack scenario:** An attacker sends 100 messages per batch to invalid pubkeys. Each is stored for 30 days. With 60 requests/min × 100 = 6000/min, after 1 hour that's 360,000 junk messages. Each counts against `maxMessagesPerRecipient` for the target — but the target is a non-existent key, so nobody is affected. The real concern is storage consumption: 360,000 × minBlobLen bytes = ~62 MB per hour per attacker IP.
338+
339+**Exploitable in practice:** The rate limit (60/min per IP) bounds the rate. The 30-day TTL bounds the total. `maxMessagesPerRecipient = 10000` per non-existent key limits per-key. The total storage impact is bounded.
340+
341+**Suggested fix:** The server could optionally validate that `to` is a valid curve point, but this adds CPU cost and the spec doesn't require it (the server can't decrypt anyway). Document as accepted.
342+
343+### 🟡 Q24. `parse_payload` `ts` field defaults to 0 when missing — allows pre-epoch ordering manipulation
344+
345+**Type:** Implementation concern (protocol.rs)
346+**Files:** `core/src/protocol.rs:100`
347+
348+`let ts = v.get("ts").and_then(|t| t.as_i64()).unwrap_or(0);`
349+
350+If a peer omits the `ts` field (or sets it to a non-integer), it defaults to 0. In `ingest_poll`, `ts` is clamped: `parsed.ts.max(0).min(now.saturating_add(MAX_FUTURE_SKEW_MS))`. A `ts` of 0 passes the `max(0)` check and is used as-is. A message with `ts = 0` would be inserted at position 0 in the conversation (earliest), and `trim_history` would age it out first. This is harmless.
351+
352+But a peer could set `ts` to a very large negative number (e.g., `i64::MIN`), which would be clamped to 0. Same result. Or a peer could set `ts` to a float (e.g., `1.5`), which `as_i64()` returns `None` for, defaulting to 0. All of these are harmless because the clamping handles them.
353+
354+**Verdict:** Not a bug. The clamping in `ingest_poll` handles all edge cases.
355+
356+### 🟡 Q25. JSON parsing of poll response uses `unwrap_or_default()` — malformed JSON silently produces empty page
357+
358+**Type:** Implementation concern (app.rs)
359+**Files:** `core/src/app.rs:1309`
360+
361+`let parsed: PollResp = serde_json::from_slice(&bytes).unwrap_or_default();`
362+
363+If the relay sends malformed JSON (or a valid JSON with wrong types), this silently defaults to an empty `PollResp` (empty events, cursor 0). The cursor of 0 would be adopted (clamped), and the next poll would re-fetch from the beginning — re-receiving all messages the client has already seen. The client's dedup (by `id` for text, by `ts` for acks and profiles) would handle the duplicates, but it wastes bandwidth and CPU.
364+
365+A hostile relay could exploit this by sending malformed JSON instead of a real response, forcing the client to re-poll from cursor 0 repeatedly. But the client would just re-process old messages (all deduped) and eventually get the real cursor from a subsequent response. The impact is wasted bandwidth, not data loss.
366+
367+**Suggested fix:** If JSON parsing fails, don't adopt the cursor and back off instead of treating it as an empty page.
368+
369+### 🟡 Q26. `delivery.ack` ack_ids filtering silently drops non-string entries — count vs. filtered count mismatch
370+
371+**Type:** Implementation concern (protocol.rs)
372+**Files:** `core/src/protocol.rs:116-126`
373+
374+The ack_ids parsing filters non-string entries: `raw.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()`. The raw count check (`raw.len() > MAX_ACK_IDS`) happens before filtering, but the post-filter check (`ack_ids.len() > MAX_ACK_IDS`) also happens. However, between the two checks, a peer could pack an array with `MAX_ACK_IDS` entries, some of which are non-strings (e.g., numbers). The raw check passes (`MAX_ACK_IDS` entries), the filter drops some, and the post-check passes (fewer than `MAX_ACK_IDS` strings). This is correct — the filtered count is what matters.
375+
376+But there's a subtlety: a peer could pack `MAX_ACK_IDS + 1` raw entries, all non-strings. The raw check fails (`MAX_ACK_IDS + 1 > MAX_ACK_IDS`), and the payload is dropped. But if the peer packs `MAX_ACK_IDS` raw entries, all non-strings, the raw check passes, the filter drops all of them, and `ack_ids` is empty. The empty ack is then sent to the sender, who processes it (marking nothing as delivered). This is harmless.
377+
378+**Verdict:** Not a bug. The double-check handles this correctly.
379+
380+---
381+
382+## 5. Federation Protocol Issues
383+
384+### 🟠 Q27. Gossip amplification — single `online` event triggers forward of *all* queued messages for a key
385+
386+**Type:** Design gap (server.knot)
387+**Files:** `server.knot:1522-1556` (`handleRecvGossip`)
388+
389+When a `online` event arrives for a key, `forwardQueuedToServer` sweeps the entire mailbox for messages to that key and stages them for forwarding. An attacker can send a single gossip request with 100 `online` events for 100 different keys, each triggering a full mailbox scan and forward staging. This is O(100 × |messages|) per request.
390+
391+More importantly, the attacker can repeatedly send `online` events for the same key. Each time, `forwardQueuedToServer` checks `notYetForwarded` and `notYetDelivered` to avoid duplicates, so re-sending the same event is a no-op for already-forwarded messages. But if the forward failed (404 from the attacker's own server, which doesn't actually have the key online), the forward entry stays in `*forwards` with `retries: 0`. A re-announce doesn't re-stage it (it's already in `*forwards`), but `retryPendingForwards` will retry it on the next sweep.
392+
393+The amplification is: one gossip request (100 events) → 100 mailbox scans + potentially 100 forward entries. Each forward entry triggers an HTTP POST to the `fromServer`. If the `fromServer` is the attacker, they receive 100 forwarded blobs — each a copy of the victim's ciphertext. This is the same as the documented gossip-redirect attack (#2), but the amplification factor (100 events per request) is not documented.
394+
395+**Exploitable in practice:** Same as finding #2, but with 100× amplification per request. The rate limit (60/min per IP) bounds the rate to 6,000 redirected keys per minute per IP.
396+
397+**Suggested fix:** Document the amplification factor. Consider limiting the number of newly-online keys per gossip batch that trigger forwards.
398+
399+### 🟡 Q28. Federation `offline` events don't trigger cleanup of pending forwards
400+
401+**Type:** Design gap (server.knot)
402+**Files:** `server.knot:1535-1541`
403+
404+When an `offline` event is received, the presence row for that `(pubkey, fromServer)` is removed, but pending forwards to that server are *not* cancelled. The forwards stay in `*forwards` and will be retried by `retryPendingForwards` until they either succeed (unlikely — the key is offline at that server), fail 10 times, or the message TTL expires.
405+
406+If the offline event was legitimate (the key actually went offline at that server), the forwards will waste retry attempts for up to `retryBackoffMax × maxForwardRetries` = 8 min × 10 = 80 minutes. During that time, the forwards occupy slots in `maxForwardsPerServer` and `maxForwardsTotal`.
407+
408+If the offline event was spoofed by an attacker (to clear a presence row), the forwards are still retried — and if the key is actually still online at that server, the forward succeeds on retry (the `no_presence` gate would pass because the key has a local session). But the presence row was removed, so `hasLocalPresence` at the peer would fail... wait, the presence row is on *our* side. The peer's presence is independent. The forward goes to the peer, and the peer checks its own local presence. If the key is still online at the peer, the forward succeeds. If not, it gets 404 and is retried.
409+
410+**Exploitable in practice:** An attacker can send fake `offline` events to clear presence rows, but the forwards continue independently. The impact is minimal — presence rows and forwards are decoupled.
411+
412+**Suggested fix:** When processing an `offline` event, optionally drop pending forwards to that server for the offline key (they'll never succeed if the key is truly offline there). But this could cause message loss if the offline event is spoofed. Better to leave as-is.
413+
414+### 🟡 Q29. `fromServer` in gossip events is not verified against the connecting peer's actual address
415+
416+**Type:** Design gap (server.knot)
417+**Files:** `server.knot:1511-1556` (`handleRecvGossip`)
418+
419+The `fromServer` field is a `ServerName` (validated for format), but it's not verified against the actual IP address of the connecting peer. An attacker can connect from IP `1.2.3.4` and claim `fromServer: "legit-relay.example.com"`. The server writes presence rows with `server: "legit-relay.example.com"` and forwards queued messages there. This is the same as the documented gossip-redirect attack (#2), but with a twist: the attacker can claim to *be* a legitimate relay, poisoning the presence table with the legitimate relay's hostname.
420+
421+When the *real* `legit-relay.example.com` later gossips, its events update the presence rows. But the attacker's rows may still be present (different `expiresAt`), and the server has multiple presence rows for the same key on different servers — which is by design. The attacker's row points to `legit-relay.example.com`, so forwards go to the legitimate relay, which doesn't have the key online — and returns 404. The legitimate relay gets junk forward requests.
422+
423+**Exploitable in practice:** This is a variant of the documented gossip-redirect, but with the attacker impersonating a legitimate relay. The legitimate relay receives spurious forward requests (bounded by `forwardRateLimit`).
424+
425+**Suggested fix:** Verify that the connecting peer's IP resolves to the claimed `fromServer` hostname (reverse DNS + forward DNS). This is the standard check for SMTP-like protocols. Blocked on `fetch` not exposing DNS.
426+
427+---
428+
429+## 6. Rate Limiting / DoS Vectors
430+
431+### 🟠 Q30. `/auth/challenge` has no per-pubkey rate limit beyond `maxChallengesPerKey = 5`
432+
433+**Type:** Design gap (server.knot)
434+**Files:** `server.knot:1166-1188` (`handleChallenge`)
435+
436+The challenge pool is globally capped at `maxChallenges = 10000` and per-key at `maxChallengesPerKey = 5`, with per-IP rate limiting at `authRateLimit = 60/min`. An attacker with many IPs (or behind a proxy with XFF spoofing when `trustForwardedFor = true`) can fill the challenge pool with challenges for random pubkeys. Each challenge is 60 seconds, and the pool LRU-evicts. But 10,000 challenges × 60 seconds means the pool is always full, and legitimate challenges are evicted.
437+
438+The comment says this is handled by LRU eviction: "an abandoned challenge may be dropped before its 60 s window elapses, which costs the affected client one retry." So the attacker can cause clients to retry their challenge/verify flow, adding latency but not blocking authentication.
439+
440+**Exploitable in practice:** With XFF spoofing (finding 8), the attacker gets unlimited rate-limit buckets. 60/min per IP × many IPs = thousands of challenges per minute. The pool (10,000) fills in ~3 minutes. After that, legitimate challenges are evicted, and clients must retry. This is a soft DoS — authentication still works but with extra latency.
441+
442+**Suggested fix:** Add a global rate on challenge creation (not just per-IP). Or cap the challenge creation rate per second regardless of source.
443+
444+### 🟡 Q31. `/messages` batch of 100 × 40 MiB would be rejected by body cap, but the rate limit counts requests not messages
445+
446+**Type:** Design gap (server.knot)
447+**Files:** `server.knot:233-234` (`sendRateLimit`), `server.knot:454` (`sendLimit`)
448+
449+The send rate limit is 60 requests/min per IP. Each request can carry up to 100 messages (bounded by the body cap). So the effective message rate is 60 × (body_cap / avg_blob_size) per minute. With a 42 MiB body cap and 42 KiB blobs, that's ~1000 messages per request × 60 = 60,000 messages/min. With 173-byte minimum blobs, it's ~251,000 messages/min per IP.
450+
451+This is documented in the code comment ("the effective message ceiling is higher"), but it means the per-IP rate limit is much less protective than it appears. An attacker with 10 IPs can send millions of messages per minute.
452+
453+**Exploitable in practice:** Bounded by the body cap (42 MiB) and the rate limit (60/min). The total data rate per IP is ~42 MiB × 60 = 2.5 GiB/min, which is significant.
454+
455+**Suggested fix:** Consider a per-message rate limit in addition to the per-request rate limit. Or reduce `maxBatchSize` for unauthenticated senders (but all senders are "authenticated" — they just have free keypairs).
456+
457+### 🟡 Q32. `maxSessions = 10000` LRU eviction is Sybil-floodable — documented but not in PROTOCOL.md §10
458+
459+**Type:** Design gap (server.knot)
460+**Files:** `server.knot:117-126`
461+
462+The code comment documents this: "global LRU eviction is Sybil-floodable: an attacker with enough IPs can evict live users' sessions." But it's not mentioned in PROTOCOL.md §10 as a DoS vector. An attacker with enough IPs can:
463+1. Generate 10,000 keypairs
464+2. Authenticate each one (60/min per IP, so ~167 IPs needed for one batch)
465+3. Fill the session pool
466+4. Legitimate users' sessions are evicted (they get 401 and must re-authenticate)
467+
468+This is a soft DoS — users re-authenticate automatically. But it adds latency and load.
469+
470+**Suggested fix:** Document in PROTOCOL.md §10. Consider per-IP session caps (rejected in the code comment as "collapses without XFF, punishes carrier-NAT") or a global session creation rate limit.
471+
472+### 🟡 Q33. `maxPresence = 100000` — gossip can fill presence table with fake keys
473+
474+**Type:** Design gap (server.knot)
475+**Files:** `server.knot:139-155`
476+
477+An attacker can send gossip with 100 fake `online` events per request (60/min per IP). Each event adds a presence row. With `maxPresencePerServer = 5000` per peer, a single attacker IP can add 5000 rows. With `maxPresence = 100000` globally, it takes 20 distinct attacker hostnames to fill the pool. Once full, legitimate presence rows are evicted by `capList` (LRU by `expiresAt`).
478+
479+Since gossip rows have `onlineGossipTtl = 90 min` and local rows have `sessionExpiry = 60 min`, local rows are evicted first (they expire sooner). The code handles this by exempting local rows from the cap:
480+
481+```
482+let localRows = filter (\\p -> isLocalServer p.server) merged
483+let remoteRows = filter (\\p -> not isLocalServer p.server) merged
484+*presence = union localRows (capList (capPerServer remoteRows from maxPresencePerServer) maxPresence)
485+```
486+
487+So local rows are always kept, and only remote (gossiped) rows are capped. An attacker can fill the remote pool, but local users' presence is never evicted. The impact is that legitimate *remote* presence (from honest peers) is evicted, causing forwards to those peers to fail.
488+
489+**Exploitable in practice:** Requires federation to be enabled and many distinct attacker hostnames (each passing `isBadServerName`). Bounded by `maxPresencePerServer = 5000` per hostname.
490+
491+**Suggested fix:** Document in PROTOCOL.md §10. Consider a per-request limit on newly-added presence rows (currently capped at `maxGossipEvents = 100` per request, which is already a bound).
492+
493+---
494+
495+## 7. Authentication / Session Management
496+
497+### 🟠 Q34. Session token is not bound to the pubkey — token theft grants full impersonation
498+
499+**Type:** Design gap (server.knot)
500+**Files:** `server.knot:356` (`type Session = {token: Text, pubkey: PubkeyHex, expiresAt: Timestamp, ip: Text}`)
501+
502+The session token is a bearer token — anyone who has it can use it. It's bound to `(pubkey, ip, expiry)`, but the `pubkey` binding is only used for *authorization* (e.g., rejecting self-sends), not for *authenticating the request*. The `authedPubkey` function looks up the token and returns the associated pubkey — it doesn't verify that the requester is the same entity that originally authenticated.
503+
504+If an attacker steals a bearer token (e.g., from a compromised proxy's access log), they can use it from the same IP (or any IP if `trustForwardedFor = false` or IP binding is disabled) to poll the victim's mailbox and send messages as the victim.
505+
506+The IP binding (`matchIp`) is the only thing preventing token replay from a different IP. But:
507+- If `trustForwardedFor = false`, IP binding is disabled (empty IP → wildcard).
508+- If the attacker is behind the same NAT/proxy as the victim, they share the IP.
509+- The token is sent in `Authorization: Bearer ***` over HTTP (TLS in production), so it's only protected by TLS.
510+
511+**Attack scenario:** A compromised proxy logs the `Authorization` header. The attacker uses the token from the same IP (or any IP if XFF is off) to poll the victim's mailbox and read their (encrypted) messages. The attacker can also send messages as the victim.
512+
513+**Exploitable in practice:** Requires token theft (compromised proxy, log exposure, or MITM without TLS). The E2E encryption means the attacker can't read message content, but they can send messages as the victim (the server doesn't verify the sender's identity on `/messages` — it only uses the bearer token's pubkey for self-send rejection).
514+
515+**Suggested fix:** This is inherent to bearer-token auth. The spec should document that a stolen bearer token grants full impersonation (modulo IP binding) until it expires (1 hour). Consider shorter token lifetimes or per-request signing.
516+
517+### 🟡 Q35. `revokeOthers: true` doesn't revoke challenges — stale challenge can be used to create a new session after revocation
518+
519+**Type:** Implementation gap (server.knot)
520+**Files:** `server.knot:1217-1221`
521+
522+When `revokeOthers: true`, the handler drops all other sessions for the pubkey, but it does *not* drop outstanding challenges for the pubkey. A challenge issued before the `revokeOthers` call is still valid for 60 seconds. An attacker who captured a challenge (e.g., by observing the `/auth/challenge` response) can still complete the `/auth/verify` flow and get a new session — after the `revokeOthers` call.
523+
524+But wait — the `/auth/verify` handler also checks the challenge's IP binding (`matchIp clientIp c.ip`). If the attacker is on a different IP, the verify fails. If they're on the same IP (NAT/proxy), it succeeds. And the new session is *not* revoked by the `revokeOthers` call (it was made after the call).
525+
526+**Attack scenario:**
527+1. Alice requests a challenge (IP 1.2.3.4)
528+2. Attacker captures the challenge response (from a compromised proxy)
529+3. Alice calls `/auth/verify` with `revokeOthers: true` (to log out other devices)
530+4. Attacker uses the captured challenge to call `/auth/verify` from IP 1.2.3.4 (same NAT)
531+5. Attacker gets a new session — not revoked by step 3
532+
533+**Exploitable in practice:** Requires the attacker to be on the same IP as the victim and to have captured the challenge. The 60-second challenge expiry limits the window.
534+
535+**Suggested fix:** When `revokeOthers: true`, also drop outstanding challenges for the pubkey.
536+
537+### 🟡 Q36. Challenge IP binding uses `clientIpOf` which reads XFF — same spoofing surface as session IP binding
538+
539+**Type:** Implementation gap (server.knot)
540+**Files:** `server.knot:1180-1188` (`handleChallenge`), `server.knot:781-784` (`clientIpOf`)
541+
542+The challenge's `ip` is set to `clientIpOf xForwardedFor`, which is the last XFF hop when `trustForwardedFor` is true. If the attacker can spoof XFF (finding 8), they can set any IP on the challenge, and then use the same spoofed IP on the verify — bypassing the IP binding entirely.
543+
544+This is the same XFF spoofing issue already documented (finding 8), but it applies to the challenge IP binding as well. The challenge IP binding is supposed to prevent a challenge obtained by a relay from being used by a different client — but with XFF spoofing, this protection is nullified.
545+
546+**Verdict:** Same as finding 8. Not a new issue, but the challenge IP binding is an additional affected surface.
547+
548+### 🟡 Q37. No rate limit on `/auth/verify` failures — unlimited signature verification attempts
549+
550+**Type:** Design gap (server.knot)
551+**Files:** `server.knot:475` (`authLimit = 60/min`)
552+
553+The `/auth/verify` route shares the `authLimit` (60/min per IP) with `/auth/challenge`. An attacker can attempt 60 signature verifications per minute per IP. Each verification is an Ed25519 signature check, which is relatively expensive (~0.1ms). 60/min = ~0.01 CPU-seconds/min — negligible.
554+
555+But the attacker doesn't need to *succeed* at verification to cause work. Each `/auth/verify` call triggers:
556+1. Hex decode of pubkey (32 bytes) and signature (64 bytes)
557+2. Ed25519 signature verification (expensive: ~0.1ms)
558+3. Linear scan of `*challenges` (up to 10,000 entries)
559+
560+The challenge scan only happens if the signature is valid (short-circuit), so a failed signature still costs the Ed25519 verification. With 60/min per IP and many IPs, the attacker can cause non-trivial CPU load.
561+
562+**Exploitable in practice:** Bounded by rate limits. The Ed25519 verification is fast. Not a practical DoS vector.
563+
564+**Suggested fix:** Consider returning 401 immediately if no matching challenge exists (before verifying the signature), to save the expensive verification when the challenge is invalid. Currently the order is: check challenge exists AND signature valid, with `sigValid` pre-computed. If the challenge doesn't exist, the `any` short-circuits and `sigValid` is never used — but `sigValid` was already computed outside the atomic. So the CPU cost is paid regardless. Move the signature verification inside the atomic block, after the challenge existence check, to short-circuit on missing challenges.
565+
566+Wait, actually looking at the code more carefully:
567+
568+```knot
569+let sigValid = case bytesFromHex pubkey of ... -- computed OUTSIDE atomic
570+result <- atomic (do
571+ ...
572+ let valid = any (\\c -> same c && ...) ch && sigValid -- sigValid used here
573+```
574+
575+`sigValid` is pre-computed, so the Ed25519 verification happens on every `/auth/verify` call regardless of whether a matching challenge exists. This is a CPU waste. If the challenge doesn't exist, the `any` returns false and `sigValid` is irrelevant — but the CPU was already spent.
576+
577+**Suggested fix:** Move the signature verification inside the atomic block, after the challenge existence check. Or compute it lazily (only if the challenge exists).
578+
579+### 🟡 Q38. `handleVerifyAuth` logs session eviction count but not the attacker — no audit trail for Sybil floods
580+
581+**Type:** Implementation gap (server.knot)
582+**Files:** `server.knot:1240`
583+
584+The handler logs `logWarn ("session pool full: evicted N session(s)...")` but doesn't log the pubkey or IP of the request that caused the eviction. An operator investigating a Sybil flood would see warnings but not know which pubkeys or IPs are responsible. This makes it hard to block the attacker.
585+
586+**Suggested fix:** Include the requesting pubkey and IP in the log message.
587+
588+---
589+
590+## 8. Spec Gaps
591+
592+### 🟡 Q39. Spec doesn't specify behavior for empty `encryptedBlob`
593+
594+**Type:** Spec gap (PROTOCOL.md §4)
595+**Files:** PROTOCOL.md §4 (Envelope), `server.knot:319-320` (`isValidBlobHex`)
596+
597+The spec says `encryptedBlob` is "Hex-encoded blob from §3" but doesn't say what happens if it's empty or shorter than `minBlobLen`. The server enforces `isValidBlobHex` (min 346 chars = 173 bytes), but the spec doesn't mention this minimum. An independent implementation might accept shorter blobs and store garbage.
598+
599+**Suggested fix:** Add the minimum blob length to PROTOCOL.md §4 or §5.
600+
601+### 🟡 Q40. Spec doesn't specify JSON encoding requirements (UTF-8, key ordering, whitespace)
602+
603+**Type:** Spec gap (PROTOCOL.md §4, §5)
604+**Files:** PROTOCOL.md §4, §5
605+
606+The spec shows JSON examples but doesn't specify:
607+- Whether keys must be in a specific order (the server uses `GossipEvent` with `eventType` and `pubkey` — order matters for some JSON parsers)
608+- Whether whitespace is allowed in the JSON (a client could send pretty-printed JSON)
609+- Whether UTF-8 is required (JSON spec allows UTF-16/UTF-32, but the server likely only handles UTF-8)
610+- Whether duplicate keys are allowed (JSON allows them, but behavior is undefined)
611+
612+These could cause implementation divergence. For example, a client that sends `{"encryptedBlob": "...", "to": "..."}` (keys reversed) would work with most JSON parsers but might confuse a strict implementation.
613+
614+**Suggested fix:** Specify that all JSON is UTF-8, keys are unordered, whitespace is allowed, and duplicate keys are undefined behavior (last one wins).
615+
616+### 🟡 Q41. Spec doesn't specify what `cursor: 0` means vs absent cursor
617+
618+**Type:** Spec gap (PROTOCOL.md §6)
619+**Files:** PROTOCOL.md §6 (`POST /poll`)
620+
621+The spec says `cursor` is "the maximum `receivedAt` value the client has already seen (`0` on first poll)." But what if the client sends `cursor: -1` or omits the field? The server's `handlePoll` clamps to `max 0 (...)`, so negative cursors become 0. But the spec doesn't say this.
622+
623+**Suggested fix:** Specify that cursor must be ≥ 0, and that 0 means "first poll" (no messages acked).
624+
625+### 🟡 Q42. Spec doesn't specify `to` field validation requirements
626+
627+**Type:** Spec gap (PROTOCOL.md §4, §6)
628+**Files:** PROTOCOL.md §4 (Envelope), PROTOCOL.md §6 (`POST /messages`)
629+
630+The spec says `to` is "Recipient's Ed25519 public key (64 lowercase hex)" but doesn't say:
631+- Must it be a valid Ed25519 public key (a valid curve point)?
632+- Is the server required to validate it?
633+- What happens if it's the sender's own key? (The server rejects this as `self_send`, but the spec doesn't say the server should check this — it's an implementation detail)
634+
635+**Suggested fix:** Specify that `to` must be 64 lowercase hex chars (the server validates format only), and that the server rejects self-sends with `400 self_send`.
636+
637+### 🟡 Q43. Spec doesn't specify the format of `challenge` in auth
638+
639+**Type:** Spec gap (PROTOCOL.md §6)
640+**Files:** PROTOCOL.md §6 (Authentication)
641+
642+The spec says the challenge is "a random challenge string" but doesn't specify:
643+- Its length or entropy
644+- Its format (hex? base64? raw text?)
645+- Whether it must be unique per challenge request
646+
647+The server uses `genToken 48` (48 hex chars, 192 bits). The client signs it as part of the auth message. If an independent implementation uses a shorter challenge (e.g., 8 chars), it would be brute-forceable.
648+
649+**Suggested fix:** Specify minimum challenge length (e.g., 32 hex chars / 128 bits) and format (lowercase hex).
650+
651+### ⚪ Q44. Spec doesn't specify `revokeOthers` semantics precisely
652+
653+**Type:** Spec gap (PROTOCOL.md §6)
654+**Files:** PROTOCOL.md §6 (Authentication step 3)
655+
656+The spec says `revokeOthers: true` "invalidates every other live session for this pubkey" but doesn't specify:
657+- Whether it also invalidates challenges (it doesn't — see Q35)
658+- Whether the revocation is immediate or eventual (it's immediate within the atomic block)
659+- What happens to in-flight requests using a revoked session (they get 401 on the next request)
660+
661+**Suggested fix:** Clarify that only sessions (not challenges) are revoked, revocation is immediate, and revoked sessions get 401 on their next request.
662+
663+---
664+
665+## 9. Privacy / Metadata Leaks
666+
667+### 🟠 Q45. Blob size reveals message type to the server
668+
669+**Type:** Privacy leak (protocol + design)
670+**Files:** PROTOCOL.md §3 (Length Padding), `core/src/protocol.rs:60-91`
671+
672+The padding buckets (256, 512, 1024, ...) obscure the exact compressed size, but the bucket boundaries still leak information about the message type:
673+- A `delivery.ack` with a single ID is tiny (~50 bytes JSON) → compresses to ~40 bytes → inner buffer ~233 bytes → blob ~233 + 16 (AEAD) = 249 → rounds to 256 bucket
674+- A `text` message with a short body is ~100 bytes JSON → compresses to ~80 bytes → inner buffer ~273 bytes → blob ~289 → rounds to 512 bucket
675+- A `profile` with a 64 KiB photo → compresses poorly (base64 is high-entropy) → inner buffer ~65 KiB → rounds to 65536 bucket
676+
677+So the server can distinguish:
678+- A `delivery.ack` (256 bucket) from a `text` (512 bucket) from a `profile` with photo (65536 bucket)
679+- A short text (512) from a long text (1024+)
680+
681+This is a metadata leak beyond what's documented. The spec says "message length is only ever obscured *upward*" and "blob size still distinguishes a one-word reply from a pasted document" — but it doesn't mention that blob size reveals the *message type* (text vs ack vs profile).
682+
683+**Attack scenario:** A relay observes that Alice sends Bob a 256-bucket blob → it's a delivery.ack (Alice received Bob's message). Then Alice sends a 65536-bucket blob → it's a profile update. The relay learns the *pattern* of communication (who acks whom, who updates their profile) without decrypting anything.
684+
685+**Exploitable in practice:** The bucket boundaries are coarse but sufficient to distinguish message types. This is a real metadata leak for a "metadata-privacy-focused messenger."
686+
687+**Suggested fix:** Use a single minimum padding bucket (e.g., always pad to at least 1024 bytes) to make all small messages indistinguishable. Or add random padding within the bucket to add noise. Or pad all messages to the same size (expensive but maximally private).
688+
689+### 🟡 Q46. `receivedAt` timing reveals sender-recipient correlation to the relay
690+
691+**Type:** Privacy leak (server.knot)
692+**Files:** `server.knot:919-925` (`appendMessage`)
693+
694+The server assigns `receivedAt` at message arrival time. If Alice sends a message to Bob via relay R, R records the arrival time. If Bob polls R shortly after, R records the poll time. The correlation between arrival and poll times reveals that Bob was online and received the message — even though the server doesn't know who sent it.
695+
696+This is inherent to the relay model (the server must see the recipient and timing), and is documented in §10 ("Recipient metadata exposure"). But the spec understates the leak: it says "servers see the recipient pubkey and arrival time" — it doesn't mention that the server can correlate arrival and poll times to infer delivery.
697+
698+**Suggested fix:** Document in §10 that the relay can infer delivery by correlating message arrival with recipient poll times.
699+
700+### 🟡 Q47. Poll cursor reveals the recipient's last-seen position to the relay
701+
702+**Type:** Privacy leak (server.knot)
703+**Files:** `server.knot:432-433` (cursor), `server.knot:1265-1275` (cursor clamping)
704+
705+The client sends its cursor (last-seen `receivedAt`) on every poll. This tells the relay exactly how far behind the recipient is — how many messages they haven't read yet. A relay can use this to:
706+- Determine if the recipient is actively reading (cursor advances each poll) or just polling (cursor stays the same)
707+- Estimate the recipient's reading speed (how fast the cursor advances)
708+- Detect when the recipient switches devices (cursor jumps backward)
709+
710+This is inherent to the long-poll model but not documented in §10.
711+
712+**Suggested fix:** Document in §10 that the cursor reveals the recipient's reading progress to the relay.
713+
714+---
715+
716+## 10. TOFU and Key Management
717+
718+### 🟠 Q48. No key change detection — TOFU silently accepts key changes without warning
719+
720+**Type:** Design gap (protocol + app.rs)
721+**Files:** PROTOCOL.md §2 (Trust Model), `core/src/app.rs:2043-2045` (sender check)
722+
723+The spec says "the first time a client encounters a public key, it is accepted and cached" and "Warn users when a contact's public key changes (TOFU violation)." But the implementation doesn't have a mechanism for key changes — because the protocol has no key rotation (§11 is future work).
724+
725+However, there's a subtler issue: the sender's identity is *inside* the AEAD ciphertext. If an attacker sends a message to Bob encrypted to Bob's key, signing with Mallory's key, Bob's client will:
726+1. Decrypt the blob (it's encrypted to Bob's key, so it decrypts successfully)
727+2. Recover the sender's pubkey (Mallory's)
728+3. Check if Mallory is in Bob's contacts
729+4. If not, auto-create a contact for Mallory and display the message
730+
731+This is by design (anyone can send you a message). But if the attacker can somehow get Bob to add Mallory's key as a contact (e.g., by social engineering), and then later send messages that appear to come from Mallory, there's no mechanism to detect that "Mallory" has changed keys — because the key IS the identity.
732+
733+The real TOFU weakness is: if Bob adds Alice's key, and Alice's key is later compromised, an attacker with Alice's key can send messages that appear to come from Alice. There's no way for Bob to detect this without out-of-band verification. The spec acknowledges this ("TOFU only" in §10), but the implementation doesn't provide a UI for out-of-band verification (comparing @p syllables).
734+
735+**Exploitable in practice:** This is the fundamental TOFU weakness, already acknowledged in the spec. The implementation gap is the lack of a verification UI, but that's a product feature, not a protocol bug.
736+
737+**Suggested fix:** Add a UI for fingerprint verification (showing @p syllables for comparison) and a warning when a new contact's key hasn't been verified.
738+
739+### 🟡 Q49. `Identity::from_secret_bytes` validates the keypair but doesn't check for weak keys
740+
741+**Type:** Implementation gap (crypto.rs)
742+**Files:** `core/src/crypto.rs:150-166`
743+
744+`from_secret_bytes` re-derives the public key from the seed and checks it matches. But it doesn't check for weak Ed25519 keys (e.g., keys where the X25519 derived key is a low-order point). The `x25519` function checks for all-zero shared secrets (low-order point detection), but this only fires during encryption/decryption, not at key load time.
745+
746+If the user's Ed25519 key happens to produce an X25519 public key that is a low-order point, every encryption to them would fail with `InvalidEphemeralKey` (because `x25519(eph_priv, recip_x)` would return all-zero). The user would be unable to receive messages but wouldn't know why.
747+
748+The probability of this is negligible (low-order points are extremely rare on Curve25519), but the code doesn't check for it at key generation time.
749+
750+**Suggested fix:** Check `x25519_public` is not a low-order point at key generation/load time. Report an error if it is.
751+
752+### 🟡 Q50. No mechanism to revoke or invalidate a compromised identity key
753+
754+**Type:** Design gap (PROTOCOL.md §11)
755+**Files:** PROTOCOL.md §11 (Future Extensions — Key Rotation)
756+
757+The spec acknowledges this in §11 (future work): "a user who loses or wants to retire a key has no in-protocol way to say so." There's no key revocation, no key rotation, and no mechanism to tell contacts that a key is compromised.
758+
759+If a user's key is stolen, the attacker can:
760+1. Decrypt all captured messages (no forward secrecy)
761+2. Send messages as the user (no revocation)
762+3. Poll the user's mailbox (if they know the server)
763+4. Receive messages intended for the user (if they redirect presence)
764+
765+The only defense is out-of-band communication ("my key was compromised, don't talk to the old key anymore"), which requires every contact to manually re-add the new key.
766+
767+**Suggested fix:** This is acknowledged as future work. Document the impact more prominently in §10: a stolen identity key is a full takeover with no in-protocol recovery.
768+
769+---
770+
771+## Summary of New Findings
772+
773+| # | Severity | Type | Title |
774+|---|----------|------|-------|
775+| Q1 | 🟡 medium | Design gap | HKDF salt omits sender identity — KCI vector |
776+| Q2 | 🟡 medium | Design gap | No AAD on AEAD — envelope fields unauthenticated |
777+| Q5 | 🟡 medium | Spec gap | CRIME/BREACH surface wider than documented for profiles |
778+| Q7 | 🟡 medium | Perf/DoS | appendMessage dedup O(n) — CPU amplification |
779+| Q8 | 🟠 high | Design gap | maxMessagesPerRecipient eviction destroys genuine mail |
780+| Q9 | 🟡 medium | Impl bug | capForwards evicts by retry count only, not destination health |
781+| Q10 | 🟡 medium | Impl concern | genToken entropy depends on Knot runtime CSPRNG |
782+| Q11 | 🟡 medium | Impl concern | Challenge lookup O(n) — timing varies with pool size |
783+| Q16 | 🟠 high | Impl concern | Cursor clamping allows relay to poison cursor → message loss |
784+| Q17 | 🟡 medium | Impl concern | Ack staleness check affected by page ordering |
785+| Q18 | 🟡 medium | Impl bug | seen_ids dedup misses trimmed messages → replay after 1000 msgs |
786+| Q23 | 🟡 medium | Impl gap | PubkeyHex accepts non-curve-point keys at route boundary |
787+| Q25 | 🟡 medium | Impl concern | Malformed poll JSON silently produces empty page |
788+| Q27 | 🟠 high | Design gap | Gossip amplification: 100 online events = 100 mailbox scans + forwards |
789+| Q28 | 🟡 medium | Design gap | Offline events don't cancel pending forwards |
790+| Q29 | 🟡 medium | Design gap | fromServer not verified against connecting peer's address |
791+| Q30 | 🟠 high | Design gap | /auth/challenge pool floodable — soft DoS via XFF spoofing |
792+| Q31 | 🟡 medium | Design gap | Rate limit counts requests not messages — effective rate much higher |
793+| Q32 | 🟡 medium | Design gap | maxSessions Sybil-floodable (documented in code, not in spec) |
794+| Q33 | 🟡 medium | Design gap | maxPresence fillable with fake keys (documented in code, not in spec) |
795+| Q34 | 🟠 high | Design gap | Session token not bound to pubkey — theft = full impersonation |
796+| Q35 | 🟡 medium | Impl gap | revokeOthers doesn't revoke challenges |
797+| Q37 | 🟡 medium | Design gap | Signature verification CPU wasted on missing challenges |
798+| Q38 | 🟡 medium | Impl gap | No audit trail (pubkey/IP) for Sybil eviction |
799+| Q39 | 🟡 medium | Spec gap | Spec doesn't specify min blob length |
800+| Q40 | 🟡 medium | Spec gap | Spec doesn't specify JSON encoding requirements |
801+| Q41 | 🟡 medium | Spec gap | Spec doesn't specify cursor validation |
802+| Q42 | 🟡 medium | Spec gap | Spec doesn't specify `to` field validation |
803+| Q43 | 🟡 medium | Spec gap | Spec doesn't specify challenge format/entropy |
804+| Q44 | ⚪ info | Spec gap | Spec doesn't specify revokeOthers semantics precisely |
805+| Q45 | 🟠 high | Privacy leak | Blob size reveals message type (ack vs text vs profile) to relay |
806+| Q46 | 🟡 medium | Privacy leak | receivedAt timing reveals delivery correlation |
807+| Q47 | 🟡 medium | Privacy leak | Poll cursor reveals reading progress to relay |
808+| Q48 | 🟠 high | Design gap | No key change detection — TOFU has no verification UI |
809+| Q49 | 🟡 medium | Impl gap | No weak key check at identity load time |
810+| Q50 | 🟡 medium | Design gap | No identity key revocation mechanism (acknowledged future work) |
811+
812+**High-severity findings (7):** Q8, Q16, Q27, Q30, Q34, Q45, Q48
813+**Medium-severity findings (24):** Q1, Q2, Q5, Q7, Q9, Q10, Q11, Q17, Q18, Q23, Q25, Q28, Q29, Q31, Q32, Q33, Q35, Q37, Q38, Q39, Q40, Q41, Q42, Q43, Q46, Q47, Q49, Q50
814+**Info (1):** Q44
815+
816+Note: Several findings marked as "not a bug" during analysis were downgraded to info or omitted from the final table. The table includes only findings that represent genuine new issues.
CLAUDE.mdnew file+1
@@ -0,0 +1 @@
1+AGENTS.md
Cargo.locknew file+2378
@@ -0,0 +1,2378 @@
1+# This file is automatically @generated by Cargo.
2+# It is not intended for manual editing.
3+version = 4
4+
5+[[package]]
6+name = "aead"
7+version = "0.5.2"
8+source = "registry+https://github.com/rust-lang/crates.io-index"
9+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
10+dependencies = [
11+ "crypto-common",
12+ "generic-array",
13+]
14+
15+[[package]]
16+name = "aho-corasick"
17+version = "1.1.4"
18+source = "registry+https://github.com/rust-lang/crates.io-index"
19+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
20+dependencies = [
21+ "memchr",
22+]
23+
24+[[package]]
25+name = "allocator-api2"
26+version = "0.2.21"
27+source = "registry+https://github.com/rust-lang/crates.io-index"
28+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
29+
30+[[package]]
31+name = "android_system_properties"
32+version = "0.1.5"
33+source = "registry+https://github.com/rust-lang/crates.io-index"
34+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
35+dependencies = [
36+ "libc",
37+]
38+
39+[[package]]
40+name = "anstream"
41+version = "1.0.0"
42+source = "registry+https://github.com/rust-lang/crates.io-index"
43+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
44+dependencies = [
45+ "anstyle",
46+ "anstyle-parse",
47+ "anstyle-query",
48+ "anstyle-wincon",
49+ "colorchoice",
50+ "is_terminal_polyfill",
51+ "utf8parse",
52+]
53+
54+[[package]]
55+name = "anstyle"
56+version = "1.0.14"
57+source = "registry+https://github.com/rust-lang/crates.io-index"
58+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
59+
60+[[package]]
61+name = "anstyle-parse"
62+version = "1.0.0"
63+source = "registry+https://github.com/rust-lang/crates.io-index"
64+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
65+dependencies = [
66+ "utf8parse",
67+]
68+
69+[[package]]
70+name = "anstyle-query"
71+version = "1.1.5"
72+source = "registry+https://github.com/rust-lang/crates.io-index"
73+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
74+dependencies = [
75+ "windows-sys",
76+]
77+
78+[[package]]
79+name = "anstyle-wincon"
80+version = "3.0.11"
81+source = "registry+https://github.com/rust-lang/crates.io-index"
82+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
83+dependencies = [
84+ "anstyle",
85+ "once_cell_polyfill",
86+ "windows-sys",
87+]
88+
89+[[package]]
90+name = "anyhow"
91+version = "1.0.102"
92+source = "registry+https://github.com/rust-lang/crates.io-index"
93+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
94+
95+[[package]]
96+name = "async-channel"
97+version = "1.9.0"
98+source = "registry+https://github.com/rust-lang/crates.io-index"
99+checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
100+dependencies = [
101+ "concurrent-queue",
102+ "event-listener",
103+ "futures-core",
104+]
105+
106+[[package]]
107+name = "async-trait"
108+version = "0.1.89"
109+source = "registry+https://github.com/rust-lang/crates.io-index"
110+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
111+dependencies = [
112+ "proc-macro2",
113+ "quote",
114+ "syn 2.0.118",
115+]
116+
117+[[package]]
118+name = "autocfg"
119+version = "1.5.1"
120+source = "registry+https://github.com/rust-lang/crates.io-index"
121+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
122+
123+[[package]]
124+name = "base64"
125+version = "0.22.1"
126+source = "registry+https://github.com/rust-lang/crates.io-index"
127+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
128+
129+[[package]]
130+name = "base64ct"
131+version = "1.8.3"
132+source = "registry+https://github.com/rust-lang/crates.io-index"
133+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
134+
135+[[package]]
136+name = "bincode"
137+version = "1.3.3"
138+source = "registry+https://github.com/rust-lang/crates.io-index"
139+checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad"
140+dependencies = [
141+ "serde",
142+]
143+
144+[[package]]
145+name = "bitflags"
146+version = "2.13.0"
147+source = "registry+https://github.com/rust-lang/crates.io-index"
148+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
149+
150+[[package]]
151+name = "block-buffer"
152+version = "0.10.4"
153+source = "registry+https://github.com/rust-lang/crates.io-index"
154+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
155+dependencies = [
156+ "generic-array",
157+]
158+
159+[[package]]
160+name = "boltffi"
161+version = "0.26.1"
162+source = "registry+https://github.com/rust-lang/crates.io-index"
163+checksum = "0d616d59232b93cbb4200fe03171e2c894d27dbee441c92649500bfe9fe66468"
164+dependencies = [
165+ "boltffi_core",
166+ "boltffi_macros",
167+]
168+
169+[[package]]
170+name = "boltffi_ast"
171+version = "0.26.1"
172+source = "registry+https://github.com/rust-lang/crates.io-index"
173+checksum = "79f01d34d075e93e96374eda34928558b88a5287b532fe0eb508400c802a2abc"
174+dependencies = [
175+ "serde",
176+]
177+
178+[[package]]
179+name = "boltffi_binding"
180+version = "0.26.1"
181+source = "registry+https://github.com/rust-lang/crates.io-index"
182+checksum = "9efa5fe74d4642e9152f52cc99facb0fc52836458c6ba6779b7eae1c33ab4a92"
183+dependencies = [
184+ "boltffi_ast",
185+ "serde",
186+]
187+
188+[[package]]
189+name = "boltffi_core"
190+version = "0.26.1"
191+source = "registry+https://github.com/rust-lang/crates.io-index"
192+checksum = "06964032c9ae4aa7d395c24cb80985d5e63c392cd6ff20f3ca61bb29a9594191"
193+dependencies = [
194+ "boltffi_macros",
195+ "url",
196+ "uuid",
197+]
198+
199+[[package]]
200+name = "boltffi_ffi_rules"
201+version = "0.26.1"
202+source = "registry+https://github.com/rust-lang/crates.io-index"
203+checksum = "070dd06ea89725dc39eb9f96d1ce6fc6822a805d6fec6c7257971f656772abc1"
204+dependencies = [
205+ "serde",
206+ "serde_json",
207+ "syn 2.0.118",
208+ "walkdir",
209+]
210+
211+[[package]]
212+name = "boltffi_macros"
213+version = "0.26.1"
214+source = "registry+https://github.com/rust-lang/crates.io-index"
215+checksum = "92bbe601ce130101d1b6b71ecd6789da4535e94154efcd5df5945090194f74be"
216+dependencies = [
217+ "boltffi_ast",
218+ "boltffi_binding",
219+ "boltffi_ffi_rules",
220+ "proc-macro2",
221+ "quote",
222+ "syn 2.0.118",
223+]
224+
225+[[package]]
226+name = "bumpalo"
227+version = "3.20.3"
228+source = "registry+https://github.com/rust-lang/crates.io-index"
229+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
230+
231+[[package]]
232+name = "byteorder"
233+version = "1.5.0"
234+source = "registry+https://github.com/rust-lang/crates.io-index"
235+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
236+
237+[[package]]
238+name = "cc"
239+version = "1.2.65"
240+source = "registry+https://github.com/rust-lang/crates.io-index"
241+checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
242+dependencies = [
243+ "find-msvc-tools",
244+ "jobserver",
245+ "libc",
246+ "shlex",
247+]
248+
249+[[package]]
250+name = "cfb"
251+version = "0.7.3"
252+source = "registry+https://github.com/rust-lang/crates.io-index"
253+checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
254+dependencies = [
255+ "byteorder",
256+ "fnv",
257+ "uuid",
258+]
259+
260+[[package]]
261+name = "cfg-if"
262+version = "1.0.4"
263+source = "registry+https://github.com/rust-lang/crates.io-index"
264+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
265+
266+[[package]]
267+name = "chacha20"
268+version = "0.9.1"
269+source = "registry+https://github.com/rust-lang/crates.io-index"
270+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
271+dependencies = [
272+ "cfg-if",
273+ "cipher",
274+ "cpufeatures",
275+]
276+
277+[[package]]
278+name = "chacha20poly1305"
279+version = "0.10.1"
280+source = "registry+https://github.com/rust-lang/crates.io-index"
281+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
282+dependencies = [
283+ "aead",
284+ "chacha20",
285+ "cipher",
286+ "poly1305",
287+ "zeroize",
288+]
289+
290+[[package]]
291+name = "chrono"
292+version = "0.4.45"
293+source = "registry+https://github.com/rust-lang/crates.io-index"
294+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
295+dependencies = [
296+ "iana-time-zone",
297+ "num-traits",
298+ "windows-link",
299+]
300+
301+[[package]]
302+name = "cipher"
303+version = "0.4.4"
304+source = "registry+https://github.com/rust-lang/crates.io-index"
305+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
306+dependencies = [
307+ "crypto-common",
308+ "inout",
309+ "zeroize",
310+]
311+
312+[[package]]
313+name = "clap"
314+version = "4.6.1"
315+source = "registry+https://github.com/rust-lang/crates.io-index"
316+checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
317+dependencies = [
318+ "clap_builder",
319+ "clap_derive",
320+]
321+
322+[[package]]
323+name = "clap_builder"
324+version = "4.6.0"
325+source = "registry+https://github.com/rust-lang/crates.io-index"
326+checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
327+dependencies = [
328+ "anstream",
329+ "anstyle",
330+ "clap_lex",
331+ "strsim",
332+]
333+
334+[[package]]
335+name = "clap_derive"
336+version = "4.6.1"
337+source = "registry+https://github.com/rust-lang/crates.io-index"
338+checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
339+dependencies = [
340+ "heck",
341+ "proc-macro2",
342+ "quote",
343+ "syn 2.0.118",
344+]
345+
346+[[package]]
347+name = "clap_lex"
348+version = "1.1.0"
349+source = "registry+https://github.com/rust-lang/crates.io-index"
350+checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
351+
352+[[package]]
353+name = "colorchoice"
354+version = "1.0.5"
355+source = "registry+https://github.com/rust-lang/crates.io-index"
356+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
357+
358+[[package]]
359+name = "concurrent-queue"
360+version = "2.5.0"
361+source = "registry+https://github.com/rust-lang/crates.io-index"
362+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
363+dependencies = [
364+ "crossbeam-utils",
365+]
366+
367+[[package]]
368+name = "const-fnv1a-hash"
369+version = "1.1.0"
370+source = "registry+https://github.com/rust-lang/crates.io-index"
371+checksum = "32b13ea120a812beba79e34316b3942a857c86ec1593cb34f27bb28272ce2cca"
372+
373+[[package]]
374+name = "const-oid"
375+version = "0.9.6"
376+source = "registry+https://github.com/rust-lang/crates.io-index"
377+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
378+
379+[[package]]
380+name = "core-foundation-sys"
381+version = "0.8.7"
382+source = "registry+https://github.com/rust-lang/crates.io-index"
383+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
384+
385+[[package]]
386+name = "cpufeatures"
387+version = "0.2.17"
388+source = "registry+https://github.com/rust-lang/crates.io-index"
389+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
390+dependencies = [
391+ "libc",
392+]
393+
394+[[package]]
395+name = "crossbeam-channel"
396+version = "0.5.15"
397+source = "registry+https://github.com/rust-lang/crates.io-index"
398+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
399+dependencies = [
400+ "crossbeam-utils",
401+]
402+
403+[[package]]
404+name = "crossbeam-utils"
405+version = "0.8.21"
406+source = "registry+https://github.com/rust-lang/crates.io-index"
407+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
408+
409+[[package]]
410+name = "crux_core"
411+version = "0.19.0"
412+source = "registry+https://github.com/rust-lang/crates.io-index"
413+checksum = "159720bb44c1a09e0089459217c8ea6c71a78d868c5bf55d5b41f8781e92af7a"
414+dependencies = [
415+ "anyhow",
416+ "bincode",
417+ "crossbeam-channel",
418+ "crux_macros",
419+ "facet 0.44.5",
420+ "facet_generate",
421+ "futures",
422+ "log",
423+ "serde",
424+ "serde_json",
425+ "slab",
426+ "thiserror",
427+]
428+
429+[[package]]
430+name = "crux_http"
431+version = "0.18.0"
432+source = "registry+https://github.com/rust-lang/crates.io-index"
433+checksum = "ef762ab0e77caf6f0fc22ec06491e83967039fe88f3916ec3c8f83b3c52f7e73"
434+dependencies = [
435+ "anyhow",
436+ "async-trait",
437+ "crux_core",
438+ "derive_builder",
439+ "encoding_rs",
440+ "facet 0.44.5",
441+ "facet-generate-attrs",
442+ "futures-util",
443+ "http-types-red-badger-temporary-fork",
444+ "pin-project-lite",
445+ "serde",
446+ "serde_bytes",
447+ "serde_json",
448+ "serde_qs 1.1.2",
449+ "thiserror",
450+ "url",
451+ "web-sys",
452+]
453+
454+[[package]]
455+name = "crux_kv"
456+version = "0.13.0"
457+source = "registry+https://github.com/rust-lang/crates.io-index"
458+checksum = "9ab7d27f05bfbd679c18c5c45a5c15619adf21cf9895a0436728fd3e6a88d783"
459+dependencies = [
460+ "anyhow",
461+ "crux_core",
462+ "facet 0.44.5",
463+ "serde",
464+ "serde_bytes",
465+ "thiserror",
466+]
467+
468+[[package]]
469+name = "crux_macros"
470+version = "0.10.0"
471+source = "registry+https://github.com/rust-lang/crates.io-index"
472+checksum = "952c0b238735c79e917b908c140c7c5f6f6921924ebee21171203fe5fb5fc3f3"
473+dependencies = [
474+ "darling 0.23.0",
475+ "heck",
476+ "proc-macro-error",
477+ "proc-macro2",
478+ "quote",
479+ "syn 2.0.118",
480+]
481+
482+[[package]]
483+name = "crux_time"
484+version = "0.17.0"
485+source = "registry+https://github.com/rust-lang/crates.io-index"
486+checksum = "a2dda3b405568dc1e0fc7a46cc913e6324e918c66fd7cd19d64cc28759d71a58"
487+dependencies = [
488+ "crux_core",
489+ "facet 0.44.5",
490+ "futures",
491+ "serde",
492+ "thiserror",
493+]
494+
495+[[package]]
496+name = "crypto-common"
497+version = "0.1.7"
498+source = "registry+https://github.com/rust-lang/crates.io-index"
499+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
500+dependencies = [
501+ "generic-array",
502+ "rand_core 0.6.4",
503+ "typenum",
504+]
505+
506+[[package]]
507+name = "curve25519-dalek"
508+version = "4.1.3"
509+source = "registry+https://github.com/rust-lang/crates.io-index"
510+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
511+dependencies = [
512+ "cfg-if",
513+ "cpufeatures",
514+ "curve25519-dalek-derive",
515+ "digest",
516+ "fiat-crypto",
517+ "rustc_version",
518+ "subtle",
519+ "zeroize",
520+]
521+
522+[[package]]
523+name = "curve25519-dalek-derive"
524+version = "0.1.1"
525+source = "registry+https://github.com/rust-lang/crates.io-index"
526+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
527+dependencies = [
528+ "proc-macro2",
529+ "quote",
530+ "syn 2.0.118",
531+]
532+
533+[[package]]
534+name = "darling"
535+version = "0.20.11"
536+source = "registry+https://github.com/rust-lang/crates.io-index"
537+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
538+dependencies = [
539+ "darling_core 0.20.11",
540+ "darling_macro 0.20.11",
541+]
542+
543+[[package]]
544+name = "darling"
545+version = "0.23.0"
546+source = "registry+https://github.com/rust-lang/crates.io-index"
547+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
548+dependencies = [
549+ "darling_core 0.23.0",
550+ "darling_macro 0.23.0",
551+]
552+
553+[[package]]
554+name = "darling_core"
555+version = "0.20.11"
556+source = "registry+https://github.com/rust-lang/crates.io-index"
557+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
558+dependencies = [
559+ "fnv",
560+ "ident_case",
561+ "proc-macro2",
562+ "quote",
563+ "strsim",
564+ "syn 2.0.118",
565+]
566+
567+[[package]]
568+name = "darling_core"
569+version = "0.23.0"
570+source = "registry+https://github.com/rust-lang/crates.io-index"
571+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
572+dependencies = [
573+ "ident_case",
574+ "proc-macro2",
575+ "quote",
576+ "strsim",
577+ "syn 2.0.118",
578+]
579+
580+[[package]]
581+name = "darling_macro"
582+version = "0.20.11"
583+source = "registry+https://github.com/rust-lang/crates.io-index"
584+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
585+dependencies = [
586+ "darling_core 0.20.11",
587+ "quote",
588+ "syn 2.0.118",
589+]
590+
591+[[package]]
592+name = "darling_macro"
593+version = "0.23.0"
594+source = "registry+https://github.com/rust-lang/crates.io-index"
595+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
596+dependencies = [
597+ "darling_core 0.23.0",
598+ "quote",
599+ "syn 2.0.118",
600+]
601+
602+[[package]]
603+name = "der"
604+version = "0.7.10"
605+source = "registry+https://github.com/rust-lang/crates.io-index"
606+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
607+dependencies = [
608+ "const-oid",
609+ "zeroize",
610+]
611+
612+[[package]]
613+name = "derive_builder"
614+version = "0.20.2"
615+source = "registry+https://github.com/rust-lang/crates.io-index"
616+checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
617+dependencies = [
618+ "derive_builder_macro",
619+]
620+
621+[[package]]
622+name = "derive_builder_core"
623+version = "0.20.2"
624+source = "registry+https://github.com/rust-lang/crates.io-index"
625+checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
626+dependencies = [
627+ "darling 0.20.11",
628+ "proc-macro2",
629+ "quote",
630+ "syn 2.0.118",
631+]
632+
633+[[package]]
634+name = "derive_builder_macro"
635+version = "0.20.2"
636+source = "registry+https://github.com/rust-lang/crates.io-index"
637+checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
638+dependencies = [
639+ "derive_builder_core",
640+ "syn 2.0.118",
641+]
642+
643+[[package]]
644+name = "digest"
645+version = "0.10.7"
646+source = "registry+https://github.com/rust-lang/crates.io-index"
647+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
648+dependencies = [
649+ "block-buffer",
650+ "crypto-common",
651+ "subtle",
652+]
653+
654+[[package]]
655+name = "displaydoc"
656+version = "0.2.6"
657+source = "registry+https://github.com/rust-lang/crates.io-index"
658+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
659+dependencies = [
660+ "proc-macro2",
661+ "quote",
662+ "syn 2.0.118",
663+]
664+
665+[[package]]
666+name = "ed25519"
667+version = "2.2.3"
668+source = "registry+https://github.com/rust-lang/crates.io-index"
669+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
670+dependencies = [
671+ "pkcs8",
672+ "signature",
673+]
674+
675+[[package]]
676+name = "ed25519-dalek"
677+version = "2.2.0"
678+source = "registry+https://github.com/rust-lang/crates.io-index"
679+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
680+dependencies = [
681+ "curve25519-dalek",
682+ "ed25519",
683+ "rand_core 0.6.4",
684+ "serde",
685+ "sha2",
686+ "subtle",
687+ "zeroize",
688+]
689+
690+[[package]]
691+name = "encoding_rs"
692+version = "0.8.35"
693+source = "registry+https://github.com/rust-lang/crates.io-index"
694+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
695+dependencies = [
696+ "cfg-if",
697+]
698+
699+[[package]]
700+name = "env_logger"
701+version = "0.10.2"
702+source = "registry+https://github.com/rust-lang/crates.io-index"
703+checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580"
704+dependencies = [
705+ "humantime",
706+ "is-terminal",
707+ "log",
708+ "regex",
709+ "termcolor",
710+]
711+
712+[[package]]
713+name = "equivalent"
714+version = "1.0.2"
715+source = "registry+https://github.com/rust-lang/crates.io-index"
716+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
717+
718+[[package]]
719+name = "event-listener"
720+version = "2.5.3"
721+source = "registry+https://github.com/rust-lang/crates.io-index"
722+checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
723+
724+[[package]]
725+name = "facet"
726+version = "0.31.8"
727+source = "registry+https://github.com/rust-lang/crates.io-index"
728+checksum = "4d643309d7c46d073b6c51e29901ed9bc3d858dc09aa21f5b32b424491e1bbef"
729+dependencies = [
730+ "facet-core 0.31.8",
731+ "facet-macros 0.31.8",
732+ "static_assertions",
733+]
734+
735+[[package]]
736+name = "facet"
737+version = "0.44.5"
738+source = "registry+https://github.com/rust-lang/crates.io-index"
739+checksum = "c78066af2cc259674a54fef24323f6081ae54a5f3a3fb53b995af7a867041c81"
740+dependencies = [
741+ "autocfg",
742+ "facet-core 0.44.4",
743+ "facet-macros 0.44.4",
744+]
745+
746+[[package]]
747+name = "facet-core"
748+version = "0.31.8"
749+source = "registry+https://github.com/rust-lang/crates.io-index"
750+checksum = "ac27076d75388bffb6c5e5118078dcbb46f2090b35256c45372ff1c1910b1aaf"
751+dependencies = [
752+ "bitflags",
753+ "impls",
754+]
755+
756+[[package]]
757+name = "facet-core"
758+version = "0.44.4"
759+source = "registry+https://github.com/rust-lang/crates.io-index"
760+checksum = "04625a816bc27bb757b5f62ec17a228b90c08631f579ee0b36eccd6c426df207"
761+dependencies = [
762+ "autocfg",
763+ "chrono",
764+ "const-fnv1a-hash",
765+ "iddqd",
766+ "impls",
767+]
768+
769+[[package]]
770+name = "facet-generate-attrs"
771+version = "0.17.0"
772+source = "registry+https://github.com/rust-lang/crates.io-index"
773+checksum = "3b430b495d59ffcf07dabe103a796d8d3ba809d12bb25a5960b605883835b3de"
774+dependencies = [
775+ "facet 0.44.5",
776+]
777+
778+[[package]]
779+name = "facet-macro-parse"
780+version = "0.44.4"
781+source = "registry+https://github.com/rust-lang/crates.io-index"
782+checksum = "a288cd230608b4d7e89b307f85be2029f265e27dcdc565a2ae5358d0c4e53f57"
783+dependencies = [
784+ "facet-macro-types",
785+ "proc-macro2",
786+ "quote",
787+]
788+
789+[[package]]
790+name = "facet-macro-types"
791+version = "0.44.4"
792+source = "registry+https://github.com/rust-lang/crates.io-index"
793+checksum = "9c805b7f1ad4bba14e1dba5c67ef810bf9eef2373364fe3916af1d08a5411296"
794+dependencies = [
795+ "proc-macro2",
796+ "quote",
797+ "unsynn",
798+]
799+
800+[[package]]
801+name = "facet-macros"
802+version = "0.31.8"
803+source = "registry+https://github.com/rust-lang/crates.io-index"
804+checksum = "391fcf43b68a76ba4a494710629a7b6fb430345a5623a6a87f7c263bfadb1643"
805+dependencies = [
806+ "facet-core 0.31.8",
807+ "facet-macros-emit",
808+]
809+
810+[[package]]
811+name = "facet-macros"
812+version = "0.44.4"
813+source = "registry+https://github.com/rust-lang/crates.io-index"
814+checksum = "e0cc46a94a2725e82f2c0095bac16665057c24d7f4f11169a7a879dbcba81d8f"
815+dependencies = [
816+ "facet-macros-impl",
817+]
818+
819+[[package]]
820+name = "facet-macros-emit"
821+version = "0.31.8"
822+source = "registry+https://github.com/rust-lang/crates.io-index"
823+checksum = "e81e536ccafa7d5ef71822c9a67a28a0b29fe0170eaf2d134c36854a3d56ee9a"
824+dependencies = [
825+ "facet-macros-parse",
826+ "quote",
827+]
828+
829+[[package]]
830+name = "facet-macros-impl"
831+version = "0.44.4"
832+source = "registry+https://github.com/rust-lang/crates.io-index"
833+checksum = "1af13fd5dfa728cc48418cf33cbcfc0d5cfdbaaf22a3d0f5229da4b1e5669fa5"
834+dependencies = [
835+ "facet-macro-parse",
836+ "facet-macro-types",
837+ "proc-macro2",
838+ "quote",
839+ "strsim",
840+ "unsynn",
841+]
842+
843+[[package]]
844+name = "facet-macros-parse"
845+version = "0.31.8"
846+source = "registry+https://github.com/rust-lang/crates.io-index"
847+checksum = "8da5d4ded1874033bdef5000712d80d28feefe2abb48bb6f31b6fe59b7e9792f"
848+dependencies = [
849+ "proc-macro2",
850+ "quote",
851+ "unsynn",
852+]
853+
854+[[package]]
855+name = "facet_generate"
856+version = "0.17.1"
857+source = "registry+https://github.com/rust-lang/crates.io-index"
858+checksum = "831d4a105b7e0abce52910f544ffa2698064b1d4864d381672137f804548a45a"
859+dependencies = [
860+ "derive_builder",
861+ "facet 0.44.5",
862+ "facet-generate-attrs",
863+ "heck",
864+ "include_dir",
865+ "indent",
866+ "indoc",
867+ "regex",
868+ "serde",
869+ "serde_json",
870+ "textwrap",
871+ "thiserror",
872+]
873+
874+[[package]]
875+name = "fastrand"
876+version = "2.4.1"
877+source = "registry+https://github.com/rust-lang/crates.io-index"
878+checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
879+
880+[[package]]
881+name = "fiat-crypto"
882+version = "0.2.9"
883+source = "registry+https://github.com/rust-lang/crates.io-index"
884+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
885+
886+[[package]]
887+name = "find-msvc-tools"
888+version = "0.1.9"
889+source = "registry+https://github.com/rust-lang/crates.io-index"
890+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
891+
892+[[package]]
893+name = "fnv"
894+version = "1.0.7"
895+source = "registry+https://github.com/rust-lang/crates.io-index"
896+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
897+
898+[[package]]
899+name = "foldhash"
900+version = "0.2.0"
901+source = "registry+https://github.com/rust-lang/crates.io-index"
902+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
903+
904+[[package]]
905+name = "form_urlencoded"
906+version = "1.2.2"
907+source = "registry+https://github.com/rust-lang/crates.io-index"
908+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
909+dependencies = [
910+ "percent-encoding",
911+]
912+
913+[[package]]
914+name = "futures"
915+version = "0.3.32"
916+source = "registry+https://github.com/rust-lang/crates.io-index"
917+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
918+dependencies = [
919+ "futures-channel",
920+ "futures-core",
921+ "futures-executor",
922+ "futures-io",
923+ "futures-sink",
924+ "futures-task",
925+ "futures-util",
926+]
927+
928+[[package]]
929+name = "futures-channel"
930+version = "0.3.32"
931+source = "registry+https://github.com/rust-lang/crates.io-index"
932+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
933+dependencies = [
934+ "futures-core",
935+ "futures-sink",
936+]
937+
938+[[package]]
939+name = "futures-core"
940+version = "0.3.32"
941+source = "registry+https://github.com/rust-lang/crates.io-index"
942+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
943+
944+[[package]]
945+name = "futures-executor"
946+version = "0.3.32"
947+source = "registry+https://github.com/rust-lang/crates.io-index"
948+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
949+dependencies = [
950+ "futures-core",
951+ "futures-task",
952+ "futures-util",
953+]
954+
955+[[package]]
956+name = "futures-io"
957+version = "0.3.32"
958+source = "registry+https://github.com/rust-lang/crates.io-index"
959+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
960+
961+[[package]]
962+name = "futures-lite"
963+version = "2.6.1"
964+source = "registry+https://github.com/rust-lang/crates.io-index"
965+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
966+dependencies = [
967+ "fastrand",
968+ "futures-core",
969+ "futures-io",
970+ "parking",
971+ "pin-project-lite",
972+]
973+
974+[[package]]
975+name = "futures-macro"
976+version = "0.3.32"
977+source = "registry+https://github.com/rust-lang/crates.io-index"
978+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
979+dependencies = [
980+ "proc-macro2",
981+ "quote",
982+ "syn 2.0.118",
983+]
984+
985+[[package]]
986+name = "futures-sink"
987+version = "0.3.32"
988+source = "registry+https://github.com/rust-lang/crates.io-index"
989+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
990+
991+[[package]]
992+name = "futures-task"
993+version = "0.3.32"
994+source = "registry+https://github.com/rust-lang/crates.io-index"
995+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
996+
997+[[package]]
998+name = "futures-util"
999+version = "0.3.32"
1000+source = "registry+https://github.com/rust-lang/crates.io-index"
1001+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
1002+dependencies = [
1003+ "futures-channel",
1004+ "futures-core",
1005+ "futures-io",
1006+ "futures-macro",
1007+ "futures-sink",
1008+ "futures-task",
1009+ "memchr",
1010+ "pin-project-lite",
1011+ "slab",
1012+]
1013+
1014+[[package]]
1015+name = "generic-array"
1016+version = "0.14.7"
1017+source = "registry+https://github.com/rust-lang/crates.io-index"
1018+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
1019+dependencies = [
1020+ "typenum",
1021+ "version_check",
1022+]
1023+
1024+[[package]]
1025+name = "getrandom"
1026+version = "0.2.17"
1027+source = "registry+https://github.com/rust-lang/crates.io-index"
1028+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
1029+dependencies = [
1030+ "cfg-if",
1031+ "libc",
1032+ "wasi",
1033+]
1034+
1035+[[package]]
1036+name = "getrandom"
1037+version = "0.3.4"
1038+source = "registry+https://github.com/rust-lang/crates.io-index"
1039+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
1040+dependencies = [
1041+ "cfg-if",
1042+ "libc",
1043+ "r-efi 5.3.0",
1044+ "wasip2",
1045+]
1046+
1047+[[package]]
1048+name = "getrandom"
1049+version = "0.4.3"
1050+source = "registry+https://github.com/rust-lang/crates.io-index"
1051+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
1052+dependencies = [
1053+ "cfg-if",
1054+ "libc",
1055+ "r-efi 6.0.0",
1056+]
1057+
1058+[[package]]
1059+name = "hashbrown"
1060+version = "0.16.1"
1061+source = "registry+https://github.com/rust-lang/crates.io-index"
1062+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
1063+dependencies = [
1064+ "allocator-api2",
1065+]
1066+
1067+[[package]]
1068+name = "heck"
1069+version = "0.5.0"
1070+source = "registry+https://github.com/rust-lang/crates.io-index"
1071+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
1072+
1073+[[package]]
1074+name = "hermit-abi"
1075+version = "0.5.2"
1076+source = "registry+https://github.com/rust-lang/crates.io-index"
1077+checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
1078+
1079+[[package]]
1080+name = "hex"
1081+version = "0.4.3"
1082+source = "registry+https://github.com/rust-lang/crates.io-index"
1083+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
1084+
1085+[[package]]
1086+name = "hkdf"
1087+version = "0.12.4"
1088+source = "registry+https://github.com/rust-lang/crates.io-index"
1089+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
1090+dependencies = [
1091+ "hmac",
1092+]
1093+
1094+[[package]]
1095+name = "hmac"
1096+version = "0.12.1"
1097+source = "registry+https://github.com/rust-lang/crates.io-index"
1098+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
1099+dependencies = [
1100+ "digest",
1101+]
1102+
1103+[[package]]
1104+name = "http-types-red-badger-temporary-fork"
1105+version = "6.0.0"
1106+source = "registry+https://github.com/rust-lang/crates.io-index"
1107+checksum = "3042c4259fc659f9fa8aea1d71c4ae4201c58a0adddd3406910aed0870c68382"
1108+dependencies = [
1109+ "anyhow",
1110+ "async-channel",
1111+ "base64",
1112+ "facet 0.31.8",
1113+ "futures-lite",
1114+ "infer",
1115+ "pin-project-lite",
1116+ "rand",
1117+ "serde",
1118+ "serde_json",
1119+ "serde_qs 0.15.0",
1120+ "serde_urlencoded",
1121+ "url",
1122+]
1123+
1124+[[package]]
1125+name = "humantime"
1126+version = "2.3.0"
1127+source = "registry+https://github.com/rust-lang/crates.io-index"
1128+checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
1129+
1130+[[package]]
1131+name = "iana-time-zone"
1132+version = "0.1.65"
1133+source = "registry+https://github.com/rust-lang/crates.io-index"
1134+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
1135+dependencies = [
1136+ "android_system_properties",
1137+ "core-foundation-sys",
1138+ "iana-time-zone-haiku",
1139+ "js-sys",
1140+ "log",
1141+ "wasm-bindgen",
1142+ "windows-core",
1143+]
1144+
1145+[[package]]
1146+name = "iana-time-zone-haiku"
1147+version = "0.1.2"
1148+source = "registry+https://github.com/rust-lang/crates.io-index"
1149+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
1150+dependencies = [
1151+ "cc",
1152+]
1153+
1154+[[package]]
1155+name = "icu_collections"
1156+version = "2.2.0"
1157+source = "registry+https://github.com/rust-lang/crates.io-index"
1158+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
1159+dependencies = [
1160+ "displaydoc",
1161+ "potential_utf",
1162+ "utf8_iter",
1163+ "yoke",
1164+ "zerofrom",
1165+ "zerovec",
1166+]
1167+
1168+[[package]]
1169+name = "icu_locale_core"
1170+version = "2.2.0"
1171+source = "registry+https://github.com/rust-lang/crates.io-index"
1172+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
1173+dependencies = [
1174+ "displaydoc",
1175+ "litemap",
1176+ "tinystr",
1177+ "writeable",
1178+ "zerovec",
1179+]
1180+
1181+[[package]]
1182+name = "icu_normalizer"
1183+version = "2.2.0"
1184+source = "registry+https://github.com/rust-lang/crates.io-index"
1185+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
1186+dependencies = [
1187+ "icu_collections",
1188+ "icu_normalizer_data",
1189+ "icu_properties",
1190+ "icu_provider",
1191+ "smallvec",
1192+ "zerovec",
1193+]
1194+
1195+[[package]]
1196+name = "icu_normalizer_data"
1197+version = "2.2.0"
1198+source = "registry+https://github.com/rust-lang/crates.io-index"
1199+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
1200+
1201+[[package]]
1202+name = "icu_properties"
1203+version = "2.2.0"
1204+source = "registry+https://github.com/rust-lang/crates.io-index"
1205+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
1206+dependencies = [
1207+ "icu_collections",
1208+ "icu_locale_core",
1209+ "icu_properties_data",
1210+ "icu_provider",
1211+ "zerotrie",
1212+ "zerovec",
1213+]
1214+
1215+[[package]]
1216+name = "icu_properties_data"
1217+version = "2.2.0"
1218+source = "registry+https://github.com/rust-lang/crates.io-index"
1219+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
1220+
1221+[[package]]
1222+name = "icu_provider"
1223+version = "2.2.0"
1224+source = "registry+https://github.com/rust-lang/crates.io-index"
1225+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
1226+dependencies = [
1227+ "displaydoc",
1228+ "icu_locale_core",
1229+ "writeable",
1230+ "yoke",
1231+ "zerofrom",
1232+ "zerotrie",
1233+ "zerovec",
1234+]
1235+
1236+[[package]]
1237+name = "iddqd"
1238+version = "0.3.18"
1239+source = "registry+https://github.com/rust-lang/crates.io-index"
1240+checksum = "616230c7d641ef971a3a5bfcc654c6b7524ab9c9fb665693c6a397dba9a14aca"
1241+dependencies = [
1242+ "allocator-api2",
1243+ "equivalent",
1244+ "foldhash",
1245+ "hashbrown",
1246+ "rustc-hash",
1247+]
1248+
1249+[[package]]
1250+name = "ident_case"
1251+version = "1.0.1"
1252+source = "registry+https://github.com/rust-lang/crates.io-index"
1253+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
1254+
1255+[[package]]
1256+name = "idna"
1257+version = "1.1.0"
1258+source = "registry+https://github.com/rust-lang/crates.io-index"
1259+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
1260+dependencies = [
1261+ "idna_adapter",
1262+ "smallvec",
1263+ "utf8_iter",
1264+]
1265+
1266+[[package]]
1267+name = "idna_adapter"
1268+version = "1.2.2"
1269+source = "registry+https://github.com/rust-lang/crates.io-index"
1270+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
1271+dependencies = [
1272+ "icu_normalizer",
1273+ "icu_properties",
1274+]
1275+
1276+[[package]]
1277+name = "impls"
1278+version = "1.0.3"
1279+source = "registry+https://github.com/rust-lang/crates.io-index"
1280+checksum = "7a46645bbd70538861a90d0f26c31537cdf1e44aae99a794fb75a664b70951bc"
1281+
1282+[[package]]
1283+name = "include_dir"
1284+version = "0.7.4"
1285+source = "registry+https://github.com/rust-lang/crates.io-index"
1286+checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
1287+dependencies = [
1288+ "include_dir_macros",
1289+]
1290+
1291+[[package]]
1292+name = "include_dir_macros"
1293+version = "0.7.4"
1294+source = "registry+https://github.com/rust-lang/crates.io-index"
1295+checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
1296+dependencies = [
1297+ "proc-macro2",
1298+ "quote",
1299+]
1300+
1301+[[package]]
1302+name = "indent"
1303+version = "0.1.1"
1304+source = "registry+https://github.com/rust-lang/crates.io-index"
1305+checksum = "d9f1a0777d972970f204fdf8ef319f1f4f8459131636d7e3c96c5d59570d0fa6"
1306+
1307+[[package]]
1308+name = "indoc"
1309+version = "2.0.7"
1310+source = "registry+https://github.com/rust-lang/crates.io-index"
1311+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
1312+dependencies = [
1313+ "rustversion",
1314+]
1315+
1316+[[package]]
1317+name = "infer"
1318+version = "0.19.0"
1319+source = "registry+https://github.com/rust-lang/crates.io-index"
1320+checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7"
1321+dependencies = [
1322+ "cfb",
1323+]
1324+
1325+[[package]]
1326+name = "inout"
1327+version = "0.1.4"
1328+source = "registry+https://github.com/rust-lang/crates.io-index"
1329+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
1330+dependencies = [
1331+ "generic-array",
1332+]
1333+
1334+[[package]]
1335+name = "is-terminal"
1336+version = "0.4.17"
1337+source = "registry+https://github.com/rust-lang/crates.io-index"
1338+checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
1339+dependencies = [
1340+ "hermit-abi",
1341+ "libc",
1342+ "windows-sys",
1343+]
1344+
1345+[[package]]
1346+name = "is_terminal_polyfill"
1347+version = "1.70.2"
1348+source = "registry+https://github.com/rust-lang/crates.io-index"
1349+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
1350+
1351+[[package]]
1352+name = "itoa"
1353+version = "1.0.18"
1354+source = "registry+https://github.com/rust-lang/crates.io-index"
1355+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
1356+
1357+[[package]]
1358+name = "jobserver"
1359+version = "0.1.34"
1360+source = "registry+https://github.com/rust-lang/crates.io-index"
1361+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
1362+dependencies = [
1363+ "getrandom 0.3.4",
1364+ "libc",
1365+]
1366+
1367+[[package]]
1368+name = "js-sys"
1369+version = "0.3.103"
1370+source = "registry+https://github.com/rust-lang/crates.io-index"
1371+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
1372+dependencies = [
1373+ "cfg-if",
1374+ "futures-util",
1375+ "wasm-bindgen",
1376+]
1377+
1378+[[package]]
1379+name = "libc"
1380+version = "0.2.186"
1381+source = "registry+https://github.com/rust-lang/crates.io-index"
1382+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
1383+
1384+[[package]]
1385+name = "litemap"
1386+version = "0.8.2"
1387+source = "registry+https://github.com/rust-lang/crates.io-index"
1388+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
1389+
1390+[[package]]
1391+name = "log"
1392+version = "0.4.33"
1393+source = "registry+https://github.com/rust-lang/crates.io-index"
1394+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
1395+
1396+[[package]]
1397+name = "memchr"
1398+version = "2.8.2"
1399+source = "registry+https://github.com/rust-lang/crates.io-index"
1400+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
1401+
1402+[[package]]
1403+name = "mutants"
1404+version = "0.0.3"
1405+source = "registry+https://github.com/rust-lang/crates.io-index"
1406+checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126"
1407+
1408+[[package]]
1409+name = "num-traits"
1410+version = "0.2.19"
1411+source = "registry+https://github.com/rust-lang/crates.io-index"
1412+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
1413+dependencies = [
1414+ "autocfg",
1415+]
1416+
1417+[[package]]
1418+name = "once_cell"
1419+version = "1.21.4"
1420+source = "registry+https://github.com/rust-lang/crates.io-index"
1421+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
1422+
1423+[[package]]
1424+name = "once_cell_polyfill"
1425+version = "1.70.2"
1426+source = "registry+https://github.com/rust-lang/crates.io-index"
1427+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
1428+
1429+[[package]]
1430+name = "opaque-debug"
1431+version = "0.3.1"
1432+source = "registry+https://github.com/rust-lang/crates.io-index"
1433+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
1434+
1435+[[package]]
1436+name = "parking"
1437+version = "2.2.1"
1438+source = "registry+https://github.com/rust-lang/crates.io-index"
1439+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
1440+
1441+[[package]]
1442+name = "percent-encoding"
1443+version = "2.3.2"
1444+source = "registry+https://github.com/rust-lang/crates.io-index"
1445+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1446+
1447+[[package]]
1448+name = "pin-project-lite"
1449+version = "0.2.17"
1450+source = "registry+https://github.com/rust-lang/crates.io-index"
1451+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1452+
1453+[[package]]
1454+name = "pkcs8"
1455+version = "0.10.2"
1456+source = "registry+https://github.com/rust-lang/crates.io-index"
1457+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
1458+dependencies = [
1459+ "der",
1460+ "spki",
1461+]
1462+
1463+[[package]]
1464+name = "pkg-config"
1465+version = "0.3.33"
1466+source = "registry+https://github.com/rust-lang/crates.io-index"
1467+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
1468+
1469+[[package]]
1470+name = "poly1305"
1471+version = "0.8.0"
1472+source = "registry+https://github.com/rust-lang/crates.io-index"
1473+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
1474+dependencies = [
1475+ "cpufeatures",
1476+ "opaque-debug",
1477+ "universal-hash",
1478+]
1479+
1480+[[package]]
1481+name = "potential_utf"
1482+version = "0.1.5"
1483+source = "registry+https://github.com/rust-lang/crates.io-index"
1484+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
1485+dependencies = [
1486+ "zerovec",
1487+]
1488+
1489+[[package]]
1490+name = "ppv-lite86"
1491+version = "0.2.21"
1492+source = "registry+https://github.com/rust-lang/crates.io-index"
1493+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
1494+dependencies = [
1495+ "zerocopy",
1496+]
1497+
1498+[[package]]
1499+name = "pretty_env_logger"
1500+version = "0.5.0"
1501+source = "registry+https://github.com/rust-lang/crates.io-index"
1502+checksum = "865724d4dbe39d9f3dd3b52b88d859d66bcb2d6a0acfd5ea68a65fb66d4bdc1c"
1503+dependencies = [
1504+ "env_logger",
1505+ "log",
1506+]
1507+
1508+[[package]]
1509+name = "proc-macro-error"
1510+version = "1.0.4"
1511+source = "registry+https://github.com/rust-lang/crates.io-index"
1512+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
1513+dependencies = [
1514+ "proc-macro-error-attr",
1515+ "proc-macro2",
1516+ "quote",
1517+ "syn 1.0.109",
1518+ "version_check",
1519+]
1520+
1521+[[package]]
1522+name = "proc-macro-error-attr"
1523+version = "1.0.4"
1524+source = "registry+https://github.com/rust-lang/crates.io-index"
1525+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
1526+dependencies = [
1527+ "proc-macro2",
1528+ "quote",
1529+ "version_check",
1530+]
1531+
1532+[[package]]
1533+name = "proc-macro2"
1534+version = "1.0.106"
1535+source = "registry+https://github.com/rust-lang/crates.io-index"
1536+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
1537+dependencies = [
1538+ "unicode-ident",
1539+]
1540+
1541+[[package]]
1542+name = "quote"
1543+version = "1.0.46"
1544+source = "registry+https://github.com/rust-lang/crates.io-index"
1545+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
1546+dependencies = [
1547+ "proc-macro2",
1548+]
1549+
1550+[[package]]
1551+name = "r-efi"
1552+version = "5.3.0"
1553+source = "registry+https://github.com/rust-lang/crates.io-index"
1554+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
1555+
1556+[[package]]
1557+name = "r-efi"
1558+version = "6.0.0"
1559+source = "registry+https://github.com/rust-lang/crates.io-index"
1560+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
1561+
1562+[[package]]
1563+name = "rand"
1564+version = "0.9.4"
1565+source = "registry+https://github.com/rust-lang/crates.io-index"
1566+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
1567+dependencies = [
1568+ "rand_chacha 0.9.0",
1569+ "rand_core 0.9.5",
1570+]
1571+
1572+[[package]]
1573+name = "rand_chacha"
1574+version = "0.3.1"
1575+source = "registry+https://github.com/rust-lang/crates.io-index"
1576+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
1577+dependencies = [
1578+ "ppv-lite86",
1579+ "rand_core 0.6.4",
1580+]
1581+
1582+[[package]]
1583+name = "rand_chacha"
1584+version = "0.9.0"
1585+source = "registry+https://github.com/rust-lang/crates.io-index"
1586+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
1587+dependencies = [
1588+ "ppv-lite86",
1589+ "rand_core 0.9.5",
1590+]
1591+
1592+[[package]]
1593+name = "rand_core"
1594+version = "0.6.4"
1595+source = "registry+https://github.com/rust-lang/crates.io-index"
1596+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1597+dependencies = [
1598+ "getrandom 0.2.17",
1599+]
1600+
1601+[[package]]
1602+name = "rand_core"
1603+version = "0.9.5"
1604+source = "registry+https://github.com/rust-lang/crates.io-index"
1605+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
1606+dependencies = [
1607+ "getrandom 0.3.4",
1608+]
1609+
1610+[[package]]
1611+name = "regex"
1612+version = "1.12.4"
1613+source = "registry+https://github.com/rust-lang/crates.io-index"
1614+checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
1615+dependencies = [
1616+ "aho-corasick",
1617+ "memchr",
1618+ "regex-automata",
1619+ "regex-syntax",
1620+]
1621+
1622+[[package]]
1623+name = "regex-automata"
1624+version = "0.4.14"
1625+source = "registry+https://github.com/rust-lang/crates.io-index"
1626+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
1627+dependencies = [
1628+ "aho-corasick",
1629+ "memchr",
1630+ "regex-syntax",
1631+]
1632+
1633+[[package]]
1634+name = "regex-syntax"
1635+version = "0.8.11"
1636+source = "registry+https://github.com/rust-lang/crates.io-index"
1637+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
1638+
1639+[[package]]
1640+name = "rustc-hash"
1641+version = "2.1.2"
1642+source = "registry+https://github.com/rust-lang/crates.io-index"
1643+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
1644+
1645+[[package]]
1646+name = "rustc_version"
1647+version = "0.4.1"
1648+source = "registry+https://github.com/rust-lang/crates.io-index"
1649+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
1650+dependencies = [
1651+ "semver",
1652+]
1653+
1654+[[package]]
1655+name = "rustversion"
1656+version = "1.0.22"
1657+source = "registry+https://github.com/rust-lang/crates.io-index"
1658+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
1659+
1660+[[package]]
1661+name = "ryu"
1662+version = "1.0.23"
1663+source = "registry+https://github.com/rust-lang/crates.io-index"
1664+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
1665+
1666+[[package]]
1667+name = "same-file"
1668+version = "1.0.6"
1669+source = "registry+https://github.com/rust-lang/crates.io-index"
1670+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
1671+dependencies = [
1672+ "winapi-util",
1673+]
1674+
1675+[[package]]
1676+name = "semver"
1677+version = "1.0.28"
1678+source = "registry+https://github.com/rust-lang/crates.io-index"
1679+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
1680+
1681+[[package]]
1682+name = "serde"
1683+version = "1.0.228"
1684+source = "registry+https://github.com/rust-lang/crates.io-index"
1685+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
1686+dependencies = [
1687+ "serde_core",
1688+ "serde_derive",
1689+]
1690+
1691+[[package]]
1692+name = "serde_bytes"
1693+version = "0.11.19"
1694+source = "registry+https://github.com/rust-lang/crates.io-index"
1695+checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
1696+dependencies = [
1697+ "serde",
1698+ "serde_core",
1699+]
1700+
1701+[[package]]
1702+name = "serde_core"
1703+version = "1.0.228"
1704+source = "registry+https://github.com/rust-lang/crates.io-index"
1705+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
1706+dependencies = [
1707+ "serde_derive",
1708+]
1709+
1710+[[package]]
1711+name = "serde_derive"
1712+version = "1.0.228"
1713+source = "registry+https://github.com/rust-lang/crates.io-index"
1714+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
1715+dependencies = [
1716+ "proc-macro2",
1717+ "quote",
1718+ "syn 2.0.118",
1719+]
1720+
1721+[[package]]
1722+name = "serde_json"
1723+version = "1.0.150"
1724+source = "registry+https://github.com/rust-lang/crates.io-index"
1725+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
1726+dependencies = [
1727+ "itoa",
1728+ "memchr",
1729+ "serde",
1730+ "serde_core",
1731+ "zmij",
1732+]
1733+
1734+[[package]]
1735+name = "serde_qs"
1736+version = "0.15.0"
1737+source = "registry+https://github.com/rust-lang/crates.io-index"
1738+checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352"
1739+dependencies = [
1740+ "percent-encoding",
1741+ "serde",
1742+ "thiserror",
1743+]
1744+
1745+[[package]]
1746+name = "serde_qs"
1747+version = "1.1.2"
1748+source = "registry+https://github.com/rust-lang/crates.io-index"
1749+checksum = "67d525c8ff68aa99e5818302259bdd02d86d0303710616f39c0f44846ff6d332"
1750+dependencies = [
1751+ "itoa",
1752+ "percent-encoding",
1753+ "ryu",
1754+ "serde",
1755+]
1756+
1757+[[package]]
1758+name = "serde_urlencoded"
1759+version = "0.7.1"
1760+source = "registry+https://github.com/rust-lang/crates.io-index"
1761+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
1762+dependencies = [
1763+ "form_urlencoded",
1764+ "itoa",
1765+ "ryu",
1766+ "serde",
1767+]
1768+
1769+[[package]]
1770+name = "sha2"
1771+version = "0.10.9"
1772+source = "registry+https://github.com/rust-lang/crates.io-index"
1773+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
1774+dependencies = [
1775+ "cfg-if",
1776+ "cpufeatures",
1777+ "digest",
1778+]
1779+
1780+[[package]]
1781+name = "shlex"
1782+version = "2.0.1"
1783+source = "registry+https://github.com/rust-lang/crates.io-index"
1784+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
1785+
1786+[[package]]
1787+name = "signature"
1788+version = "2.2.0"
1789+source = "registry+https://github.com/rust-lang/crates.io-index"
1790+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
1791+dependencies = [
1792+ "rand_core 0.6.4",
1793+]
1794+
1795+[[package]]
1796+name = "skrepka_core"
1797+version = "0.1.0"
1798+dependencies = [
1799+ "anyhow",
1800+ "base64",
1801+ "boltffi",
1802+ "chacha20poly1305",
1803+ "clap",
1804+ "crux_core",
1805+ "crux_http",
1806+ "crux_kv",
1807+ "crux_time",
1808+ "curve25519-dalek",
1809+ "ed25519-dalek",
1810+ "facet 0.44.5",
1811+ "hex",
1812+ "hkdf",
1813+ "log",
1814+ "pretty_env_logger",
1815+ "rand_chacha 0.3.1",
1816+ "rand_core 0.6.4",
1817+ "serde",
1818+ "serde_json",
1819+ "sha2",
1820+ "url",
1821+ "uuid",
1822+ "x25519-dalek",
1823+ "zeroize",
1824+ "zstd",
1825+]
1826+
1827+[[package]]
1828+name = "slab"
1829+version = "0.4.12"
1830+source = "registry+https://github.com/rust-lang/crates.io-index"
1831+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
1832+
1833+[[package]]
1834+name = "smallvec"
1835+version = "1.15.2"
1836+source = "registry+https://github.com/rust-lang/crates.io-index"
1837+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
1838+
1839+[[package]]
1840+name = "smawk"
1841+version = "0.3.3"
1842+source = "registry+https://github.com/rust-lang/crates.io-index"
1843+checksum = "e8e2fb0f499abb4d162f2bedad68f5ef91a1682b5a03596ddb67efd37768d100"
1844+
1845+[[package]]
1846+name = "spki"
1847+version = "0.7.3"
1848+source = "registry+https://github.com/rust-lang/crates.io-index"
1849+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
1850+dependencies = [
1851+ "base64ct",
1852+ "der",
1853+]
1854+
1855+[[package]]
1856+name = "stable_deref_trait"
1857+version = "1.2.1"
1858+source = "registry+https://github.com/rust-lang/crates.io-index"
1859+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1860+
1861+[[package]]
1862+name = "static_assertions"
1863+version = "1.1.0"
1864+source = "registry+https://github.com/rust-lang/crates.io-index"
1865+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
1866+
1867+[[package]]
1868+name = "strsim"
1869+version = "0.11.1"
1870+source = "registry+https://github.com/rust-lang/crates.io-index"
1871+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
1872+
1873+[[package]]
1874+name = "subtle"
1875+version = "2.6.1"
1876+source = "registry+https://github.com/rust-lang/crates.io-index"
1877+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
1878+
1879+[[package]]
1880+name = "syn"
1881+version = "1.0.109"
1882+source = "registry+https://github.com/rust-lang/crates.io-index"
1883+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
1884+dependencies = [
1885+ "proc-macro2",
1886+ "unicode-ident",
1887+]
1888+
1889+[[package]]
1890+name = "syn"
1891+version = "2.0.118"
1892+source = "registry+https://github.com/rust-lang/crates.io-index"
1893+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
1894+dependencies = [
1895+ "proc-macro2",
1896+ "quote",
1897+ "unicode-ident",
1898+]
1899+
1900+[[package]]
1901+name = "synstructure"
1902+version = "0.13.2"
1903+source = "registry+https://github.com/rust-lang/crates.io-index"
1904+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
1905+dependencies = [
1906+ "proc-macro2",
1907+ "quote",
1908+ "syn 2.0.118",
1909+]
1910+
1911+[[package]]
1912+name = "termcolor"
1913+version = "1.4.1"
1914+source = "registry+https://github.com/rust-lang/crates.io-index"
1915+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
1916+dependencies = [
1917+ "winapi-util",
1918+]
1919+
1920+[[package]]
1921+name = "textwrap"
1922+version = "0.16.2"
1923+source = "registry+https://github.com/rust-lang/crates.io-index"
1924+checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
1925+dependencies = [
1926+ "smawk",
1927+ "unicode-linebreak",
1928+ "unicode-width",
1929+]
1930+
1931+[[package]]
1932+name = "thiserror"
1933+version = "2.0.18"
1934+source = "registry+https://github.com/rust-lang/crates.io-index"
1935+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
1936+dependencies = [
1937+ "thiserror-impl",
1938+]
1939+
1940+[[package]]
1941+name = "thiserror-impl"
1942+version = "2.0.18"
1943+source = "registry+https://github.com/rust-lang/crates.io-index"
1944+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
1945+dependencies = [
1946+ "proc-macro2",
1947+ "quote",
1948+ "syn 2.0.118",
1949+]
1950+
1951+[[package]]
1952+name = "tinystr"
1953+version = "0.8.3"
1954+source = "registry+https://github.com/rust-lang/crates.io-index"
1955+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
1956+dependencies = [
1957+ "displaydoc",
1958+ "zerovec",
1959+]
1960+
1961+[[package]]
1962+name = "typenum"
1963+version = "1.20.1"
1964+source = "registry+https://github.com/rust-lang/crates.io-index"
1965+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
1966+
1967+[[package]]
1968+name = "unicode-ident"
1969+version = "1.0.24"
1970+source = "registry+https://github.com/rust-lang/crates.io-index"
1971+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
1972+
1973+[[package]]
1974+name = "unicode-linebreak"
1975+version = "0.1.5"
1976+source = "registry+https://github.com/rust-lang/crates.io-index"
1977+checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
1978+
1979+[[package]]
1980+name = "unicode-width"
1981+version = "0.2.2"
1982+source = "registry+https://github.com/rust-lang/crates.io-index"
1983+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
1984+
1985+[[package]]
1986+name = "universal-hash"
1987+version = "0.5.1"
1988+source = "registry+https://github.com/rust-lang/crates.io-index"
1989+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
1990+dependencies = [
1991+ "crypto-common",
1992+ "subtle",
1993+]
1994+
1995+[[package]]
1996+name = "unsynn"
1997+version = "0.3.0"
1998+source = "registry+https://github.com/rust-lang/crates.io-index"
1999+checksum = "501a7adf1a4bd9951501e5c66621e972ef8874d787628b7f90e64f936ef7ec0a"
2000+dependencies = [
2001+ "mutants",
2002+ "proc-macro2",
2003+ "rustc-hash",
2004+]
2005+
2006+[[package]]
2007+name = "url"
2008+version = "2.5.8"
2009+source = "registry+https://github.com/rust-lang/crates.io-index"
2010+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
2011+dependencies = [
2012+ "form_urlencoded",
2013+ "idna",
2014+ "percent-encoding",
2015+ "serde",
2016+ "serde_derive",
2017+]
2018+
2019+[[package]]
2020+name = "utf8_iter"
2021+version = "1.0.4"
2022+source = "registry+https://github.com/rust-lang/crates.io-index"
2023+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
2024+
2025+[[package]]
2026+name = "utf8parse"
2027+version = "0.2.2"
2028+source = "registry+https://github.com/rust-lang/crates.io-index"
2029+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
2030+
2031+[[package]]
2032+name = "uuid"
2033+version = "1.23.4"
2034+source = "registry+https://github.com/rust-lang/crates.io-index"
2035+checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
2036+dependencies = [
2037+ "getrandom 0.4.3",
2038+ "js-sys",
2039+ "wasm-bindgen",
2040+]
2041+
2042+[[package]]
2043+name = "version_check"
2044+version = "0.9.5"
2045+source = "registry+https://github.com/rust-lang/crates.io-index"
2046+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2047+
2048+[[package]]
2049+name = "walkdir"
2050+version = "2.5.0"
2051+source = "registry+https://github.com/rust-lang/crates.io-index"
2052+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
2053+dependencies = [
2054+ "same-file",
2055+ "winapi-util",
2056+]
2057+
2058+[[package]]
2059+name = "wasi"
2060+version = "0.11.1+wasi-snapshot-preview1"
2061+source = "registry+https://github.com/rust-lang/crates.io-index"
2062+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
2063+
2064+[[package]]
2065+name = "wasip2"
2066+version = "1.0.4+wasi-0.2.12"
2067+source = "registry+https://github.com/rust-lang/crates.io-index"
2068+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
2069+dependencies = [
2070+ "wit-bindgen",
2071+]
2072+
2073+[[package]]
2074+name = "wasm-bindgen"
2075+version = "0.2.126"
2076+source = "registry+https://github.com/rust-lang/crates.io-index"
2077+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
2078+dependencies = [
2079+ "cfg-if",
2080+ "once_cell",
2081+ "rustversion",
2082+ "wasm-bindgen-macro",
2083+ "wasm-bindgen-shared",
2084+]
2085+
2086+[[package]]
2087+name = "wasm-bindgen-macro"
2088+version = "0.2.126"
2089+source = "registry+https://github.com/rust-lang/crates.io-index"
2090+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
2091+dependencies = [
2092+ "quote",
2093+ "wasm-bindgen-macro-support",
2094+]
2095+
2096+[[package]]
2097+name = "wasm-bindgen-macro-support"
2098+version = "0.2.126"
2099+source = "registry+https://github.com/rust-lang/crates.io-index"
2100+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
2101+dependencies = [
2102+ "bumpalo",
2103+ "proc-macro2",
2104+ "quote",
2105+ "syn 2.0.118",
2106+ "wasm-bindgen-shared",
2107+]
2108+
2109+[[package]]
2110+name = "wasm-bindgen-shared"
2111+version = "0.2.126"
2112+source = "registry+https://github.com/rust-lang/crates.io-index"
2113+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
2114+dependencies = [
2115+ "unicode-ident",
2116+]
2117+
2118+[[package]]
2119+name = "web-sys"
2120+version = "0.3.103"
2121+source = "registry+https://github.com/rust-lang/crates.io-index"
2122+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
2123+dependencies = [
2124+ "js-sys",
2125+ "wasm-bindgen",
2126+]
2127+
2128+[[package]]
2129+name = "winapi-util"
2130+version = "0.1.11"
2131+source = "registry+https://github.com/rust-lang/crates.io-index"
2132+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
2133+dependencies = [
2134+ "windows-sys",
2135+]
2136+
2137+[[package]]
2138+name = "windows-core"
2139+version = "0.62.2"
2140+source = "registry+https://github.com/rust-lang/crates.io-index"
2141+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
2142+dependencies = [
2143+ "windows-implement",
2144+ "windows-interface",
2145+ "windows-link",
2146+ "windows-result",
2147+ "windows-strings",
2148+]
2149+
2150+[[package]]
2151+name = "windows-implement"
2152+version = "0.60.2"
2153+source = "registry+https://github.com/rust-lang/crates.io-index"
2154+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
2155+dependencies = [
2156+ "proc-macro2",
2157+ "quote",
2158+ "syn 2.0.118",
2159+]
2160+
2161+[[package]]
2162+name = "windows-interface"
2163+version = "0.59.3"
2164+source = "registry+https://github.com/rust-lang/crates.io-index"
2165+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
2166+dependencies = [
2167+ "proc-macro2",
2168+ "quote",
2169+ "syn 2.0.118",
2170+]
2171+
2172+[[package]]
2173+name = "windows-link"
2174+version = "0.2.1"
2175+source = "registry+https://github.com/rust-lang/crates.io-index"
2176+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
2177+
2178+[[package]]
2179+name = "windows-result"
2180+version = "0.4.1"
2181+source = "registry+https://github.com/rust-lang/crates.io-index"
2182+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
2183+dependencies = [
2184+ "windows-link",
2185+]
2186+
2187+[[package]]
2188+name = "windows-strings"
2189+version = "0.5.1"
2190+source = "registry+https://github.com/rust-lang/crates.io-index"
2191+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
2192+dependencies = [
2193+ "windows-link",
2194+]
2195+
2196+[[package]]
2197+name = "windows-sys"
2198+version = "0.61.2"
2199+source = "registry+https://github.com/rust-lang/crates.io-index"
2200+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
2201+dependencies = [
2202+ "windows-link",
2203+]
2204+
2205+[[package]]
2206+name = "wit-bindgen"
2207+version = "0.57.1"
2208+source = "registry+https://github.com/rust-lang/crates.io-index"
2209+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
2210+
2211+[[package]]
2212+name = "writeable"
2213+version = "0.6.3"
2214+source = "registry+https://github.com/rust-lang/crates.io-index"
2215+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
2216+
2217+[[package]]
2218+name = "x25519-dalek"
2219+version = "2.0.1"
2220+source = "registry+https://github.com/rust-lang/crates.io-index"
2221+checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
2222+dependencies = [
2223+ "curve25519-dalek",
2224+ "rand_core 0.6.4",
2225+ "serde",
2226+ "zeroize",
2227+]
2228+
2229+[[package]]
2230+name = "yoke"
2231+version = "0.8.3"
2232+source = "registry+https://github.com/rust-lang/crates.io-index"
2233+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
2234+dependencies = [
2235+ "stable_deref_trait",
2236+ "yoke-derive",
2237+ "zerofrom",
2238+]
2239+
2240+[[package]]
2241+name = "yoke-derive"
2242+version = "0.8.2"
2243+source = "registry+https://github.com/rust-lang/crates.io-index"
2244+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
2245+dependencies = [
2246+ "proc-macro2",
2247+ "quote",
2248+ "syn 2.0.118",
2249+ "synstructure",
2250+]
2251+
2252+[[package]]
2253+name = "zerocopy"
2254+version = "0.8.52"
2255+source = "registry+https://github.com/rust-lang/crates.io-index"
2256+checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
2257+dependencies = [
2258+ "zerocopy-derive",
2259+]
2260+
2261+[[package]]
2262+name = "zerocopy-derive"
2263+version = "0.8.52"
2264+source = "registry+https://github.com/rust-lang/crates.io-index"
2265+checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
2266+dependencies = [
2267+ "proc-macro2",
2268+ "quote",
2269+ "syn 2.0.118",
2270+]
2271+
2272+[[package]]
2273+name = "zerofrom"
2274+version = "0.1.8"
2275+source = "registry+https://github.com/rust-lang/crates.io-index"
2276+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
2277+dependencies = [
2278+ "zerofrom-derive",
2279+]
2280+
2281+[[package]]
2282+name = "zerofrom-derive"
2283+version = "0.1.7"
2284+source = "registry+https://github.com/rust-lang/crates.io-index"
2285+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
2286+dependencies = [
2287+ "proc-macro2",
2288+ "quote",
2289+ "syn 2.0.118",
2290+ "synstructure",
2291+]
2292+
2293+[[package]]
2294+name = "zeroize"
2295+version = "1.9.0"
2296+source = "registry+https://github.com/rust-lang/crates.io-index"
2297+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
2298+dependencies = [
2299+ "zeroize_derive",
2300+]
2301+
2302+[[package]]
2303+name = "zeroize_derive"
2304+version = "1.5.0"
2305+source = "registry+https://github.com/rust-lang/crates.io-index"
2306+checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
2307+dependencies = [
2308+ "proc-macro2",
2309+ "quote",
2310+ "syn 2.0.118",
2311+]
2312+
2313+[[package]]
2314+name = "zerotrie"
2315+version = "0.2.4"
2316+source = "registry+https://github.com/rust-lang/crates.io-index"
2317+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
2318+dependencies = [
2319+ "displaydoc",
2320+ "yoke",
2321+ "zerofrom",
2322+]
2323+
2324+[[package]]
2325+name = "zerovec"
2326+version = "0.11.6"
2327+source = "registry+https://github.com/rust-lang/crates.io-index"
2328+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
2329+dependencies = [
2330+ "yoke",
2331+ "zerofrom",
2332+ "zerovec-derive",
2333+]
2334+
2335+[[package]]
2336+name = "zerovec-derive"
2337+version = "0.11.3"
2338+source = "registry+https://github.com/rust-lang/crates.io-index"
2339+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
2340+dependencies = [
2341+ "proc-macro2",
2342+ "quote",
2343+ "syn 2.0.118",
2344+]
2345+
2346+[[package]]
2347+name = "zmij"
2348+version = "1.0.21"
2349+source = "registry+https://github.com/rust-lang/crates.io-index"
2350+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
2351+
2352+[[package]]
2353+name = "zstd"
2354+version = "0.13.3"
2355+source = "registry+https://github.com/rust-lang/crates.io-index"
2356+checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
2357+dependencies = [
2358+ "zstd-safe",
2359+]
2360+
2361+[[package]]
2362+name = "zstd-safe"
2363+version = "7.2.4"
2364+source = "registry+https://github.com/rust-lang/crates.io-index"
2365+checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
2366+dependencies = [
2367+ "zstd-sys",
2368+]
2369+
2370+[[package]]
2371+name = "zstd-sys"
2372+version = "2.0.16+zstd.1.5.7"
2373+source = "registry+https://github.com/rust-lang/crates.io-index"
2374+checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
2375+dependencies = [
2376+ "cc",
2377+ "pkg-config",
2378+]
Cargo.tomlnew file+54
@@ -0,0 +1,54 @@
1+[workspace]
2+members = ["core"]
3+resolver = "2"
4+
5+# The curve arithmetic is ~100x slower unoptimized, and the tests do real
6+# signing and real X25519 — a poll page of a thousand messages is minutes of
7+# debug-mode scalar mults. Optimizing just these crates keeps `cargo test` in
8+# seconds while everything we actually debug stays at opt-level 0.
9+[profile.dev.package.curve25519-dalek]
10+opt-level = 3
11+[profile.dev.package.ed25519-dalek]
12+opt-level = 3
13+[profile.dev.package.x25519-dalek]
14+opt-level = 3
15+[profile.dev.package.zstd-sys]
16+opt-level = 3
17+
18+[workspace.package]
19+edition = "2021"
20+rust-version = "1.90"
21+license = "MIT"
22+repository = "https://github.com/ilyakooo0/skrepka"
23+
24+[workspace.dependencies]
25+crux_core = "0.19"
26+crux_http = "0.18"
27+crux_kv = "0.13"
28+crux_time = "0.17"
29+boltffi = "0.26"
30+facet = { version = "=0.44", features = ["chrono"] }
31+serde = { version = "1.0", features = ["derive", "rc"] }
32+serde_json = "1.0"
33+
34+# crypto
35+ed25519-dalek = { version = "2", features = ["rand_core"] }
36+x25519-dalek = { version = "2", features = ["static_secrets"] }
37+curve25519-dalek = "4"
38+chacha20poly1305 = "0.10"
39+hkdf = "0.12"
40+sha2 = "0.10"
41+zstd = "0.13"
42+rand_core = { version = "0.6", features = ["getrandom"] }
43+rand_chacha = "0.3"
44+uuid = { version = "1", features = ["v4"] }
45+hex = "0.4"
46+base64 = "0.22"
47+zeroize = { version = "1", features = ["derive"] }
48+url = "2"
49+
50+# codegen bin
51+anyhow = "1.0"
52+clap = { version = "4", features = ["derive"] }
53+log = "0.4"
54+pretty_env_logger = "0.5"
Justfilenew file+49
@@ -0,0 +1,49 @@
1+# Skrepka iOS client (Rust core + Crux + SwiftUI shell)
2+#
3+# One-time setup:
4+# rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
5+# cargo install boltffi_cli
6+# brew install xcodegen
7+
8+sim_id := env_var_or_default("SIM_ID", "")
9+bundle := "lol.skrepka.SkrepkaApp-iOS"
10+
11+default: test
12+
13+# Run the Rust core test suite (crypto, protocol, @p, state machine).
14+test:
15+ cargo test -p skrepka_core
16+
17+# Generate the Swift type bindings from the core.
18+typegen:
19+ cargo run -q -p skrepka_core --bin codegen --features codegen -- \
20+ --language swift --output-dir apple/generated/App
21+
22+# Build the core for Apple and package the xcframework + Swift package.
23+pack:
24+ cd core && boltffi pack apple
25+
26+# Generate the Xcode project from project.yml.
27+project:
28+ cd apple && xcodegen generate
29+
30+# Full regeneration: types, xcframework, Xcode project.
31+generate: typegen pack project
32+
33+# Build the iOS app for a booted simulator (set SIM_ID, or pass a destination).
34+build sim=sim_id:
35+ @test -n "{{sim}}" || ( echo "error: no simulator. Set SIM_ID=<udid> or run: just build <udid>"; echo " list booted devices with: xcrun simctl list devices booted"; exit 1 )
36+ cd apple && xcodebuild -project SkrepkaApp.xcodeproj -scheme SkrepkaApp-iOS \
37+ -destination 'platform=iOS Simulator,id={{sim}}' \
38+ -derivedDataPath build -skipPackagePluginValidation build
39+
40+# Build, then install + launch on a booted simulator.
41+run sim=sim_id: (build sim)
42+ cd apple && xcrun simctl install {{sim}} \
43+ build/Build/Products/Debug-iphonesimulator/SkrepkaApp-iOS.app
44+ xcrun simctl launch {{sim}} {{bundle}}
45+
46+# Run a local relay server for testing (resets the disposable relay db first).
47+server:
48+ rm -f server.db server.db-wal server.db-shm
49+ ./server
PROTOCOL-REVIEW.mdnew file+416
@@ -0,0 +1,416 @@
1+# PROTOCOL.md Review — Holes vs. Implementation
2+
3+**Date:** 2026-06-20 (revised 2026-07-11 against the Rust core)
4+**Scope:** Cross-check of gaps found in `PROTOCOL.md` against the actual code
5+(`server.knot`, `core/` — the Rust/Crux shared core). Each item records whether
6+the implementation already handles it, partially handles it, or leaves it open.
7+
8+> The original review was written against the F# client, which has since been
9+> replaced by the Rust core in `core/` (logic) plus a logic-free SwiftUI shell in
10+> `apple/`. Citations below point at the Rust code; findings that the rewrite
11+> closed are marked as such.
12+
13+Legend: ✅ fixed · 🟡 partial / mitigated · ❌ open · ⚪ not-a-bug (spec wording only)
14+
15+---
16+
17+## ✅ 1. Auth challenge is not bound to the server (session relay) — fixed
18+
19+**Severity: high — fixed.**
20+
21+The client signed the bare challenge string and the server verified the bare
22+challenge string. Nothing bound the signed material to a server identity, so a
23+relay could forward another server's challenge and replay the signature.
24+
25+**Attack (historical):** a malicious/relay server the client connects to fetched
26+a challenge for the client's pubkey from a legit server A, handed it to the
27+client, collected the signature, and replayed it to A's `/auth/verify` —
28+obtaining a session **as the client** on A (could `/poll` the client's mailbox
29+and send as them). The challenge's IP-binding (`matchIp`) did not help, because
30+the relay server was the client-of-record to A throughout.
31+
32+**Fix (applied):** the client now signs
33+`"skrepka-auth-v1:" + server_host + ":" + challenge` where `server_host` is the
34+bare lowercased hostname it dialed (`Identity::sign_challenge`,
35+`core/src/crypto.rs`; the tag is `AUTH_TAG`), and the server verifies against
36+`"skrepka-auth-v1:" ++ beforeColon serverHost ++ ":" ++ challenge` using its own
37+configured `serverHost` (`server.knot` `handleVerifyAuth`). The `skrepka-auth-v1:`
38+prefix is a domain-separation tag so an auth signature can never be confused with
39+a message signature (which is over `recipientPub || compressed`). Breaking change:
40+client and server must be upgraded together.
41+
42+Operational corollary: a relay's `serverHost` **must** be the public hostname
43+clients dial, or every signature fails to verify. It defaults to `"localhost"`,
44+so `install.sh` passes `--serverHost=${DOMAIN}` in the systemd unit it writes.
45+
46+---
47+
48+## ❌ 2. Gossip presence spoofing redirects a victim's mail to the attacker
49+
50+**Severity: high — open (consequence of open federation).**
51+
52+`/federation/gossip` is unauthenticated. On a newly-`online` event the origin
53+forwards the recipient's queued messages **to the announcing `fromServer`**
54+(`handleRecvGossip` → `forwardQueuedToServer pk fromServer`, `server.knot`).
55+
56+**Attack:** a hostile peer announces `{eventType:"online", pubkey:<victim>,
57+fromServer:<attacker>}`. The origin forwards all of the victim's queued
58+ciphertext to the attacker, who harvests ciphertext copies + recipient/timing/size
59+metadata. The receiver-side "recipient must be locally online" gate is checked by
60+the attacker's own server, so it is not a real gate.
61+
62+**Existing partial defense:** the `isBadServerName` SSRF deny-list blocks
63+internal targets, and gossip is rate-limited per peer IP. Neither authenticates
64+the peer, and the deny-list is purely lexical — a public-looking hostname under
65+attacker DNS control still passes (documented at `isBadServerName`).
66+
67+`PROTOCOL.md` §10 understates this as "a hostile server can observe gossip" — it
68+can actively **redirect delivery**. At minimum the spec should say so; a real fix
69+needs signed/authenticated presence or peer allow-listing.
70+
71+---
72+
73+## ✅ 3. Replay protection is narrower than §10 claims — closed in the Rust core
74+
75+**Severity: medium — fixed.**
76+
77+§10 claimed "Replay protection"; the underlying mechanism is the
78+recipient-bound signature, which stops replay to a *different* recipient, not
79+re-delivery of the same blob to the same recipient. Per-type status, all in
80+`ingest_poll` (`core/src/app.rs`):
81+
82+- `text` — ✅ deduplicated by `id` before it is appended to a conversation.
83+- `delivery.ack` — ✅ harmless: it only flips `Sent → Delivered`, idempotent.
84+- `profile` — ✅ **fixed** (was open in the F# client): each `Contact` carries
85+ `last_profile_ts` (`core/src/model.rs`), and an incoming `profile` whose `ts`
86+ predates it is dropped instead of applied, so a replayed *older* profile can no
87+ longer roll a contact's name/bio/photo back.
88+
89+`PROTOCOL.md` §4 and §10 have been updated to say the reference client enforces
90+the staleness check. §10's replay wording should still be read as "replay against
91+a *different* recipient" — re-delivery to the same recipient is prevented by the
92+per-type guards above, not by the crypto.
93+
94+---
95+
96+## 🟡 4. Open `/federation/forward` allows mailbox injection
97+
98+**Severity: medium — mitigated, not closed.**
99+
100+Any host can POST a blob for any locally-online `toKey`; the endpoint is
101+unauthenticated.
102+
103+- Mitigations present: per-IP rate limit (`forwardRateLimit`, `server.knot`),
104+ the SSRF filter on origins, and the `no_presence` gate.
105+- Residual: junk blobs still reach an online recipient's poll stream; the spec
106+ never says what a client does with an undecryptable blob. The core drops it
107+ silently — `crypto::decrypt` returning `Err` just `continue`s the ingest loop
108+ (`ingest_poll`, `core/src/app.rs`). Worth documenting as an accepted,
109+ rate-limited spam surface.
110+
111+---
112+
113+## ⚪ 5. `receivedAt` "millisecond timestamp" vs "monotonic" — wording only
114+
115+**Not a bug.** `appendMessage` assigns `seqTs = max t (currentSeq seqRows + 1)`
116+(`server.knot`), which is **strictly monotonic**, so no two messages share a
117+`receivedAt` and the `cursor >= receivedAt` implicit-ack cannot drop an unseen
118+message. The spec just describes the same value as both a "millisecond timestamp"
119+and "monotonic," which reads as ambiguous. Recommend: describe it as "a
120+monotonic sequence seeded from millisecond wall-clock; treat as an opaque
121+checkpoint, not a clock."
122+
123+---
124+
125+## ✅ 6. Total request-body cap — fixed in deployment, undocumented in spec
126+
127+The per-blob cap is `maxBlobLen` (40 MiB hex) enforced by the `BlobHex` type, and
128+a batch is capped at `maxBatchSize = 100` (`server.knot`). The naive reading
129+"100 × 40 MiB = 4 GiB request" is **prevented** by a runtime body cap set in
130+deployment:
131+
132+- `install.sh` — `ExecStart=… --http-max-body-bytes=42M` caps the *entire*
133+ request body (chosen just above `maxBlobLen` + envelope; the runtime default is
134+ 16 MiB, which would reject a legitimate max-size blob with a bare 413).
135+
136+So a batch can carry one max-size blob or many small ones, but total ≤ 42 MiB.
137+`PROTOCOL.md` §5 should state this total-body cap (and that operators must keep
138+`--http-max-body-bytes` > `maxBlobLen`).
139+
140+---
141+
142+## ✅ 7. No crypto / wire-format version negotiation — fixed
143+
144+**Severity: low — fixed.**
145+
146+`HKDF_INFO = "skrepka-v1"` is hardcoded (`core/src/crypto.rs`), and the plaintext payload carries no version field. The wire format now carries a leading version byte (`WIRE_VERSION = 0x01`, `core/src/crypto.rs`): `encrypt` prepends it, and `decrypt` reads and checks it before any other processing, returning `CryptoError::Decrypt` on mismatch. `MIN_BLOB_LEN` includes the extra byte (169 bytes). "Ignore unknown `type`" (`protocol::parse_payload`) gives payload-type agility; the version byte gives the same for the wire format, AEAD, KDF, and curve choices. A future revision that changes crypto primitives should bump both the version byte and the HKDF `info` string.
147+
148+---
149+
150+## ⚪ 8. X-Forwarded-For trust is spoofable without a proxy — by design
151+
152+`trustForwardedFor = True` by default (`server.knot`); the session/rate-limit IP
153+is the last XFF hop (`pickClientIp`), and `matchIp` treats an empty bound IP as a
154+wildcard. This is correct **behind a trusted proxy** (the `install.sh` topology,
155+Caddy in front) but spoofable when the server is directly exposed — a client could
156+forge the header to mint fresh rate-limit buckets. The flag and its
157+`--trustForwardedFor=False` override exist; the spec presents IP-binding as a
158+protection without the proxy caveat. Recommend documenting it.
159+
160+---
161+
162+## Minor / noted
163+
164+- The SSRF deny-list (`isBadServerName`) is **lexical only**: it filters the
165+ hostname string, never the address it resolves to, so an attacker-controlled
166+ name that resolves to a private IP passes. Closing it requires resolve-then-pin,
167+ which the runtime's `fetch` does not expose. Documented in place.
168+- HKDF salt is `ephemeral_pub || recipient_x25519_pub` (both public), binds no
169+ sender — fine for confidentiality, no KCI/UKS guarantee beyond the signature.
170+- Presence TTL (90 min, `onlineGossipTtl`) outlives the 1 h session, so location
171+ metadata and stale forwards can persist ~90 min after disconnect.
172+- `/auth/challenge` is unauthenticated; fillable by anyone but capped
173+ (`maxChallenges = 10000`, `maxChallengesPerKey = 5`) and rate-limited per
174+ client IP. It is deliberately **not** limited per submitted pubkey: keypairs are
175+ free, so a pubkey-keyed bucket refills on every rotation (Sybil), and it would
176+ also let an attacker exhaust a victim's login budget.
177+
178+---
179+
180+## 🟡 9. Federation retry loop can starve the prune sweep
181+
182+**Severity: low — design tradeoff, not a code bug.**
183+
184+`backgroundRetryForwards` runs `retryPendingForwards` inline (not via `fork`),
185+so a sweep that hits many slow or timing-out peers blocks until every
186+`fetch` in the batch resolves. Each `fetch` has no explicit timeout — the
187+runtime's HTTP client holds the connection — so a sweep over N unreachable
188+peers can take N × (TCP connect timeout) before returning. During that time
189+`backgroundPrune` (on its own `fork`) is free to run, but its writes to
190+`*messages` and `*forwardFailures` conflict with `retryPendingForwards`'s
191+`atomic` blocks, so `pruneExpired`'s atomic retries lose to the retry loop
192+and are effectively starved: expired messages, sessions, and challenges
193+pile up until the retry sweep finishes.
194+
195+In practice this only matters under federation with many dead peers and a
196+low `retryBackoffMax` — the `shouldRetryServer` gate skips peers in backoff,
197+so after one failed sweep most peers are gated out of the next one. The
198+first sweep after a network partition is the worst case.
199+
200+**No fix applied.** The deliberate design (inline, not forked) prevents two
201+concurrent `retryPendingForwards` from seeing the same `*forwards` snapshot
202+and double-counting `recordFailure`. Forking it would reintroduce that race.
203+A real fix would either (a) give `fetch` a deadline and bound the sweep
204+time, or (b) run `retryPendingForwards` in a transaction that yields
205+`*messages`/`*forwardFailures` to `pruneExpired` between per-peer batches.
206+Both need runtime support the current `fetch` does not expose.
207+
208+---
209+
210+## Suggested follow-ups
211+
212+1. **Spec edits (safe, no behavior change):** fold #2, #4, #6, #8 caveats into
213+ `PROTOCOL.md` (§6 auth, §7 federation, §5 size, §10 security). #3's spec text
214+ is already updated.
215+2. **Code fixes:** #1 (bind challenge to server) and #3 (profile `ts` staleness
216+ guard) are **done**.
217+3. #2 and #4 are inherent to open, unauthenticated federation — decide whether to
218+ document-as-accepted or add peer authentication.
219+4. **#9** is a design tradeoff; document as accepted until `fetch` supports a
220+ deadline.
221+
222+---
223+
224+## Findings blocked on runtime/protocol support (2026-07-13 audit)
225+
226+These findings are documented here for tracking. Each is blocked on a capability
227+the current runtime or protocol does not expose, so no code fix is possible
228+today.
229+
230+### ❌ A. `now_ms()` impurity breaks MVU replayability
231+
232+**Severity: medium — blocked on `crux_time` refactor.**
233+
234+`now_ms()` reads the system clock from inside `update()` (app.rs:427), which is
235+supposed to be a pure function of `(Event, Model)`. This affects `SendText`,
236+`SaveProfile`, `ingest_poll`, `flush_next` (first_attempt stamping), and ack
237+timestamping. The state machine cannot be replayed deterministically, and tests
238+cannot pin "now".
239+
240+The fix is to take the time as a `crux_time` effect and feed it back as an
241+event — but every timestamped path would become a two-step round-trip through
242+the shell. A TODO comment is in place at the call site. This refactor is worth
243+doing but is not a drive-by fix.
244+
245+### ❌ B. Token comparison timing side-channel
246+
247+**Severity: medium — blocked on Knot runtime.**
248+
249+`s.token == token` is plain string equality, which short-circuits on the first
250+differing byte (server.knot:761). An attacker who can time the response learns
251+how many leading characters of a guess were correct. Bounded by the 192-bit
252+token and per-IP rate limit. The correct fix is a `constantTimeEquals` primitive
253+in the Knot runtime. Documented in PROTOCOL.md §6 and in-code comments.
254+
255+### ❌ C. Open federation — gossip redirect & forward injection
256+
257+**Severity: high — accepted v0.1 limitation, blocked on peer authentication.**
258+
259+Both `/federation/*` endpoints are unauthenticated. An attacker can redirect a
260+victim's queued ciphertext or inject blobs into an online recipient's mailbox.
261+Fully documented in PROTOCOL-REVIEW.md #2, #4 and PROTOCOL.md §10. The fix
262+requires signed presence gossip and authenticated forward requests, which needs
263+a key-to-home-server binding the protocol does not yet define.
264+
265+### ❌ D. SSRF deny-list is lexical only
266+
267+**Severity: medium — blocked on `fetch` exposing DNS resolution.**
268+
269+`isBadServerName` inspects the hostname string, never the resolved address.
270+DNS-rebinding TOCTOU bypasses it. The fix needs resolve-then-pin (fuse DNS
271+resolution with connection), which `fetch` does not expose. Documented in
272+PROTOCOL-REVIEW.md and in-code comments at `isBadServerName`.
273+
274+### ❌ E. Federation retry sweep can starve the prune sweep
275+
276+**Severity: low — design tradeoff, blocked on `fetch` deadline support.**
277+
278+`backgroundRetryForwards` runs inline, so a sweep over many unreachable peers
279+blocks until every `fetch` resolves. The prune sweep's writes conflict with the
280+retry loop's atomic blocks. A fix needs either a `fetch` deadline or a
281+transaction that yields between per-peer batches, neither of which the current
282+`fetch` supports. Documented in PROTOCOL-REVIEW.md #9.
283+
284+---
285+
286+## Fixed in third-pass audit (2026-07-13)
287+
288+### ✅ F. Cursor advanced before budget check — message loss on oversized poll page
289+
290+**Severity: high — fixed.**
291+
292+`ingest_poll` advanced `model.cursor` to `page.cursor` before the event loop.
293+If the `MAX_POLL_TOTAL_BYTES` budget was exhausted mid-page, the loop broke
294+but the cursor stayed at `page.cursor`. The next poll acked the whole page to
295+the relay, which deleted every event the client never processed — permanent
296+message loss. A relay serving 4+ large blobs (each up to 20 MiB) in a single
297+50-event page was enough to trigger this.
298+
299+**Fix:** Save the pre-page cursor and restore it when the budget is exhausted.
300+
301+### ✅ G. `send_text` did not check if peer is blocked
302+
303+**Severity: medium — fixed.**
304+
305+Blocking was documented as cutting a peer off in both directions, and the
306+ingest side correctly dropped blocked peers' messages. But `send_text` had no
307+block check, so a user could keep sending messages to a blocked contact. The
308+Swift `ChatView` composer was also not disabled for blocked peers.
309+
310+**Fix:** `send_text` now refuses with an error when the active peer is blocked.
311+The Swift composer disables both the text field and the send button when
312+`activePeerBlocked` is true.
313+
314+### ✅ H. `compressed` and `signed` intermediates not zeroized in `crypto::encrypt`
315+
316+**Severity: low — fixed.**
317+
318+The `inner` buffer was correctly `Zeroize`d after encryption, but the
319+`compressed` (compressed plaintext) and `signed` (signature input containing
320+recipient_pub + compressed + padding) intermediates were plain `Vec<u8>`s
321+that retained sensitive data in freed memory. Both are now `Zeroizing<Vec<u8>>`.
322+
323+### ✅ I. PROTOCOL.md §10 "No cryptographic agility" contradicted §3
324+
325+**Severity: spec only — fixed.**
326+
327+The §10 risk table said "The wire format has no version field" but §3 had
328+been updated to describe a version byte (`0x01`) and a "Cryptographic
329+Versioning" section. The table entry now accurately reflects the version
330+byte's existence and its limitations.
331+
332+### ✅ J. PROTOCOL.md §4 "Blocking" didn't match implementation
333+
334+**Severity: spec only — fixed.**
335+
336+The spec said blocked senders' messages "are still received and decrypted but
337+are not displayed." The implementation drops them after decryption (the sender
338+identity is inside the AEAD ciphertext, so decryption is necessary to learn who
339+the message is from). The spec now accurately describes the implementation:
340+messages are decrypted to recover the sender identity, then dropped without
341+storage, display, or ack, and blocking is bidirectional.
342+
343+---
344+
345+## Fixed in sixth-pass audit (2026-07-13)
346+
347+### ✅ K. No send-side body length check — silent message loss
348+
349+**Severity: medium — fixed.**
350+
351+`send_text` did not check `body.len()` against `protocol::MAX_BODY_LEN`. The
352+recipient's `parse_payload` enforces the cap and silently drops oversized text,
353+so a user who pastes a very long message would see it stored locally, sent to
354+the relay, and marked "sent" — but it would never arrive, never get acked, and
355+the sender would see it stuck as "sent" forever with no error.
356+
357+**Fix:** `send_text` now refuses with `"message too long"` before queueing.
358+
359+### ✅ L. No profile field length checks in SaveProfile — silent broadcast failure
360+
361+**Severity: medium — fixed.**
362+
363+`SaveProfile` broadcast `display_name`, `bio`, and `photo` without checking
364+against `MAX_DISPLAY_NAME_LEN` (128 chars), `MAX_BIO_LEN` (1024 chars), or
365+`MAX_PHOTO_LEN` (64 KiB). Every recipient's `parse_payload` would silently drop
366+the oversized profile, creating a divergence between what the sender sees and
367+what contacts see.
368+
369+**Fix:** All three fields are validated before the profile is saved or broadcast.
370+
371+### ✅ M. `Decrypted` plaintext not zeroized
372+
373+**Severity: low — fixed.**
374+
375+The `inner` buffer and `signed` intermediate were carefully `Zeroizing<Vec<u8>>`,
376+but the output `Decrypted { plaintext: Vec<u8> }` escaped the zeroization
377+boundary. The decrypted message content sat in freed memory until the allocator
378+reused that page. `plaintext` is now `Zeroizing<Vec<u8>>`.
379+
380+### ✅ N. No send watchdog — stuck `flushing` guard
381+
382+**Severity: low — fixed.**
383+
384+The poll loop had a watchdog (`POLL_WATCHDOG_MS`) that recovered from a dropped
385+HTTP effect, but the send loop had no equivalent. If `SendResult` never came
386+back (a cancelled task, a backgrounded app, a bug), `flushing` stayed `true`
387+forever and the outbox was permanently stuck. A `FlushWatchdog` event (armed
388+alongside the send, same pattern as the poll watchdog) now releases the guard
389+and retries the flush after `FLUSH_WATCHDOG_MS` (90 s).
390+
391+### ✅ O. No nickname length check in AddContact
392+
393+**Severity: very low — fixed.**
394+
395+The `nickname` in `AddContact` was stored without any length bound. A user could
396+set a megabyte-long nickname, bloating the contacts kv blob (rewritten in full on
397+every change). `nickname.len()` is now checked against `MAX_DISPLAY_NAME_LEN`.
398+
399+---
400+
401+## Fixed in seventh-pass audit (2026-07-14)
402+
403+### ✅ P. `encrypt`'s `inner` buffer not zeroized on AEAD failure
404+
405+**Severity: low — fixed.**
406+
407+The `inner` buffer in `crypto::encrypt` (sender pubkey || signature ||
408+compressed_len || compressed || padding) was a plain `Vec<u8>`. It was
409+explicitly `zeroize()`d on the success path, but if `cipher.encrypt()`
410+returned `Err`, the `?` operator returned early and `inner` was dropped
411+without zeroization — the sender's identity, signature, and compressed
412+plaintext remained in freed memory. Every other sensitive intermediate in
413+`encrypt` (`compressed`, `signed`, `padding`, `eph_priv`, `raw_secret`,
414+`key`) was already `Zeroizing`; `inner` was the sole exception. Now
415+`Zeroizing<Vec<u8>>`, matching the pattern established for `decrypt`'s
416+`inner` and `signed` in the fourth-pass audit.
PROTOCOL.md+429−342
@@ -1,6 +1,6 @@
1# Skrepka Protocol Specification
1# Skrepka Protocol Specification
2
2
3−**Version:** 0.1 (Draft)
3+**Version:** 0.2 (Draft)
4
4
5---
5---
6
6
@@ -12,15 +12,15 @@ Skrepka is a decentralized, end-to-end encrypted messaging protocol. It has no u
12
12
13- **No accounts.** A user's identity is their cryptographic keypair. No registration, no passwords, no servers storing credentials.
13- **No accounts.** A user's identity is their cryptographic keypair. No registration, no passwords, no servers storing credentials.
14- **No home server.** Users connect to any server in the mesh and can switch freely. Servers are disposable relays.
14- **No home server.** Users connect to any server in the mesh and can switch freely. Servers are disposable relays.
15−- **End-to-end encryption.** Servers see only envelope metadata. Message content is opaque.
15+- **End-to-end encryption.** Servers see only routing metadata. Message content — including the sender's identity — is opaque to the server.
16- **Open federation.** Any server can join the mesh. Operators may optionally blocklist specific peers.
16- **Open federation.** Any server can join the mesh. Operators may optionally blocklist specific peers.
17−- **Ephemeral.** All messages expire after 30 days — on servers and on devices. No permanent history.
17+- **Ephemeral.** Server mailboxes expire after 30 days. No permanent server-side history.
18- **Simplicity.** Plain HTTP transport, no session negotiation, no ratchets.
18- **Simplicity.** Plain HTTP transport, no session negotiation, no ratchets.
19
19
20### Non-Goals
20### Non-Goals
21
21
22- Forward secrecy (per-message ephemeral keys provide partial protection, but no ratchet)
22- Forward secrecy (per-message ephemeral keys provide partial protection, but no ratchet)
23−- Metadata privacy (servers see sender, recipient, and timestamp)
23+- Recipient metadata privacy (servers see the recipient and timestamps; the sender is not visible to servers)
24- Durable message storage (servers queue temporarily, not permanently)
24- Durable message storage (servers queue temporarily, not permanently)
25- Peer-to-peer delivery (at least one server must be reachable)
25- Peer-to-peer delivery (at least one server must be reachable)
26
26
@@ -38,32 +38,37 @@ Each device generates its own independent keypair and is treated as a separate i
38
38
39### Key Format
39### Key Format
40
40
41−Public keys are encoded as **base64url** (RFC 4648 §5, no padding).
41+Public keys, signatures, and encrypted blobs are encoded as **lowercase hexadecimal** on the wire and in storage. An Ed25519 public key is 32 bytes (64 hex chars); a signature is 64 bytes (128 hex chars).
42
42
43−### Identity URI
43+### Sharing an Identity
44
44
45−```
45+Identities are shared out-of-band as plain text or QR codes. The client displays public keys in two forms:
46−skrepka://<base64url(ed25519_pubkey)>@<server_host>
47−```
48
46
49−Example:
47+- **Hex** — 64 lowercase hex characters (the canonical wire form).
48+- **@p (phonetic)** — the public key rendered as Urbit-style syllables, hyphen-joined with no leading prefix (e.g. `ridler-binzod-marbud-...`). Easier for humans to read and verify by voice. The client accepts either form when adding a contact.
50
49
51−```
50+#### @p Encoding
52−skrepka://dGhpcyBpcyBhIHRlc3Q@relay.example.com
51+
53−```
52+@p is a display encoding only — it never appears on the wire and carries no information the hex form does not. It is specified here so an independent client renders the *same* syllables, since users compare them aloud to verify keys: a client with a divergent table would show a different name for the same identity and defeat out-of-band verification.
53+
54+The 32-byte key is encoded **byte-pair by byte-pair, most-significant first**. Each pair `(hi, lo)` becomes one four-letter word `PREFIX[hi] ++ SUFFIX[lo]`, and the 16 words are joined with `-`. Both tables hold 256 three-letter syllables each and are **identical to Urbit's** (`po.hoon`); the reference copies live in [`core/src/phonemic.rs`](core/src/phonemic.rs). Neither table contains duplicates, so decoding is an unambiguous reverse lookup, and a 32-byte key always yields exactly 16 words.
54
55
55−The URI contains everything needed to reach someone:
56+Worked example — the key beginning `0x00 0x00 0x01 0x01 ...`:
56
57
57−- **Public key** — who they are
58+| byte pair | prefix | suffix | word |
58−- **Server host** — a server where they were last seen or any known relay
59+|-----------|--------|--------|----------|
60+| `00 00` | `doz` | `zod` | `dozzod` |
61+| `01 01` | `mar` | `nec` | `marnec` |
59
62
60−Since there is no home server, the server component is a hint, not an authority. The recipient may be connected to a different server by the time a message is sent. The gossip layer resolves current location.
63+Note the prefix at index **124** is **`nis`** (…`ran`, `nis`, `wol`, `mis`…), matching `po.hoon`; it is not `nic`, which is a common transcription error.
61
64
62−Identity URIs are designed to be shared out-of-band: copied as text, embedded in links, or scanned as QR codes.
65+Clients accept either form when adding a contact; hex remains canonical everywhere else.
66+
67+Servers are addressed separately. A client stores a single server URL (e.g. `https://relay.example.com`) in local settings; this is purely a transport choice and has nothing to do with identity. Two users on different servers can still talk via federation.
63
68
64### Trust Model
69### Trust Model
65
70
66−**Trust-on-First-Use (TOFU).** The first time a client encounters a public key, it is accepted and cached. Optional out-of-band verification (e.g., comparing fingerprints in person) is supported but not required by the protocol.
71+**Trust-on-First-Use (TOFU).** The first time a client encounters a public key, it is accepted and cached. Optional out-of-band verification (e.g., comparing fingerprints or @p syllables in person) is supported but not required by the protocol.
67
72
68### Contacts
73### Contacts
69
74
@@ -81,61 +86,123 @@ There is no global directory. Users assign local nicknames to public keys on the
81| Key agreement | X25519 |
86| Key agreement | X25519 |
82| Key derivation | HKDF-SHA256 |
87| Key derivation | HKDF-SHA256 |
83| Symmetric cipher | XChaCha20-Poly1305 |
88| Symmetric cipher | XChaCha20-Poly1305 |
89+| Compression | zstd (pre-encryption) |
84
90
85### Per-Message Encryption
91### Per-Message Encryption
86
92
87Each message is independently encrypted. There are no sessions, no ratchet, and no state between sender and recipient.
93Each message is independently encrypted. There are no sessions, no ratchet, and no state between sender and recipient.
88
94
95+The wire-visible "envelope" carries only the recipient and the opaque blob. The sender's public key and signature are placed **inside** the AEAD ciphertext, so the server learns nothing about who sent a given message. The signature is bound to the recipient's public key, preventing a captured ciphertext from being replayed against a different recipient.
96+
89**Encryption flow:**
97**Encryption flow:**
90
98
911. Generate an ephemeral X25519 keypair:
991. Generate an ephemeral X25519 keypair:
92 ```
100 ```
93 ephemeral_private, ephemeral_public = x25519_generate()
101 ephemeral_private, ephemeral_public = x25519_generate()
94 ```
102 ```
95−2. Compute the raw shared secret using the ephemeral private key and the recipient's long-term X25519 public key:
103+2. Derive the recipient's X25519 public key from their Ed25519 public key, and compute the raw shared secret:
96 ```
104 ```
105+ recipient_x25519_public = ed25519_pk_to_curve25519(recipient_ed25519_public)
97 raw_secret = x25519(ephemeral_private, recipient_x25519_public)
106 raw_secret = x25519(ephemeral_private, recipient_x25519_public)
98 ```
107 ```
99−3. Derive the encryption key using HKDF-SHA256:
108+3. Derive the symmetric encryption key using HKDF-SHA256:
100 ```
109 ```
101 key = hkdf_sha256(
110 key = hkdf_sha256(
102− ikm = raw_secret,
111+ ikm = raw_secret,
103 salt = ephemeral_public || recipient_x25519_public,
112 salt = ephemeral_public || recipient_x25519_public,
104− info = "skrepka-v1",
113+ info = "skrepka-v2",
105 len = 32
114 len = 32
106 )
115 )
107 ```
116 ```
108−4. Generate a random 24-byte nonce:
117+4. zstd-compress the plaintext payload (see §4):
118+ ```
119+ compressed = zstd(plaintext_bytes) // standard zstd frame (embeds content size)
120+ ```
121+5. Sign a domain-separation tag, the recipient pubkey, and the compressed plaintext with padding. The `"skrepka-msg-v2:"` prefix is a domain-separation tag distinguishing message signatures from auth-challenge signatures (which begin with `"skrepka-auth-v1:"`, §6), so a signature produced in one context can never validate in the other. Binding the signature to the recipient prevents the same blob from being replayed against another recipient:
122+ ```
123+ compressed_len = uint32_be(len(compressed))
124+ padding = random_bytes(target_blob_size - unpadded_blob_size)
125+ signature = ed25519_sign(sender_ed25519_private,
126+ "skrepka-msg-v2:" || recipient_ed25519_public || compressed_len || compressed || padding)
109 ```
127 ```
110− nonce = random_bytes(24)
128+6. Build the inner buffer that will be encrypted:
111 ```
129 ```
112−5. Encrypt the plaintext message:
130+ inner = sender_ed25519_public || signature || compressed_len || compressed || padding
113 ```
131 ```
114− ciphertext = xchacha20_poly1305_encrypt(key, nonce, plaintext)
132+7. Generate a random 24-byte nonce and AEAD-encrypt the inner buffer with
133+ associated data binding the envelope header and recipient identity:
115 ```
134 ```
116−6. Sign the ciphertext with the sender's Ed25519 private key:
135+ nonce = random_bytes(24)
136+ aad = version_byte || ephemeral_public || nonce || recipient_ed25519_public
137+ ciphertext = xchacha20_poly1305_encrypt(key, nonce, inner, aad)
117 ```
138 ```
118− signature = ed25519_sign(sender_ed25519_private, ciphertext)
139+ The AAD authenticates every cleartext field a relay can see (version,
140+ ephemeral public key, nonce) and the recipient's Ed25519 public key.
141+ A relay that swaps the envelope `to` field to a different recipient causes
142+ AEAD decryption to fail at the wrong recipient — the blob is dropped before
143+ any cursor advance, and the original recipient's message stays in the
144+ mailbox. Without AAD, a relay could silently misdeliver messages by
145+ swapping `to`, making the censorship undetectable to both sender and
146+ recipient.
147+8. Emit the on-wire blob:
119 ```
148 ```
149+ blob = version_byte || ephemeral_public || nonce || ciphertext
150+ ```
151+ where `version_byte` is `0x02` (see *Cryptographic Versioning* below).
120
152
121**Decryption flow:**
153**Decryption flow:**
122
154
123−1. Verify the signature against the sender's Ed25519 public key.
155+1. Read the leading version byte (reject the blob if it is not recognized). Split the rest: 32-byte `ephemeral_public`, 24-byte `nonce`, remainder is `ciphertext`.
124−2. Compute the raw shared secret using the recipient's long-term X25519 private key and the ephemeral public key from the message.
156+2. Derive the recipient's X25519 private key from the recipient's Ed25519 private key, compute the raw shared secret with `ephemeral_public`, and HKDF-derive the same `key`.
125−3. Derive the encryption key using HKDF-SHA256 with the same parameters.
157+3. Reconstruct the AAD: `version_byte || ephemeral_public || nonce || recipient_ed25519_public`. AEAD-decrypt the ciphertext with this AAD to recover the inner buffer.
126−4. Decrypt the ciphertext using the derived key and nonce.
158+4. Split: 32-byte `sender_ed25519_public`, 64-byte `signature`, 4-byte `compressed_len` (big-endian u32), then `compressed_len` bytes of `compressed` (the remainder is padding).
159+5. Verify the signature over `"skrepka-msg-v2:" || recipient_ed25519_public || inner[96..]` (i.e. the domain-separation tag followed by `compressed_len || compressed || padding`) using `sender_ed25519_public`. Reject the message on failure.
160+6. zstd-decompress the `compressed` bytes (only the `compressed_len`-length slice, not the trailing padding) to recover the plaintext payload.
127
161
128### Encrypted Blob Format
162### Encrypted Blob Format
129
163
164+On the wire (cleartext, server-visible):
165+
166+```
167+version (1 byte, currently 0x02)
168+ephemeral_x25519_pubkey (32 bytes)
169+nonce (24 bytes)
170+ciphertext (variable, includes 16-byte AEAD tag)
171+```
172+
173+After AEAD decryption, the inner buffer is:
174+
130```
175```
131−ephemeral_x25519_pubkey (32 bytes)
132−nonce (24 bytes)
133−ciphertext (variable)
134−sender_ed25519_pubkey (32 bytes)
176+sender_ed25519_pubkey (32 bytes)
135−signature (64 bytes)
177+signature (64 bytes)
178+compressed_len (4 bytes, big-endian u32)
179+compressed_plaintext (variable, zstd; exactly compressed_len bytes)
180+padding (variable, random)
136```
181```
137
182
138−Total overhead: 152 bytes per message.
183+The sender's pubkey and signature are inside the AEAD ciphertext: the server never sees them, and only the intended recipient can recover the sender's identity. The `compressed_len` field tells the recipient exactly where the zstd frame ends, so trailing padding bytes are never fed to the decompressor. The minimum blob size is `1 + 32 + 24 + 32 + 64 + 4 + 16 = 173` bytes (346 hex chars). A relay rejects any blob shorter than this with `400 invalid_message`, since it cannot possibly carry a valid envelope.
184+
185+### Length Padding
186+
187+Blob sizes are padded to fixed boundaries so the on-wire length does not reveal the exact compressed (and thus plaintext) size to relays and network observers. The padding is added *inside* the AEAD ciphertext (after the compressed data, before encryption), so the server only sees the padded size.
188+
189+The bucket boundaries are:
190+
191+```
192+256, 512, 1024, 2048, 4096, 8192, 16384, 32768, 65536
193+```
194+
195+A blob is rounded up to the smallest bucket ≥ its unpadded size. Above 65536 bytes, the blob is rounded up to the next multiple of 65536. The padding bytes are randomly generated per message, so two encryptions of the same plaintext produce the same blob size (same bucket) but different blob contents. The on-wire blob is therefore `173 + len(compressed) + len(padding)` bytes, and that padded size is all a relay or network observer sees.
196+
197+> **Compression before encryption leaks plaintext structure.** The payload is zstd-compressed *then* encrypted (steps 4–7), so the ciphertext length is a function of how *compressible* the plaintext was, not just how long it was. XChaCha20-Poly1305 is a stream cipher with no padding: without length padding the blob would be exactly `173 + len(compressed)` bytes, and that number would be visible to every relay and network observer. Version 0x02 adds length padding (see *Length Padding* above), which buckets blob sizes to fixed boundaries so the exact compressed size is hidden.
198+>
199+> Two consequences. First, message length is only ever obscured *upward*: a long but repetitive message can produce a smaller blob than a short random-looking one, and blob size still distinguishes a one-word reply from a pasted document. Second — the more serious one — where an attacker can influence part of the plaintext that is compressed together with a secret, the compressed size reveals whether the two share structure. That is the CRIME/BREACH pattern, and it is a real (if narrow) concern for a format that compresses attacker-influenceable text alongside sensitive text in one frame.
200+>
201+> Skrepka accepts the remaining risk: payloads are small, each message is compressed independently (no cross-message compression context), and there is no adaptive-guessing oracle in the protocol — an attacker cannot make the client re-encrypt a chosen variant of a secret on demand. Implementations must **not** add one (e.g. by compressing several messages, or attacker-supplied and user-supplied text, into a shared frame). Length padding (version 0x02) obscures the exact compressed size by bucketing blob lengths, but does not eliminate the CRIME/BREACH side channel within a single bucket — it only makes the granularity coarser.
202+
203+### Cryptographic Versioning
204+
205+The wire format carries a leading **version byte** (currently `0x02`) on every encrypted blob. A recipient that sees a version it does not recognise MUST reject the blob without attempting further processing (no AEAD decrypt, no key derivation, no decompression) — the reference client returns a decryption error and drops the blob. This ensures a future revision can introduce a new AEAD, KDF, or curve by bumping this byte without risk of misinterpreting an unknown format. Version 0x02 adds AEAD associated data (AAD): the cleartext header `version_byte || ephemeral_public || nonce` and the recipient's Ed25519 public key are authenticated alongside the ciphertext, so a relay that tampers with any envelope field — including swapping the `to` recipient — causes AEAD decryption to fail rather than silently misdelivering the blob. The HKDF `info` string is the fixed constant `"skrepka-v2"` for version 0x02. The unknown-`type` rule (§4) gives forward compatibility for *payload* types; the version byte gives the same for the wire format, AEAD, KDF, and curve choices. A future revision that changes the crypto primitives should bump both the version byte and the HKDF `info` string together — the reference implementation derives the info string from the version to ensure they cannot drift (see `crypto.rs`).
139
206
140---
207---
141
208
@@ -143,124 +210,153 @@ Total overhead: 152 bytes per message.
143
210
144### Envelope
211### Envelope
145
212
146−The envelope is what servers see. It is transmitted in cleartext (not encrypted) so servers can route messages.
213+The envelope is what servers see. It carries only the recipient and the opaque encrypted blob — there is **no `from` field**. The sender's identity is recoverable only by the recipient, from inside the decrypted blob.
147
214
148```json
215```json
149{
216{
150− "to": "<base64url(recipient_ed25519_pubkey)>",
217+ "to": "<hex(recipient_ed25519_pubkey)>",
151− "from": "<base64url(sender_ed25519_pubkey)>",
152− "encrypted_blob": "<base64url(encrypted_blob)>",
218+ "encryptedBlob": "<hex(encrypted_blob)>"
153− "timestamp": 1679000000
154}
219}
155```
220```
156
221
157−| Field | Type | Description |
222+| Field | Type | Description |
158−|------------------|--------|--------------------------------------------------|
223+|-----------------|--------|--------------------------------------------------|
159−| `to` | string | Recipient's Ed25519 public key (base64url) |
224+| `to` | string | Recipient's Ed25519 public key — exactly 64 lowercase hex chars. The server validates the **format** only (length and charset); it does not verify the decoded bytes form a valid Ed25519 curve point. |
160−| `from` | string | Sender's Ed25519 public key (base64url) |
225+| `encryptedBlob` | string | Hex-encoded blob from §3. Must be at least 346 hex chars (173 bytes); the server rejects shorter blobs with `400 invalid_message`. |
161−| `encrypted_blob` | string | The encrypted blob from §3 (base64url) |
162−| `timestamp` | number | Unix timestamp (seconds) when the message was sent |
163
226
164−Servers route based on `to` and `from`. They never inspect `encrypted_blob`.
227+Servers route based on `to`. They cannot inspect `encryptedBlob` and they do not know the sender. The `to` field is format-validated only — the server does not check that it represents a valid Ed25519 curve point, so blobs addressed to non-existent keys are stored and forwarded normally (the intended recipient simply cannot decrypt them). A message whose `to` matches the authenticated sender's own pubkey is rejected with `400 self_send` (§6, *POST /messages*).
165
228
166### Plaintext Payload
229### Plaintext Payload
167
230
168−After decryption, the plaintext is a JSON object with a `type` field:
231+After decryption (and zstd decompression), the plaintext is a UTF-8 JSON object with a `type` field:
169
232
170```json
233```json
171{
234{
172 "type": "<message_type>",
235 "type": "<message_type>",
173− "id": "<unique_message_id>",
236+ "ts": 1679000000000,
174− "timestamp": 1679000000,
175 ...
237 ...
176}
238}
177```
239```
178
240
241+- `type` and `ts` are present on every payload. `ts` is the sender's wall-clock at send time, in **milliseconds** since the Unix epoch.
179−The `id` field is a client-generated UUID v4. Clients MUST deduplicate incoming messages by `id` — the same message may arrive more than once due to presence fan-out (see §7).
242+- `id` is a client-generated unique ID (e.g. UUID v4) carried by `text` messages only. Clients deduplicate incoming `text` messages by `id`. `delivery.ack` and `profile` payloads do not carry an `id`.
180−
181−Clients MUST silently ignore message types they do not recognize. This allows newer clients to introduce new types without breaking older ones.
243+- Clients MUST silently drop message types they do not recognize — no error is surfaced to the sender, and the blob is treated as if it were not there. This allows newer clients to introduce new types without breaking older ones.
182
244
183### Message Types
245### Message Types
184
246
247+The current implementation defines three plaintext message types:
248+
185−| Type | Description | Additional Fields |
249+| Type | Description | Additional Fields |
186−|-------------------|------------------------------------------|-------------------------------------------------|
250+|-----------------|------------------------------------------|------------------------------------------------|
187−| `text` | A text message | `body` (string) |
251+| `text` | A text message | `body` (string) |
188−| `media` | An inline media attachment | `media_type` (MIME), `filename`, `data` (base64)|
189−| `group.invite` | Invite to a group (see §8) | `group_id`, `group_name`, `members` |
190−| `group.leave` | Leave a group | `group_id` |
191−| `group.update` | Update group metadata | `group_id`, `group_name`, `members` |
192−| `delivery.ack` | Delivery acknowledgement | `ack_ids` (array of message IDs) |
252+| `delivery.ack` | Sender-side delivery confirmation | `ack_ids` (array of message IDs) |
193−| `read` | Read receipt | `read_ids` (array of message IDs) |
194−| `profile` | Share display name and/or photo | `display_name` (string), `photo` (base64, optional) |
253+| `profile` | Share display name, bio, and/or photo | `display_name`, `bio` (strings), `photo` (base64-encoded image, optional) |
254+
255+Future message types (media attachments, read receipts, group messages) are not part of this revision.
256+
257+### Payload Size Limits
258+
259+A decrypted payload is attacker-controlled: a peer can put any value in any field. The reference client enforces the following caps and silently drops a payload that exceeds any of them (treated identically to an unknown type). An independent client SHOULD enforce the same caps, or it risks storing, rendering, and re-transmitting unbounded attacker-supplied data.
260+
261+| Field | Cap | Rationale |
262+|-------------------|-------------|--------------------------------------------------------|
263+| `text.body` | 64 KiB | A text body is never legitimately this long. |
264+| `text.id` | 128 chars | IDs are UUIDs; 128 chars is generous. |
265+| `profile.display_name` | 128 chars (Unicode) | A display name is not a bio. |
266+| `profile.bio` | 1024 chars (Unicode) | Enough for a paragraph. |
267+| `profile.photo` | 64 KiB (base64) | A 256px JPEG is ~10-30 KiB; 64 KiB is headroom. |
268+| `delivery.ack.ack_ids` | 1024 entries | Bounds the O(n·m) scan a peer can dictate. |
195
269
196−Media files are embedded inline in the encrypted payload as base64-encoded `data`. There is no separate media hosting or upload endpoint.
270+The caps on `display_name` and `bio` are measured in Unicode characters (not bytes), so a multi-byte emoji costs the same as an ASCII letter. A payload sitting exactly at the cap is accepted; the bound is inclusive.
197
271
198### Profiles
272### Profiles
199
273
200−Users can share a display name and photo with contacts by sending a `profile` message. This is sent as a regular encrypted message — servers never see profile data.
274+Users share a display name, bio, and photo with contacts by sending a `profile` message. This is a regular encrypted message — servers never see profile data.
201
275
202```json
276```json
203{
277{
204 "type": "profile",
278 "type": "profile",
205− "id": "msg_456",
279+ "ts": 1679000000000,
206− "timestamp": 1679000000,
207 "display_name": "Alice",
280 "display_name": "Alice",
281+ "bio": "writing things",
208 "photo": "<base64(jpeg_data)>"
282 "photo": "<base64(jpeg_data)>"
209}
283}
210```
284```
211
285
212−Clients SHOULD send a `profile` message to a contact on first interaction and whenever the user updates their name or photo. The recipient's client caches the profile locally. The `photo` field is optional and MAY be omitted to update only the display name.
286+Clients SHOULD send a `profile` message to a contact on first interaction and whenever the user updates their name, bio, or photo. The recipient's client caches the profile locally. `photo` is optional and MAY be omitted.
287+
288+Because `profile` messages carry no `id`, they are not deduplicated, and a captured `profile` blob can be replayed to the same recipient (see §10, *Same-recipient replay*). To prevent an old profile from clobbering a newer one, clients SHOULD ignore an incoming `profile` whose `ts` is older than the `ts` of the last profile already stored for that contact. **The reference client enforces this**: it records the `ts` of the most recent profile accepted per contact and drops any incoming `profile` that predates it, so a replayed stale profile cannot roll a contact's cached name, bio, or photo back. A client that skipped the check would be vulnerable to exactly that rollback.
289+
290+### Delivery Acks
291+
292+When a recipient successfully decrypts an incoming `text` message, the recipient's client SHOULD send a `delivery.ack` message back to the sender:
293+
294+```json
295+{
296+ "type": "delivery.ack",
297+ "ack_ids": ["msg_123", "msg_124"],
298+ "ts": 1679000000000
299+}
300+```
301+
302+This is the only delivery-confirmation channel in the protocol: the server itself does **not** report delivery status to senders. See §9 for the full flow.
213
303
214### Blocking
304### Blocking
215
305
216−Blocking is client-side only. A blocked sender's messages are still received and decrypted but are not displayed to the user. The protocol does not provide a mechanism to tell a server to reject messages from a specific pubkey.
306+Blocking is client-side only. A blocked sender's messages are decrypted to recover the sender identity (which lives inside the AEAD ciphertext, §3), then dropped without storage, display, or ack — the reference client does not store, display, or ack them. Decryption is necessary because the sender identity is inside the AEAD ciphertext; the decrypted content is never persisted or shown. The protocol does not provide a mechanism to tell a server to reject messages from a specific pubkey. Blocking also cuts the peer off in the other direction: the reference client refuses to send new messages to a blocked contact and drops any queued outgoing messages (including delivery acks) for them when the block is applied.
217
307
218---
308---
219
309
220## 5. Transport
310## 5. Transport
221
311
222−All communication uses **plain HTTP**.
312+All communication uses **plain HTTP**, served over TLS in production.
223
313
224| Direction | Mechanism |
314| Direction | Mechanism |
225|--------------------|----------------------|
315|--------------------|----------------------|
226| Client → Server | HTTP POST |
316| Client → Server | HTTP POST |
227−| Server → Client | Long polling |
317+| Server → Client | Long polling (POST) |
228| Server → Server | HTTP POST |
318| Server → Server | HTTP POST |
229
319
230**Why HTTP + long polling:**
320**Why HTTP + long polling:**
231- No special protocols. Works through proxies, load balancers, and CDNs.
321- No special protocols. Works through proxies, load balancers, and CDNs.
232−- Long polling provides near-real-time delivery over plain HTTP request/response cycles.
322+- Long polling provides near-real-time delivery over plain request/response cycles.
233- No persistent connections, no WebSocket upgrade, no gRPC toolchain, no raw TCP.
323- No persistent connections, no WebSocket upgrade, no gRPC toolchain, no raw TCP.
234
324
235### Server Discovery
325### Server Discovery
236
326
237−New clients discover servers via a well-known URL:
327+Skrepka has no central directory. Clients are configured with a single server URL (stored locally) and stay on it until the user changes it. The repository ships a `servers.json` file listing public relays as a convenience:
238−
239−```
240−https://skrepka.org/servers.json
241−```
242−
243−The response is a JSON array of server hostnames:
244
328
245```json
329```json
246−{
330+[
247− "servers": [
331+ "https://1.skrepka.lol",
248− { "host": "relay1.example.com", "region": "eu" },
332+ "https://2.skrepka.lol",
249− { "host": "relay2.example.com", "region": "us" },
333+ "https://3.skrepka.lol"
250− { "host": "relay3.example.com", "region": "ap" }
251− ]
334+]
252−}
253```
335```
254
336
255−The client picks a server from the list (e.g., by latency or region preference) and connects. This list is a bootstrap mechanism only — once connected, the client may learn about other servers through the mesh.
337+This file is a static suggestion, not a federation directory. Federation between servers is configured server-side, independently of any client-facing list: a relay reaches the mesh by being given **seed peers** (`--seedPeers=host1,host2`), which it gossips to unconditionally until presence gossip teaches it the rest of the mesh. A relay with no seeds and no inbound gossip stays isolated — clients on it can still message each other, but not users on other relays. See §7 for the bootstrap mechanism.
256
338
257### Content Type
339### Content Type
258
340
259−All request and response bodies use `application/json`.
341+All request and response bodies use `application/json`. The following encoding rules apply to every JSON body (request and response):
342+
343+- **Encoding:** UTF-8. Implementations MUST NOT use UTF-16 or UTF-32.
344+- **Key ordering:** Object keys are unordered. A sender may emit them in any order; a receiver MUST NOT depend on key ordering for correctness.
345+- **Whitespace:** Inter-token whitespace (spaces, tabs, newlines) is allowed and does not affect semantics. Pretty-printed JSON is valid.
346+- **Duplicate keys:** If an object contains the same key more than once, behavior is undefined. Implementations SHOULD avoid producing duplicates and MAY reject or retain only the first value for a repeated key.
347+
348+### Federation Transport
349+
350+Servers contact peers over `https://<peer-host>`. Federation traffic carries no bearer tokens — federation endpoints are open to any caller, with abuse-related defenses (SSRF egress filtering, presence-gated forwarding, per-peer linear backoff) handled internally. Those defenses bound abuse; they do **not** authenticate the peer, and an unauthenticated caller can both redirect a victim's queued mail (via `/federation/gossip`) and inject blobs into an online recipient's mailbox (via `/federation/forward`). Both are accepted limitations of v0.1 — see §7 for the endpoints and §10 for the security analysis.
260
351
261### Message Size Limit
352### Message Size Limit
262
353
263−The maximum size of an encrypted blob is **20 MB**. Servers MUST reject messages exceeding this limit. This applies to the entire encrypted payload, including inline media.
354+The on-wire `encryptedBlob` (hex) is capped at **40 MiB** (about 20 MiB of binary payload). A blob over the cap is rejected at validation as a malformed message (`400`), not with `413`.
355+
356+Two limits apply to a `/messages` request, independently of the per-blob cap:
357+
358+- **Batch count** — at most 100 messages per request (`413 batch_too_large`).
359+- **Total request body** — the runtime enforces a single HTTP body-size cap (`--http-max-body-bytes`, returning `413` with no JSON error code). Operators **must** set this above `maxBlobLen` plus the JSON envelope — the reference deployment uses `42M` — otherwise the runtime's default 16 MiB cap would reject a maximum-size blob before the per-blob `400` check is reached. Because this caps the whole body, a batch cannot smuggle 100 × 40 MiB: the total payload across all messages in one request is bounded by this cap.
264
360
265---
361---
266
362
@@ -273,189 +369,134 @@ When a client connects, it proves ownership of its keypair:
2731. Client sends its public key:
3691. Client sends its public key:
274 ```
370 ```
275 POST /auth/challenge
371 POST /auth/challenge
276− { "pubkey": "<base64url(ed25519_pubkey)>" }
372+ { "pubkey": "<hex(ed25519_pubkey)>" }
277 ```
373 ```
278
374
279−2. Server responds with a random challenge (valid for 60 seconds):
375+2. Server responds with a random challenge string (valid for 60 s):
280 ```json
376 ```json
281− { "challenge": "<base64url(random_32_bytes)>" }
377+ { "challenge": "<random_hex>", "expiresAt": 1679003660000 }
282 ```
378 ```
379+ The challenge is a lowercase hex string of at least 32 chars (128 bits of entropy). The reference server uses 48 hex chars (192 bits). The challenge MUST be unique per challenge request and consumed on first use (verified).
283
380
284−3. Client signs the challenge and sends it back:
381+3. Client signs the **UTF-8 bytes of `"skrepka-auth-v1:" + server_host + ":" + challenge`** with its Ed25519 private key and submits. `server_host` is the bare lowercased hostname of the server the client dialed (no scheme, no port, no trailing dot). For IPv6 literals, the host must include square brackets (e.g. `[::1]`), matching the URL host syntax — the server's `serverHost` configuration must also include brackets for IPv6. The `skrepka-auth-v1:` prefix is a domain-separation tag distinguishing auth signatures from message signatures (which begin with `"skrepka-msg-v2:"` and sign `recipientPub ++ compressed`, §3):
285 ```
382 ```
286 POST /auth/verify
383 POST /auth/verify
287 {
384 {
288− "pubkey": "<base64url(ed25519_pubkey)>",
385+ "pubkey": "<hex(ed25519_pubkey)>",
289− "challenge": "<base64url(challenge)>",
386+ "challenge": "<random_hex>",
290− "signature": "<base64url(ed25519_sign(private_key, challenge))>"
387+ "signature": "<hex(ed25519_sign(private_key, utf8(\"skrepka-auth-v1:\" + server_host + \":\" + challenge)))>",
388+ "revokeOthers": false
291 }
389 }
292 ```
390 ```
391+ The server verifies against its own configured hostname (`serverHost`, port stripped). Binding the signature to the target host prevents a malicious or relay server from forwarding a challenge it obtained from a *different* server and replaying the client's signature there to impersonate the client (§10, *Unauthenticated session relay*).
392+ Setting `revokeOthers: true` invalidates every other live session for this pubkey (useful for "log out other devices"). Revocation is **immediate** (within the atomic block that processes the verify) and a revoked session receives `401 unauthorized` on its next request. Note that `revokeOthers` revokes **sessions only**, not outstanding challenges — a challenge issued before the `revokeOthers` call remains valid for its 60-second window. (The reference implementation has been fixed to also drop outstanding challenges for the pubkey on `revokeOthers: true`; independent implementations SHOULD do the same.)
293
393
2944. Server verifies the signature. On success, returns a session token valid for **1 hour**:
3944. Server verifies the signature. On success, returns a session token valid for **1 hour**:
295 ```json
395 ```json
296− { "token": "<session_token>", "expires_at": 1679003600 }
396+ { "token": "<session_token>", "expiresAt": 1679003600000 }
297 ```
397 ```
298
398
299−The session token is included in all subsequent requests as a bearer token:
399+The session token is included in subsequent authenticated requests as a bearer token:
300```
400```
301Authorization: Bearer <session_token>
401Authorization: Bearer <session_token>
302```
402```
303
403
304−When a token expires, the server responds with `401`. The client re-authenticates by repeating the challenge-response flow. Servers SHOULD broadcast an `offline` gossip event only if the client does not re-authenticate within a grace period (e.g., 60 seconds).
404+When the server trusts proxy headers (`trustForwardedFor`, on by default), sessions are bound to the client's source IP — the last `X-Forwarded-For` hop. A later request from a different IP is rejected as `unauthorized`. If proxy-header trust is disabled, or no `X-Forwarded-For` header is present, the session is not IP-bound and this check is skipped.
305
405
306−### Endpoints
406+> **IP binding and mobile clients.** IP binding means a session does not survive a change of source address. In the reference deployment (Caddy in front, `trustForwardedFor` on) a phone that moves between Wi-Fi and cellular — or is NAT-rebound by its carrier — comes back on a new IP, so its next `/poll` or `/messages` gets `401 unauthorized` even though the token has not expired. This is expected: a client MUST treat `401` on any authenticated request as "re-run challenge/verify", not as a fatal error, and retry the request with the fresh token. The cost is one extra round trip per network change; the benefit is that a stolen bearer token is not usable from another network. Operators who prefer sessions that roam can run with `--trustForwardedFor=False`, which leaves sessions un-bound (the check is skipped when no trusted IP is available).
307
407
308−#### `POST /auth/challenge` — Request auth challenge
408+When a token expires, the server responds with `401`. The client re-authenticates by repeating the challenge-response flow.
309
409
310−**Request:**
410+> **Channel binding.** The signed payload includes the target server's hostname under the `skrepka-auth-v1:` domain-separation tag (step 3). Because the client binds to the host it actually dialed and the server verifies against its own `serverHost`, a relay server cannot forward a challenge it obtained from a *different* server and replay the resulting signature to impersonate the client there: the client's signature commits to the relay's hostname, not the victim server's, so verification at the victim fails. This binding is a **breaking change** — client and server must agree on the signed payload. The host comparison is on the bare hostname only (port and scheme excluded), so the same identity authenticates regardless of the port a server listens on.
311−```json
312−{ "pubkey": "<base64url(ed25519_pubkey)>" }
313−```
314
411
315−**Response:**
412+A successful `/auth/verify` also installs the pubkey in the local presence table and broadcasts an `online` gossip event to known peers (§7).
316−```json
317−{ "challenge": "<base64url(random_32_bytes)>" }
318−```
319−
320−#### `POST /auth/verify` — Complete auth
321−
322−**Request:**
323−```json
324−{
325− "pubkey": "<base64url(ed25519_pubkey)>",
326− "challenge": "<base64url(challenge)>",
327− "signature": "<base64url(signature)>"
328−}
329−```
330−
331−**Response:**
332−```json
333−{ "token": "<session_token>", "expires_at": 1679003600 }
334−```
335
413
336### Error Responses
414### Error Responses
337
415
338−All endpoints return errors in a consistent format:
416+Errors return a non-2xx HTTP status with a body of:
339
417
340```json
418```json
341−{
342− "error": "<error_code>",
419+{ "error": "<code>" }
343− "message": "<human-readable description>"
344−}
345```
420```
346
421
347−| HTTP Status | Error Code | Meaning |
422+| HTTP Status | Error Code | Meaning |
348−|-------------|----------------------|--------------------------------------|
423+|-------------|-----------------------|--------------------------------------|
349−| 400 | `bad_request` | Malformed request body |
424+| 400 | `self_send` | A message in `/messages` is addressed to the sender |
425+| 400 | `invalid_message` | A message in `/messages` has a malformed `to` or oversized/invalid `encryptedBlob` |
426+| 400 | `invalid_request` | Malformed federation request: `fromServer` equals this server, fails the SSRF deny-list, or the event count exceeds the cap |
427+| 401 | `invalid` | `/auth/verify` failed (bad signature, or an expired/wrong-IP challenge) |
350−| 401 | `unauthorized` | Missing, invalid, or expired token |
428+| 401 | `unauthorized` | `/poll` or `/messages` with a missing, invalid, or expired token, or a request from a different IP |
351−| 403 | `challenge_expired` | Auth challenge has expired (>60s) |
429+| 403 | `federation_disabled` | A `/federation/*` endpoint was called while federation is disabled |
352−| 404 | `not_found` | Unknown pubkey or resource |
430+| 404 | `no_presence` | Federated forward arrived for a recipient with no live local session |
353−| 413 | `payload_too_large` | Encrypted blob exceeds 20 MB |
431+| 413 | `batch_too_large` | A `/messages` batch exceeds the 100-message cap. A bare `413` with no JSON body (the runtime's HTTP body-size cap, `--http-max-body-bytes`) is also possible when the total request body exceeds the operator-configured limit; clients MUST treat a bodyless `413` the same as `batch_too_large` for retry purposes. Operators **must** set `--http-max-body-bytes` above `maxBlobLen` plus the JSON envelope (the reference deployment uses `42M`), otherwise the runtime's default 16 MiB cap rejects a maximum-size blob before the per-blob `400` check is reached. |
354−| 429 | `rate_limited` | Too many requests |
432+| 429 | (HTTP only) | Per-route rate limit exceeded |
355−| 500 | `internal_error` | Server error |
433+| 503 | `capacity` | Server-side resource cap reached |
434+
435+> **Timing side-channel on token comparison (known gap).** The reference server compares the presented bearer token against each stored session token with plain string equality, which short-circuits on the first differing byte and so leaks, through response timing, how long a correct prefix an attacker guessed. The 192-bit token and the per-IP rate limit on `/poll` and `/messages` bound the attack — a blind guess is infeasible, and the sample count needed to resolve a per-byte timing difference across a network is large — but they do not eliminate it. A constant-time comparison is the correct fix; the reference implementation is blocked on the absence of such a primitive in its runtime. Implementations on platforms that offer one (`crypto_verify`, `subtle`, `hmac.compare_digest`, …) SHOULD use it.
356
436
357−#### `GET /poll` — Long poll for new events
437+### `POST /poll` — Long poll for new events
358
438
359−The client makes a blocking GET request. The server holds the connection open until new events are available or a timeout is reached.
439+The client makes a blocking POST. The server holds the connection open until new events are available or the long-poll timeout (25 s) elapses.
360
440
361**Headers:** `Authorization: Bearer <token>`
441**Headers:** `Authorization: Bearer <token>`
362
442
363−**Query parameters:**
443+**Request:**
444+```json
445+{ "cursor": 0 }
446+```
364
447
365−| Parameter | Type | Description |
448+`cursor` is the maximum `receivedAt` value the client has already seen. It MUST be a non-negative integer. `0` means "first poll" — no messages have been acked, so the server returns all pending events. Negative cursors are clamped to `0` by the server.
366−|--------------|--------|--------------------------------------------------|
367−| `timeout` | number | Max seconds to wait (default: 30, max: 60) |
368−| `after` | string | Cursor from the previous poll response (optional) |
369
449
370**Response:**
450**Response:**
371```json
451```json
372{
452{
373 "events": [
453 "events": [
374− {
454+ { "encryptedBlob": "<hex>" }
375− "type": "message",
376− "data": { "to": "...", "from": "...", "encrypted_blob": "...", "timestamp": 1679000000 },
377− "id": "evt_001"
378− },
379− {
380− "type": "ack",
381− "data": { "message_ids": ["msg_abc123"] },
382− "id": "evt_002"
383− }
384 ],
455 ],
385− "cursor": "evt_002"
456+ "cursor": 1679000000123
386}
457}
387```
458```
388
459
460+- `cursor` is a server-side **strictly-monotonic sequence**, seeded from the wall-clock millisecond at assignment but bumped to `previous + 1` whenever messages arrive within the same millisecond. Two messages therefore never share a `receivedAt`, so advancing the cursor past one message can never silently drop another. Treat it as an opaque checkpoint, not a clock.
461+- The server clamps the supplied cursor to `max(0, min(max(t, curSeq), cursor))`, where `t` is the current wall-clock millisecond and `curSeq` is the last assigned sequence — note the ceiling is `max(t, curSeq)`, not just `curSeq`, so under idle the clamp allows acking up to the current wall-clock, while under sustained bursts (where the sequence has run ahead of the clock) it still refuses to ack beyond what has actually been issued.
462+- Advancing past a message acts as an **implicit ack**: when the next poll arrives with a `cursor` greater than or equal to a stored message's `receivedAt`, the server drops the stored message **and any pending federation forwards for it**.
389−- If events are available immediately, the server responds right away.
463+- If events are available immediately, the server responds right away. Otherwise it parks the connection on a row-level wait against the mailbox and returns when a new matching message lands or after 25 s, whichever comes first.
390−- If no events are available, the server holds the connection until one arrives or `timeout` expires.
464+- An empty page (`events: []`) is returned on timeout, with the cursor echoed back.
391−- On timeout with no events, the server returns `{ "events": [], "cursor": "..." }`.
392−- The client should immediately issue the next poll after receiving a response.
465+- A single response is capped at 50 events; the client should poll again immediately after receiving any response.
393−
394−**Event types:**
395−
396−| Type | Data |
397−|------------|-------------------------------------|
398−| `message` | Envelope JSON (see §4) |
399−| `ack` | `{ "message_ids": ["id1", "id2"] }`|
400
466
401−#### `POST /messages` — Send a message
467+### `POST /messages` — Send messages
402
468
403**Headers:** `Authorization: Bearer <token>`
469**Headers:** `Authorization: Bearer <token>`
404
470
471+Sends are **batched** and all-or-nothing. Up to 100 messages per request:
472+
405**Request:**
473**Request:**
406```json
474```json
407{
475{
408− "to": "<base64url(recipient_ed25519_pubkey)>",
476+ "messages": [
409− "encrypted_blob": "<base64url(encrypted_blob)>",
477+ { "to": "<hex(recipient_pubkey)>", "encryptedBlob": "<hex>" }
410− "timestamp": 1679000000
478+ ]
411}
479}
412```
480```
413
481
414−The server fills in `from` from the authenticated session.
415−
416**Response:**
482**Response:**
417−```json
483+- `200 {}` on success — every message in the batch has been enqueued for delivery (locally, via federation, or both).
418−{
484+- `400 { "error": "self_send" }` if any message targets the authenticated pubkey.
419− "status": "delivered" | "queued",
485+- `401 / 429 / 503` per the table above.
420− "message_id": "<server_assigned_id>"
421−}
422−```
423
486
424−- `delivered` — recipient is online, message was forwarded.
487+The server learns the sender from the bearer token, uses it only to reject self-sends and to enforce per-session rate limits, and does **not** persist the sender alongside the stored message.
425−- `queued` — recipient is offline, message is queued for later delivery.
426
488
427−#### `POST /messages/ack` — Acknowledge receipt
489+Whether a recipient is currently reachable, offline, or known via federation is internal to the server. The sender's UI learns "they got it" only via a returning `delivery.ack` (§4) from the recipient — there is no server-issued delivery status.
428
490
429−**Headers:** `Authorization: Bearer <token>`
491+There is **no `/messages/ack` endpoint**: poll cursor advance is the only acknowledgement signal a recipient sends to the server.
430
492
431−**Request:**
493+### Endpoints Not Present
432−```json
433−{ "message_ids": ["id1", "id2"] }
434−```
435
494
436−**Response:**
495+The following endpoints exist in earlier drafts of this spec but are **not** implemented:
437−```json
438−{ "ok": true }
439−```
440−
441−The server deletes acknowledged messages from its queue and forwards the ACK to the sender's server.
442−
443−#### `GET /lookup/:pubkey` — Look up a user's presence
444−
445−**Response (online):**
446−```json
447−{
448− "online": true,
449− "server": "other-server.example.com"
450−}
451−```
452
496
453−**Response (offline):**
497+- `POST /messages/ack` — replaced by implicit ack on poll cursor advance.
454−```json
498+- `GET /lookup/:pubkey` — presence information is internal; clients have no way to query it.
455−{
499+- `POST /federation/ack` — federation cleanup is driven by the receiving peer's poll-ack, not a back-channel.
456− "online": false
457−}
458−```
459
500
460---
501---
461
502
@@ -463,141 +504,107 @@ The server deletes acknowledged messages from its queue and forwards the ACK to
463
504
464### Mesh Formation
505### Mesh Formation
465
506
466−Federation is open. A server joins the mesh by connecting to one or more known peers. No approval is required.
507+Federation is open and **off by default** (`federationEnabled = False`); an operator opts in explicitly. There is no central registry.
467−
468−Server operators configure a list of initial peers. When a server starts, it announces itself to its peers, which propagate the announcement. Over time, each server builds a view of the full mesh.
469
508
470−Operators may maintain an optional **blocklist** of server hostnames or IPs to refuse connections from.
509+A server joins the mesh in two steps:
471
510
472−### Gossip (Presence Announcements)
511+1. **Identity.** It is configured with its own public hostname (`serverHost`). This is the name it puts in the `fromServer` field of every gossip it sends, and the name peers must be able to resolve to reach it. It must pass the deny-list below, so the default `localhost` cannot federate.
512+2. **Bootstrap.** It is configured with one or more **seed peers** (`seedPeers`, a comma-separated host list). These are gossiped to unconditionally, alongside any peers already known from presence.
473
513
474−When a user connects to or disconnects from a server, the server broadcasts a presence update to all known peers:
514+Seeding is what makes the mesh formable at all. A relay otherwise learns of a peer *only* by receiving gossip from it — presence rows remember the server that announced each key — so two fresh relays that have never heard of each other both begin with an empty gossip target set and neither ever speaks first. Seeding **one** side of a pair is sufficient: the first gossip carries `fromServer`, so the seed learns of the sender and gossips back from then on. Seed peers are gossip *targets* only; they are not written into the presence table (a presence row is keyed by the pubkey it announces, and a seed is just a hostname), so a seed that never answers costs nothing but a periodic failed request under the standard per-peer backoff.
475
515
476−**Online:**
516+```sh
477−```json
517+# relay A, seeded with relay B — B needs no seed of its own to reach A
478−{
518+skrepka-server --serverHost=a.example.com --federationEnabled=true --seedPeers=b.example.com
479− "type": "online",
480− "pubkey": "<base64url(ed25519_pubkey)>",
481− "server": "this-server.example.com",
482− "timestamp": 1679000000,
483− "ttl": 3600
484−}
485```
519```
486
520
487−**Offline:**
521+> **Implementation note (reference server).** `serverHost`, `federationEnabled` and `seedPeers` are configuration, and the reference relay exposes them as `--name=value` settings. As of Knot `2026.6.26.1947` the runtime *accepts but silently ignores* these flags at startup — they currently take effect only when passed to `knot build` — so a relay configured purely on the command line will run with the compiled-in defaults (`localhost`, federation off, no seeds) and quietly fail to federate. Verify with the startup log, which prints the federation state and seed list it actually loaded.
488−```json
489−{
490− "type": "offline",
491− "pubkey": "<base64url(ed25519_pubkey)>",
492− "server": "this-server.example.com",
493− "timestamp": 1679000000
494−}
495−```
496
522
497−Each server maintains a **presence table** — a mapping of public keys to a **set of servers** where they are currently connected. A single pubkey may appear at multiple servers simultaneously (e.g., multiple devices with different keypairs connecting independently, or stale gossip). Presence entries expire after `ttl` seconds if not refreshed.
523+Operators may apply an inbound blocklist; outbound federation is filtered through a built-in **SSRF deny-list** that rejects:
498
524
499−Gossip messages are forwarded to peers with a decrementing hop counter to prevent infinite propagation.
525+- `localhost`, and the `.localhost`, `.local`, `.internal`, `.arpa`, `.onion` suffixes
526+- Private and special-use IPv4 ranges: `0/8`, `10/8`, `100.64/10` (CGNAT), `127.0.0.0/8`, `169.254/16` (link-local), `172.16/12`, `192.0.2/24` and `203.0.113/24` (TEST-NET), `192.168/16`, `198.18/15` (benchmarking), and everything from `224/4` upward (multicast + reserved)
527+- Obfuscated IPv4 literals: bare integer/"dword" form (e.g. `2130706433`), `0x`-prefixed hex, and dotted forms with a leading-zero (octal) octet (e.g. `0177.0.0.1`)
528+- IPv6 literals — any host containing more than one `:` (covering `::`, `::1`, link-local, ULA, and global addresses) and any bracketed `[…]` form
529+- The empty string, and any host beginning with `.` or `:`
530+- Anything containing characters outside `[a-z0-9-.:]`
500
531
501−### Gossip Endpoint
532+This applies both to outbound `fetch` calls and to the `fromServer` field on inbound federation requests.
502
533
503−```
534+### Gossip (Presence Announcements)
504−POST /federation/gossip
535+
505−```
536+When a user authenticates or polls, the server marks them locally online and broadcasts an `online` event. When a session expires or the polled presence row times out, an `offline` event is sent.
537+
538+**Event format (one entry per pubkey):**
506
539
507−**Request:**
508```json
540```json
509−{
510− "events": [
511− { "type": "online", "pubkey": "...", "server": "...", "timestamp": ..., "ttl": ... },
541+{ "eventType": "online", "pubkey": "<hex>" }
512− { "type": "offline", "pubkey": "...", "server": "...", "timestamp": ... }
542+{ "eventType": "offline", "pubkey": "<hex>" }
513− ],
514− "hops_remaining": 3
515−}
516```
543```
517
544
518−### Message Forwarding
545+Events carry no timestamp, no server name, and no TTL on the wire. Each receiver anchors a local TTL (90 minutes by default) at the moment of receipt; the originating server is taken from the transport-level `fromServer` field, not the event body.
519
546
520−When a server receives a message for a recipient, it checks the presence table. If the pubkey appears at multiple servers, the message is forwarded to **all** of them. This ensures delivery even with stale gossip or multi-server presence.
547+Gossip is **single-hop**: when a server receives a gossip batch, it updates its presence table and (for newly-online keys) forwards any locally-queued messages to the originator, but it does **not** propagate the events to its own peers. There is no hop counter.
521
548
522−```
549+A server may hold multiple presence rows for the same pubkey — one per peer that has reported the pubkey online — to handle multi-device or in-flight handoff between servers.
550+
523−POST /federation/forward
551+### `POST /federation/gossip` — Ingest peer presence events
524−```
525
552
526**Request:**
553**Request:**
527```json
554```json
528{
555{
529− "to": "<base64url(recipient_ed25519_pubkey)>",
556+ "events": [
530− "from": "<base64url(sender_ed25519_pubkey)>",
557+ { "eventType": "online", "pubkey": "<hex>" },
531− "encrypted_blob": "<base64url(encrypted_blob)>",
558+ { "eventType": "offline", "pubkey": "<hex>" }
532− "timestamp": 1679000000,
559+ ],
533− "origin_server": "sender-server.example.com",
560+ "fromServer": "peer.example.com"
534− "message_id": "<id>"
535}
561}
536```
562```
537
563
538−The receiving server delivers the message to the connected client on its next poll.
564+- Up to 100 events per request.
539−
565+- Rejected with `400 invalid_request` if `fromServer` equals this server's identity, fails the SSRF deny-list, or the event count exceeds the cap.
540−### ACK Forwarding
566+- `online` events for keys not previously seen at `fromServer` trigger an immediate forward sweep: any locally-queued messages addressed to those keys are scheduled for `/federation/forward`.
541
567
542−When a recipient ACKs a message, the ACK is forwarded back to the origin server so it can delete the queued blob:
568+> **Unauthenticated — and this endpoint steers delivery.** `/federation/gossip` accepts any caller, and the `fromServer` field is self-asserted. Because an `online` event causes the receiving server to forward that pubkey's queued mail to `fromServer`, anyone who can reach a relay can *redirect* a victim's inbound ciphertext to a host of their choosing by announcing `{ "eventType": "online", "pubkey": <victim>, "fromServer": <attacker-host> }`. See §10, *Open federation — gossip redirect*.
543
569
544−```
545−POST /federation/ack
570+### `POST /federation/forward` — Receive a federated message
546−```
547
571
548**Request:**
572**Request:**
549```json
573```json
550{
574{
551− "message_ids": ["id1", "id2"],
575+ "toKey": "<hex(recipient_pubkey)>",
552− "origin_server": "sender-server.example.com"
576+ "encryptedBlob": "<hex>"
553}
577}
554```
578```
555
579
556−---
580+There is no `from`, no origin-server hint, and no sender timestamp — the receiving server simply records the blob for the recipient.
557
581
558−## 8. Group Messaging
582+The receiver **requires the recipient to be currently locally online** (i.e. have an unexpired local presence row). If not, it returns `404 no_presence` and the origin retries (see below) or holds the message in its own mailbox.
559
583
560−Groups are a **client-side concept**. Servers are entirely unaware of groups — they see only individual messages between pairs of public keys.
584+> **Unauthenticated — mailbox injection is possible.** `/federation/forward` accepts any caller: there is no bearer token, no peer signature, and nothing ties the request to a server the receiver actually federates with. Any host that can reach a relay can therefore inject arbitrary blobs into the mailbox of any recipient who is currently locally online. The only bounds are the per-source-IP rate limit on the route (`forwardLimit`), the SSRF deny-list (which constrains where a relay will *send*, not who may call it), and the `no_presence` gate (which limits injection to online recipients). Injected junk is undecryptable, so a client drops it silently on ingest — but it still occupies the recipient's poll stream and the relay's mailbox until the cursor advances past it. This is an accepted limitation of v0.1; see §10, *Open federation — forward injection*. Peer authentication is the intended fix.
561
585
562−### Group Structure
586+### Forward Retries
563
587
564−A group is defined locally on each member's device:
588+Forwards that fail (network error or `no_presence`) remain queued at the origin and are retried by a background loop:
565
589
566−```json
590+- Linear per-peer backoff: `retryBackoffBase × failures`, capped at `retryBackoffMax` (defaults: base 30 s, cap 8 min).
567−{
591+- Up to `maxForwardRetries` attempts (default 10), then the forward is dropped.
568− "group_id": "<random_uuid>",
592+- Per-peer failure counters age out after 24 h, after which the next attempt is treated as fresh.
569− "group_name": "Weekend Plans",
593+- A single successful call to a peer clears its failure counter (no half-open state).
570− "members": [
571− "<base64url(pubkey_alice)>",
572− "<base64url(pubkey_bob)>",
573− "<base64url(pubkey_carol)>"
574− ]
575−}
576−```
577
594
578−### Sending to a Group
595+When the recipient finally polls a server holding their mail (local or federated), the implicit ack on cursor advance flushes the stored blob and prunes any remaining outbound forwards for it.
579
596
580−When a user sends a message to a group, the client **fans out** — it encrypts and sends the message individually to each group member. Each fan-out message includes the `group_id` in the plaintext payload so the recipient's client can display it in the correct conversation:
597+### What's NOT Federated
581−
582−```json
583−{
584− "type": "text",
585− "id": "msg_123",
586− "timestamp": 1679000000,
587− "group_id": "550e8400-e29b-41d4-a716-446655440000",
588− "body": "See you Saturday!"
589−}
590−```
591
598
592−From the server's perspective, a group message of N members is N separate 1:1 messages.
599+- **Message ack roundtrips.** There is no `/federation/ack`; the origin server learns "the message landed" by observing that its outbound forward succeeded and that no other forwards for the same blob remain pending.
600+- **Multi-hop gossip.** Each server only sees the presence events of peers it directly federates with.
601+- **Cross-server presence queries.** Servers do not ask each other "is this key online?" — they wait for a gossip update.
593
602
594−### Membership Management
603+---
595
604
596−- **Invite:** Send a `group.invite` message to the new member containing the group ID, name, and member list.
605+## 8. Group Messaging
597−- **Leave:** Send a `group.leave` message to all remaining members.
598−- **Update:** Send a `group.update` message to all members when the name or membership changes.
599
606
600−All membership operations are performed by the client. There is no server-side group state, no group admin role enforced by the protocol, and no consensus mechanism. Clients trust the membership updates they receive from other members.
607+Group messaging is **not implemented** in the current version. Future revisions may add it as a client-side concept (encrypting one message per group member, with a shared `group_id` in the plaintext payload). Servers would remain entirely unaware of groups.
601
608
602---
609---
603
610
@@ -605,43 +612,51 @@ All membership operations are performed by the client. There is no server-side g
605
612
606### Queuing
613### Queuing
607
614
608−When a server receives a message for a recipient who is not currently online (not in the presence table), the **sender's server queues** the encrypted blob.
615+When a server accepts a message via `/messages`, it always writes the blob to its **local mailbox** under a fresh monotonic `receivedAt`. In parallel, if the recipient is known (via gossip) to be online at one or more remote servers, a federation `forward` is queued and attempted asynchronously per server.
609
616
610−The sender's server continues to listen to gossip. When a presence announcement indicates the recipient is online at some server, the sender's server forwards the queued message to that server.
617+If the recipient is not online anywhere, the message simply stays in the local mailbox until either:
611
618
612−### TTL
619+- The recipient connects to this server and polls (and acks via cursor advance), or
620+- An `online` gossip event arrives naming a server where the recipient is now reachable, at which point the queued message is forwarded there.
613
621
614−All messages have a **30-day TTL**, enforced in two places:
622+### TTL
615
623
616−- **Server-side:** Queued messages are deleted 30 days after their timestamp. Servers are temporary relays, not long-term storage.
624+All messages have a **30-day TTL**, enforced server-side: queued messages are deleted 30 days after their `receivedAt`. Servers are temporary relays, not long-term storage. Clients SHOULD also age out local message history.
617−- **Client-side:** Clients MUST delete messages from local storage 30 days after their timestamp. There is no permanent message history.
618
625
619### Delivery Flow
626### Delivery Flow
620
627
621```
628```
6221. Alice sends a message to Bob (offline).
6291. Alice sends a message to Bob (offline).
623−2. Alice's server queues the encrypted blob.
630+2. Alice's server appends the encrypted blob to its local mailbox.
624− Returns: { "status": "queued" }
631+ Returns: 200 {}
625
632
6263. ...time passes...
6333. ...time passes...
627
634
628−4. Bob connects to Server C.
635+4. Bob connects to Server C; Server C marks Bob locally online and gossips
629−5. Server C gossips: ONLINE { bob_pubkey, server_c }
636+ { eventType: "online", pubkey: bob } to its peers.
630−6. Alice's server sees the gossip, forwards the queued message to Server C.
637+5. Alice's server receives the gossip and queues a /federation/forward to Server C.
631−7. Server C delivers to Bob on his next poll.
638+6. Server C accepts the forward (Bob is locally online) and appends to Bob's mailbox.
632−8. Bob's client sends ACK to Server C.
639+7. Bob's client polls Server C, receives the event, advances its cursor.
633−9. Server C forwards ACK to Alice's server.
640+8. Cursor advance acts as an implicit ack: Server C deletes the stored blob and
634−10. Alice's server deletes the blob.
641+ the corresponding pending forward (so the chain does not double-deliver).
635−11. Next time Alice connects, she receives delivery confirmation.
642+9. Bob's client sends an encrypted `delivery.ack` message back to Alice.
643+10. When Alice next polls and decrypts that, her UI marks her message delivered.
636```
644```
637
645
638−Alice does not need to stay online. Her server handles delivery independently.
646+Note: Alice does not need to stay online for steps 4–9. Her server handles federation independently.
639
647
640### Failure Modes
648### Failure Modes
641
649
642- If the sender's server goes down before delivering, queued messages are **lost**. This is an accepted tradeoff — servers are disposable.
650- If the sender's server goes down before delivering, queued messages are **lost**. This is an accepted tradeoff — servers are disposable.
651+- If forwarding to a peer fails repeatedly, the forward is dropped after `maxForwardRetries`; the message remains in the origin's mailbox and will only be delivered if the recipient ever polls the origin directly.
643−- If the recipient never comes back online within 30 days, the message is deleted.
652+- If the recipient never comes back online within 30 days, the message is deleted by the server-side TTL.
644−- The sender sees `queued` status until ACK is received, at which point it becomes `delivered`.
653+- The sender's UI sees no delivery status until a `delivery.ack` is received from the recipient.
654+
655+### Message Ordering
656+
657+Message ordering is **best-effort** and guaranteed only within a single server. Each server assigns every message a strictly-monotonic `receivedAt` sequence (unique per message, never colliding even under bursts within one millisecond), which provides total ordering for that server's mailbox; the `cursor` returned by `/poll` reflects this sequence.
658+
659+Across federated servers, messages may arrive out of order due to network latency, retry delays, or differing reception times. Clients SHOULD use the sender-provided `ts` field (from the decrypted plaintext payload, §4) for display ordering and treat `cursor` as a polling checkpoint only, not a global ordering guarantee.
645
660
646---
661---
647
662
@@ -649,32 +664,104 @@ Alice does not need to stay online. Her server handles delivery independently.
649
664
650### Threat Model
665### Threat Model
651
666
652−Skrepka protects message **content** from servers and network observers. It does not attempt to hide **metadata**.
667+Skrepka protects message **content and sender identity** from servers and network observers. It does not attempt to hide the recipient.
653
668
654### What Is Protected
669### What Is Protected
655
670
656−| Property | Mechanism |
671+| Property | Mechanism |
657−|-----------------------|----------------------------------------------|
672+|---------------------------|------------------------------------------------------------|
658−| Message confidentiality | E2E encryption (X25519 + HKDF-SHA256 + XChaCha20-Poly1305) |
673+| Message confidentiality | E2E encryption (X25519 + HKDF-SHA256 + XChaCha20-Poly1305) |
659−| Sender authenticity | Ed25519 signature on every message |
674+| Sender authenticity | Ed25519 signature on every message |
660−| Replay protection | Unique nonce per message |
675+| Sender anonymity vs. server | Sender pubkey is inside the AEAD ciphertext — servers see the recipient but not the sender |
661−| Key compromise (partial) | Ephemeral keys per message — past messages whose blobs were already discarded cannot be decrypted even if the recipient's long-term key is compromised |
676+| Replay protection (cross-recipient) | Unique nonce per message; signature binds the recipient pubkey, so a captured blob cannot be replayed against a *different* recipient. This does **not** stop re-delivery of the same blob to the *same* recipient — see *Same-recipient replay* below |
677+| Deletion-dependent recovery (**not** post-compromise security) | Each message uses a fresh ephemeral X25519 key, so there is no shared long-term session secret to steal. But this buys **nothing cryptographic** against a later key compromise: the recipient's long-term key still opens *any* blob an attacker has kept a copy of. The only thing protecting an old message is that the ciphertext no longer exists — relays delete on ack/TTL, and clients delete on request. That is a *deletion* property, not a *forward secrecy* property, and it fails completely against anyone who recorded the traffic. See *No forward secrecy* below, which is the same fact stated as a risk. |
662
678
663### What Is NOT Protected
679### What Is NOT Protected
664
680
665−| Risk | Details |
681+| Risk | Details |
666−|------------------------------|-----------------------------------------------------|
682+|-----------------------------------|-----------------------------------------------------|
667−| **No forward secrecy** | If a message blob is captured *and* the recipient's long-term X25519 key is later compromised, that specific message can be decrypted. No ratchet means no post-compromise recovery. |
683+| **No forward secrecy** | A captured blob plus a later compromise of the recipient's long-term key **is enough to decrypt that message** — the per-message ephemeral key is the *sender's*, and the recipient's half of the exchange is their long-term identity key, which never rotates. So an adversary who records ciphertext today (a network observer, or any relay it passed through) and obtains the recipient's key at any point in the future can read everything it recorded. There is no ratchet, so there is also no post-compromise recovery: once the key leaks, future messages are readable too until the user rotates identity. Do not read the per-message ephemeral keys as forward secrecy; they are not. **Additionally**, because the HKDF salt (`ephemeral_public ‖ recipient_x25519_public`) omits the sender's identity, compromising the recipient's private key also enables a *key-compromise impersonation (KCI)* vector: the attacker can decrypt a captured blob, replace the sender identity and signature inside the inner buffer with their own, re-encrypt with the same derived key, and re-deliver. The recipient sees a valid message apparently from a different sender. Including the sender pubkey in the HKDF salt or info would close this by construction, but requires a wire-format change. The signature already prevents forgery *without* key compromise, so KCI is an additional consequence of an already-catastrophic key leak, not a standalone attack. |
668−| **Metadata exposure** | Servers see `{to, from, timestamp}` in the envelope. They know who is talking to whom and when. Message size is also visible. |
684+| **No AAD on AEAD — envelope fields unauthenticated** (v0.1, fixed in v0.2) | In v0.1, XChaCha20-Poly1305 was called with no associated data. The envelope's `to` field was not authenticated by the AEAD — a relay could swap `to` to a different recipient's pubkey, silently misdelivering and censoring messages with zero detectability. **v0.2 fixes this**: the AAD includes `version_byte ‖ ephemeral_public ‖ nonce ‖ recipient_ed25519_public`, binding the envelope to the ciphertext. A `to`-swap now causes AEAD failure at the wrong recipient, and the original recipient's message stays in the mailbox. |
669−| **Presence gossip leaks location** | All servers in the mesh learn which server a user is connected to. |
685+| **Recipient metadata exposure** | Servers see the recipient pubkey and arrival time of every message, plus the rough size of each blob. They do **not** see the sender. **The relay can also infer delivery** by correlating a message's arrival time with the recipient's subsequent poll time — the gap between the two reveals whether the recipient was online and actively reading. |
670−| **No durable delivery** | If the sender's server goes down, queued messages are lost. |
686+| **Blob size reveals message type** | The length-padding buckets (§3, *Length Padding*) are coarse enough that a relay can distinguish payload types: a `delivery.ack` with a single ID (~233 bytes) falls in the 256-byte bucket, a short `text` message falls in the 512-byte bucket, and a `profile` with a photo falls in the 65536-byte bucket. A relay observing the bucket pattern learns who acks whom and who updates their profile — without decrypting anything. Finer-grained padding (e.g., a single minimum bucket for all small messages) would reduce this leak. |
671−| **No server authentication** | Servers are not authenticated to each other by default. A malicious server could join the mesh and observe gossip. |
687+| **Poll cursor reveals reading progress** | The client sends its cursor (last-seen `receivedAt`) on every poll, telling the relay exactly how far behind the recipient is. A relay can determine whether the recipient is actively reading (cursor advances each poll) or just polling (cursor stays the same), estimate reading speed, and detect device switches (cursor jumps backward). This is inherent to the long-poll model. |
672−| **Group membership visible to members** | Group member lists are shared in plaintext among members. A compromised member reveals the full group. |
688+| **CRIME/BREACH surface on profiles** | The CRIME/BREACH side channel (§3, *Length Padding*) extends beyond text messages to `profile` broadcasts: `display_name`, `bio`, and `photo` are compressed together in one zstd frame. If an attacker can influence part of the profile text (e.g., the user sets their display name based on something the attacker controls), the compressed size could leak information about the user's other profile fields. The risk is low in practice — the base64-encoded photo is high-entropy and nearly incompressible, and bucket padding coarsens the granularity — but the spec's claim that "an attacker cannot make the client re-encrypt a chosen variant of a secret on demand" is too strong for profile broadcasts. |
689+| **Presence gossip leaks location** | Federated peers learn which server a user is currently connected to. A presence row is anchored for up to 90 min (`onlineGossipTtl`), which outlives the 1 h session, so location metadata — and forwards aimed at the user — can linger for up to ~90 min after the user disconnects. |
690+| **No durable delivery** | If the sender's server goes down or all forward retries fail, messages may be lost. |
691+| **Mailbox eviction under flood (DoS)** | The per-recipient mailbox cap (`maxMessagesPerRecipient`, default 10 000) evicts the oldest messages when the cap is reached. An authenticated attacker (pubkeys are free to mint) can flood a victim's mailbox with junk, pushing out genuine undelivered messages. The victim's client only sees junk; the genuine messages are permanently destroyed. At 60 requests/min × 100 messages = 6 000/min, the attacker fills the cap in ~2 minutes (per IP; multiple IPs are faster). This is an accepted design tradeoff of v0.1 — a real fix requires per-sender quotas or pricing. |
692+| **Unbounded mailbox storage** | The per-recipient mailbox cap (`maxMessagesPerRecipient`) bounds each recipient but not the count of distinct recipient keys. An attacker can fill the server's disk by sending messages to many distinct invalid pubkeys, bounded only by the 30-day TTL and rate limits. The reference server now enforces a global `maxMessagesTotal` cap (default 1 000 000) that evicts the globally-oldest rows on overflow, symmetric with the other state refs. |
693+| **CPU amplification via dedup scan** | `appendMessage` performs an O(n) full-scan dedup check on every message (§6). Under a burst of 100-message batches, each message triggers a scan of the entire mailbox. With large mailboxes this is quadratic and amplifies CPU cost per request. Bounded by rate limits (60/min per IP) and the fact that the attacker must be authenticated, but the amplification is real. |
694+| **Clock forward-jump pins the sequence counter** | The `receivedAt` sequence counter is a forward-only ratchet: `max(wall_clock, previous + 1)`. A single NTP forward-jump (server migration, misconfiguration) pins the counter at the jumped value, delaying all TTL pruning until real time catches up. Backward jumps are safe (the counter dominates). |
695+| **Challenge pool floodable (soft DoS)** | The challenge pool is globally capped at `maxChallenges` (default 10 000) with per-IP rate limiting. An attacker with many IPs — or behind a proxy with XFF spoofing when `trustForwardedFor = true` — can fill the pool with challenges for random pubkeys, causing legitimate challenges to be LRU-evicted. Authentication still works but with extra latency (clients retry). This is a soft DoS. |
696+| **Rate limit counts requests, not messages** | The per-IP send rate limit (60/min) counts requests, and each request can carry up to 100 messages. The effective message rate is therefore 60 × 100 = 6 000 messages/min per IP (bounded by the body-size cap), much higher than the per-request limit suggests. An attacker with multiple IPs can send millions of messages per minute. |
697+| **Session pool Sybil-floodable** | The global session pool (`maxSessions`, default 10 000) uses LRU eviction. An attacker with enough IPs can generate thousands of keypairs, authenticate each one, and fill the pool — evicting legitimate users' sessions. Users re-authenticate automatically (they get `401` and retry), so this is a soft DoS adding latency and load. Per-IP session caps were considered and rejected because they collapse behind carrier-grade NAT. |
698+| **Presence table fillable with fake keys** | The presence table (`maxPresence`, default 100 000) can be filled with fake keys via gossip (100 events per request, 60/min per IP). Local rows are exempt from the cap, so local users' presence is never evicted, but legitimate *remote* presence (from honest peers) can be evicted, causing forwards to those peers to fail. Requires federation to be enabled. |
699+| **Gossip amplification** | A single `/federation/gossip` request with 100 `online` events triggers up to 100 mailbox scans and forward-staging operations. The reference server caps the number of newly-online keys that trigger forward sweeps per batch (`maxNewlyOnlineForwards`, default 10), limiting the amplification to 10× per request. An attacker can still send 100 online events per request, but only 10 trigger forward sweeps; the remaining keys are written to `*presence` without a forward sweep. Bounded by the per-IP rate limit (60/min). |
700+| **`fromServer` not verified** | The `fromServer` field in gossip events is self-asserted and not verified against the connecting peer's actual network address — the server has **no mechanism** to verify that the connecting peer is who it claims to be. An attacker can claim `fromServer: "legit-relay.example.com"` from any IP, poisoning the presence table with the legitimate relay's hostname. Forwards go to the legitimate relay (which doesn't have the key online → 404), causing spurious forward requests. This is a variant of the gossip-redirect attack; the intended fix is reverse-DNS verification of the connecting peer, which requires runtime DNS support not currently available. |
701+| **No identity revocation** | There is no in-protocol mechanism to revoke or invalidate a compromised identity key. A stolen key allows full takeover: decrypt all captured messages, send messages as the user, poll their mailbox, and receive their messages via presence redirect. The only defense is out-of-band communication. See §11 (Future Extensions — Key Rotation) for the intended direction. |
702+| **Unauthenticated session relay** (mitigated) | The auth signature is bound to the target server's hostname under a domain-separation tag (§6), so a malicious/relay server can no longer forward another server's challenge and replay the resulting signature to impersonate the client elsewhere. Residual caveat: the binding is to the hostname the client dialed, so it assumes the client reaches each server under its true `serverHost` (DNS/TLS integrity). |
703+| **Same-recipient replay** | Nothing stops a captured blob from being re-delivered to its original recipient. The damage is bounded per payload type: `text` is deduplicated by `id` (the reference client maintains a persistent `seen_ids` set that survives `trim_history`), `delivery.ack` is idempotent, and `profile` is guarded by the per-contact `ts` staleness check of §4. |
704+| **Open federation — gossip redirect** | `/federation/gossip` is unauthenticated; an attacker announces the victim online at a host it controls and origin relays forward the victim's queued ciphertext there. **Active users are also affected**: even when the recipient is locally online and actively polling, a forged remote presence row causes each new incoming message to be duplicated to the attacker's server — the victim receives everything normally but the attacker silently collects ciphertext metadata for the live inbound stream. See *Open federation* below. |
705+| **Open federation — forward injection** | `/federation/forward` is unauthenticated; any host can inject arbitrary blobs into a currently-online recipient's mailbox. See *Open federation* below. |
706+| **Bearer token theft = full impersonation** | Sessions are bearer-token-based: anyone who obtains the token can use it. The token is bound to `(pubkey, ip, expiry)`, but the `pubkey` binding is for authorization (self-send rejection), not for authenticating the request. If an attacker steals a bearer token (e.g., from a compromised proxy's access log), they can poll the victim's mailbox and send messages as the victim — modulo IP binding. Token expiry (1 hour) is the only automatic recovery. Consider shorter token lifetimes or per-request signing. |
707+| **Token entropy depends on runtime CSPRNG** | Session and challenge tokens are generated by the server's runtime random source. Implementations SHOULD verify that their RNG is cryptographically secure (e.g., backed by `/dev/urandom` or an OS-provided CSPRNG). |
708+| **Non-curve-point `to` keys accepted** | The server validates that `to` is 64 lowercase hex chars but does not check that the decoded bytes form a valid Ed25519 curve point. Blobs addressed to non-existent keys are stored and forwarded normally, consuming storage until TTL expiry. |
709+| **No crypto rotation performed** | The wire format carries a version byte (§3, *Cryptographic Versioning*) and a fixed HKDF `info` (`"skrepka-v2"`), so a future revision *can* introduce new AEAD/KDF/curve choices by bumping both. Version 0x02 added AEAD associated data (AAD) to bind envelope fields to the ciphertext. No further rotation has been performed. |
710+| **TOFU only — no verification UI** | A first-time public key is trusted on encounter; users must compare fingerprints out-of-band (e.g., @p syllables in person) to detect MITM at first contact. The reference client does not yet provide a UI for this out-of-band verification. Independent clients SHOULD implement such a UI. |
711+
712+### Open Federation (accepted limitation of v0.1)
713+
714+Every `/federation/*` endpoint accepts **any caller**. There is no bearer token, no peer signature, and no allow-list: a relay cannot tell a genuine peer from an arbitrary host on the internet. Two concrete attacks follow, and it is worth stating them precisely, because "a hostile server can observe gossip" understates both.
715+
716+**1. Gossip redirect — actively rerouting a victim's mail.** The `fromServer` field on `/federation/gossip` is self-asserted, and an `online` event for a previously-unseen key makes the receiving relay sweep its mailbox and forward that key's queued messages to `fromServer` (§7). So an attacker does not have to wait and watch. It posts:
717+
718+```json
719+{ "events": [ { "eventType": "online", "pubkey": "<victim>" } ],
720+ "fromServer": "attacker.example.com" }
721+```
722+
723+to every relay it can reach. Each relay holding mail for the victim now forwards that ciphertext to the attacker's host. The receiver-side "recipient must be locally online" gate does not help — that check runs on the *attacker's* server, which the attacker controls. Message **content** stays protected by E2E encryption, but the attacker harvests ciphertext copies and recipient/timing/size metadata — a real leak for a metadata-privacy-focused messenger.
724+
725+**2. Forward injection — spam into an online recipient's mailbox.** `/federation/forward` is unauthenticated, so any host can inject arbitrary blobs into the mailbox of any recipient who is currently locally online. Injected junk is undecryptable, so a client drops it silently on ingest — but it occupies the recipient's poll stream and the relay's mailbox until the cursor advances past it. The only bounds are the per-source-IP rate limit (`forwardLimit`), the SSRF deny-list (which constrains where a relay will *send*, not who may call it), and the `no_presence` gate (which limits injection to online recipients).
726+
727+**3. Forward cancellation — deleting queued mail-in-flight.** The offline-event forward cancellation (§7) **widens** the unauthenticated-gossip blast radius from writing presence to **deleting queued forwards** — an attacker claiming `fromServer: X` can cancel pending forwards to any real relay X by announcing an `offline` event for the relevant key, so mail queued for delivery to X is dropped from the origin's outbox (the message itself stays in `*messages` and is re-forwarded if the key reappears on another peer, so this delays rather than destroys delivery).
728+
729+**Mitigation path.** Both attacks are accepted limitations of v0.1's unauthenticated federation. The intended fix is peer authentication — signed presence gossip and authenticated forward requests — or an explicit peer allow-list gate before `forwardQueuedToServer`. Until then, operators who federate only with trusted peers (or not at all) are unaffected.
673
730
674### Recommendations for Implementers
731### Recommendations for Implementers
675
732
676- Store private keys in platform-secure storage (Keychain, Keystore, etc.).
733- Store private keys in platform-secure storage (Keychain, Keystore, etc.).
677- Warn users when a contact's public key changes (TOFU violation).
734- Warn users when a contact's public key changes (TOFU violation).
678- Consider pinning server TLS certificates for additional transport security.
735- Consider pinning server TLS certificates for additional transport security.
679−- Implement rate limiting on server endpoints to mitigate spam.
736+- Federate only with peers operated by trusted operators if presence metadata is sensitive.
680−- Servers SHOULD delete message blobs immediately after successful delivery and ACK.
737+- Servers SHOULD delete message blobs as soon as the implicit ack (cursor advance) lands.
738+
739+---
740+
741+## 11. Future Extensions
742+
743+Nothing in this section is implemented or normative in v0.1. It records the shape the extension is expected to take, so that clients avoid design choices that would foreclose it.
744+
745+### Key Rotation
746+
747+Identity in Skrepka *is* the Ed25519 keypair (§2), so today a user who loses or wants to retire a key has no in-protocol way to say so: they have to re-share a new key out-of-band, and every contact must re-add them by hand. Worse, because contacts are TOFU (§2, *Trust Model*), a contact who simply receives a message from an unfamiliar key cannot distinguish "my peer rotated" from "someone is impersonating my peer".
748+
749+A future revision may add a `key_rotation` message type (§4) that lets a user signal the change in-band:
750+
751+```json
752+{
753+ "type": "key_rotation",
754+ "new_pubkey": "<hex(new_ed25519_pubkey)>",
755+ "ts": 1679000000000
756+}
757+```
758+
759+The message is a normal plaintext payload: encrypted to each contact and, per §4, signed by the **old** key — which is exactly the point. Only the holder of the retiring key can announce its successor, so the announcement carries the same authority as any other message from that identity, and a recipient who already trusts the old key can extend that trust to the new one without a fresh out-of-band fingerprint check. Recipients would update the contact entry in place: replace the stored pubkey with `new_pubkey`, keep the existing display name, avatar, and message history, and retain the old key only as a *former identity* so that already-received messages still verify.
760+
761+Open questions a full specification must settle:
762+
763+- **Proof of possession of the new key.** A signature by the old key alone proves the *announcement* is authentic, not that the announcer holds the new secret. A cross-signature (the payload additionally signed by the new key, over both pubkeys) closes that gap and should be considered mandatory.
764+- **Ordering and replay.** Rotations must be totally ordered per identity, or a replayed old `key_rotation` could roll a contact back to a superseded key. The per-contact `ts` staleness rule of §4 is the obvious mechanism, and it inherits that rule's weakness (§10, *Same-recipient replay*) — a monotonic rotation counter is likely the safer construction.
765+- **Contacts who miss the announcement.** A contact offline past the 30-day TTL (§9) never receives the rotation and is left holding a dead key. Re-announcing on reconnect, or letting a client ask a peer to re-send its current identity, would need to be specified.
766+- **Revocation vs. rotation.** These are different operations. Rotation says "use this key from now on" and assumes the old key is still under the owner's control; revocation says "the old key is compromised — distrust it", and an attacker holding the compromised key can forge exactly the rotation message that would hijack the identity. A rotation announcement therefore MUST NOT be treated as a compromise recovery mechanism. Recovering from a stolen key needs a separate design (an offline recovery key, or out-of-band re-verification) and is out of scope here.
767+- **No retroactive protection.** Rotating does not protect past traffic. Messages already sent to the old key remain decryptable by whoever holds it, exactly as §10, *No forward secrecy* describes.
README.mdnew file+51
@@ -0,0 +1,51 @@
1+# Skrepka
2+
3+Decentralized, end-to-end encrypted messaging with no accounts and no home servers. Identity is an Ed25519 keypair; messages route over plain HTTP through any relay in an open federated mesh. A relay only ever sees the recipient's public key and an opaque blob — the sender's identity and signature live *inside* the AEAD ciphertext, so a server never learns who sent a message.
4+
5+See [PROTOCOL.md](PROTOCOL.md) for the full specification.
6+
7+## What's here
8+
9+| Path | What it is |
10+| --- | --- |
11+| `core/` | The shared core (Rust, [Crux](https://redbadger.github.io/crux/)). All business logic: identity, crypto, auth, the long-poll loop, the outbox, contacts, profiles, message ingest. |
12+| `apple/` | The iOS client (Swift + SwiftUI, iOS 17+). A thin shell: it executes HTTP/key-value/timer effects and renders the view model. No business logic. |
13+| `server.knot` | The relay server, written in [Knot](https://github.com/ilyakooo0/knot) and compiled to a native binary. |
14+| `servers.json` | A public relay list. A static suggestion, not a directory. |
15+
16+Crypto is X25519 + XChaCha20-Poly1305 with HKDF-SHA256, signed with Ed25519 — see `core/src/crypto.rs`, which is written to be auditable against the spec.
17+
18+## Build the client
19+
20+One-time setup:
21+
22+```sh
23+rustup target add aarch64-apple-ios aarch64-apple-ios-sim x86_64-apple-ios
24+cargo install boltffi_cli
25+brew install xcodegen
26+```
27+
28+Then (`just` alone runs `test`):
29+
30+```sh
31+just test # run the core test suite
32+just generate # regenerate Swift types, xcframework, and the Xcode project
33+just build SIM_ID=<udid> # build for a booted simulator
34+just run SIM_ID=<udid> # install + launch on that simulator
35+```
36+
37+Run `just generate` after any change to the core's `Event`, `ViewModel`, or `Effect` types — otherwise the Swift side compiles against stale generated types.
38+
39+## Run a relay
40+
41+To install one on a Linux host, with a systemd service and [Caddy](https://caddyserver.com) as a reverse proxy with automatic TLS:
42+
43+```sh
44+curl -fsSL https://raw.githubusercontent.com/ilyakooo0/skrepka/master/install.sh | sudo DOMAIN=skrepka.example.com sh
45+```
46+
47+Re-running the same command updates the binary and restarts the service.
48+
49+Every top-level constant in `server.knot` is overridable at startup as `--name=value` (`--serverHost=`, `--federationEnabled=False`, `--trustForwardedFor=False`, …); run the binary with `--help` for the full list. `--serverHost` must be the public hostname clients use to reach the relay: it is the federation identity, and clients bind their auth signature to it.
50+
51+To run a relay locally you need a prebuilt `./server` binary (`knot build server.knot`, or grab a release asset); then `just server` resets the disposable database and starts it.
apple/SkrepkaApp/Core.swiftnew file+98
@@ -0,0 +1,98 @@
1+import Foundation
2+import Skrepka
3+import SkrepkaShared
4+
5+/// Drives the Rust core: serializes events in, deserializes effects out, performs
6+/// each effect (HTTP, key-value, timer, render) and resolves it back into the core.
7+@MainActor
8+final class Core: ObservableObject {
9+ /// One core per process, reachable outside the view tree.
10+ ///
11+ /// `SkrepkaApp` holds this in a `@StateObject`, but the background-refresh handler runs
12+ /// with no view installed — and a `@StateObject` read from there hands back a *fresh*
13+ /// instance every time. That would mean a second `CoreFFI` with its own identity, its own
14+ /// kv state and its own poll loop, writing over the real one's files and rendering into
15+ /// nothing. The state machine is a singleton in fact; make it one in the type.
16+ static let shared = Core()
17+
18+ @Published var view: ViewModel
19+
20+ /// A Keychain failure never reaches the core — without an identity there is no
21+ /// `IdentityLoaded` event to carry it — so the shell holds it. `RootView` shows this
22+ /// instead of spinning on a progress view forever.
23+ @Published private(set) var identityError: String?
24+
25+ private let core = CoreFFI()
26+ private let store = KvStore()
27+
28+ private init() {
29+ // The FFI's guard returns empty bytes when the core panics; force-try
30+ // on empty bytes crashes the app. Fall back to a default ViewModel instead.
31+ self.view = (try? ViewModel.bincodeDeserialize(input: [UInt8](core.view()))) ?? ViewModel()
32+ bootIdentity()
33+ }
34+
35+ // MARK: - Event/effect loop
36+
37+ func update(_ event: Event) {
38+ guard let eventData = try? event.bincodeSerialize() else { return }
39+ let effects = [UInt8](core.update(data: Data(eventData)))
40+ dispatch(effects)
41+ }
42+
43+ private func resolve(_ id: UInt32, _ output: [UInt8]) {
44+ let effects = [UInt8](core.resolve(id: id, data: Data(output)))
45+ dispatch(effects)
46+ }
47+
48+ private func dispatch(_ effects: [UInt8]) {
49+ guard let requests = try? Requests.bincodeDeserialize(input: effects).value else { return }
50+ for request in requests {
51+ process(request)
52+ }
53+ }
54+
55+ private func process(_ request: Request) {
56+ let id = request.id
57+ switch request.effect {
58+ case .render:
59+ self.view = (try? ViewModel.bincodeDeserialize(input: [UInt8](core.view()))) ?? self.view
60+
61+ case .http(let req):
62+ Task { [weak self] in
63+ let result = await Http.perform(req)
64+ guard let output = try? result.bincodeSerialize() else { return }
65+ self?.resolve(id, output)
66+ }
67+
68+ case .keyValue(let op):
69+ // The file I/O runs on the store's own serial queue; hop the result back to
70+ // the main actor, because the core is single-threaded and `.render` publishes.
71+ store.handle(op) { [weak self] result in
72+ guard let output = try? result.bincodeSerialize() else { return }
73+ Task { @MainActor in self?.resolve(id, output) }
74+ }
75+
76+ case .time(let req):
77+ Time.handle(req) { [weak self] response in
78+ guard let output = try? response.bincodeSerialize() else { return }
79+ self?.resolve(id, output)
80+ }
81+ }
82+ }
83+
84+ // MARK: - Identity
85+
86+ /// Loads the identity key, or reports why it could not. Safe to call again — that is
87+ /// what the retry affordance on the error screen does, and the common failure (the
88+ /// device has not been unlocked since boot) resolves on its own.
89+ func bootIdentity() {
90+ switch Keychain.loadOrCreateIdentity() {
91+ case .success(let key):
92+ identityError = nil
93+ update(.identityLoaded([UInt8](key)))
94+ case .failure(let error):
95+ identityError = error.message
96+ }
97+ }
98+}
apple/SkrepkaApp/Effects.swiftnew file+428
@@ -0,0 +1,428 @@
1+import CryptoKit
2+import Foundation
3+import Security
4+import Skrepka
5+
6+// MARK: - HTTP
7+
8+/// Hard cap on any HTTP response body we will buffer. A hostile relay
9+/// can answer any endpoint with a multi-gigabyte body; without this cap
10+/// `URLSession.data(for:)` would OOM the app before the core's own
11+/// `MAX_POLL_RESPONSE_BYTES` check (which runs on the wrong side of the
12+/// FFI boundary to prevent the allocation) ever fires.
13+private let maxResponseBytes = 64 * 1024 * 1024 // 64 MiB
14+
15+/// The one session delegate, handling two defenses against an untrusted relay.
16+///
17+/// **Redirect cancellation.** URLSession replays the original request's headers onto a
18+/// redirect target, including `Authorization: Bearer <session_token>`. A hostile (or
19+/// compromised) relay could answer `/poll` or `/messages` with a 302 to a host it chooses
20+/// and harvest the bearer token from the request we would dutifully re-send there. Sessions
21+/// are bound to a single relay by design (§6), so a cross-host redirect is never legitimate
22+/// — refusing it costs nothing and closes the token-exfiltration path. Same-host redirects
23+/// (e.g. HTTP → HTTPS upgrade on the same host) are allowed; the Authorization header stays
24+/// within the relay's own host. Returning `nil` from the completion handler does not error
25+/// the task: it stops the redirect and hands the 3xx response itself back to the caller,
26+/// which the core sees as a non-2xx status and treats as a failed request.
27+///
28+/// **Response size limiting.** As body chunks arrive we accumulate the byte count per task
29+/// and cancel the task the moment it crosses `maxResponseBytes`, so a multi-gigabyte body
30+/// never fully materializes in memory. Cancelling surfaces to the caller as a `URLError`
31+/// with code `.cancelled`, which `Http.perform` maps to a transport error.
32+private final class HttpSessionDelegate: NSObject, URLSessionTaskDelegate, URLSessionDataDelegate {
33+ let maxResponseBytes: Int
34+
35+ /// Per-task accumulated byte counts. The delegate is shared across every concurrent
36+ /// request, so a single shared counter would conflate their bodies — key by task instead.
37+ /// Guarded by `lock` because delegate callbacks arrive on the session's own (non-main)
38+ /// queue and may interleave across tasks.
39+ private var taskSizes: [URLSessionTask: Int] = [:]
40+ private let lock = NSLock()
41+
42+ init(maxResponseBytes: Int) {
43+ self.maxResponseBytes = maxResponseBytes
44+ }
45+
46+ func urlSession(
47+ _ session: URLSession,
48+ task: URLSessionTask,
49+ willPerformHTTPRedirection response: HTTPURLResponse,
50+ newRequest request: URLRequest,
51+ completionHandler: @escaping (URLRequest?) -> Void
52+ ) {
53+ // Allow same-host redirects (e.g. HTTP → HTTPS upgrade on the same host).
54+ // Cancel cross-host redirects — a hostile relay could redirect to an
55+ // attacker-controlled host and harvest the Authorization header.
56+ if let originalURL = task.currentRequest?.url,
57+ let redirectHost = request.url?.host?.lowercased(),
58+ let originalHost = originalURL.host?.lowercased(),
59+ redirectHost == originalHost {
60+ completionHandler(request)
61+ } else {
62+ completionHandler(nil)
63+ }
64+ }
65+
66+ func urlSession(
67+ _ session: URLSession,
68+ dataTask: URLSessionDataTask,
69+ didReceive data: Data
70+ ) {
71+ lock.lock()
72+ let total = (taskSizes[dataTask] ?? 0) + data.count
73+ taskSizes[dataTask] = total
74+ let exceeded = total > maxResponseBytes
75+ lock.unlock()
76+ if exceeded {
77+ dataTask.cancel()
78+ }
79+ }
80+
81+ func urlSession(
82+ _ session: URLSession,
83+ task: URLSessionTask,
84+ didCompleteWithError error: Error?
85+ ) {
86+ lock.lock()
87+ taskSizes.removeValue(forKey: task)
88+ lock.unlock()
89+ }
90+}
91+
92+enum Http {
93+ /// Strongly held by `session` for the app's lifetime — the session is never invalidated,
94+ /// so the delegate is never released. That is the intended shape here, not a leak.
95+ private static let httpDelegate = HttpSessionDelegate(maxResponseBytes: maxResponseBytes)
96+
97+ /// A private, ephemeral session. `URLSession.shared` would hand every relay a
98+ /// persistent cookie jar and an on-disk response cache — a relay is untrusted
99+ /// infrastructure and must not be able to pin a stable identifier on the client
100+ /// or leave polled ciphertext lying around on disk. It would also follow redirects,
101+ /// which is what `HttpSessionDelegate` is here to prevent.
102+ private static let session: URLSession = {
103+ let config = URLSessionConfiguration.ephemeral
104+ config.httpCookieAcceptPolicy = .never
105+ config.httpShouldSetCookies = false
106+ config.httpCookieStorage = nil
107+ config.urlCache = nil
108+ config.requestCachePolicy = .reloadIgnoringLocalAndRemoteCacheData
109+ config.timeoutIntervalForRequest = 70 // server long-polls for ~25s
110+ config.tlsMinimumSupportedProtocolVersion = .TLSv12
111+ return URLSession(configuration: config, delegate: httpDelegate, delegateQueue: nil)
112+ }()
113+
114+ static func perform(_ req: HttpRequest) async -> HttpResult {
115+ guard let url = URL(string: req.url),
116+ let scheme = url.scheme,
117+ scheme == "http" || scheme == "https" else {
118+ return .err(.url(req.url))
119+ }
120+ var request = URLRequest(url: url)
121+ request.httpMethod = req.method
122+ request.timeoutInterval = 70 // server long-polls for ~25s
123+ for header in req.headers {
124+ request.setValue(header.value, forHTTPHeaderField: header.name)
125+ }
126+ if !req.body.isEmpty {
127+ request.httpBody = Data(req.body)
128+ }
129+ do {
130+ let (data, response) = try await session.data(for: request)
131+ let http = response as? HTTPURLResponse
132+ return .ok(HttpResponse(
133+ status: UInt16(clamping: http?.statusCode ?? 0),
134+ headers: headers(of: http),
135+ body: [UInt8](data)
136+ ))
137+ } catch let error as URLError where error.code == .cancelled {
138+ // The size-limit delegate cancels the task when the body exceeds the cap.
139+ // Treat it as a transport error so the core backs off and retries.
140+ return .err(.io("response body exceeded \(maxResponseBytes) bytes"))
141+ } catch let error as URLError where error.code == .timedOut {
142+ return .err(.timeout)
143+ } catch {
144+ return .err(.io(error.localizedDescription))
145+ }
146+ }
147+
148+ /// HTTP header names are case-insensitive; the core matches them lowercased.
149+ private static func headers(of response: HTTPURLResponse?) -> [HttpHeader] {
150+ guard let response else { return [] }
151+ return response.allHeaderFields.compactMap { (name, value) -> HttpHeader? in
152+ guard let name = name as? String, let value = value as? String else { return nil }
153+ return HttpHeader(name: name.lowercased(), value: value)
154+ }
155+ }
156+}
157+
158+// MARK: - Timer
159+
160+@MainActor
161+enum Time {
162+ /// Live timers, so `.clear` can actually cancel one. Resolving a crux effect twice
163+ /// (once from `.clear`, once from the work item that was never cancelled) traps in
164+ /// the core, so a dangling work item is a latent crash, not just wasted work.
165+ private static var timers: [TimerId: DispatchWorkItem] = [:]
166+
167+ static func handle(_ req: TimeRequest, resolve: @MainActor @escaping (TimeResponse) -> Void) {
168+ switch req {
169+ case .now:
170+ let now = Date().timeIntervalSince1970
171+ let seconds = UInt64(now)
172+ let nanos = UInt32((now - Double(seconds)) * 1_000_000_000)
173+ resolve(.now(instant: Instant(seconds: seconds, nanos: nanos)))
174+ case .notifyAfter(let id, let duration):
175+ let seconds = Double(duration.nanos) / 1_000_000_000
176+ schedule(id, after: seconds) { resolve(.durationElapsed(id: id)) }
177+ case .notifyAt(let id, let instant):
178+ let target = Date(timeIntervalSince1970: Double(instant.seconds)
179+ + Double(instant.nanos) / 1_000_000_000)
180+ schedule(id, after: max(0, target.timeIntervalSinceNow)) {
181+ resolve(.instantArrived(id: id))
182+ }
183+ case .clear(let id):
184+ timers.removeValue(forKey: id)?.cancel()
185+ resolve(.cleared(id: id))
186+ }
187+ }
188+
189+ private static func schedule(
190+ _ id: TimerId,
191+ after seconds: TimeInterval,
192+ fire: @MainActor @escaping () -> Void
193+ ) {
194+ timers.removeValue(forKey: id)?.cancel() // a reused id supersedes the old timer
195+ let work = DispatchWorkItem {
196+ MainActor.assumeIsolated {
197+ timers.removeValue(forKey: id)
198+ fire()
199+ }
200+ }
201+ timers[id] = work
202+ DispatchQueue.main.asyncAfter(deadline: .now() + seconds, execute: work)
203+ }
204+}
205+
206+// MARK: - Key-value store (one file per key under Application Support)
207+
208+final class KvStore {
209+ private let dir: URL
210+ /// Serial: the core issues overlapping reads and writes to the same key, and the
211+ /// file for a key is the only copy of that state. Off the main actor, because
212+ /// `messages:<peer>` grows without bound and a write of it must not stutter the UI.
213+ private let queue = DispatchQueue(label: "lol.skrepka.kv")
214+
215+ init() {
216+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
217+ var url = base.appendingPathComponent("skrepka/kv", isDirectory: true)
218+ try? FileManager.default.createDirectory(
219+ at: url,
220+ withIntermediateDirectories: true,
221+ attributes: [.protectionKey: FileProtectionType.completeUnlessOpen]
222+ )
223+ // Plaintext message history: it must not ride along in an iCloud/iTunes backup,
224+ // which is not covered by the device passcode the way the Keychain item is.
225+ var values = URLResourceValues()
226+ values.isExcludedFromBackup = true
227+ try? url.setResourceValues(values)
228+ dir = url
229+
230+ // Ordered ahead of every operation: `handle` runs on the same serial queue, so
231+ // the rename is done before the first read can look for a file under the new name.
232+ queue.async { [self] in migrateLegacyNames() }
233+ }
234+
235+ /// Rename the files an older build wrote, which substituted `_` for `:`.
236+ ///
237+ /// `messages:<peer>` lived in `messages_<peer>`; `fileURL(forKey:)` now percent-encodes,
238+ /// so it looks for `messages%3A<peer>` and finds nothing. Without this, an upgrade opens
239+ /// every conversation empty — and the first new message writes a fresh file, orphaning
240+ /// the old one (plaintext history) on disk forever. The other five keys are alphanumeric,
241+ /// so their names are unchanged and they need no migration.
242+ private func migrateLegacyNames() {
243+ let fm = FileManager.default
244+ guard let names = try? fm.contentsOfDirectory(atPath: dir.path) else { return }
245+ for name in names where name.hasPrefix("messages_") {
246+ let peer = String(name.dropFirst("messages_".count))
247+ let destination = fileURL(forKey: "messages:\(peer)")
248+ guard !fm.fileExists(atPath: destination.path) else { continue }
249+ try? fm.moveItem(at: dir.appendingPathComponent(name), to: destination)
250+ }
251+ }
252+
253+ /// Runs the operation on the serial queue and resolves from there. The core does not
254+ /// care which thread resolves an effect — `Core` hops the result back to the main
255+ /// actor before it touches the state machine.
256+ func handle(_ op: KeyValueOperation, completion: @escaping (KeyValueResult) -> Void) {
257+ queue.async { [self] in completion(perform(op)) }
258+ }
259+
260+ private func perform(_ op: KeyValueOperation) -> KeyValueResult {
261+ switch op {
262+ case .get(let key):
263+ switch read(key) {
264+ case .success(let value): return .ok(response: .get(value: value))
265+ case .failure(let error): return .err(error: .io(message: error.localizedDescription))
266+ }
267+
268+ case .set(let key, let value):
269+ do {
270+ // Atomic: a crash mid-write must not leave a truncated messages:<peer>.
271+ try Data(value).write(
272+ to: fileURL(forKey: key),
273+ options: [.atomic, .completeFileProtectionUnlessOpen]
274+ )
275+ // The core discards `previous`, so reading the old value back would
276+ // double the cost of every write to buy nothing.
277+ return .ok(response: .set(previous: .none))
278+ } catch {
279+ return .err(error: .io(message: error.localizedDescription))
280+ }
281+
282+ case .delete(let key):
283+ switch read(key) {
284+ case .failure(let error):
285+ return .err(error: .io(message: error.localizedDescription))
286+ case .success(let previous):
287+ let url = fileURL(forKey: key)
288+ do {
289+ if FileManager.default.fileExists(atPath: url.path) {
290+ try FileManager.default.removeItem(at: url)
291+ }
292+ return .ok(response: .delete(previous: previous))
293+ } catch {
294+ return .err(error: .io(message: error.localizedDescription))
295+ }
296+ }
297+
298+ case .exists(let key):
299+ let present = FileManager.default.fileExists(atPath: fileURL(forKey: key).path)
300+ return .ok(response: .exists(isPresent: present))
301+
302+ case .listKeys:
303+ // Never used by the core. Reporting success with no keys would read as
304+ // "the store is empty" — say so honestly instead.
305+ return .err(error: .io(message: "listKeys is not implemented"))
306+ }
307+ }
308+
309+ /// A missing file is an empty key; anything else is a real failure. Collapsing the
310+ /// two would let a transient read error look like "no contacts yet", and the next
311+ /// save would write that emptiness back over the real store.
312+ private func read(_ key: String) -> Result<Value, Error> {
313+ let url = fileURL(forKey: key)
314+ guard FileManager.default.fileExists(atPath: url.path) else { return .success(.none) }
315+ do {
316+ return .success(.bytes([UInt8](try Data(contentsOf: url))))
317+ } catch {
318+ return .failure(error)
319+ }
320+ }
321+
322+ /// Percent-encoding, so the key → filename mapping is injective. Substituting `_`
323+ /// for both `:` and `/` would collide `messages:ab` with `messages/ab`, and two
324+ /// peers' histories would land in the same file.
325+ private func fileURL(forKey key: String) -> URL {
326+ let encoded = key.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? key
327+ return dir.appendingPathComponent(encoded)
328+ }
329+}
330+
331+// MARK: - Keychain identity (64-byte libsodium-form Ed25519 secret key)
332+
333+enum Keychain {
334+ private static let service = "lol.skrepka.identity"
335+ private static let account = "identity"
336+
337+ /// `Result`'s failure type has to be an `Error`, and `OSStatus` is a bare `Int32`.
338+ struct Status: Error { let code: OSStatus }
339+
340+ enum IdentityError: Error {
341+ /// The item may well exist — we just could not read it (e.g. the device has not
342+ /// been unlocked since boot). Never generate a replacement on this path.
343+ case unreadable(OSStatus)
344+ case corrupt(Int)
345+ case notStored(OSStatus)
346+
347+ var message: String {
348+ switch self {
349+ case .unreadable(let status):
350+ return "Could not read the identity key from the Keychain (\(status)). "
351+ + "Unlock the device and try again."
352+ case .corrupt(let count):
353+ return "The stored identity key is \(count) bytes, not 64. "
354+ + "Reinstalling the app will create a new identity — the old one is unrecoverable."
355+ case .notStored(let status):
356+ return "Could not save the new identity key to the Keychain (\(status))."
357+ }
358+ }
359+ }
360+
361+ /// Load the stored 64-byte key, or — only when the Keychain positively reports that
362+ /// no item exists — generate and persist a fresh one.
363+ static func loadOrCreateIdentity() -> Result<Data, IdentityError> {
364+ switch load() {
365+ case .failure(let status):
366+ return .failure(.unreadable(status.code))
367+
368+ case .success(.some(let existing)):
369+ guard existing.count == 64 else { return .failure(.corrupt(existing.count)) }
370+ return .success(existing)
371+
372+ case .success(.none):
373+ // Generate a valid Ed25519 keypair; sk64 = seed(32) || pub(32).
374+ let priv = Curve25519.Signing.PrivateKey()
375+ var key = Data()
376+ key.append(priv.rawRepresentation) // 32-byte seed
377+ key.append(priv.publicKey.rawRepresentation) // 32-byte public key
378+ if let status = store(key) { return .failure(.notStored(status)) }
379+ return .success(key)
380+ }
381+ }
382+
383+ /// `errSecItemNotFound` is the only status that means "no identity yet". Every other
384+ /// failure leaves the question open, and the caller must not answer it by minting a
385+ /// new keypair — that would overwrite an identity we merely failed to read.
386+ private static func load() -> Result<Data?, Status> {
387+ let query: [String: Any] = [
388+ kSecClass as String: kSecClassGenericPassword,
389+ kSecAttrService as String: service,
390+ kSecAttrAccount as String: account,
391+ kSecReturnData as String: true,
392+ kSecMatchLimit as String: kSecMatchLimitOne,
393+ ]
394+ var item: CFTypeRef?
395+ let status = SecItemCopyMatching(query as CFDictionary, &item)
396+ switch status {
397+ case errSecSuccess: return .success(item as? Data)
398+ case errSecItemNotFound: return .success(nil)
399+ default: return .failure(Status(code: status))
400+ }
401+ }
402+
403+ /// Update in place, adding only if nothing is there. Delete-then-add would open a
404+ /// window where a crash (or a kill) leaves no key at all, and the next launch would
405+ /// silently mint a *new* identity — losing the old one, and with it every contact's
406+ /// idea of who we are.
407+ ///
408+ /// Returns `nil` on success, or the failing `OSStatus`.
409+ private static func store(_ data: Data) -> OSStatus? {
410+ let query: [String: Any] = [
411+ kSecClass as String: kSecClassGenericPassword,
412+ kSecAttrService as String: service,
413+ kSecAttrAccount as String: account,
414+ ]
415+ let attributes: [String: Any] = [
416+ kSecValueData as String: data,
417+ // ThisDeviceOnly: the identity key must never ride along in a device backup.
418+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
419+ ]
420+
421+ let updated = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
422+ if updated == errSecSuccess { return nil }
423+ guard updated == errSecItemNotFound else { return updated }
424+
425+ let added = SecItemAdd(query.merging(attributes) { current, _ in current } as CFDictionary, nil)
426+ return added == errSecSuccess ? nil : added
427+ }
428+}
apple/SkrepkaApp/Forms.swiftnew file+198
@@ -0,0 +1,198 @@
1+import ImageIO
2+import Skrepka
3+import SwiftUI
4+import UIKit
5+
6+/// Decode an avatar, bounded to `maxPixel` on its long edge.
7+///
8+/// A contact's `photo` is whatever bytes a peer put in a `profile` payload, and nothing
9+/// upstream bounds the image's *decoded* size — only the blob's. `UIImage(data:)` would
10+/// honour whatever dimensions the header claims, so a few KB of base64 declaring
11+/// 30000x30000 costs ~3.6 GB of bitmap and the app is jetsammed. ImageIO decodes straight
12+/// to the thumbnail size, so the cost is capped no matter what the source claims to be.
13+///
14+/// 512 covers the largest avatar we draw (88pt at @3x) with headroom; our own photos come
15+/// out of `PhotoPicker` at 256, and a thumbnail is never upscaled past the source.
16+func decodeImage(_ base64: String, maxPixel: Int = 512) -> UIImage? {
17+ guard !base64.isEmpty, let data = Data(base64Encoded: base64) else { return nil }
18+ let options: [CFString: Any] = [
19+ kCGImageSourceCreateThumbnailFromImageAlways: true,
20+ kCGImageSourceCreateThumbnailWithTransform: true,
21+ kCGImageSourceThumbnailMaxPixelSize: maxPixel,
22+ ]
23+ guard let source = CGImageSourceCreateWithData(data as CFData, nil),
24+ let thumb = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
25+ else { return nil }
26+ return UIImage(cgImage: thumb)
27+}
28+
29+struct AddContactView: View {
30+ @ObservedObject var core: Core
31+ @State private var input = ""
32+ @State private var nickname = ""
33+ @State private var scanning = false
34+
35+ var body: some View {
36+ NavigationStack {
37+ Form {
38+ Section("Public key") {
39+ TextField("@p syllables or hex", text: $input, axis: .vertical)
40+ .autocorrectionDisabled()
41+ .textInputAutocapitalization(.never)
42+ Button { scanning = true } label: {
43+ Label("Scan QR code", systemImage: "qrcode.viewfinder")
44+ }
45+ }
46+ Section("Nickname (optional)") {
47+ TextField("e.g. Alice", text: $nickname)
48+ }
49+ if !core.view.error.isEmpty {
50+ Text(core.view.error).foregroundColor(.red)
51+ }
52+ }
53+ .navigationTitle("Add contact")
54+ .toolbar {
55+ ToolbarItem(placement: .cancellationAction) {
56+ Button("Cancel") { core.update(.showConversations) }
57+ }
58+ ToolbarItem(placement: .confirmationAction) {
59+ Button("Add") {
60+ core.update(.addContact(input: input, nickname: nickname))
61+ }.disabled(input.isEmpty)
62+ }
63+ }
64+ .sheet(isPresented: $scanning) {
65+ QRScannerView { code in
66+ input = scannedKeyPayload(code)
67+ scanning = false
68+ }
69+ }
70+ }
71+ }
72+}
73+
74+struct SettingsView: View {
75+ @ObservedObject var core: Core
76+ @State private var serverUrl = ""
77+ @State private var copied = false
78+
79+ var body: some View {
80+ NavigationStack {
81+ Form {
82+ Section("Your identity") {
83+ HStack {
84+ Avatar(base64: core.view.profile.photo, name: core.view.profile.displayName)
85+ VStack(alignment: .leading) {
86+ Text(core.view.profile.displayName.isEmpty
87+ ? "No name" : core.view.profile.displayName).font(.headline)
88+ Text(core.view.connStatus.capitalized)
89+ .font(.caption).foregroundColor(.secondary)
90+ }
91+ }
92+ if let qr = qrImage(core.view.myPubkeyHex) {
93+ Image(uiImage: qr).interpolation(.none).resizable().scaledToFit()
94+ .frame(maxWidth: 200).frame(maxWidth: .infinity)
95+ }
96+ Text(core.view.myPubkeyOb).font(.system(.footnote, design: .monospaced))
97+ .textSelection(.enabled)
98+ Button {
99+ // Expire the pasteboard item: a public key is not a secret, but it
100+ // is an identifier, and every app on the device can read the board.
101+ UIPasteboard.general.setItems(
102+ [[UIPasteboard.typeAutomatic: core.view.myPubkeyHex]],
103+ options: [.expirationDate: Date().addingTimeInterval(60)]
104+ )
105+ copied = true
106+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) { copied = false }
107+ } label: {
108+ Label(copied ? "Copied!" : "Copy key", systemImage: "doc.on.doc")
109+ }
110+ Button { core.update(.showEditProfile) } label: {
111+ Label("Edit profile", systemImage: "pencil")
112+ }
113+ }
114+ Section("Server") {
115+ TextField("https://relay.example.com", text: $serverUrl)
116+ .autocorrectionDisabled().textInputAutocapitalization(.never)
117+ Button("Save & reconnect") {
118+ core.update(.setServerUrl(serverUrl))
119+ }.disabled(serverUrl.isEmpty || serverUrl == core.view.serverUrl)
120+ // A rejected URL sets `error` in the core and leaves the page open;
121+ // without this the Save button just does nothing, forever.
122+ if !core.view.error.isEmpty {
123+ Text(core.view.error).foregroundColor(.red)
124+ }
125+ }
126+ }
127+ .navigationTitle("Settings")
128+ .toolbar {
129+ ToolbarItem(placement: .cancellationAction) {
130+ Button("Done") { core.update(.showConversations) }
131+ }
132+ }
133+ .onAppear { serverUrl = core.view.serverUrl }
134+ }
135+ }
136+}
137+
138+struct EditProfileView: View {
139+ @ObservedObject var core: Core
140+ @State private var name = ""
141+ @State private var bio = ""
142+ @State private var photo: String?
143+ @State private var picking = false
144+
145+ var body: some View {
146+ NavigationStack {
147+ Form {
148+ Section {
149+ HStack {
150+ Spacer()
151+ Button { picking = true } label: {
152+ Avatar(base64: photo ?? "", name: name, size: 88)
153+ }
154+ Spacer()
155+ }
156+ Button("Choose photo") { picking = true }
157+ .frame(maxWidth: .infinity)
158+ }
159+ Section("Name") { TextField("Display name", text: $name) }
160+ Section("Bio") { TextField("About you", text: $bio, axis: .vertical) }
161+ // SaveProfile sets model.error on validation failures ("display name
162+ // too long", "bio too long", "photo too large") but leaves the page
163+ // open. Without displaying it the user taps Save, nothing happens, and
164+ // they have no idea why.
165+ if !core.view.error.isEmpty {
166+ Section {
167+ Text(core.view.error).foregroundColor(.red)
168+ }
169+ }
170+ }
171+ .navigationTitle("Edit profile")
172+ .toolbar {
173+ ToolbarItem(placement: .cancellationAction) {
174+ Button("Cancel") { core.update(.showConversations) }
175+ }
176+ ToolbarItem(placement: .confirmationAction) {
177+ Button("Save") {
178+ core.update(.saveProfile(displayName: name, bio: bio, photo: photo))
179+ }
180+ }
181+ }
182+ .sheet(isPresented: $picking) {
183+ PhotoPicker(
184+ onPick: { base64 in
185+ photo = base64
186+ picking = false
187+ },
188+ onCancel: { picking = false }
189+ )
190+ }
191+ .onAppear {
192+ name = core.view.profile.displayName
193+ bio = core.view.profile.bio
194+ photo = core.view.profile.photo.isEmpty ? nil : core.view.profile.photo
195+ }
196+ }
197+ }
198+}
apple/SkrepkaApp/Info-iOS.plistnew file+49
@@ -0,0 +1,49 @@
1+<?xml version="1.0" encoding="UTF-8"?>
2+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+<plist version="1.0">
4+<dict>
5+ <key>CFBundleDevelopmentRegion</key>
6+ <string>$(DEVELOPMENT_LANGUAGE)</string>
7+ <key>CFBundleDisplayName</key>
8+ <string>Skrepka</string>
9+ <key>CFBundleExecutable</key>
10+ <string>$(EXECUTABLE_NAME)</string>
11+ <key>CFBundleIdentifier</key>
12+ <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
13+ <key>CFBundleInfoDictionaryVersion</key>
14+ <string>6.0</string>
15+ <key>CFBundleName</key>
16+ <string>$(PRODUCT_NAME)</string>
17+ <key>CFBundlePackageType</key>
18+ <string>APPL</string>
19+ <key>CFBundleShortVersionString</key>
20+ <string>1.0</string>
21+ <key>CFBundleVersion</key>
22+ <string>1</string>
23+ <key>NSAppTransportSecurity</key>
24+ <dict>
25+ <key>NSAllowsLocalNetworking</key>
26+ <true/>
27+ <key>NSExceptionDomains</key>
28+ <dict>
29+ <key>localhost</key>
30+ <dict>
31+ <key>NSExceptionAllowsInsecureHTTPLoads</key>
32+ <true/>
33+ <key>NSIncludesSubdomains</key>
34+ <true/>
35+ </dict>
36+ </dict>
37+ </dict>
38+ <key>NSCameraUsageDescription</key>
39+ <string>Scan a contact's QR code to add them.</string>
40+ <key>NSPhotoLibraryUsageDescription</key>
41+ <string>Choose a photo for your profile.</string>
42+ <key>UILaunchScreen</key>
43+ <dict/>
44+ <key>UISupportedInterfaceOrientations</key>
45+ <array>
46+ <string>UIInterfaceOrientationPortrait</string>
47+ </array>
48+</dict>
49+</plist>
apple/SkrepkaApp/PhotoPicker.swiftnew file+90
@@ -0,0 +1,90 @@
1+import ImageIO
2+import PhotosUI
3+import SwiftUI
4+import UIKit
5+import UniformTypeIdentifiers
6+
7+/// Presents the system photo picker; returns a 256px JPEG as base64.
8+struct PhotoPicker: UIViewControllerRepresentable {
9+ let onPick: (String) -> Void
10+ var onCancel: () -> Void = {}
11+
12+ func makeCoordinator() -> Coordinator { Coordinator(onPick: onPick, onCancel: onCancel) }
13+
14+ func makeUIViewController(context: Context) -> PHPickerViewController {
15+ var config = PHPickerConfiguration()
16+ config.filter = .images
17+ config.selectionLimit = 1
18+ let picker = PHPickerViewController(configuration: config)
19+ picker.delegate = context.coordinator
20+ return picker
21+ }
22+
23+ func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}
24+
25+ final class Coordinator: NSObject, PHPickerViewControllerDelegate {
26+ let onPick: (String) -> Void
27+ let onCancel: () -> Void
28+
29+ init(onPick: @escaping (String) -> Void, onCancel: @escaping () -> Void) {
30+ self.onPick = onPick
31+ self.onCancel = onCancel
32+ }
33+
34+ func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
35+ // Empty results means the user cancelled. Dismissal belongs to the caller's
36+ // `isPresented` binding — dismissing the controller behind SwiftUI's back
37+ // leaves the binding true, and the sheet never opens again.
38+ guard let provider = results.first?.itemProvider,
39+ provider.hasItemConformingToTypeIdentifier(UTType.image.identifier) else {
40+ DispatchQueue.main.async { self.onCancel() }
41+ return
42+ }
43+ _ = provider.loadFileRepresentation(forTypeIdentifier: UTType.image.identifier) { url, _ in
44+ // The temp file is deleted the moment this callback returns: decode inline.
45+ let base64 = url.flatMap { Self.thumbnailBase64(at: $0) }
46+ DispatchQueue.main.async {
47+ if let base64 { self.onPick(base64) } else { self.onCancel() }
48+ }
49+ }
50+ }
51+
52+ /// Downsample through ImageIO, which decodes straight to the target size. Loading
53+ /// a `UIImage` first would materialize the full bitmap — ~190 MB for a 48-megapixel
54+ /// shot — to then throw all but 256px of it away.
55+ static func thumbnailBase64(at url: URL) -> String? {
56+ let options: [CFString: Any] = [
57+ kCGImageSourceCreateThumbnailFromImageAlways: true,
58+ kCGImageSourceCreateThumbnailWithTransform: true, // honour the EXIF orientation
59+ kCGImageSourceShouldCacheImmediately: true,
60+ kCGImageSourceThumbnailMaxPixelSize: 256, // pixels, not points: no @2x/@3x inflation
61+ ]
62+ guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
63+ let thumb = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary)
64+ else { return nil }
65+ let image = UIImage(cgImage: thumb)
66+ // Reduce quality until the base64 fits within the protocol cap (MAX_PHOTO_LEN,
67+ // 64 KiB). A high-detail 256px frame can exceed the cap even at q0.7; without
68+ // this loop `SaveProfile` rejects the whole profile with no recourse for the user.
69+ let maxBase64Len = 64 * 1024
70+ var quality: CGFloat = 0.7
71+ while quality > 0.1 {
72+ if let jpegData = image.jpegData(compressionQuality: quality) {
73+ let base64 = jpegData.base64EncodedString()
74+ if base64.count <= maxBase64Len {
75+ return base64
76+ }
77+ }
78+ quality -= 0.1
79+ }
80+ // Last resort: the smallest quality that still produces something.
81+ // If even this exceeds the cap, return nil so onCancel fires rather
82+ // than sending an oversized payload the core will reject.
83+ let lastResort = image.jpegData(compressionQuality: 0.1)?.base64EncodedString()
84+ if let result = lastResort, result.count <= maxBase64Len {
85+ return result
86+ }
87+ return nil
88+ }
89+ }
90+}
apple/SkrepkaApp/QRView.swiftnew file+149
@@ -0,0 +1,149 @@
1+import AVFoundation
2+import CoreImage.CIFilterBuiltins
3+import SwiftUI
4+import UIKit
5+
6+/// Rendering context for QR generation. Building a CIContext is expensive (it spins up a
7+/// GPU/CPU render pipeline) and `qrImage` is called from inside a SwiftUI `body`.
8+private let ciContext = CIContext()
9+
10+/// Generate a QR code image from a string.
11+func qrImage(_ string: String) -> UIImage? {
12+ let context = ciContext
13+ let filter = CIFilter.qrCodeGenerator()
14+ filter.message = Data(string.utf8)
15+ guard let output = filter.outputImage else { return nil }
16+ let scaled = output.transformed(by: CGAffineTransform(scaleX: 8, y: 8))
17+ guard let cg = context.createCGImage(scaled, from: scaled.extent) else { return nil }
18+ return UIImage(cgImage: cg)
19+}
20+
21+/// A QR code carries whatever its author put there. Strip a leading scheme so a share
22+/// link (`skrepka:<key>`, `https://…/<key>`) yields the bare key the core will accept,
23+/// and so the pubkey field never silently fills with an unrelated URL.
24+// NOTE: This URL scheme stripping is arguably business logic that belongs in
25+// the core (see AGENTS.md). It is kept here for now because moving it requires
26+// an FFI change. The core's `try_parse_pubkey` validates the result regardless.
27+func scannedKeyPayload(_ raw: String) -> String {
28+ var value = raw.trimmingCharacters(in: .whitespacesAndNewlines)
29+ let schemes = ["skrepka://", "skrepka:", "https://", "http://"]
30+ if let scheme = schemes.first(where: { value.lowercased().hasPrefix($0) }) {
31+ value = String(value.dropFirst(scheme.count))
32+ // What remains of a link is host/path#key — the key is the last component.
33+ if let key = value.split(whereSeparator: { "/#?".contains($0) }).last {
34+ value = String(key)
35+ }
36+ }
37+ return value.trimmingCharacters(in: .whitespacesAndNewlines)
38+}
39+
40+/// A camera-backed QR scanner. Calls `onScan` with the decoded payload once.
41+struct QRScannerView: UIViewControllerRepresentable {
42+ let onScan: (String) -> Void
43+
44+ func makeCoordinator() -> Coordinator { Coordinator(onScan: onScan) }
45+
46+ func makeUIViewController(context: Context) -> ScannerVC {
47+ let vc = ScannerVC()
48+ vc.coordinator = context.coordinator
49+ return vc
50+ }
51+
52+ func updateUIViewController(_ uiViewController: ScannerVC, context: Context) {}
53+
54+ final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
55+ let onScan: (String) -> Void
56+ private var done = false
57+ init(onScan: @escaping (String) -> Void) { self.onScan = onScan }
58+
59+ func metadataOutput(
60+ _ output: AVCaptureMetadataOutput,
61+ didOutput metadataObjects: [AVMetadataObject],
62+ from connection: AVCaptureConnection
63+ ) {
64+ guard !done,
65+ let obj = metadataObjects.first as? AVMetadataMachineReadableCodeObject,
66+ let value = obj.stringValue else { return }
67+ done = true
68+ DispatchQueue.main.async { self.onScan(value) }
69+ }
70+ }
71+}
72+
73+final class ScannerVC: UIViewController {
74+ weak var coordinator: QRScannerView.Coordinator?
75+ private let session = AVCaptureSession()
76+ private var previewLayer: AVCaptureVideoPreviewLayer?
77+
78+ override func viewDidLoad() {
79+ super.viewDidLoad()
80+ view.backgroundColor = .black
81+
82+ let status = AVCaptureDevice.authorizationStatus(for: .video)
83+ switch status {
84+ case .authorized:
85+ startSession()
86+ case .notDetermined:
87+ AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
88+ DispatchQueue.main.async {
89+ if granted { self?.startSession() }
90+ else { self?.showPermissionDenied() }
91+ }
92+ }
93+ default:
94+ showPermissionDenied()
95+ }
96+ }
97+
98+ private func startSession() {
99+ guard let device = AVCaptureDevice.default(for: .video),
100+ let input = try? AVCaptureDeviceInput(device: device),
101+ session.canAddInput(input) else { return }
102+ session.addInput(input)
103+
104+ let output = AVCaptureMetadataOutput()
105+ guard session.canAddOutput(output) else { return }
106+ session.addOutput(output)
107+ output.setMetadataObjectsDelegate(coordinator, queue: .main)
108+ output.metadataObjectTypes = [.qr]
109+
110+ let preview = AVCaptureVideoPreviewLayer(session: session)
111+ preview.frame = view.layer.bounds
112+ preview.videoGravity = .resizeAspectFill
113+ view.layer.addSublayer(preview)
114+ previewLayer = preview
115+ }
116+
117+ private func showPermissionDenied() {
118+ let label = UILabel()
119+ label.text = "Camera permission required"
120+ label.textAlignment = .center
121+ label.textColor = .secondaryLabel
122+ view.addSubview(label)
123+ label.translatesAutoresizingMaskIntoConstraints = false
124+ NSLayoutConstraint.activate([
125+ label.centerXAnchor.constraint(equalTo: view.centerXAnchor),
126+ label.centerYAnchor.constraint(equalTo: view.centerYAnchor),
127+ ])
128+ }
129+
130+ override func viewDidLayoutSubviews() {
131+ super.viewDidLayoutSubviews()
132+ previewLayer?.frame = view.bounds
133+ }
134+
135+ override func viewWillAppear(_ animated: Bool) {
136+ super.viewWillAppear(animated)
137+ if !session.isRunning && !session.inputs.isEmpty {
138+ DispatchQueue.global(qos: .userInitiated).async { self.session.startRunning() }
139+ }
140+ }
141+
142+ override func viewWillDisappear(_ animated: Bool) {
143+ super.viewWillDisappear(animated)
144+ // Both start and stop block until the camera pipeline settles — never on main.
145+ if session.isRunning {
146+ DispatchQueue.global(qos: .userInitiated).async { self.session.stopRunning() }
147+ }
148+ }
149+}
apple/SkrepkaApp/SkrepkaApp.swiftnew file+166
@@ -0,0 +1,166 @@
1+import BackgroundTasks
2+import SwiftUI
3+import UIKit
4+
5+@main
6+struct SkrepkaApp: App {
7+ /// Must stay in step with `BGTaskSchedulerPermittedIdentifiers` in `project.yml`.
8+ /// An identifier the Info.plist does not list is refused by `submit` — quietly, and
9+ /// forever: the task simply never runs, and nothing at runtime says why.
10+ static let refreshTaskId = "lol.skrepka.SkrepkaApp.fetch"
11+
12+ @StateObject private var core = Core.shared
13+ @Environment(\.scenePhase) private var scenePhase
14+
15+ init() {
16+ // Launch handlers must all be registered before the app finishes launching, so this
17+ // cannot wait for a view to appear. The handler is called on a background queue;
18+ // `BackgroundRefresh` hops to the main actor, where the core lives.
19+ BGTaskScheduler.shared.register(
20+ forTaskWithIdentifier: Self.refreshTaskId,
21+ using: nil
22+ ) { task in
23+ guard let task = task as? BGAppRefreshTask else {
24+ task.setTaskCompleted(success: false)
25+ return
26+ }
27+ BackgroundRefresh.handle(task)
28+ }
29+ }
30+
31+ var body: some Scene {
32+ WindowGroup {
33+ RootView(core: core)
34+ .onChange(of: scenePhase) { _, phase in
35+ switch phase {
36+ case .active:
37+ // Backgrounding kills the long-poll socket and the app stops running,
38+ // so on return the core would otherwise sit out its whole backoff
39+ // (up to 30s) before noticing. Reconnect immediately instead.
40+ guard core.view.hasIdentity else { return }
41+ core.update(.connect)
42+ case .background:
43+ // Ask for a refresh pass on the way out — that is the only window in
44+ // which a pending request is any use, and the system replaces the
45+ // previous request for this identifier rather than queueing another.
46+ BackgroundRefresh.schedule()
47+ default:
48+ break
49+ }
50+ }
51+ }
52+ }
53+}
54+
55+/// Opportunistic catch-up while the app is backgrounded.
56+///
57+/// This does **not** keep the poll loop alive — iOS grants no such thing to a non-VoIP app.
58+/// `BGAppRefreshTask` wakes us for a few tens of seconds at times the system chooses from the
59+/// user's launch habits (`earliestBeginDate` is a floor, not a schedule: a pass may be hours
60+/// late, or never come at all if the app was force-quit), and suspends us again as soon as we
61+/// report completion. So this drains whatever the relay is already holding, on the system's
62+/// terms. Prompt delivery would need a push, and the protocol has no APNs surface at all.
63+@MainActor
64+enum BackgroundRefresh {
65+ /// The system coalesces and defers as it sees fit; asking for less does not make it come
66+ /// sooner, it only tells the scheduler we have nothing useful to do before then.
67+ private static let earliestBegin: TimeInterval = 30 * 60
68+
69+ /// The grant is around 30s, and an app that habitually burns all of it — or worse, gets
70+ /// expired — is scheduled less often. Leave headroom.
71+ private static let budget: TimeInterval = 25
72+
73+ /// How long to hold the poll open once we are online. The relay answers a poll at once
74+ /// when it is holding mail and otherwise long-polls ~25s, so a few seconds of silence
75+ /// already *is* the answer "nothing waiting" — there is nothing to be gained by waiting
76+ /// out the long poll, and the budget is better left unspent.
77+ private static let idleGrace: TimeInterval = 5
78+
79+ static func schedule() {
80+ let request = BGAppRefreshTaskRequest(identifier: SkrepkaApp.refreshTaskId)
81+ request.earliestBeginDate = Date(timeIntervalSinceNow: earliestBegin)
82+ // Throws on `.notPermitted` (the identifier is missing from the Info.plist — a
83+ // packaging bug, see `SkrepkaApp.refreshTaskId`) and on `.tooManyPendingTaskRequests`.
84+ // Neither is actionable from here, and neither is worth failing a scene transition.
85+ try? BGTaskScheduler.shared.submit(request)
86+ }
87+
88+ nonisolated static func handle(_ task: BGAppRefreshTask) {
89+ let work = Task { @MainActor in await run(task) }
90+ // Expiry only *cancels*; `run` owns the single `setTaskCompleted` call.
91+ // The handler is assigned immediately after Task creation — the window
92+ // where the task is alive but unhandled is one assignment, and the system
93+ // does not expire a task in its first few milliseconds.
94+ task.expirationHandler = { work.cancel() }
95+ }
96+
97+ private static func run(_ task: BGAppRefreshTask) async {
98+ // Before anything that can fail or be cut short: we still want the next pass.
99+ schedule()
100+
101+ // Every kv file is written `completeUnlessOpen`, so while the device is
102+ // locked an *existing* one cannot be opened and every read fails. The
103+ // core now distinguishes a failed read from an absent key (`parse_kv`
104+ // returns `Err` vs `Ok(None)`), so it surfaces a storage error instead
105+ // of silently loading an empty model. But the guard is still the right
106+ // call: a locked device means the core would come up with errors on
107+ // every key, no contacts, and no history — and even if it doesn't
108+ // overwrite, it would poll into an empty conversation list and ack
109+ // messages the user can't see. Locked means we must not run at all.
110+ guard UIApplication.shared.isProtectedDataAvailable else {
111+ task.setTaskCompleted(success: false)
112+ return
113+ }
114+
115+ let deadline = Date(timeIntervalSinceNow: budget)
116+ let core = Core.shared
117+
118+ // Reading `Core.shared` is what boots the core when the system launched us straight
119+ // into the background. Startup is asynchronous — the identity, then five kv loads —
120+ // and the core fires its own `Connect` once the settings land. It must be left to do
121+ // that: nudging it beforehand would authenticate against the *default* relay, because
122+ // the configured URL has not been read off disk yet.
123+ guard await settle(by: deadline, until: { core.view.hasIdentity }) else {
124+ task.setTaskCompleted(success: false)
125+ return
126+ }
127+
128+ // Still offline once startup has had its chance: either the core is idle, or it was
129+ // suspended part-way through a backoff whose timer will not come due for another 30s.
130+ // A `Connect` collapses that wait. (It is a no-op when the core already believes it
131+ // is online — there, a stale poll is the watchdog's problem, not ours.)
132+ let startupDeadline = min(deadline, Date(timeIntervalSinceNow: 3))
133+ let connecting = await settle(by: startupDeadline, until: {
134+ core.view.connStatus != "offline"
135+ })
136+ if !connecting {
137+ core.update(.connect)
138+ }
139+
140+ // Online is the token in hand and the poll issued. Anything the relay was holding
141+ // comes back on that first page.
142+ guard await settle(by: deadline, until: { core.view.connStatus == "online" }) else {
143+ task.setTaskCompleted(success: false)
144+ return
145+ }
146+ try? await Task.sleep(for: .seconds(idleGrace))
147+
148+ task.setTaskCompleted(success: !Task.isCancelled)
149+ }
150+
151+ /// Waits for a condition on the view, or for the deadline (or expiry) to overtake it.
152+ ///
153+ /// The core has nothing to await — it publishes a `ViewModel` and that is the whole of
154+ /// its output — so the shell watches that. A 500ms tick across the handful of transitions
155+ /// this cares about is cheaper than threading a Combine subscription through an actor hop.
156+ /// The previous 200ms interval was unnecessarily aggressive for a background task with a
157+ /// 25s budget that only cares about a few state transitions.
158+ private static func settle(by deadline: Date, until condition: () -> Bool) async -> Bool {
159+ while !condition() {
160+ guard !Task.isCancelled, Date() < deadline else { return false }
161+ // Cancellation makes this throw at once; the guard above is what breaks the loop.
162+ try? await Task.sleep(for: .milliseconds(500))
163+ }
164+ return true
165+ }
166+}
apple/SkrepkaApp/Views.swiftnew file+390
@@ -0,0 +1,390 @@
1+import Skrepka
2+import SwiftUI
3+import UIKit
4+
5+let accent = Color(red: 98 / 255, green: 54 / 255, blue: 1.0)
6+
7+/// Hoisted out of `body`: a DateFormatter costs real work to build, and these are read
8+/// once per message bubble on every render.
9+private let timeFormatter: DateFormatter = {
10+ let fmt = DateFormatter()
11+ fmt.timeStyle = .short // locale-aware: 24h or AM/PM, as the user has it set
12+ return fmt
13+}()
14+
15+private let dateFormatter: DateFormatter = {
16+ let fmt = DateFormatter()
17+ fmt.dateStyle = .short
18+ return fmt
19+}()
20+
21+func formatTime(_ ms: Int64) -> String {
22+ timeFormatter.string(from: Date(timeIntervalSince1970: Double(ms) / 1000))
23+}
24+
25+/// Conversation-list stamp: the time for today, the date for anything older.
26+func formatStamp(_ ms: Int64) -> String {
27+ let date = Date(timeIntervalSince1970: Double(ms) / 1000)
28+ return Calendar.current.isDateInToday(date)
29+ ? timeFormatter.string(from: date)
30+ : dateFormatter.string(from: date)
31+}
32+
33+struct RootView: View {
34+ @ObservedObject var core: Core
35+
36+ var body: some View {
37+ Group {
38+ if !core.view.hasIdentity {
39+ identity
40+ } else {
41+ switch core.view.page {
42+ case "chat": ChatView(core: core)
43+ case "add_contact": AddContactView(core: core)
44+ case "settings": SettingsView(core: core)
45+ case "edit_profile": EditProfileView(core: core)
46+ default: ConversationsView(core: core)
47+ }
48+ }
49+ }
50+ }
51+
52+ /// Without an identity there is no app. If loading one failed, say so — a spinner
53+ /// that never resolves is the worst possible way to report a Keychain error.
54+ @ViewBuilder private var identity: some View {
55+ let error = core.identityError ?? (core.view.error.isEmpty ? nil : core.view.error)
56+ if let error {
57+ VStack(spacing: 12) {
58+ Image(systemName: "exclamationmark.triangle")
59+ .font(.largeTitle).foregroundColor(.orange)
60+ Text("Identity unavailable").font(.headline)
61+ Text(error)
62+ .font(.footnote).foregroundColor(.secondary)
63+ .multilineTextAlignment(.center)
64+ Button("Try again") { core.bootIdentity() }
65+ .buttonStyle(.borderedProminent).tint(accent)
66+ }
67+ .padding(32)
68+ } else {
69+ ProgressView("Generating identity…")
70+ }
71+ }
72+}
73+
74+struct ConnDot: View {
75+ let status: String
76+ var color: Color {
77+ switch status {
78+ case "online": return .green
79+ case "connecting": return .orange
80+ default: return .gray
81+ }
82+ }
83+ var body: some View {
84+ HStack(spacing: 5) {
85+ Circle().fill(color).frame(width: 8, height: 8)
86+ Text(status.capitalized).font(.caption).foregroundColor(.secondary)
87+ }
88+ .fixedSize()
89+ }
90+}
91+
92+/// Decoded avatars, keyed by their base64 source. A contact's photo is attacker-supplied
93+/// bytes off the wire; decoding it synchronously inside `body` hands any peer a way to
94+/// stall the main thread on every single render.
95+private let avatarCache: NSCache<NSString, UIImage> = {
96+ let cache = NSCache<NSString, UIImage>()
97+ cache.countLimit = 128
98+ return cache
99+}()
100+
101+struct Avatar: View {
102+ let base64: String
103+ let name: String
104+ var size: CGFloat = 44
105+ @State private var image: UIImage?
106+
107+ var body: some View {
108+ Group {
109+ if let image {
110+ Image(uiImage: image).resizable().scaledToFill()
111+ .frame(width: size, height: size).clipShape(Circle())
112+ } else {
113+ ZStack {
114+ Circle().fill(accent.opacity(0.15))
115+ Text(initials(name)).font(.system(size: size * 0.4, weight: .semibold))
116+ .foregroundColor(accent)
117+ }.frame(width: size, height: size)
118+ }
119+ }
120+ .task(id: base64) { await load() }
121+ }
122+
123+ private func load() async {
124+ guard !base64.isEmpty else {
125+ image = nil
126+ return
127+ }
128+ let key = base64 as NSString
129+ if let cached = avatarCache.object(forKey: key) {
130+ image = cached
131+ return
132+ }
133+ let source = base64
134+ let decoded = await Task.detached(priority: .userInitiated) { decodeImage(source) }.value
135+ guard !Task.isCancelled else { return }
136+ if let decoded { avatarCache.setObject(decoded, forKey: key) }
137+ image = decoded
138+ }
139+}
140+
141+func initials(_ name: String) -> String {
142+ let parts = name.split(separator: " ")
143+ let chars = parts.prefix(2).compactMap { $0.first }
144+ return chars.isEmpty ? "?" : String(chars).uppercased()
145+}
146+
147+/// Toggles `Contact::blocked` in the core. Blocked peers stay in the contact list —
148+/// listed apart — so the block is always reversible from the UI.
149+struct BlockToggle: View {
150+ @ObservedObject var core: Core
151+ let peer: String
152+ let blocked: Bool
153+
154+ var body: some View {
155+ Button(role: blocked ? nil : .destructive) {
156+ core.update(.setBlocked(peer: peer, blocked: !blocked))
157+ } label: {
158+ Label(blocked ? "Unblock" : "Block",
159+ systemImage: blocked ? "hand.raised.slash" : "hand.raised")
160+ }
161+ }
162+}
163+
164+/// Permanently removes a contact, their conversation history, and any queued
165+/// outbox items for them. Unlike blocking (which silences but keeps the entry),
166+/// deletion is irreversible — so a confirmation prompt gates the action.
167+struct DeleteContactButton: View {
168+ @ObservedObject var core: Core
169+ let peer: String
170+
171+ @State private var confirming = false
172+
173+ var body: some View {
174+ Button(role: .destructive) {
175+ confirming = true
176+ } label: {
177+ Label("Delete contact", systemImage: "trash")
178+ }
179+ .confirmationDialog(
180+ "Delete this contact? Their message history will be removed and cannot be recovered.",
181+ isPresented: $confirming,
182+ titleVisibility: .visible
183+ ) {
184+ Button("Delete", role: .destructive) {
185+ core.update(.deleteContact(peer: peer))
186+ }
187+ Button("Cancel", role: .cancel) {}
188+ }
189+ }
190+}
191+
192+struct ConversationsView: View {
193+ @ObservedObject var core: Core
194+
195+ var visible: [ContactVM] { core.view.contacts.filter { !$0.blocked } }
196+ var blocked: [ContactVM] { core.view.contacts.filter { $0.blocked } }
197+
198+ var body: some View {
199+ NavigationStack {
200+ Group {
201+ if core.view.contacts.isEmpty {
202+ ContentUnavailableView(
203+ "No conversations",
204+ systemImage: "bubble.left.and.bubble.right",
205+ description: Text("Tap + to add a contact by their key or QR code.")
206+ )
207+ } else {
208+ List {
209+ ForEach(visible, id: \.pubkey) { row($0) }
210+ if !blocked.isEmpty {
211+ Section("Blocked") {
212+ ForEach(blocked, id: \.pubkey) { row($0).opacity(0.5) }
213+ }
214+ }
215+ }
216+ }
217+ }
218+ .navigationTitle("Skrepka")
219+ .toolbar {
220+ ToolbarItem(placement: .topBarLeading) { ConnDot(status: core.view.connStatus) }
221+ ToolbarItem(placement: .topBarTrailing) {
222+ Button { core.update(.showAddContact) } label: { Image(systemName: "plus") }
223+ }
224+ ToolbarItem(placement: .topBarTrailing) {
225+ Button { core.update(.showSettings) } label: { Image(systemName: "gearshape") }
226+ }
227+ }
228+ }
229+ }
230+
231+ private func row(_ contact: ContactVM) -> some View {
232+ Button { core.update(.openChat(contact.pubkey)) } label: {
233+ HStack(spacing: 12) {
234+ Avatar(base64: contact.photo, name: contact.name)
235+ VStack(alignment: .leading, spacing: 2) {
236+ Text(contact.name).font(.headline).foregroundColor(.primary)
237+ Text(contact.lastMessage.isEmpty ? contact.ob : contact.lastMessage)
238+ .font(.subheadline).foregroundColor(.secondary).lineLimit(1)
239+ }
240+ Spacer()
241+ if contact.lastTs > 0 {
242+ Text(formatStamp(contact.lastTs))
243+ .font(.caption).foregroundColor(.secondary)
244+ }
245+ }
246+ }
247+ .contextMenu {
248+ BlockToggle(core: core, peer: contact.pubkey, blocked: contact.blocked)
249+ DeleteContactButton(core: core, peer: contact.pubkey)
250+ }
251+ }
252+}
253+
254+struct ChatView: View {
255+ @ObservedObject var core: Core
256+ @State private var draft = ""
257+
258+ var body: some View {
259+ VStack(spacing: 0) {
260+ messages
261+ composer
262+ }
263+ .safeAreaInset(edge: .top) { header }
264+ }
265+
266+ /// The open chat's contact row. The core only fills in `photo` for this one peer,
267+ /// and only while their chat is up — a base64 avatar is far too big to clone into
268+ /// every contact on every render — so the header is the one place it can be shown.
269+ private var peer: ContactVM? {
270+ core.view.contacts.first { $0.pubkey == core.view.activePeer }
271+ }
272+
273+ private var header: some View {
274+ HStack(spacing: 12) {
275+ Button { core.update(.back) } label: { Image(systemName: "chevron.left") }
276+ Avatar(base64: peer?.photo ?? "", name: core.view.activePeerName, size: 32)
277+ VStack(alignment: .leading, spacing: 1) {
278+ Text(core.view.activePeerName).font(.headline)
279+ Text(core.view.activePeerOb).font(.caption2).foregroundColor(.secondary).lineLimit(1)
280+ }
281+ Spacer()
282+ if core.view.activePeerBlocked {
283+ Text("Blocked").font(.caption).foregroundColor(.secondary)
284+ }
285+ Menu {
286+ BlockToggle(
287+ core: core,
288+ peer: core.view.activePeer,
289+ blocked: core.view.activePeerBlocked
290+ )
291+ DeleteContactButton(core: core, peer: core.view.activePeer)
292+ } label: {
293+ Image(systemName: "ellipsis.circle")
294+ }
295+ }
296+ .padding(.horizontal).padding(.vertical, 8)
297+ .background(.bar)
298+ }
299+
300+ private var messages: some View {
301+ ScrollViewReader { proxy in
302+ ScrollView {
303+ LazyVStack(spacing: 8) {
304+ ForEach(core.view.messages, id: \.id) { msg in
305+ MessageBubble(msg: msg).id(msg.id)
306+ }
307+ }.padding()
308+ }
309+ .onAppear {
310+ // A chat opens at its newest message, not its oldest.
311+ if let last = core.view.messages.last {
312+ withAnimation(nil) { proxy.scrollTo(last.id, anchor: .bottom) }
313+ }
314+ }
315+ .onChange(of: core.view.messages.count) {
316+ if let last = core.view.messages.last { proxy.scrollTo(last.id, anchor: .bottom) }
317+ }
318+ }
319+ }
320+
321+ private var composer: some View {
322+ VStack(spacing: 0) {
323+ // Errors from send_text ("message too long", "contact list is full",
324+ // "gave up sending …", "invalid key") are set on model.error and rendered
325+ // into view.error, but without this the chat page swallowed them — the
326+ // user tapped send, it failed, and they saw nothing.
327+ if !core.view.error.isEmpty {
328+ Text(core.view.error)
329+ .font(.caption)
330+ .foregroundColor(.red)
331+ .frame(maxWidth: .infinity, alignment: .leading)
332+ .padding(.horizontal)
333+ .padding(.top, 4)
334+ }
335+ HStack(spacing: 8) {
336+ TextField("Message", text: $draft, axis: .vertical)
337+ .textFieldStyle(.roundedBorder)
338+ .lineLimit(1...4)
339+ .disabled(core.view.activePeerBlocked)
340+ Button {
341+ let text = draft.trimmingCharacters(in: .whitespacesAndNewlines)
342+ guard !text.isEmpty else { return }
343+ core.update(.composeChanged(text))
344+ core.update(.sendText)
345+ // Only clear the local draft when the core actually accepted the
346+ // message. send_text refuses (and sets error) on a blocked peer,
347+ // an oversized body, or a full contact list — and it preserves
348+ // model.compose on those paths so the user's text is not lost.
349+ // Clearing draft unconditionally wiped it from the UI even though
350+ // the core kept it, so the failure was both invisible and
351+ // destructive: the text was gone with no explanation.
352+ if core.view.error.isEmpty {
353+ draft = ""
354+ }
355+ } label: {
356+ Image(systemName: "arrow.up.circle.fill").font(.title)
357+ }
358+ .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
359+ || core.view.activePeerBlocked)
360+ }
361+ }
362+ .padding(.horizontal).padding(.vertical, 8)
363+ .background(.bar)
364+ }
365+}
366+
367+struct MessageBubble: View {
368+ let msg: MessageVM
369+ var body: some View {
370+ HStack {
371+ if msg.outgoing { Spacer(minLength: 40) }
372+ VStack(alignment: msg.outgoing ? .trailing : .leading, spacing: 2) {
373+ Text(msg.body)
374+ .foregroundColor(msg.outgoing ? .white : .primary)
375+ HStack(spacing: 4) {
376+ Text(formatTime(msg.ts)).font(.caption2)
377+ .foregroundColor(msg.outgoing ? .white.opacity(0.7) : .secondary)
378+ if msg.outgoing {
379+ Image(systemName: msg.delivered ? "checkmark.circle.fill" : "checkmark")
380+ .font(.caption2).foregroundColor(.white.opacity(0.8))
381+ }
382+ }
383+ }
384+ .padding(.horizontal, 12).padding(.vertical, 8)
385+ .background(msg.outgoing ? accent : Color(.systemGray5))
386+ .clipShape(RoundedRectangle(cornerRadius: 16))
387+ if !msg.outgoing { Spacer(minLength: 40) }
388+ }
389+ }
390+}
apple/project.ymlnew file+71
@@ -0,0 +1,71 @@
1+name: SkrepkaApp
2+packages:
3+ Shared:
4+ path: ./generated/Shared
5+ App:
6+ path: ./generated/App/Skrepka
7+options:
8+ bundleIdPrefix: lol.skrepka
9+ deploymentTarget:
10+ iOS: "17.0"
11+attributes:
12+ BuildIndependentTargetsInParallel: true
13+
14+targets:
15+ SkrepkaApp-iOS:
16+ type: application
17+ platform: iOS
18+ deploymentTarget: "17.0"
19+ sources:
20+ - path: SkrepkaApp
21+ excludes:
22+ - "Info-*.plist"
23+ - "*.entitlements"
24+ settings:
25+ base:
26+ # iPhone only: the app declares portrait-only, which iPadOS rejects for an iPad
27+ # build (it requires landscape too). Rather than pretend to support a form factor
28+ # the layout was never designed for, don't claim it.
29+ TARGETED_DEVICE_FAMILY: "1"
30+ entitlements:
31+ path: SkrepkaApp/SkrepkaApp.entitlements
32+ properties:
33+ # Plaintext message history sits in Application Support. Default every file the
34+ # app creates to Complete protection, so it is unreadable while the device is
35+ # locked — the KV store then opts into UnlessOpen for the files it holds open.
36+ com.apple.developer.default-data-protection: NSFileProtectionComplete
37+ info:
38+ path: SkrepkaApp/Info-iOS.plist
39+ properties:
40+ CFBundleDisplayName: Skrepka
41+ UILaunchScreen: {}
42+ UISupportedInterfaceOrientations:
43+ - UIInterfaceOrientationPortrait
44+ NSCameraUsageDescription: Scan a contact's QR code to add them.
45+ NSPhotoLibraryUsageDescription: Choose a photo for your profile.
46+ # Opportunistic catch-up polls while backgrounded (BGAppRefreshTask). `fetch` is the
47+ # mode BGAppRefreshTask runs under; `processing` is for long deferrable work and is
48+ # not what this is. The scheduler refuses — silently — any identifier not listed in
49+ # BGTaskSchedulerPermittedIdentifiers, so it must match `SkrepkaApp.refreshTaskId`.
50+ UIBackgroundModes:
51+ - fetch
52+ BGTaskSchedulerPermittedIdentifiers:
53+ - lol.skrepka.SkrepkaApp.fetch
54+ # Declared so App Store Connect stops asking on every upload.
55+ ITSAppUsesNonExemptEncryption: false
56+ # Relays must be reachable over TLS. The only cleartext exception is a
57+ # locally-run `just server`, for development.
58+ NSAppTransportSecurity:
59+ NSAllowsLocalNetworking: true
60+ NSExceptionDomains:
61+ localhost:
62+ NSExceptionAllowsInsecureHTTPLoads: true
63+ NSIncludesSubdomains: true
64+ dependencies:
65+ - package: Shared
66+ product: SkrepkaShared
67+ - package: App
68+ product: Skrepka
69+ scheme:
70+ management:
71+ shared: true
client/elm-stuff/0.19.1/lockdeleted

No changes to the content.

client/elm.jsdeleted−9626
@@ -1,9626 +0,0 @@
1−(function(scope){
2−'use strict';
3−
4−function F(arity, fun, wrapper) {
5− wrapper.a = arity;
6− wrapper.f = fun;
7− return wrapper;
8−}
9−
10−function F2(fun) {
11− return F(2, fun, function(a) { return function(b) { return fun(a,b); }; })
12−}
13−function F3(fun) {
14− return F(3, fun, function(a) {
15− return function(b) { return function(c) { return fun(a, b, c); }; };
16− });
17−}
18−function F4(fun) {
19− return F(4, fun, function(a) { return function(b) { return function(c) {
20− return function(d) { return fun(a, b, c, d); }; }; };
21− });
22−}
23−function F5(fun) {
24− return F(5, fun, function(a) { return function(b) { return function(c) {
25− return function(d) { return function(e) { return fun(a, b, c, d, e); }; }; }; };
26− });
27−}
28−function F6(fun) {
29− return F(6, fun, function(a) { return function(b) { return function(c) {
30− return function(d) { return function(e) { return function(f) {
31− return fun(a, b, c, d, e, f); }; }; }; }; };
32− });
33−}
34−function F7(fun) {
35− return F(7, fun, function(a) { return function(b) { return function(c) {
36− return function(d) { return function(e) { return function(f) {
37− return function(g) { return fun(a, b, c, d, e, f, g); }; }; }; }; }; };
38− });
39−}
40−function F8(fun) {
41− return F(8, fun, function(a) { return function(b) { return function(c) {
42− return function(d) { return function(e) { return function(f) {
43− return function(g) { return function(h) {
44− return fun(a, b, c, d, e, f, g, h); }; }; }; }; }; }; };
45− });
46−}
47−function F9(fun) {
48− return F(9, fun, function(a) { return function(b) { return function(c) {
49− return function(d) { return function(e) { return function(f) {
50− return function(g) { return function(h) { return function(i) {
51− return fun(a, b, c, d, e, f, g, h, i); }; }; }; }; }; }; }; };
52− });
53−}
54−
55−function A2(fun, a, b) {
56− return fun.a === 2 ? fun.f(a, b) : fun(a)(b);
57−}
58−function A3(fun, a, b, c) {
59− return fun.a === 3 ? fun.f(a, b, c) : fun(a)(b)(c);
60−}
61−function A4(fun, a, b, c, d) {
62− return fun.a === 4 ? fun.f(a, b, c, d) : fun(a)(b)(c)(d);
63−}
64−function A5(fun, a, b, c, d, e) {
65− return fun.a === 5 ? fun.f(a, b, c, d, e) : fun(a)(b)(c)(d)(e);
66−}
67−function A6(fun, a, b, c, d, e, f) {
68− return fun.a === 6 ? fun.f(a, b, c, d, e, f) : fun(a)(b)(c)(d)(e)(f);
69−}
70−function A7(fun, a, b, c, d, e, f, g) {
71− return fun.a === 7 ? fun.f(a, b, c, d, e, f, g) : fun(a)(b)(c)(d)(e)(f)(g);
72−}
73−function A8(fun, a, b, c, d, e, f, g, h) {
74− return fun.a === 8 ? fun.f(a, b, c, d, e, f, g, h) : fun(a)(b)(c)(d)(e)(f)(g)(h);
75−}
76−function A9(fun, a, b, c, d, e, f, g, h, i) {
77− return fun.a === 9 ? fun.f(a, b, c, d, e, f, g, h, i) : fun(a)(b)(c)(d)(e)(f)(g)(h)(i);
78−}
79−
80−console.warn('Compiled in DEV mode. Follow the advice at https://elm-lang.org/0.19.1/optimize for better performance and smaller assets.');
81−
82−
83−var _JsArray_empty = [];
84−
85−function _JsArray_singleton(value)
86−{
87− return [value];
88−}
89−
90−function _JsArray_length(array)
91−{
92− return array.length;
93−}
94−
95−var _JsArray_initialize = F3(function(size, offset, func)
96−{
97− var result = new Array(size);
98−
99− for (var i = 0; i < size; i++)
100− {
101− result[i] = func(offset + i);
102− }
103−
104− return result;
105−});
106−
107−var _JsArray_initializeFromList = F2(function (max, ls)
108−{
109− var result = new Array(max);
110−
111− for (var i = 0; i < max && ls.b; i++)
112− {
113− result[i] = ls.a;
114− ls = ls.b;
115− }
116−
117− result.length = i;
118− return _Utils_Tuple2(result, ls);
119−});
120−
121−var _JsArray_unsafeGet = F2(function(index, array)
122−{
123− return array[index];
124−});
125−
126−var _JsArray_unsafeSet = F3(function(index, value, array)
127−{
128− var length = array.length;
129− var result = new Array(length);
130−
131− for (var i = 0; i < length; i++)
132− {
133− result[i] = array[i];
134− }
135−
136− result[index] = value;
137− return result;
138−});
139−
140−var _JsArray_push = F2(function(value, array)
141−{
142− var length = array.length;
143− var result = new Array(length + 1);
144−
145− for (var i = 0; i < length; i++)
146− {
147− result[i] = array[i];
148− }
149−
150− result[length] = value;
151− return result;
152−});
153−
154−var _JsArray_foldl = F3(function(func, acc, array)
155−{
156− var length = array.length;
157−
158− for (var i = 0; i < length; i++)
159− {
160− acc = A2(func, array[i], acc);
161− }
162−
163− return acc;
164−});
165−
166−var _JsArray_foldr = F3(function(func, acc, array)
167−{
168− for (var i = array.length - 1; i >= 0; i--)
169− {
170− acc = A2(func, array[i], acc);
171− }
172−
173− return acc;
174−});
175−
176−var _JsArray_map = F2(function(func, array)
177−{
178− var length = array.length;
179− var result = new Array(length);
180−
181− for (var i = 0; i < length; i++)
182− {
183− result[i] = func(array[i]);
184− }
185−
186− return result;
187−});
188−
189−var _JsArray_indexedMap = F3(function(func, offset, array)
190−{
191− var length = array.length;
192− var result = new Array(length);
193−
194− for (var i = 0; i < length; i++)
195− {
196− result[i] = A2(func, offset + i, array[i]);
197− }
198−
199− return result;
200−});
201−
202−var _JsArray_slice = F3(function(from, to, array)
203−{
204− return array.slice(from, to);
205−});
206−
207−var _JsArray_appendN = F3(function(n, dest, source)
208−{
209− var destLen = dest.length;
210− var itemsToCopy = n - destLen;
211−
212− if (itemsToCopy > source.length)
213− {
214− itemsToCopy = source.length;
215− }
216−
217− var size = destLen + itemsToCopy;
218− var result = new Array(size);
219−
220− for (var i = 0; i < destLen; i++)
221− {
222− result[i] = dest[i];
223− }
224−
225− for (var i = 0; i < itemsToCopy; i++)
226− {
227− result[i + destLen] = source[i];
228− }
229−
230− return result;
231−});
232−
233−
234−
235−// LOG
236−
237−
238−var _Debug_log = F2(function(tag, value)
239−{
240− console.log(tag + ': ' + _Debug_toString(value));
241− return value;
242−});
243−
244−
245−// TODOS
246−
247−function _Debug_todo(moduleName, region)
248−{
249− return function(message) {
250− _Debug_crash(8, moduleName, region, message);
251− };
252−}
253−
254−function _Debug_todoCase(moduleName, region, value)
255−{
256− return function(message) {
257− _Debug_crash(9, moduleName, region, value, message);
258− };
259−}
260−
261−
262−// TO STRING
263−
264−
265−function _Debug_toString(value)
266−{
267− return _Debug_toAnsiString(false, value);
268−}
269−
270−function _Debug_toAnsiString(ansi, value)
271−{
272− if (typeof value === 'function')
273− {
274− return _Debug_internalColor(ansi, '<function>');
275− }
276−
277− if (typeof value === 'boolean')
278− {
279− return _Debug_ctorColor(ansi, value ? 'True' : 'False');
280− }
281−
282− if (typeof value === 'number')
283− {
284− return _Debug_numberColor(ansi, value + '');
285− }
286−
287− if (value instanceof String)
288− {
289− return _Debug_charColor(ansi, "'" + _Debug_addSlashes(value, true) + "'");
290− }
291−
292− if (typeof value === 'string')
293− {
294− return _Debug_stringColor(ansi, '"' + _Debug_addSlashes(value, false) + '"');
295− }
296−
297− if (typeof value === 'object' && '$' in value)
298− {
299− var tag = value.$;
300−
301− if (typeof tag === 'number')
302− {
303− return _Debug_internalColor(ansi, '<internals>');
304− }
305−
306− if (tag[0] === '#')
307− {
308− var output = [];
309− for (var k in value)
310− {
311− if (k === '$') continue;
312− output.push(_Debug_toAnsiString(ansi, value[k]));
313− }
314− return '(' + output.join(',') + ')';
315− }
316−
317− if (tag === 'Set_elm_builtin')
318− {
319− return _Debug_ctorColor(ansi, 'Set')
320− + _Debug_fadeColor(ansi, '.fromList') + ' '
321− + _Debug_toAnsiString(ansi, $elm$core$Set$toList(value));
322− }
323−
324− if (tag === 'RBNode_elm_builtin' || tag === 'RBEmpty_elm_builtin')
325− {
326− return _Debug_ctorColor(ansi, 'Dict')
327− + _Debug_fadeColor(ansi, '.fromList') + ' '
328− + _Debug_toAnsiString(ansi, $elm$core$Dict$toList(value));
329− }
330−
331− if (tag === 'Array_elm_builtin')
332− {
333− return _Debug_ctorColor(ansi, 'Array')
334− + _Debug_fadeColor(ansi, '.fromList') + ' '
335− + _Debug_toAnsiString(ansi, $elm$core$Array$toList(value));
336− }
337−
338− if (tag === '::' || tag === '[]')
339− {
340− var output = '[';
341−
342− value.b && (output += _Debug_toAnsiString(ansi, value.a), value = value.b)
343−
344− for (; value.b; value = value.b) // WHILE_CONS
345− {
346− output += ',' + _Debug_toAnsiString(ansi, value.a);
347− }
348− return output + ']';
349− }
350−
351− var output = '';
352− for (var i in value)
353− {
354− if (i === '$') continue;
355− var str = _Debug_toAnsiString(ansi, value[i]);
356− var c0 = str[0];
357− var parenless = c0 === '{' || c0 === '(' || c0 === '[' || c0 === '<' || c0 === '"' || str.indexOf(' ') < 0;
358− output += ' ' + (parenless ? str : '(' + str + ')');
359− }
360− return _Debug_ctorColor(ansi, tag) + output;
361− }
362−
363− if (typeof DataView === 'function' && value instanceof DataView)
364− {
365− return _Debug_stringColor(ansi, '<' + value.byteLength + ' bytes>');
366− }
367−
368− if (typeof File !== 'undefined' && value instanceof File)
369− {
370− return _Debug_internalColor(ansi, '<' + value.name + '>');
371− }
372−
373− if (typeof value === 'object')
374− {
375− var output = [];
376− for (var key in value)
377− {
378− var field = key[0] === '_' ? key.slice(1) : key;
379− output.push(_Debug_fadeColor(ansi, field) + ' = ' + _Debug_toAnsiString(ansi, value[key]));
380− }
381− if (output.length === 0)
382− {
383− return '{}';
384− }
385− return '{ ' + output.join(', ') + ' }';
386− }
387−
388− return _Debug_internalColor(ansi, '<internals>');
389−}
390−
391−function _Debug_addSlashes(str, isChar)
392−{
393− var s = str
394− .replace(/\\/g, '\\\\')
395− .replace(/\n/g, '\\n')
396− .replace(/\t/g, '\\t')
397− .replace(/\r/g, '\\r')
398− .replace(/\v/g, '\\v')
399− .replace(/\0/g, '\\0');
400−
401− if (isChar)
402− {
403− return s.replace(/\'/g, '\\\'');
404− }
405− else
406− {
407− return s.replace(/\"/g, '\\"');
408− }
409−}
410−
411−function _Debug_ctorColor(ansi, string)
412−{
413− return ansi ? '\x1b[96m' + string + '\x1b[0m' : string;
414−}
415−
416−function _Debug_numberColor(ansi, string)
417−{
418− return ansi ? '\x1b[95m' + string + '\x1b[0m' : string;
419−}
420−
421−function _Debug_stringColor(ansi, string)
422−{
423− return ansi ? '\x1b[93m' + string + '\x1b[0m' : string;
424−}
425−
426−function _Debug_charColor(ansi, string)
427−{
428− return ansi ? '\x1b[92m' + string + '\x1b[0m' : string;
429−}
430−
431−function _Debug_fadeColor(ansi, string)
432−{
433− return ansi ? '\x1b[37m' + string + '\x1b[0m' : string;
434−}
435−
436−function _Debug_internalColor(ansi, string)
437−{
438− return ansi ? '\x1b[36m' + string + '\x1b[0m' : string;
439−}
440−
441−function _Debug_toHexDigit(n)
442−{
443− return String.fromCharCode(n < 10 ? 48 + n : 55 + n);
444−}
445−
446−
447−// CRASH
448−
449−
450−
451−
452−function _Debug_crash(identifier, fact1, fact2, fact3, fact4)
453−{
454− switch(identifier)
455− {
456− case 0:
457− throw new Error('What node should I take over? In JavaScript I need something like:\n\n Elm.Main.init({\n node: document.getElementById("elm-node")\n })\n\nYou need to do this with any Browser.sandbox or Browser.element program.');
458−
459− case 1:
460− throw new Error('Browser.application programs cannot handle URLs like this:\n\n ' + document.location.href + '\n\nWhat is the root? The root of your file system? Try looking at this program with `elm reactor` or some other server.');
461−
462− case 2:
463− var jsonErrorString = fact1;
464− throw new Error('Problem with the flags given to your Elm program on initialization.\n\n' + jsonErrorString);
465−
466− case 3:
467− var portName = fact1;
468− throw new Error('There can only be one port named `' + portName + '`, but your program has multiple.');
469−
470− case 4:
471− var portName = fact1;
472− var problem = fact2;
473− throw new Error('Trying to send an unexpected type of value through port `' + portName + '`:\n' + problem);
474−
475− case 5:
476− throw new Error('Trying to use `(==)` on functions.\nThere is no way to know if functions are "the same" in the Elm sense.\nRead more about this at https://package.elm-lang.org/packages/elm/core/latest/Basics#== which describes why it is this way and what the better version will look like.');
477−
478− case 6:
479− var moduleName = fact1;
480− throw new Error('Your page is loading multiple Elm scripts with a module named ' + moduleName + '. Maybe a duplicate script is getting loaded accidentally? If not, rename one of them so I know which is which!');
481−
482− case 8:
483− var moduleName = fact1;
484− var region = fact2;
485− var message = fact3;
486− throw new Error('TODO in module `' + moduleName + '` ' + _Debug_regionToString(region) + '\n\n' + message);
487−
488− case 9:
489− var moduleName = fact1;
490− var region = fact2;
491− var value = fact3;
492− var message = fact4;
493− throw new Error(
494− 'TODO in module `' + moduleName + '` from the `case` expression '
495− + _Debug_regionToString(region) + '\n\nIt received the following value:\n\n '
496− + _Debug_toString(value).replace('\n', '\n ')
497− + '\n\nBut the branch that handles it says:\n\n ' + message.replace('\n', '\n ')
498− );
499−
500− case 10:
501− throw new Error('Bug in https://github.com/elm/virtual-dom/issues');
502−
503− case 11:
504− throw new Error('Cannot perform mod 0. Division by zero error.');
505− }
506−}
507−
508−function _Debug_regionToString(region)
509−{
510− if (region.start.line === region.end.line)
511− {
512− return 'on line ' + region.start.line;
513− }
514− return 'on lines ' + region.start.line + ' through ' + region.end.line;
515−}
516−
517−
518−
519−// EQUALITY
520−
521−function _Utils_eq(x, y)
522−{
523− for (
524− var pair, stack = [], isEqual = _Utils_eqHelp(x, y, 0, stack);
525− isEqual && (pair = stack.pop());
526− isEqual = _Utils_eqHelp(pair.a, pair.b, 0, stack)
527− )
528− {}
529−
530− return isEqual;
531−}
532−
533−function _Utils_eqHelp(x, y, depth, stack)
534−{
535− if (x === y)
536− {
537− return true;
538− }
539−
540− if (typeof x !== 'object' || x === null || y === null)
541− {
542− typeof x === 'function' && _Debug_crash(5);
543− return false;
544− }
545−
546− if (depth > 100)
547− {
548− stack.push(_Utils_Tuple2(x,y));
549− return true;
550− }
551−
552− /**/
553− if (x.$ === 'Set_elm_builtin')
554− {
555− x = $elm$core$Set$toList(x);
556− y = $elm$core$Set$toList(y);
557− }
558− if (x.$ === 'RBNode_elm_builtin' || x.$ === 'RBEmpty_elm_builtin')
559− {
560− x = $elm$core$Dict$toList(x);
561− y = $elm$core$Dict$toList(y);
562− }
563− //*/
564−
565− /**_UNUSED/
566− if (x.$ < 0)
567− {
568− x = $elm$core$Dict$toList(x);
569− y = $elm$core$Dict$toList(y);
570− }
571− //*/
572−
573− for (var key in x)
574− {
575− if (!_Utils_eqHelp(x[key], y[key], depth + 1, stack))
576− {
577− return false;
578− }
579− }
580− return true;
581−}
582−
583−var _Utils_equal = F2(_Utils_eq);
584−var _Utils_notEqual = F2(function(a, b) { return !_Utils_eq(a,b); });
585−
586−
587−
588−// COMPARISONS
589−
590−// Code in Generate/JavaScript.hs, Basics.js, and List.js depends on
591−// the particular integer values assigned to LT, EQ, and GT.
592−
593−function _Utils_cmp(x, y, ord)
594−{
595− if (typeof x !== 'object')
596− {
597− return x === y ? /*EQ*/ 0 : x < y ? /*LT*/ -1 : /*GT*/ 1;
598− }
599−
600− /**/
601− if (x instanceof String)
602− {
603− var a = x.valueOf();
604− var b = y.valueOf();
605− return a === b ? 0 : a < b ? -1 : 1;
606− }
607− //*/
608−
609− /**_UNUSED/
610− if (typeof x.$ === 'undefined')
611− //*/
612− /**/
613− if (x.$[0] === '#')
614− //*/
615− {
616− return (ord = _Utils_cmp(x.a, y.a))
617− ? ord
618− : (ord = _Utils_cmp(x.b, y.b))
619− ? ord
620− : _Utils_cmp(x.c, y.c);
621− }
622−
623− // traverse conses until end of a list or a mismatch
624− for (; x.b && y.b && !(ord = _Utils_cmp(x.a, y.a)); x = x.b, y = y.b) {} // WHILE_CONSES
625− return ord || (x.b ? /*GT*/ 1 : y.b ? /*LT*/ -1 : /*EQ*/ 0);
626−}
627−
628−var _Utils_lt = F2(function(a, b) { return _Utils_cmp(a, b) < 0; });
629−var _Utils_le = F2(function(a, b) { return _Utils_cmp(a, b) < 1; });
630−var _Utils_gt = F2(function(a, b) { return _Utils_cmp(a, b) > 0; });
631−var _Utils_ge = F2(function(a, b) { return _Utils_cmp(a, b) >= 0; });
632−
633−var _Utils_compare = F2(function(x, y)
634−{
635− var n = _Utils_cmp(x, y);
636− return n < 0 ? $elm$core$Basics$LT : n ? $elm$core$Basics$GT : $elm$core$Basics$EQ;
637−});
638−
639−
640−// COMMON VALUES
641−
642−var _Utils_Tuple0 = { $: '#0' };
643−
644−function _Utils_Tuple2(a, b) { return { $: '#2', a: a, b: b }; }
645−
646−function _Utils_Tuple3(a, b, c) { return { $: '#3', a: a, b: b, c: c }; }
647−
648−function _Utils_chr(c) { return new String(c); }
649−
650−
651−// RECORDS
652−
653−function _Utils_update(oldRecord, updatedFields)
654−{
655− var newRecord = {};
656−
657− for (var key in oldRecord)
658− {
659− newRecord[key] = oldRecord[key];
660− }
661−
662− for (var key in updatedFields)
663− {
664− newRecord[key] = updatedFields[key];
665− }
666−
667− return newRecord;
668−}
669−
670−
671−// APPEND
672−
673−var _Utils_append = F2(_Utils_ap);
674−
675−function _Utils_ap(xs, ys)
676−{
677− // append Strings
678− if (typeof xs === 'string')
679− {
680− return xs + ys;
681− }
682−
683− // append Lists
684− if (!xs.b)
685− {
686− return ys;
687− }
688− var root = _List_Cons(xs.a, ys);
689− xs = xs.b
690− for (var curr = root; xs.b; xs = xs.b) // WHILE_CONS
691− {
692− curr = curr.b = _List_Cons(xs.a, ys);
693− }
694− return root;
695−}
696−
697−
698−
699−var _List_Nil = { $: '[]' };
700−
701−function _List_Cons(hd, tl) { return { $: '::', a: hd, b: tl }; }
702−
703−
704−var _List_cons = F2(_List_Cons);
705−
706−function _List_fromArray(arr)
707−{
708− var out = _List_Nil;
709− for (var i = arr.length; i--; )
710− {
711− out = _List_Cons(arr[i], out);
712− }
713− return out;
714−}
715−
716−function _List_toArray(xs)
717−{
718− for (var out = []; xs.b; xs = xs.b) // WHILE_CONS
719− {
720− out.push(xs.a);
721− }
722− return out;
723−}
724−
725−var _List_map2 = F3(function(f, xs, ys)
726−{
727− for (var arr = []; xs.b && ys.b; xs = xs.b, ys = ys.b) // WHILE_CONSES
728− {
729− arr.push(A2(f, xs.a, ys.a));
730− }
731− return _List_fromArray(arr);
732−});
733−
734−var _List_map3 = F4(function(f, xs, ys, zs)
735−{
736− for (var arr = []; xs.b && ys.b && zs.b; xs = xs.b, ys = ys.b, zs = zs.b) // WHILE_CONSES
737− {
738− arr.push(A3(f, xs.a, ys.a, zs.a));
739− }
740− return _List_fromArray(arr);
741−});
742−
743−var _List_map4 = F5(function(f, ws, xs, ys, zs)
744−{
745− for (var arr = []; ws.b && xs.b && ys.b && zs.b; ws = ws.b, xs = xs.b, ys = ys.b, zs = zs.b) // WHILE_CONSES
746− {
747− arr.push(A4(f, ws.a, xs.a, ys.a, zs.a));
748− }
749− return _List_fromArray(arr);
750−});
751−
752−var _List_map5 = F6(function(f, vs, ws, xs, ys, zs)
753−{
754− for (var arr = []; vs.b && ws.b && xs.b && ys.b && zs.b; vs = vs.b, ws = ws.b, xs = xs.b, ys = ys.b, zs = zs.b) // WHILE_CONSES
755− {
756− arr.push(A5(f, vs.a, ws.a, xs.a, ys.a, zs.a));
757− }
758− return _List_fromArray(arr);
759−});
760−
761−var _List_sortBy = F2(function(f, xs)
762−{
763− return _List_fromArray(_List_toArray(xs).sort(function(a, b) {
764− return _Utils_cmp(f(a), f(b));
765− }));
766−});
767−
768−var _List_sortWith = F2(function(f, xs)
769−{
770− return _List_fromArray(_List_toArray(xs).sort(function(a, b) {
771− var ord = A2(f, a, b);
772− return ord === $elm$core$Basics$EQ ? 0 : ord === $elm$core$Basics$LT ? -1 : 1;
773− }));
774−});
775−
776−
777−
778−// MATH
779−
780−var _Basics_add = F2(function(a, b) { return a + b; });
781−var _Basics_sub = F2(function(a, b) { return a - b; });
782−var _Basics_mul = F2(function(a, b) { return a * b; });
783−var _Basics_fdiv = F2(function(a, b) { return a / b; });
784−var _Basics_idiv = F2(function(a, b) { return (a / b) | 0; });
785−var _Basics_pow = F2(Math.pow);
786−
787−var _Basics_remainderBy = F2(function(b, a) { return a % b; });
788−
789−// https://www.microsoft.com/en-us/research/wp-content/uploads/2016/02/divmodnote-letter.pdf
790−var _Basics_modBy = F2(function(modulus, x)
791−{
792− var answer = x % modulus;
793− return modulus === 0
794− ? _Debug_crash(11)
795− :
796− ((answer > 0 && modulus < 0) || (answer < 0 && modulus > 0))
797− ? answer + modulus
798− : answer;
799−});
800−
801−
802−// TRIGONOMETRY
803−
804−var _Basics_pi = Math.PI;
805−var _Basics_e = Math.E;
806−var _Basics_cos = Math.cos;
807−var _Basics_sin = Math.sin;
808−var _Basics_tan = Math.tan;
809−var _Basics_acos = Math.acos;
810−var _Basics_asin = Math.asin;
811−var _Basics_atan = Math.atan;
812−var _Basics_atan2 = F2(Math.atan2);
813−
814−
815−// MORE MATH
816−
817−function _Basics_toFloat(x) { return x; }
818−function _Basics_truncate(n) { return n | 0; }
819−function _Basics_isInfinite(n) { return n === Infinity || n === -Infinity; }
820−
821−var _Basics_ceiling = Math.ceil;
822−var _Basics_floor = Math.floor;
823−var _Basics_round = Math.round;
824−var _Basics_sqrt = Math.sqrt;
825−var _Basics_log = Math.log;
826−var _Basics_isNaN = isNaN;
827−
828−
829−// BOOLEANS
830−
831−function _Basics_not(bool) { return !bool; }
832−var _Basics_and = F2(function(a, b) { return a && b; });
833−var _Basics_or = F2(function(a, b) { return a || b; });
834−var _Basics_xor = F2(function(a, b) { return a !== b; });
835−
836−
837−
838−var _String_cons = F2(function(chr, str)
839−{
840− return chr + str;
841−});
842−
843−function _String_uncons(string)
844−{
845− var word = string.charCodeAt(0);
846− return !isNaN(word)
847− ? $elm$core$Maybe$Just(
848− 0xD800 <= word && word <= 0xDBFF
849− ? _Utils_Tuple2(_Utils_chr(string[0] + string[1]), string.slice(2))
850− : _Utils_Tuple2(_Utils_chr(string[0]), string.slice(1))
851− )
852− : $elm$core$Maybe$Nothing;
853−}
854−
855−var _String_append = F2(function(a, b)
856−{
857− return a + b;
858−});
859−
860−function _String_length(str)
861−{
862− return str.length;
863−}
864−
865−var _String_map = F2(function(func, string)
866−{
867− var len = string.length;
868− var array = new Array(len);
869− var i = 0;
870− while (i < len)
871− {
872− var word = string.charCodeAt(i);
873− if (0xD800 <= word && word <= 0xDBFF)
874− {
875− array[i] = func(_Utils_chr(string[i] + string[i+1]));
876− i += 2;
877− continue;
878− }
879− array[i] = func(_Utils_chr(string[i]));
880− i++;
881− }
882− return array.join('');
883−});
884−
885−var _String_filter = F2(function(isGood, str)
886−{
887− var arr = [];
888− var len = str.length;
889− var i = 0;
890− while (i < len)
891− {
892− var char = str[i];
893− var word = str.charCodeAt(i);
894− i++;
895− if (0xD800 <= word && word <= 0xDBFF)
896− {
897− char += str[i];
898− i++;
899− }
900−
901− if (isGood(_Utils_chr(char)))
902− {
903− arr.push(char);
904− }
905− }
906− return arr.join('');
907−});
908−
909−function _String_reverse(str)
910−{
911− var len = str.length;
912− var arr = new Array(len);
913− var i = 0;
914− while (i < len)
915− {
916− var word = str.charCodeAt(i);
917− if (0xD800 <= word && word <= 0xDBFF)
918− {
919− arr[len - i] = str[i + 1];
920− i++;
921− arr[len - i] = str[i - 1];
922− i++;
923− }
924− else
925− {
926− arr[len - i] = str[i];
927− i++;
928− }
929− }
930− return arr.join('');
931−}
932−
933−var _String_foldl = F3(function(func, state, string)
934−{
935− var len = string.length;
936− var i = 0;
937− while (i < len)
938− {
939− var char = string[i];
940− var word = string.charCodeAt(i);
941− i++;
942− if (0xD800 <= word && word <= 0xDBFF)
943− {
944− char += string[i];
945− i++;
946− }
947− state = A2(func, _Utils_chr(char), state);
948− }
949− return state;
950−});
951−
952−var _String_foldr = F3(function(func, state, string)
953−{
954− var i = string.length;
955− while (i--)
956− {
957− var char = string[i];
958− var word = string.charCodeAt(i);
959− if (0xDC00 <= word && word <= 0xDFFF)
960− {
961− i--;
962− char = string[i] + char;
963− }
964− state = A2(func, _Utils_chr(char), state);
965− }
966− return state;
967−});
968−
969−var _String_split = F2(function(sep, str)
970−{
971− return str.split(sep);
972−});
973−
974−var _String_join = F2(function(sep, strs)
975−{
976− return strs.join(sep);
977−});
978−
979−var _String_slice = F3(function(start, end, str) {
980− return str.slice(start, end);
981−});
982−
983−function _String_trim(str)
984−{
985− return str.trim();
986−}
987−
988−function _String_trimLeft(str)
989−{
990− return str.replace(/^\s+/, '');
991−}
992−
993−function _String_trimRight(str)
994−{
995− return str.replace(/\s+$/, '');
996−}
997−
998−function _String_words(str)
999−{
1000− return _List_fromArray(str.trim().split(/\s+/g));
1001−}
1002−
1003−function _String_lines(str)
1004−{
1005− return _List_fromArray(str.split(/\r\n|\r|\n/g));
1006−}
1007−
1008−function _String_toUpper(str)
1009−{
1010− return str.toUpperCase();
1011−}
1012−
1013−function _String_toLower(str)
1014−{
1015− return str.toLowerCase();
1016−}
1017−
1018−var _String_any = F2(function(isGood, string)
1019−{
1020− var i = string.length;
1021− while (i--)
1022− {
1023− var char = string[i];
1024− var word = string.charCodeAt(i);
1025− if (0xDC00 <= word && word <= 0xDFFF)
1026− {
1027− i--;
1028− char = string[i] + char;
1029− }
1030− if (isGood(_Utils_chr(char)))
1031− {
1032− return true;
1033− }
1034− }
1035− return false;
1036−});
1037−
1038−var _String_all = F2(function(isGood, string)
1039−{
1040− var i = string.length;
1041− while (i--)
1042− {
1043− var char = string[i];
1044− var word = string.charCodeAt(i);
1045− if (0xDC00 <= word && word <= 0xDFFF)
1046− {
1047− i--;
1048− char = string[i] + char;
1049− }
1050− if (!isGood(_Utils_chr(char)))
1051− {
1052− return false;
1053− }
1054− }
1055− return true;
1056−});
1057−
1058−var _String_contains = F2(function(sub, str)
1059−{
1060− return str.indexOf(sub) > -1;
1061−});
1062−
1063−var _String_startsWith = F2(function(sub, str)
1064−{
1065− return str.indexOf(sub) === 0;
1066−});
1067−
1068−var _String_endsWith = F2(function(sub, str)
1069−{
1070− return str.length >= sub.length &&
1071− str.lastIndexOf(sub) === str.length - sub.length;
1072−});
1073−
1074−var _String_indexes = F2(function(sub, str)
1075−{
1076− var subLen = sub.length;
1077−
1078− if (subLen < 1)
1079− {
1080− return _List_Nil;
1081− }
1082−
1083− var i = 0;
1084− var is = [];
1085−
1086− while ((i = str.indexOf(sub, i)) > -1)
1087− {
1088− is.push(i);
1089− i = i + subLen;
1090− }
1091−
1092− return _List_fromArray(is);
1093−});
1094−
1095−
1096−// TO STRING
1097−
1098−function _String_fromNumber(number)
1099−{
1100− return number + '';
1101−}
1102−
1103−
1104−// INT CONVERSIONS
1105−
1106−function _String_toInt(str)
1107−{
1108− var total = 0;
1109− var code0 = str.charCodeAt(0);
1110− var start = code0 == 0x2B /* + */ || code0 == 0x2D /* - */ ? 1 : 0;
1111−
1112− for (var i = start; i < str.length; ++i)
1113− {
1114− var code = str.charCodeAt(i);
1115− if (code < 0x30 || 0x39 < code)
1116− {
1117− return $elm$core$Maybe$Nothing;
1118− }
1119− total = 10 * total + code - 0x30;
1120− }
1121−
1122− return i == start
1123− ? $elm$core$Maybe$Nothing
1124− : $elm$core$Maybe$Just(code0 == 0x2D ? -total : total);
1125−}
1126−
1127−
1128−// FLOAT CONVERSIONS
1129−
1130−function _String_toFloat(s)
1131−{
1132− // check if it is a hex, octal, or binary number
1133− if (s.length === 0 || /[\sxbo]/.test(s))
1134− {
1135− return $elm$core$Maybe$Nothing;
1136− }
1137− var n = +s;
1138− // faster isNaN check
1139− return n === n ? $elm$core$Maybe$Just(n) : $elm$core$Maybe$Nothing;
1140−}
1141−
1142−function _String_fromList(chars)
1143−{
1144− return _List_toArray(chars).join('');
1145−}
1146−
1147−
1148−
1149−
1150−function _Char_toCode(char)
1151−{
1152− var code = char.charCodeAt(0);
1153− if (0xD800 <= code && code <= 0xDBFF)
1154− {
1155− return (code - 0xD800) * 0x400 + char.charCodeAt(1) - 0xDC00 + 0x10000
1156− }
1157− return code;
1158−}
1159−
1160−function _Char_fromCode(code)
1161−{
1162− return _Utils_chr(
1163− (code < 0 || 0x10FFFF < code)
1164− ? '\uFFFD'
1165− :
1166− (code <= 0xFFFF)
1167− ? String.fromCharCode(code)
1168− :
1169− (code -= 0x10000,
1170− String.fromCharCode(Math.floor(code / 0x400) + 0xD800, code % 0x400 + 0xDC00)
1171− )
1172− );
1173−}
1174−
1175−function _Char_toUpper(char)
1176−{
1177− return _Utils_chr(char.toUpperCase());
1178−}
1179−
1180−function _Char_toLower(char)
1181−{
1182− return _Utils_chr(char.toLowerCase());
1183−}
1184−
1185−function _Char_toLocaleUpper(char)
1186−{
1187− return _Utils_chr(char.toLocaleUpperCase());
1188−}
1189−
1190−function _Char_toLocaleLower(char)
1191−{
1192− return _Utils_chr(char.toLocaleLowerCase());
1193−}
1194−
1195−
1196−
1197−/**/
1198−function _Json_errorToString(error)
1199−{
1200− return $elm$json$Json$Decode$errorToString(error);
1201−}
1202−//*/
1203−
1204−
1205−// CORE DECODERS
1206−
1207−function _Json_succeed(msg)
1208−{
1209− return {
1210− $: 0,
1211− a: msg
1212− };
1213−}
1214−
1215−function _Json_fail(msg)
1216−{
1217− return {
1218− $: 1,
1219− a: msg
1220− };
1221−}
1222−
1223−function _Json_decodePrim(decoder)
1224−{
1225− return { $: 2, b: decoder };
1226−}
1227−
1228−var _Json_decodeInt = _Json_decodePrim(function(value) {
1229− return (typeof value !== 'number')
1230− ? _Json_expecting('an INT', value)
1231− :
1232− (-2147483647 < value && value < 2147483647 && (value | 0) === value)
1233− ? $elm$core$Result$Ok(value)
1234− :
1235− (isFinite(value) && !(value % 1))
1236− ? $elm$core$Result$Ok(value)
1237− : _Json_expecting('an INT', value);
1238−});
1239−
1240−var _Json_decodeBool = _Json_decodePrim(function(value) {
1241− return (typeof value === 'boolean')
1242− ? $elm$core$Result$Ok(value)
1243− : _Json_expecting('a BOOL', value);
1244−});
1245−
1246−var _Json_decodeFloat = _Json_decodePrim(function(value) {
1247− return (typeof value === 'number')
1248− ? $elm$core$Result$Ok(value)
1249− : _Json_expecting('a FLOAT', value);
1250−});
1251−
1252−var _Json_decodeValue = _Json_decodePrim(function(value) {
1253− return $elm$core$Result$Ok(_Json_wrap(value));
1254−});
1255−
1256−var _Json_decodeString = _Json_decodePrim(function(value) {
1257− return (typeof value === 'string')
1258− ? $elm$core$Result$Ok(value)
1259− : (value instanceof String)
1260− ? $elm$core$Result$Ok(value + '')
1261− : _Json_expecting('a STRING', value);
1262−});
1263−
1264−function _Json_decodeList(decoder) { return { $: 3, b: decoder }; }
1265−function _Json_decodeArray(decoder) { return { $: 4, b: decoder }; }
1266−
1267−function _Json_decodeNull(value) { return { $: 5, c: value }; }
1268−
1269−var _Json_decodeField = F2(function(field, decoder)
1270−{
1271− return {
1272− $: 6,
1273− d: field,
1274− b: decoder
1275− };
1276−});
1277−
1278−var _Json_decodeIndex = F2(function(index, decoder)
1279−{
1280− return {
1281− $: 7,
1282− e: index,
1283− b: decoder
1284− };
1285−});
1286−
1287−function _Json_decodeKeyValuePairs(decoder)
1288−{
1289− return {
1290− $: 8,
1291− b: decoder
1292− };
1293−}
1294−
1295−function _Json_mapMany(f, decoders)
1296−{
1297− return {
1298− $: 9,
1299− f: f,
1300− g: decoders
1301− };
1302−}
1303−
1304−var _Json_andThen = F2(function(callback, decoder)
1305−{
1306− return {
1307− $: 10,
1308− b: decoder,
1309− h: callback
1310− };
1311−});
1312−
1313−function _Json_oneOf(decoders)
1314−{
1315− return {
1316− $: 11,
1317− g: decoders
1318− };
1319−}
1320−
1321−
1322−// DECODING OBJECTS
1323−
1324−var _Json_map1 = F2(function(f, d1)
1325−{
1326− return _Json_mapMany(f, [d1]);
1327−});
1328−
1329−var _Json_map2 = F3(function(f, d1, d2)
1330−{
1331− return _Json_mapMany(f, [d1, d2]);
1332−});
1333−
1334−var _Json_map3 = F4(function(f, d1, d2, d3)
1335−{
1336− return _Json_mapMany(f, [d1, d2, d3]);
1337−});
1338−
1339−var _Json_map4 = F5(function(f, d1, d2, d3, d4)
1340−{
1341− return _Json_mapMany(f, [d1, d2, d3, d4]);
1342−});
1343−
1344−var _Json_map5 = F6(function(f, d1, d2, d3, d4, d5)
1345−{
1346− return _Json_mapMany(f, [d1, d2, d3, d4, d5]);
1347−});
1348−
1349−var _Json_map6 = F7(function(f, d1, d2, d3, d4, d5, d6)
1350−{
1351− return _Json_mapMany(f, [d1, d2, d3, d4, d5, d6]);
1352−});
1353−
1354−var _Json_map7 = F8(function(f, d1, d2, d3, d4, d5, d6, d7)
1355−{
1356− return _Json_mapMany(f, [d1, d2, d3, d4, d5, d6, d7]);
1357−});
1358−
1359−var _Json_map8 = F9(function(f, d1, d2, d3, d4, d5, d6, d7, d8)
1360−{
1361− return _Json_mapMany(f, [d1, d2, d3, d4, d5, d6, d7, d8]);
1362−});
1363−
1364−
1365−// DECODE
1366−
1367−var _Json_runOnString = F2(function(decoder, string)
1368−{
1369− try
1370− {
1371− var value = JSON.parse(string);
1372− return _Json_runHelp(decoder, value);
1373− }
1374− catch (e)
1375− {
1376− return $elm$core$Result$Err(A2($elm$json$Json$Decode$Failure, 'This is not valid JSON! ' + e.message, _Json_wrap(string)));
1377− }
1378−});
1379−
1380−var _Json_run = F2(function(decoder, value)
1381−{
1382− return _Json_runHelp(decoder, _Json_unwrap(value));
1383−});
1384−
1385−function _Json_runHelp(decoder, value)
1386−{
1387− switch (decoder.$)
1388− {
1389− case 2:
1390− return decoder.b(value);
1391−
1392− case 5:
1393− return (value === null)
1394− ? $elm$core$Result$Ok(decoder.c)
1395− : _Json_expecting('null', value);
1396−
1397− case 3:
1398− if (!_Json_isArray(value))
1399− {
1400− return _Json_expecting('a LIST', value);
1401− }
1402− return _Json_runArrayDecoder(decoder.b, value, _List_fromArray);
1403−
1404− case 4:
1405− if (!_Json_isArray(value))
1406− {
1407− return _Json_expecting('an ARRAY', value);
1408− }
1409− return _Json_runArrayDecoder(decoder.b, value, _Json_toElmArray);
1410−
1411− case 6:
1412− var field = decoder.d;
1413− if (typeof value !== 'object' || value === null || !(field in value))
1414− {
1415− return _Json_expecting('an OBJECT with a field named `' + field + '`', value);
1416− }
1417− var result = _Json_runHelp(decoder.b, value[field]);
1418− return ($elm$core$Result$isOk(result)) ? result : $elm$core$Result$Err(A2($elm$json$Json$Decode$Field, field, result.a));
1419−
1420− case 7:
1421− var index = decoder.e;
1422− if (!_Json_isArray(value))
1423− {
1424− return _Json_expecting('an ARRAY', value);
1425− }
1426− if (index >= value.length)
1427− {
1428− return _Json_expecting('a LONGER array. Need index ' + index + ' but only see ' + value.length + ' entries', value);
1429− }
1430− var result = _Json_runHelp(decoder.b, value[index]);
1431− return ($elm$core$Result$isOk(result)) ? result : $elm$core$Result$Err(A2($elm$json$Json$Decode$Index, index, result.a));
1432−
1433− case 8:
1434− if (typeof value !== 'object' || value === null || _Json_isArray(value))
1435− {
1436− return _Json_expecting('an OBJECT', value);
1437− }
1438−
1439− var keyValuePairs = _List_Nil;
1440− // TODO test perf of Object.keys and switch when support is good enough
1441− for (var key in value)
1442− {
1443− if (Object.prototype.hasOwnProperty.call(value, key))
1444− {
1445− var result = _Json_runHelp(decoder.b, value[key]);
1446− if (!$elm$core$Result$isOk(result))
1447− {
1448− return $elm$core$Result$Err(A2($elm$json$Json$Decode$Field, key, result.a));
1449− }
1450− keyValuePairs = _List_Cons(_Utils_Tuple2(key, result.a), keyValuePairs);
1451− }
1452− }
1453− return $elm$core$Result$Ok($elm$core$List$reverse(keyValuePairs));
1454−
1455− case 9:
1456− var answer = decoder.f;
1457− var decoders = decoder.g;
1458− for (var i = 0; i < decoders.length; i++)
1459− {
1460− var result = _Json_runHelp(decoders[i], value);
1461− if (!$elm$core$Result$isOk(result))
1462− {
1463− return result;
1464− }
1465− answer = answer(result.a);
1466− }
1467− return $elm$core$Result$Ok(answer);
1468−
1469− case 10:
1470− var result = _Json_runHelp(decoder.b, value);
1471− return (!$elm$core$Result$isOk(result))
1472− ? result
1473− : _Json_runHelp(decoder.h(result.a), value);
1474−
1475− case 11:
1476− var errors = _List_Nil;
1477− for (var temp = decoder.g; temp.b; temp = temp.b) // WHILE_CONS
1478− {
1479− var result = _Json_runHelp(temp.a, value);
1480− if ($elm$core$Result$isOk(result))
1481− {
1482− return result;
1483− }
1484− errors = _List_Cons(result.a, errors);
1485− }
1486− return $elm$core$Result$Err($elm$json$Json$Decode$OneOf($elm$core$List$reverse(errors)));
1487−
1488− case 1:
1489− return $elm$core$Result$Err(A2($elm$json$Json$Decode$Failure, decoder.a, _Json_wrap(value)));
1490−
1491− case 0:
1492− return $elm$core$Result$Ok(decoder.a);
1493− }
1494−}
1495−
1496−function _Json_runArrayDecoder(decoder, value, toElmValue)
1497−{
1498− var len = value.length;
1499− var array = new Array(len);
1500− for (var i = 0; i < len; i++)
1501− {
1502− var result = _Json_runHelp(decoder, value[i]);
1503− if (!$elm$core$Result$isOk(result))
1504− {
1505− return $elm$core$Result$Err(A2($elm$json$Json$Decode$Index, i, result.a));
1506− }
1507− array[i] = result.a;
1508− }
1509− return $elm$core$Result$Ok(toElmValue(array));
1510−}
1511−
1512−function _Json_isArray(value)
1513−{
1514− return Array.isArray(value) || (typeof FileList !== 'undefined' && value instanceof FileList);
1515−}
1516−
1517−function _Json_toElmArray(array)
1518−{
1519− return A2($elm$core$Array$initialize, array.length, function(i) { return array[i]; });
1520−}
1521−
1522−function _Json_expecting(type, value)
1523−{
1524− return $elm$core$Result$Err(A2($elm$json$Json$Decode$Failure, 'Expecting ' + type, _Json_wrap(value)));
1525−}
1526−
1527−
1528−// EQUALITY
1529−
1530−function _Json_equality(x, y)
1531−{
1532− if (x === y)
1533− {
1534− return true;
1535− }
1536−
1537− if (x.$ !== y.$)
1538− {
1539− return false;
1540− }
1541−
1542− switch (x.$)
1543− {
1544− case 0:
1545− case 1:
1546− return x.a === y.a;
1547−
1548− case 2:
1549− return x.b === y.b;
1550−
1551− case 5:
1552− return x.c === y.c;
1553−
1554− case 3:
1555− case 4:
1556− case 8:
1557− return _Json_equality(x.b, y.b);
1558−
1559− case 6:
1560− return x.d === y.d && _Json_equality(x.b, y.b);
1561−
1562− case 7:
1563− return x.e === y.e && _Json_equality(x.b, y.b);
1564−
1565− case 9:
1566− return x.f === y.f && _Json_listEquality(x.g, y.g);
1567−
1568− case 10:
1569− return x.h === y.h && _Json_equality(x.b, y.b);
1570−
1571− case 11:
1572− return _Json_listEquality(x.g, y.g);
1573− }
1574−}
1575−
1576−function _Json_listEquality(aDecoders, bDecoders)
1577−{
1578− var len = aDecoders.length;
1579− if (len !== bDecoders.length)
1580− {
1581− return false;
1582− }
1583− for (var i = 0; i < len; i++)
1584− {
1585− if (!_Json_equality(aDecoders[i], bDecoders[i]))
1586− {
1587− return false;
1588− }
1589− }
1590− return true;
1591−}
1592−
1593−
1594−// ENCODE
1595−
1596−var _Json_encode = F2(function(indentLevel, value)
1597−{
1598− return JSON.stringify(_Json_unwrap(value), null, indentLevel) + '';
1599−});
1600−
1601−function _Json_wrap(value) { return { $: 0, a: value }; }
1602−function _Json_unwrap(value) { return value.a; }
1603−
1604−
1605−function _Json_emptyArray() { return []; }
1606−function _Json_emptyObject() { return {}; }
1607−
1608−var _Json_addField = F3(function(key, value, object)
1609−{
1610− var unwrapped = _Json_unwrap(value);
1611− if (!(key === 'toJSON' && typeof unwrapped === 'function'))
1612− {
1613− object[key] = unwrapped;
1614− }
1615− return object;
1616−});
1617−
1618−function _Json_addEntry(func)
1619−{
1620− return F2(function(entry, array)
1621− {
1622− array.push(_Json_unwrap(func(entry)));
1623− return array;
1624− });
1625−}
1626−
1627−var _Json_encodeNull = _Json_wrap(null);
1628−
1629−
1630−
1631−// TASKS
1632−
1633−function _Scheduler_succeed(value)
1634−{
1635− return {
1636− $: 0,
1637− a: value
1638− };
1639−}
1640−
1641−function _Scheduler_fail(error)
1642−{
1643− return {
1644− $: 1,
1645− a: error
1646− };
1647−}
1648−
1649−function _Scheduler_binding(callback)
1650−{
1651− return {
1652− $: 2,
1653− b: callback,
1654− c: null
1655− };
1656−}
1657−
1658−var _Scheduler_andThen = F2(function(callback, task)
1659−{
1660− return {
1661− $: 3,
1662− b: callback,
1663− d: task
1664− };
1665−});
1666−
1667−var _Scheduler_onError = F2(function(callback, task)
1668−{
1669− return {
1670− $: 4,
1671− b: callback,
1672− d: task
1673− };
1674−});
1675−
1676−function _Scheduler_receive(callback)
1677−{
1678− return {
1679− $: 5,
1680− b: callback
1681− };
1682−}
1683−
1684−
1685−// PROCESSES
1686−
1687−var _Scheduler_guid = 0;
1688−
1689−function _Scheduler_rawSpawn(task)
1690−{
1691− var proc = {
1692− $: 0,
1693− e: _Scheduler_guid++,
1694− f: task,
1695− g: null,
1696− h: []
1697− };
1698−
1699− _Scheduler_enqueue(proc);
1700−
1701− return proc;
1702−}
1703−
1704−function _Scheduler_spawn(task)
1705−{
1706− return _Scheduler_binding(function(callback) {
1707− callback(_Scheduler_succeed(_Scheduler_rawSpawn(task)));
1708− });
1709−}
1710−
1711−function _Scheduler_rawSend(proc, msg)
1712−{
1713− proc.h.push(msg);
1714− _Scheduler_enqueue(proc);
1715−}
1716−
1717−var _Scheduler_send = F2(function(proc, msg)
1718−{
1719− return _Scheduler_binding(function(callback) {
1720− _Scheduler_rawSend(proc, msg);
1721− callback(_Scheduler_succeed(_Utils_Tuple0));
1722− });
1723−});
1724−
1725−function _Scheduler_kill(proc)
1726−{
1727− return _Scheduler_binding(function(callback) {
1728− var task = proc.f;
1729− if (task.$ === 2 && task.c)
1730− {
1731− task.c();
1732− }
1733−
1734− proc.f = null;
1735−
1736− callback(_Scheduler_succeed(_Utils_Tuple0));
1737− });
1738−}
1739−
1740−
1741−/* STEP PROCESSES
1742−
1743−type alias Process =
1744− { $ : tag
1745− , id : unique_id
1746− , root : Task
1747− , stack : null | { $: SUCCEED | FAIL, a: callback, b: stack }
1748− , mailbox : [msg]
1749− }
1750−
1751−*/
1752−
1753−
1754−var _Scheduler_working = false;
1755−var _Scheduler_queue = [];
1756−
1757−
1758−function _Scheduler_enqueue(proc)
1759−{
1760− _Scheduler_queue.push(proc);
1761− if (_Scheduler_working)
1762− {
1763− return;
1764− }
1765− _Scheduler_working = true;
1766− while (proc = _Scheduler_queue.shift())
1767− {
1768− _Scheduler_step(proc);
1769− }
1770− _Scheduler_working = false;
1771−}
1772−
1773−
1774−function _Scheduler_step(proc)
1775−{
1776− while (proc.f)
1777− {
1778− var rootTag = proc.f.$;
1779− if (rootTag === 0 || rootTag === 1)
1780− {
1781− while (proc.g && proc.g.$ !== rootTag)
1782− {
1783− proc.g = proc.g.i;
1784− }
1785− if (!proc.g)
1786− {
1787− return;
1788− }
1789− proc.f = proc.g.b(proc.f.a);
1790− proc.g = proc.g.i;
1791− }
1792− else if (rootTag === 2)
1793− {
1794− proc.f.c = proc.f.b(function(newRoot) {
1795− proc.f = newRoot;
1796− _Scheduler_enqueue(proc);
1797− });
1798− return;
1799− }
1800− else if (rootTag === 5)
1801− {
1802− if (proc.h.length === 0)
1803− {
1804− return;
1805− }
1806− proc.f = proc.f.b(proc.h.shift());
1807− }
1808− else // if (rootTag === 3 || rootTag === 4)
1809− {
1810− proc.g = {
1811− $: rootTag === 3 ? 0 : 1,
1812− b: proc.f.b,
1813− i: proc.g
1814− };
1815− proc.f = proc.f.d;
1816− }
1817− }
1818−}
1819−
1820−
1821−
1822−function _Process_sleep(time)
1823−{
1824− return _Scheduler_binding(function(callback) {
1825− var id = setTimeout(function() {
1826− callback(_Scheduler_succeed(_Utils_Tuple0));
1827− }, time);
1828−
1829− return function() { clearTimeout(id); };
1830− });
1831−}
1832−
1833−
1834−
1835−
1836−// PROGRAMS
1837−
1838−
1839−var _Platform_worker = F4(function(impl, flagDecoder, debugMetadata, args)
1840−{
1841− return _Platform_initialize(
1842− flagDecoder,
1843− args,
1844− impl.init,
1845− impl.update,
1846− impl.subscriptions,
1847− function() { return function() {} }
1848− );
1849−});
1850−
1851−
1852−
1853−// INITIALIZE A PROGRAM
1854−
1855−
1856−function _Platform_initialize(flagDecoder, args, init, update, subscriptions, stepperBuilder)
1857−{
1858− var result = A2(_Json_run, flagDecoder, _Json_wrap(args ? args['flags'] : undefined));
1859− $elm$core$Result$isOk(result) || _Debug_crash(2 /**/, _Json_errorToString(result.a) /**/);
1860− var managers = {};
1861− var initPair = init(result.a);
1862− var model = initPair.a;
1863− var stepper = stepperBuilder(sendToApp, model);
1864− var ports = _Platform_setupEffects(managers, sendToApp);
1865−
1866− function sendToApp(msg, viewMetadata)
1867− {
1868− var pair = A2(update, msg, model);
1869− stepper(model = pair.a, viewMetadata);
1870− _Platform_enqueueEffects(managers, pair.b, subscriptions(model));
1871− }
1872−
1873− _Platform_enqueueEffects(managers, initPair.b, subscriptions(model));
1874−
1875− return ports ? { ports: ports } : {};
1876−}
1877−
1878−
1879−
1880−// TRACK PRELOADS
1881−//
1882−// This is used by code in elm/browser and elm/http
1883−// to register any HTTP requests that are triggered by init.
1884−//
1885−
1886−
1887−var _Platform_preload;
1888−
1889−
1890−function _Platform_registerPreload(url)
1891−{
1892− _Platform_preload.add(url);
1893−}
1894−
1895−
1896−
1897−// EFFECT MANAGERS
1898−
1899−
1900−var _Platform_effectManagers = {};
1901−
1902−
1903−function _Platform_setupEffects(managers, sendToApp)
1904−{
1905− var ports;
1906−
1907− // setup all necessary effect managers
1908− for (var key in _Platform_effectManagers)
1909− {
1910− var manager = _Platform_effectManagers[key];
1911−
1912− if (manager.a)
1913− {
1914− ports = ports || {};
1915− ports[key] = manager.a(key, sendToApp);
1916− }
1917−
1918− managers[key] = _Platform_instantiateManager(manager, sendToApp);
1919− }
1920−
1921− return ports;
1922−}
1923−
1924−
1925−function _Platform_createManager(init, onEffects, onSelfMsg, cmdMap, subMap)
1926−{
1927− return {
1928− b: init,
1929− c: onEffects,
1930− d: onSelfMsg,
1931− e: cmdMap,
1932− f: subMap
1933− };
1934−}
1935−
1936−
1937−function _Platform_instantiateManager(info, sendToApp)
1938−{
1939− var router = {
1940− g: sendToApp,
1941− h: undefined
1942− };
1943−
1944− var onEffects = info.c;
1945− var onSelfMsg = info.d;
1946− var cmdMap = info.e;
1947− var subMap = info.f;
1948−
1949− function loop(state)
1950− {
1951− return A2(_Scheduler_andThen, loop, _Scheduler_receive(function(msg)
1952− {
1953− var value = msg.a;
1954−
1955− if (msg.$ === 0)
1956− {
1957− return A3(onSelfMsg, router, value, state);
1958− }
1959−
1960− return cmdMap && subMap
1961− ? A4(onEffects, router, value.i, value.j, state)
1962− : A3(onEffects, router, cmdMap ? value.i : value.j, state);
1963− }));
1964− }
1965−
1966− return router.h = _Scheduler_rawSpawn(A2(_Scheduler_andThen, loop, info.b));
1967−}
1968−
1969−
1970−
1971−// ROUTING
1972−
1973−
1974−var _Platform_sendToApp = F2(function(router, msg)
1975−{
1976− return _Scheduler_binding(function(callback)
1977− {
1978− router.g(msg);
1979− callback(_Scheduler_succeed(_Utils_Tuple0));
1980− });
1981−});
1982−
1983−
1984−var _Platform_sendToSelf = F2(function(router, msg)
1985−{
1986− return A2(_Scheduler_send, router.h, {
1987− $: 0,
1988− a: msg
1989− });
1990−});
1991−
1992−
1993−
1994−// BAGS
1995−
1996−
1997−function _Platform_leaf(home)
1998−{
1999− return function(value)
2000− {
2001− return {
2002− $: 1,
2003− k: home,
2004− l: value
2005− };
2006− };
2007−}
2008−
2009−
2010−function _Platform_batch(list)
2011−{
2012− return {
2013− $: 2,
2014− m: list
2015− };
2016−}
2017−
2018−
2019−var _Platform_map = F2(function(tagger, bag)
2020−{
2021− return {
2022− $: 3,
2023− n: tagger,
2024− o: bag
2025− }
2026−});
2027−
2028−
2029−
2030−// PIPE BAGS INTO EFFECT MANAGERS
2031−//
2032−// Effects must be queued!
2033−//
2034−// Say your init contains a synchronous command, like Time.now or Time.here
2035−//
2036−// - This will produce a batch of effects (FX_1)
2037−// - The synchronous task triggers the subsequent `update` call
2038−// - This will produce a batch of effects (FX_2)
2039−//
2040−// If we just start dispatching FX_2, subscriptions from FX_2 can be processed
2041−// before subscriptions from FX_1. No good! Earlier versions of this code had
2042−// this problem, leading to these reports:
2043−//
2044−// https://github.com/elm/core/issues/980
2045−// https://github.com/elm/core/pull/981
2046−// https://github.com/elm/compiler/issues/1776
2047−//
2048−// The queue is necessary to avoid ordering issues for synchronous commands.
2049−
2050−
2051−// Why use true/false here? Why not just check the length of the queue?
2052−// The goal is to detect "are we currently dispatching effects?" If we
2053−// are, we need to bail and let the ongoing while loop handle things.
2054−//
2055−// Now say the queue has 1 element. When we dequeue the final element,
2056−// the queue will be empty, but we are still actively dispatching effects.
2057−// So you could get queue jumping in a really tricky category of cases.
2058−//
2059−var _Platform_effectsQueue = [];
2060−var _Platform_effectsActive = false;
2061−
2062−
2063−function _Platform_enqueueEffects(managers, cmdBag, subBag)
2064−{
2065− _Platform_effectsQueue.push({ p: managers, q: cmdBag, r: subBag });
2066−
2067− if (_Platform_effectsActive) return;
2068−
2069− _Platform_effectsActive = true;
2070− for (var fx; fx = _Platform_effectsQueue.shift(); )
2071− {
2072− _Platform_dispatchEffects(fx.p, fx.q, fx.r);
2073− }
2074− _Platform_effectsActive = false;
2075−}
2076−
2077−
2078−function _Platform_dispatchEffects(managers, cmdBag, subBag)
2079−{
2080− var effectsDict = {};
2081− _Platform_gatherEffects(true, cmdBag, effectsDict, null);
2082− _Platform_gatherEffects(false, subBag, effectsDict, null);
2083−
2084− for (var home in managers)
2085− {
2086− _Scheduler_rawSend(managers[home], {
2087− $: 'fx',
2088− a: effectsDict[home] || { i: _List_Nil, j: _List_Nil }
2089− });
2090− }
2091−}
2092−
2093−
2094−function _Platform_gatherEffects(isCmd, bag, effectsDict, taggers)
2095−{
2096− switch (bag.$)
2097− {
2098− case 1:
2099− var home = bag.k;
2100− var effect = _Platform_toEffect(isCmd, home, taggers, bag.l);
2101− effectsDict[home] = _Platform_insert(isCmd, effect, effectsDict[home]);
2102− return;
2103−
2104− case 2:
2105− for (var list = bag.m; list.b; list = list.b) // WHILE_CONS
2106− {
2107− _Platform_gatherEffects(isCmd, list.a, effectsDict, taggers);
2108− }
2109− return;
2110−
2111− case 3:
2112− _Platform_gatherEffects(isCmd, bag.o, effectsDict, {
2113− s: bag.n,
2114− t: taggers
2115− });
2116− return;
2117− }
2118−}
2119−
2120−
2121−function _Platform_toEffect(isCmd, home, taggers, value)
2122−{
2123− function applyTaggers(x)
2124− {
2125− for (var temp = taggers; temp; temp = temp.t)
2126− {
2127− x = temp.s(x);
2128− }
2129− return x;
2130− }
2131−
2132− var map = isCmd
2133− ? _Platform_effectManagers[home].e
2134− : _Platform_effectManagers[home].f;
2135−
2136− return A2(map, applyTaggers, value)
2137−}
2138−
2139−
2140−function _Platform_insert(isCmd, newEffect, effects)
2141−{
2142− effects = effects || { i: _List_Nil, j: _List_Nil };
2143−
2144− isCmd
2145− ? (effects.i = _List_Cons(newEffect, effects.i))
2146− : (effects.j = _List_Cons(newEffect, effects.j));
2147−
2148− return effects;
2149−}
2150−
2151−
2152−
2153−// PORTS
2154−
2155−
2156−function _Platform_checkPortName(name)
2157−{
2158− if (_Platform_effectManagers[name])
2159− {
2160− _Debug_crash(3, name)
2161− }
2162−}
2163−
2164−
2165−
2166−// OUTGOING PORTS
2167−
2168−
2169−function _Platform_outgoingPort(name, converter)
2170−{
2171− _Platform_checkPortName(name);
2172− _Platform_effectManagers[name] = {
2173− e: _Platform_outgoingPortMap,
2174− u: converter,
2175− a: _Platform_setupOutgoingPort
2176− };
2177− return _Platform_leaf(name);
2178−}
2179−
2180−
2181−var _Platform_outgoingPortMap = F2(function(tagger, value) { return value; });
2182−
2183−
2184−function _Platform_setupOutgoingPort(name)
2185−{
2186− var subs = [];
2187− var converter = _Platform_effectManagers[name].u;
2188−
2189− // CREATE MANAGER
2190−
2191− var init = _Process_sleep(0);
2192−
2193− _Platform_effectManagers[name].b = init;
2194− _Platform_effectManagers[name].c = F3(function(router, cmdList, state)
2195− {
2196− for ( ; cmdList.b; cmdList = cmdList.b) // WHILE_CONS
2197− {
2198− // grab a separate reference to subs in case unsubscribe is called
2199− var currentSubs = subs;
2200− var value = _Json_unwrap(converter(cmdList.a));
2201− for (var i = 0; i < currentSubs.length; i++)
2202− {
2203− currentSubs[i](value);
2204− }
2205− }
2206− return init;
2207− });
2208−
2209− // PUBLIC API
2210−
2211− function subscribe(callback)
2212− {
2213− subs.push(callback);
2214− }
2215−
2216− function unsubscribe(callback)
2217− {
2218− // copy subs into a new array in case unsubscribe is called within a
2219− // subscribed callback
2220− subs = subs.slice();
2221− var index = subs.indexOf(callback);
2222− if (index >= 0)
2223− {
2224− subs.splice(index, 1);
2225− }
2226− }
2227−
2228− return {
2229− subscribe: subscribe,
2230− unsubscribe: unsubscribe
2231− };
2232−}
2233−
2234−
2235−
2236−// INCOMING PORTS
2237−
2238−
2239−function _Platform_incomingPort(name, converter)
2240−{
2241− _Platform_checkPortName(name);
2242− _Platform_effectManagers[name] = {
2243− f: _Platform_incomingPortMap,
2244− u: converter,
2245− a: _Platform_setupIncomingPort
2246− };
2247− return _Platform_leaf(name);
2248−}
2249−
2250−
2251−var _Platform_incomingPortMap = F2(function(tagger, finalTagger)
2252−{
2253− return function(value)
2254− {
2255− return tagger(finalTagger(value));
2256− };
2257−});
2258−
2259−
2260−function _Platform_setupIncomingPort(name, sendToApp)
2261−{
2262− var subs = _List_Nil;
2263− var converter = _Platform_effectManagers[name].u;
2264−
2265− // CREATE MANAGER
2266−
2267− var init = _Scheduler_succeed(null);
2268−
2269− _Platform_effectManagers[name].b = init;
2270− _Platform_effectManagers[name].c = F3(function(router, subList, state)
2271− {
2272− subs = subList;
2273− return init;
2274− });
2275−
2276− // PUBLIC API
2277−
2278− function send(incomingValue)
2279− {
2280− var result = A2(_Json_run, converter, _Json_wrap(incomingValue));
2281−
2282− $elm$core$Result$isOk(result) || _Debug_crash(4, name, result.a);
2283−
2284− var value = result.a;
2285− for (var temp = subs; temp.b; temp = temp.b) // WHILE_CONS
2286− {
2287− sendToApp(temp.a(value));
2288− }
2289− }
2290−
2291− return { send: send };
2292−}
2293−
2294−
2295−
2296−// EXPORT ELM MODULES
2297−//
2298−// Have DEBUG and PROD versions so that we can (1) give nicer errors in
2299−// debug mode and (2) not pay for the bits needed for that in prod mode.
2300−//
2301−
2302−
2303−
2304−
2305−function _Platform_mergeExportsProd(obj, exports)
2306−{
2307− for (var name in exports)
2308− {
2309− (name in obj)
2310− ? (name == 'init')
2311− ? _Debug_crash(6)
2312− : _Platform_mergeExportsProd(obj[name], exports[name])
2313− : (obj[name] = exports[name]);
2314− }
2315−}
2316−
2317−
2318−function _Platform_export(exports)
2319−{
2320− scope['Elm']
2321− ? _Platform_mergeExportsDebug('Elm', scope['Elm'], exports)
2322− : scope['Elm'] = exports;
2323−}
2324−
2325−
2326−function _Platform_mergeExportsDebug(moduleName, obj, exports)
2327−{
2328− for (var name in exports)
2329− {
2330− (name in obj)
2331− ? (name == 'init')
2332− ? _Debug_crash(6, moduleName)
2333− : _Platform_mergeExportsDebug(moduleName + '.' + name, obj[name], exports[name])
2334− : (obj[name] = exports[name]);
2335− }
2336−}
2337−
2338−
2339−
2340−var _Lynx_g = (typeof globalThis !== 'undefined' ? globalThis : scope);
2341−function _Lynx_papi(name) { return _Lynx_g['_' + '_' + name] || function() {}; }
2342−var _Lynx_createViewFn = _Lynx_papi('CreateView');
2343−var _Lynx_createTextFn = _Lynx_papi('CreateText');
2344−var _Lynx_createImageFn = _Lynx_papi('CreateImage');
2345−function _Lynx_createElement(tag) {
2346− var pId = (_Lynx_g['native'] && _Lynx_g['native']['currentPageId']) || 0;
2347− return (tag === 'text' ? _Lynx_createTextFn
2348− : tag === 'image' ? _Lynx_createImageFn
2349− : _Lynx_createViewFn)(pId);
2350−}
2351−var _Lynx_createRawText = _Lynx_papi('CreateRawText');
2352−var _Lynx_appendChild = _Lynx_papi('AppendElement');
2353−var _Lynx_removeElement = _Lynx_papi('RemoveElement');
2354−var _Lynx_replaceElement = _Lynx_papi('ReplaceElement');
2355−var _Lynx_insertBefore = _Lynx_papi('InsertElementBefore');
2356−var _Lynx_setAttribute = _Lynx_papi('SetAttribute');
2357−var _Lynx_getChildren = _Lynx_papi('GetChildren');
2358−var _Lynx_getParent = _Lynx_papi('GetParent');
2359−var _Lynx_addEvent = _Lynx_papi('AddEvent');
2360−var _Lynx_setInlineStyles = _Lynx_papi('SetInlineStyles');
2361−var _VirtualDom_elementMeta = new WeakMap();
2362−function _VirtualDom_getMeta(el) {
2363− var meta = _VirtualDom_elementMeta.get(el);
2364− if (!meta) { meta = {}; _VirtualDom_elementMeta.set(el, meta); }
2365− return meta;
2366−}
2367−
2368−
2369−// HELPERS
2370−
2371−
2372−
2373−
2374−
2375−function _VirtualDom_appendChild(parent, child)
2376−{
2377− _Lynx_appendChild(parent, child);
2378−}
2379−
2380−var _VirtualDom_init = F4(function(virtualNode, flagDecoder, debugMetadata, args)
2381−{
2382− // NOTE: this function needs _Platform_export available to work
2383− var rootPage = args['node'] || _Lynx_g['page'];
2384− var tree = _VirtualDom_render(virtualNode, function() {});
2385− _Lynx_appendChild(rootPage, tree);
2386− return {};
2387−});
2388−
2389−
2390−
2391−// TEXT
2392−
2393−
2394−function _VirtualDom_text(string)
2395−{
2396− return {
2397− $: 0,
2398− a: string
2399− };
2400−}
2401−
2402−
2403−
2404−// NODE
2405−
2406−
2407−var _VirtualDom_nodeNS = F2(function(namespace, tag)
2408−{
2409− return F2(function(factList, kidList)
2410− {
2411− for (var kids = [], descendantsCount = 0; kidList.b; kidList = kidList.b) // WHILE_CONS
2412− {
2413− var kid = kidList.a;
2414− descendantsCount += (kid.b || 0);
2415− kids.push(kid);
2416− }
2417− descendantsCount += kids.length;
2418−
2419− return {
2420− $: 1,
2421− c: tag,
2422− d: _VirtualDom_organizeFacts(factList),
2423− e: kids,
2424− f: namespace,
2425− b: descendantsCount
2426− };
2427− });
2428−});
2429−
2430−
2431−var _VirtualDom_node = _VirtualDom_nodeNS(undefined);
2432−
2433−
2434−
2435−// KEYED NODE
2436−
2437−
2438−var _VirtualDom_keyedNodeNS = F2(function(namespace, tag)
2439−{
2440− return F2(function(factList, kidList)
2441− {
2442− for (var kids = [], descendantsCount = 0; kidList.b; kidList = kidList.b) // WHILE_CONS
2443− {
2444− var kid = kidList.a;
2445− descendantsCount += (kid.b.b || 0);
2446− kids.push(kid);
2447− }
2448− descendantsCount += kids.length;
2449−
2450− return {
2451− $: 2,
2452− c: tag,
2453− d: _VirtualDom_organizeFacts(factList),
2454− e: kids,
2455− f: namespace,
2456− b: descendantsCount
2457− };
2458− });
2459−});
2460−
2461−
2462−var _VirtualDom_keyedNode = _VirtualDom_keyedNodeNS(undefined);
2463−
2464−
2465−
2466−// CUSTOM
2467−
2468−
2469−function _VirtualDom_custom(factList, model, render, diff)
2470−{
2471− return {
2472− $: 3,
2473− d: _VirtualDom_organizeFacts(factList),
2474− g: model,
2475− h: render,
2476− i: diff
2477− };
2478−}
2479−
2480−
2481−
2482−// MAP
2483−
2484−
2485−var _VirtualDom_map = F2(function(tagger, node)
2486−{
2487− return {
2488− $: 4,
2489− j: tagger,
2490− k: node,
2491− b: 1 + (node.b || 0)
2492− };
2493−});
2494−
2495−
2496−
2497−// LAZY
2498−
2499−
2500−function _VirtualDom_thunk(refs, thunk)
2501−{
2502− return {
2503− $: 5,
2504− l: refs,
2505− m: thunk,
2506− k: undefined
2507− };
2508−}
2509−
2510−var _VirtualDom_lazy = F2(function(func, a)
2511−{
2512− return _VirtualDom_thunk([func, a], function() {
2513− return func(a);
2514− });
2515−});
2516−
2517−var _VirtualDom_lazy2 = F3(function(func, a, b)
2518−{
2519− return _VirtualDom_thunk([func, a, b], function() {
2520− return A2(func, a, b);
2521− });
2522−});
2523−
2524−var _VirtualDom_lazy3 = F4(function(func, a, b, c)
2525−{
2526− return _VirtualDom_thunk([func, a, b, c], function() {
2527− return A3(func, a, b, c);
2528− });
2529−});
2530−
2531−var _VirtualDom_lazy4 = F5(function(func, a, b, c, d)
2532−{
2533− return _VirtualDom_thunk([func, a, b, c, d], function() {
2534− return A4(func, a, b, c, d);
2535− });
2536−});
2537−
2538−var _VirtualDom_lazy5 = F6(function(func, a, b, c, d, e)
2539−{
2540− return _VirtualDom_thunk([func, a, b, c, d, e], function() {
2541− return A5(func, a, b, c, d, e);
2542− });
2543−});
2544−
2545−var _VirtualDom_lazy6 = F7(function(func, a, b, c, d, e, f)
2546−{
2547− return _VirtualDom_thunk([func, a, b, c, d, e, f], function() {
2548− return A6(func, a, b, c, d, e, f);
2549− });
2550−});
2551−
2552−var _VirtualDom_lazy7 = F8(function(func, a, b, c, d, e, f, g)
2553−{
2554− return _VirtualDom_thunk([func, a, b, c, d, e, f, g], function() {
2555− return A7(func, a, b, c, d, e, f, g);
2556− });
2557−});
2558−
2559−var _VirtualDom_lazy8 = F9(function(func, a, b, c, d, e, f, g, h)
2560−{
2561− return _VirtualDom_thunk([func, a, b, c, d, e, f, g, h], function() {
2562− return A8(func, a, b, c, d, e, f, g, h);
2563− });
2564−});
2565−
2566−
2567−
2568−// FACTS
2569−
2570−
2571−var _VirtualDom_on = F2(function(key, handler)
2572−{
2573− return {
2574− $: 'a0',
2575− n: key,
2576− o: handler
2577− };
2578−});
2579−var _VirtualDom_style = F2(function(key, value)
2580−{
2581− return {
2582− $: 'a1',
2583− n: key,
2584− o: value
2585− };
2586−});
2587−var _VirtualDom_property = F2(function(key, value)
2588−{
2589− return {
2590− $: 'a2',
2591− n: key,
2592− o: value
2593− };
2594−});
2595−var _VirtualDom_attribute = F2(function(key, value)
2596−{
2597− return {
2598− $: 'a3',
2599− n: key,
2600− o: value
2601− };
2602−});
2603−var _VirtualDom_attributeNS = F3(function(namespace, key, value)
2604−{
2605− return {
2606− $: 'a4',
2607− n: key,
2608− o: { f: namespace, o: value }
2609− };
2610−});
2611−
2612−
2613−
2614−// XSS ATTACK VECTOR CHECKS
2615−//
2616−// For some reason, tabs can appear in href protocols and it still works.
2617−// So '\tjava\tSCRIPT:alert("!!!")' and 'javascript:alert("!!!")' are the same
2618−// in practice. That is why _VirtualDom_RE_js and _VirtualDom_RE_js_html look
2619−// so freaky.
2620−//
2621−// Pulling the regular expressions out to the top level gives a slight speed
2622−// boost in small benchmarks (4-10%) but hoisting values to reduce allocation
2623−// can be unpredictable in large programs where JIT may have a harder time with
2624−// functions are not fully self-contained. The benefit is more that the js and
2625−// js_html ones are so weird that I prefer to see them near each other.
2626−
2627−
2628−
2629−
2630−
2631−function _VirtualDom_noScript(tag)
2632−{
2633− return tag;
2634−}
2635−
2636−function _VirtualDom_noOnOrFormAction(key)
2637−{
2638− return key;
2639−}
2640−
2641−function _VirtualDom_noInnerHtmlOrFormAction(key)
2642−{
2643− return key;
2644−}
2645−
2646−function _VirtualDom_noJavaScriptUri(value)
2647−{
2648− return value;
2649−}
2650−
2651−function _VirtualDom_noJavaScriptOrHtmlUri(value)
2652−{
2653− return value;
2654−}
2655−
2656−function _VirtualDom_noJavaScriptOrHtmlJson(value)
2657−{
2658− return value;
2659−}
2660−
2661−
2662−
2663−// MAP FACTS
2664−
2665−
2666−var _VirtualDom_mapAttribute = F2(function(func, attr)
2667−{
2668− return (attr.$ === 'a0')
2669− ? A2(_VirtualDom_on, attr.n, _VirtualDom_mapHandler(func, attr.o))
2670− : attr;
2671−});
2672−
2673−function _VirtualDom_mapHandler(func, handler)
2674−{
2675− var tag = $lynxjs_elm$virtual_dom$VirtualDom$toHandlerInt(handler);
2676−
2677− // 0 = Normal
2678− // 1 = MayStopPropagation
2679− // 2 = MayPreventDefault
2680− // 3 = Custom
2681−
2682− return {
2683− $: handler.$,
2684− a:
2685− !tag
2686− ? A2($elm$json$Json$Decode$map, func, handler.a)
2687− :
2688− A3($elm$json$Json$Decode$map2,
2689− tag < 3
2690− ? _VirtualDom_mapEventTuple
2691− : _VirtualDom_mapEventRecord,
2692− $elm$json$Json$Decode$succeed(func),
2693− handler.a
2694− )
2695− };
2696−}
2697−
2698−var _VirtualDom_mapEventTuple = F2(function(func, tuple)
2699−{
2700− return _Utils_Tuple2(func(tuple.a), tuple.b);
2701−});
2702−
2703−var _VirtualDom_mapEventRecord = F2(function(func, record)
2704−{
2705− return {
2706− message: func(record.message),
2707− stopPropagation: record.stopPropagation,
2708− preventDefault: record.preventDefault
2709− }
2710−});
2711−
2712−
2713−
2714−// ORGANIZE FACTS
2715−
2716−
2717−function _VirtualDom_organizeFacts(factList)
2718−{
2719− for (var facts = {}; factList.b; factList = factList.b) // WHILE_CONS
2720− {
2721− var entry = factList.a;
2722−
2723− var tag = entry.$;
2724− var key = entry.n;
2725− var value = entry.o;
2726−
2727− if (tag === 'a2')
2728− {
2729− (key === 'className')
2730− ? _VirtualDom_addClass(facts, key, _Json_unwrap(value))
2731− : facts[key] = _Json_unwrap(value);
2732−
2733− continue;
2734− }
2735−
2736− var subFacts = facts[tag] || (facts[tag] = {});
2737− (tag === 'a3' && key === 'class')
2738− ? _VirtualDom_addClass(subFacts, key, value)
2739− : subFacts[key] = value;
2740− }
2741−
2742− return facts;
2743−}
2744−
2745−function _VirtualDom_addClass(object, key, newClass)
2746−{
2747− var classes = object[key];
2748− object[key] = classes ? classes + ' ' + newClass : newClass;
2749−}
2750−
2751−
2752−
2753−// RENDER
2754−
2755−
2756−function _VirtualDom_render(vNode, eventNode)
2757−{
2758− var tag = vNode.$;
2759−
2760− if (tag === 5)
2761− {
2762− return _VirtualDom_render(vNode.k || (vNode.k = vNode.m()), eventNode);
2763− }
2764−
2765− if (tag === 0)
2766− {
2767− return _Lynx_createRawText(vNode.a);
2768− }
2769−
2770− if (tag === 4)
2771− {
2772− var subNode = vNode.k;
2773− var tagger = vNode.j;
2774−
2775− while (subNode.$ === 4)
2776− {
2777− typeof tagger !== 'object'
2778− ? tagger = [tagger, subNode.j]
2779− : tagger.push(subNode.j);
2780−
2781− subNode = subNode.k;
2782− }
2783−
2784− var subEventRoot = { j: tagger, p: eventNode };
2785− var domNode = _VirtualDom_render(subNode, subEventRoot);
2786− _VirtualDom_getMeta(domNode).elm_event_node_ref = subEventRoot;
2787− return domNode;
2788− }
2789−
2790− if (tag === 3)
2791− {
2792− var domNode = vNode.h(vNode.g);
2793− _VirtualDom_applyFacts(domNode, eventNode, vNode.d);
2794− return domNode;
2795− }
2796−
2797− // at this point `tag` must be 1 or 2
2798−
2799− var domNode = _Lynx_createElement(vNode.c);
2800−
2801− _VirtualDom_applyFacts(domNode, eventNode, vNode.d);
2802−
2803− for (var kids = vNode.e, i = 0; i < kids.length; i++)
2804− {
2805− var kid = tag === 1 ? kids[i] : kids[i].b;
2806− if (kid.$ === 0)
2807− {
2808− _Lynx_appendChild(domNode, _Lynx_createRawText(kid.a));
2809− }
2810− else
2811− {
2812− _VirtualDom_appendChild(domNode, _VirtualDom_render(kid, eventNode));
2813− }
2814− }
2815−
2816− return domNode;
2817−}
2818−
2819−
2820−
2821−// APPLY FACTS
2822−
2823−
2824−function _VirtualDom_applyFacts(domNode, eventNode, facts)
2825−{
2826− for (var key in facts)
2827− {
2828− var value = facts[key];
2829−
2830− key === 'a1'
2831− ? _VirtualDom_applyStyles(domNode, value)
2832− :
2833− key === 'a0'
2834− ? _VirtualDom_applyEvents(domNode, eventNode, value)
2835− :
2836− key === 'a3'
2837− ? _VirtualDom_applyAttrs(domNode, value)
2838− :
2839− key === 'a4'
2840− ? _VirtualDom_applyAttrsNS(domNode, value)
2841− :
2842− (key !== 'value' && key !== 'checked' || domNode[key] !== value) && _Lynx_setAttribute(domNode, key === 'className' ? 'class' : key, String(value));
2843− }
2844−}
2845−
2846−
2847−
2848−// APPLY STYLES
2849−
2850−
2851−function _VirtualDom_applyStyles(domNode, styles)
2852−{
2853− _Lynx_setInlineStyles(domNode, styles);
2854−}
2855−
2856−
2857−
2858−// APPLY ATTRS
2859−
2860−
2861−function _VirtualDom_applyAttrs(domNode, attrs)
2862−{
2863− for (var key in attrs)
2864− {
2865− var value = attrs[key];
2866− if (typeof value !== 'undefined')
2867− {
2868− _Lynx_setAttribute(domNode, key, value);
2869− }
2870− }
2871−}
2872−
2873−
2874−
2875−// APPLY NAMESPACED ATTRS
2876−
2877−
2878−function _VirtualDom_applyAttrsNS(domNode, nsAttrs)
2879−{
2880− for (var key in nsAttrs)
2881− {
2882− var pair = nsAttrs[key];
2883− var value = pair.o;
2884−
2885− if (typeof value !== 'undefined')
2886− {
2887− _Lynx_setAttribute(domNode, key, value);
2888− }
2889− }
2890−}
2891−
2892−
2893−
2894−// APPLY EVENTS
2895−
2896−
2897−function _VirtualDom_applyEvents(domNode, eventNode, events)
2898−{
2899− var meta = _VirtualDom_getMeta(domNode);
2900− var allCallbacks = meta.elmFs || (meta.elmFs = {});
2901−
2902− for (var key in events)
2903− {
2904− var newHandler = events[key];
2905− var oldCallback = allCallbacks[key];
2906−
2907− if (!newHandler)
2908− {
2909− allCallbacks[key] = undefined;
2910− continue;
2911− }
2912−
2913− if (oldCallback)
2914− {
2915− var oldHandler = oldCallback.q;
2916− if (oldHandler.$ === newHandler.$)
2917− {
2918− oldCallback.q = newHandler;
2919− continue;
2920− }
2921− }
2922−
2923− oldCallback = _VirtualDom_makeCallback(eventNode, newHandler);
2924− allCallbacks[key] = oldCallback;
2925−
2926− var lynxEventName = _VirtualDom_mapEventName(key);
2927− var handlerType = $lynxjs_elm$virtual_dom$VirtualDom$toHandlerInt(newHandler);
2928− var prefix = (handlerType === 1) ? 'catch' : 'bind';
2929− _Lynx_addEvent(domNode, 'catchEvent', lynxEventName, { type: 'worklet', value: oldCallback });
2930− }
2931−}
2932−
2933−function _VirtualDom_mapEventName(name)
2934−{
2935− switch (name)
2936− {
2937− case 'click': return 'tap';
2938− case 'dblclick': return 'tap';
2939− case 'mousedown': return 'touchstart';
2940− case 'mousemove': return 'touchmove';
2941− case 'mouseup': return 'touchend';
2942− default: return name;
2943− }
2944−}
2945−
2946−
2947−
2948−// PASSIVE EVENTS
2949−
2950−
2951−
2952−// EVENT HANDLERS
2953−
2954−
2955−function _VirtualDom_makeCallback(eventNode, initialHandler)
2956−{
2957− function callback(event)
2958− {
2959− var handler = callback.q;
2960− var result = _Json_runHelp(handler.a, event);
2961−
2962− if (!$elm$core$Result$isOk(result))
2963− {
2964− return;
2965− }
2966−
2967− var tag = $lynxjs_elm$virtual_dom$VirtualDom$toHandlerInt(handler);
2968−
2969− // 0 = Normal
2970− // 1 = MayStopPropagation
2971− // 2 = MayPreventDefault
2972− // 3 = Custom
2973−
2974− var value = result.a;
2975− var message = !tag ? value : tag < 3 ? value.a : value.message;
2976− var stopPropagation = tag == 1 ? value.b : tag == 3 && value.stopPropagation;
2977− var currentEventNode = eventNode;
2978− var tagger;
2979− var i;
2980− while (tagger = currentEventNode.j)
2981− {
2982− if (typeof tagger == 'function')
2983− {
2984− message = tagger(message);
2985− }
2986− else
2987− {
2988− for (var i = tagger.length; i--; )
2989− {
2990− message = tagger[i](message);
2991− }
2992− }
2993− currentEventNode = currentEventNode.p;
2994− }
2995− currentEventNode(message, stopPropagation); // stopPropagation implies isSync
2996− }
2997−
2998− callback.q = initialHandler;
2999−
3000− return callback;
3001−}
3002−
3003−function _VirtualDom_equalEvents(x, y)
3004−{
3005− return x.$ == y.$ && _Json_equality(x.a, y.a);
3006−}
3007−
3008−
3009−
3010−// DIFF
3011−
3012−
3013−// TODO: Should we do patches like in iOS?
3014−//
3015−// type Patch
3016−// = At Int Patch
3017−// | Batch (List Patch)
3018−// | Change ...
3019−//
3020−// How could it not be better?
3021−//
3022−function _VirtualDom_diff(x, y)
3023−{
3024− var patches = [];
3025− _VirtualDom_diffHelp(x, y, patches, 0);
3026− return patches;
3027−}
3028−
3029−
3030−function _VirtualDom_pushPatch(patches, type, index, data)
3031−{
3032− var patch = {
3033− $: type,
3034− r: index,
3035− s: data,
3036− t: undefined,
3037− u: undefined
3038− };
3039− patches.push(patch);
3040− return patch;
3041−}
3042−
3043−
3044−function _VirtualDom_diffHelp(x, y, patches, index)
3045−{
3046− if (x === y)
3047− {
3048− return;
3049− }
3050−
3051− var xType = x.$;
3052− var yType = y.$;
3053−
3054− // Bail if you run into different types of nodes. Implies that the
3055− // structure has changed significantly and it's not worth a diff.
3056− if (xType !== yType)
3057− {
3058− if (xType === 1 && yType === 2)
3059− {
3060− y = _VirtualDom_dekey(y);
3061− yType = 1;
3062− }
3063− else
3064− {
3065− _VirtualDom_pushPatch(patches, 0, index, y);
3066− return;
3067− }
3068− }
3069−
3070− // Now we know that both nodes are the same $.
3071− switch (yType)
3072− {
3073− case 5:
3074− var xRefs = x.l;
3075− var yRefs = y.l;
3076− var i = xRefs.length;
3077− var same = i === yRefs.length;
3078− while (same && i--)
3079− {
3080− same = xRefs[i] === yRefs[i];
3081− }
3082− if (same)
3083− {
3084− y.k = x.k;
3085− return;
3086− }
3087− y.k = y.m();
3088− var subPatches = [];
3089− _VirtualDom_diffHelp(x.k, y.k, subPatches, 0);
3090− subPatches.length > 0 && _VirtualDom_pushPatch(patches, 1, index, subPatches);
3091− return;
3092−
3093− case 4:
3094− // gather nested taggers
3095− var xTaggers = x.j;
3096− var yTaggers = y.j;
3097− var nesting = false;
3098−
3099− var xSubNode = x.k;
3100− while (xSubNode.$ === 4)
3101− {
3102− nesting = true;
3103−
3104− typeof xTaggers !== 'object'
3105− ? xTaggers = [xTaggers, xSubNode.j]
3106− : xTaggers.push(xSubNode.j);
3107−
3108− xSubNode = xSubNode.k;
3109− }
3110−
3111− var ySubNode = y.k;
3112− while (ySubNode.$ === 4)
3113− {
3114− nesting = true;
3115−
3116− typeof yTaggers !== 'object'
3117− ? yTaggers = [yTaggers, ySubNode.j]
3118− : yTaggers.push(ySubNode.j);
3119−
3120− ySubNode = ySubNode.k;
3121− }
3122−
3123− // Just bail if different numbers of taggers. This implies the
3124− // structure of the virtual DOM has changed.
3125− if (nesting && xTaggers.length !== yTaggers.length)
3126− {
3127− _VirtualDom_pushPatch(patches, 0, index, y);
3128− return;
3129− }
3130−
3131− // check if taggers are "the same"
3132− if (nesting ? !_VirtualDom_pairwiseRefEqual(xTaggers, yTaggers) : xTaggers !== yTaggers)
3133− {
3134− _VirtualDom_pushPatch(patches, 2, index, yTaggers);
3135− }
3136−
3137− // diff everything below the taggers
3138− _VirtualDom_diffHelp(xSubNode, ySubNode, patches, index + 1);
3139− return;
3140−
3141− case 0:
3142− if (x.a !== y.a)
3143− {
3144− _VirtualDom_pushPatch(patches, 3, index, y.a);
3145− }
3146− return;
3147−
3148− case 1:
3149− _VirtualDom_diffNodes(x, y, patches, index, _VirtualDom_diffKids);
3150− return;
3151−
3152− case 2:
3153− _VirtualDom_diffNodes(x, y, patches, index, _VirtualDom_diffKeyedKids);
3154− return;
3155−
3156− case 3:
3157− if (x.h !== y.h)
3158− {
3159− _VirtualDom_pushPatch(patches, 0, index, y);
3160− return;
3161− }
3162−
3163− var factsDiff = _VirtualDom_diffFacts(x.d, y.d);
3164− factsDiff && _VirtualDom_pushPatch(patches, 4, index, factsDiff);
3165−
3166− var patch = y.i(x.g, y.g);
3167− patch && _VirtualDom_pushPatch(patches, 5, index, patch);
3168−
3169− return;
3170− }
3171−}
3172−
3173−// assumes the incoming arrays are the same length
3174−function _VirtualDom_pairwiseRefEqual(as, bs)
3175−{
3176− for (var i = 0; i < as.length; i++)
3177− {
3178− if (as[i] !== bs[i])
3179− {
3180− return false;
3181− }
3182− }
3183−
3184− return true;
3185−}
3186−
3187−function _VirtualDom_diffNodes(x, y, patches, index, diffKids)
3188−{
3189− // Bail if obvious indicators have changed. Implies more serious
3190− // structural changes such that it's not worth it to diff.
3191− if (x.c !== y.c || x.f !== y.f)
3192− {
3193− _VirtualDom_pushPatch(patches, 0, index, y);
3194− return;
3195− }
3196−
3197− var factsDiff = _VirtualDom_diffFacts(x.d, y.d);
3198− factsDiff && _VirtualDom_pushPatch(patches, 4, index, factsDiff);
3199−
3200− diffKids(x, y, patches, index);
3201−}
3202−
3203−
3204−
3205−// DIFF FACTS
3206−
3207−
3208−// TODO Instead of creating a new diff object, it's possible to just test if
3209−// there *is* a diff. During the actual patch, do the diff again and make the
3210−// modifications directly. This way, there's no new allocations. Worth it?
3211−function _VirtualDom_diffFacts(x, y, category)
3212−{
3213− var diff;
3214−
3215− // look for changes and removals
3216− for (var xKey in x)
3217− {
3218− if (xKey === 'a1' || xKey === 'a0' || xKey === 'a3' || xKey === 'a4')
3219− {
3220− var subDiff = _VirtualDom_diffFacts(x[xKey], y[xKey] || {}, xKey);
3221− if (subDiff)
3222− {
3223− diff = diff || {};
3224− diff[xKey] = subDiff;
3225− }
3226− continue;
3227− }
3228−
3229− // remove if not in the new facts
3230− if (!(xKey in y))
3231− {
3232− diff = diff || {};
3233− diff[xKey] =
3234− !category
3235− ? (typeof x[xKey] === 'string' ? '' : null)
3236− :
3237− (category === 'a1')
3238− ? ''
3239− :
3240− (category === 'a0' || category === 'a3')
3241− ? undefined
3242− :
3243− { f: x[xKey].f, o: undefined };
3244−
3245− continue;
3246− }
3247−
3248− var xValue = x[xKey];
3249− var yValue = y[xKey];
3250−
3251− // reference equal, so don't worry about it
3252− if (xValue === yValue && xKey !== 'value' && xKey !== 'checked'
3253− || category === 'a0' && _VirtualDom_equalEvents(xValue, yValue))
3254− {
3255− continue;
3256− }
3257−
3258− diff = diff || {};
3259− diff[xKey] = yValue;
3260− }
3261−
3262− // add new stuff
3263− for (var yKey in y)
3264− {
3265− if (!(yKey in x))
3266− {
3267− diff = diff || {};
3268− diff[yKey] = y[yKey];
3269− }
3270− }
3271−
3272− return diff;
3273−}
3274−
3275−
3276−
3277−// DIFF KIDS
3278−
3279−
3280−function _VirtualDom_diffKids(xParent, yParent, patches, index)
3281−{
3282− var xKids = xParent.e;
3283− var yKids = yParent.e;
3284−
3285− var xLen = xKids.length;
3286− var yLen = yKids.length;
3287−
3288− // FIGURE OUT IF THERE ARE INSERTS OR REMOVALS
3289−
3290− if (xLen > yLen)
3291− {
3292− _VirtualDom_pushPatch(patches, 6, index, {
3293− v: yLen,
3294− i: xLen - yLen
3295− });
3296− }
3297− else if (xLen < yLen)
3298− {
3299− _VirtualDom_pushPatch(patches, 7, index, {
3300− v: xLen,
3301− e: yKids
3302− });
3303− }
3304−
3305− // PAIRWISE DIFF EVERYTHING ELSE
3306−
3307− for (var minLen = xLen < yLen ? xLen : yLen, i = 0; i < minLen; i++)
3308− {
3309− var xKid = xKids[i];
3310− _VirtualDom_diffHelp(xKid, yKids[i], patches, ++index);
3311− index += xKid.b || 0;
3312− }
3313−}
3314−
3315−
3316−
3317−// KEYED DIFF
3318−
3319−
3320−function _VirtualDom_diffKeyedKids(xParent, yParent, patches, rootIndex)
3321−{
3322− var localPatches = [];
3323−
3324− var changes = {}; // Dict String Entry
3325− var inserts = []; // Array { index : Int, entry : Entry }
3326− // type Entry = { tag : String, vnode : VNode, index : Int, data : _ }
3327−
3328− var xKids = xParent.e;
3329− var yKids = yParent.e;
3330− var xLen = xKids.length;
3331− var yLen = yKids.length;
3332− var xIndex = 0;
3333− var yIndex = 0;
3334−
3335− var index = rootIndex;
3336−
3337− while (xIndex < xLen && yIndex < yLen)
3338− {
3339− var x = xKids[xIndex];
3340− var y = yKids[yIndex];
3341−
3342− var xKey = x.a;
3343− var yKey = y.a;
3344− var xNode = x.b;
3345− var yNode = y.b;
3346−
3347− var newMatch = undefined;
3348− var oldMatch = undefined;
3349−
3350− // check if keys match
3351−
3352− if (xKey === yKey)
3353− {
3354− index++;
3355− _VirtualDom_diffHelp(xNode, yNode, localPatches, index);
3356− index += xNode.b || 0;
3357−
3358− xIndex++;
3359− yIndex++;
3360− continue;
3361− }
3362−
3363− // look ahead 1 to detect insertions and removals.
3364−
3365− var xNext = xKids[xIndex + 1];
3366− var yNext = yKids[yIndex + 1];
3367−
3368− if (xNext)
3369− {
3370− var xNextKey = xNext.a;
3371− var xNextNode = xNext.b;
3372− oldMatch = yKey === xNextKey;
3373− }
3374−
3375− if (yNext)
3376− {
3377− var yNextKey = yNext.a;
3378− var yNextNode = yNext.b;
3379− newMatch = xKey === yNextKey;
3380− }
3381−
3382−
3383− // swap x and y
3384− if (newMatch && oldMatch)
3385− {
3386− index++;
3387− _VirtualDom_diffHelp(xNode, yNextNode, localPatches, index);
3388− _VirtualDom_insertNode(changes, localPatches, xKey, yNode, yIndex, inserts);
3389− index += xNode.b || 0;
3390−
3391− index++;
3392− _VirtualDom_removeNode(changes, localPatches, xKey, xNextNode, index);
3393− index += xNextNode.b || 0;
3394−
3395− xIndex += 2;
3396− yIndex += 2;
3397− continue;
3398− }
3399−
3400− // insert y
3401− if (newMatch)
3402− {
3403− index++;
3404− _VirtualDom_insertNode(changes, localPatches, yKey, yNode, yIndex, inserts);
3405− _VirtualDom_diffHelp(xNode, yNextNode, localPatches, index);
3406− index += xNode.b || 0;
3407−
3408− xIndex += 1;
3409− yIndex += 2;
3410− continue;
3411− }
3412−
3413− // remove x
3414− if (oldMatch)
3415− {
3416− index++;
3417− _VirtualDom_removeNode(changes, localPatches, xKey, xNode, index);
3418− index += xNode.b || 0;
3419−
3420− index++;
3421− _VirtualDom_diffHelp(xNextNode, yNode, localPatches, index);
3422− index += xNextNode.b || 0;
3423−
3424− xIndex += 2;
3425− yIndex += 1;
3426− continue;
3427− }
3428−
3429− // remove x, insert y
3430− if (xNext && xNextKey === yNextKey)
3431− {
3432− index++;
3433− _VirtualDom_removeNode(changes, localPatches, xKey, xNode, index);
3434− _VirtualDom_insertNode(changes, localPatches, yKey, yNode, yIndex, inserts);
3435− index += xNode.b || 0;
3436−
3437− index++;
3438− _VirtualDom_diffHelp(xNextNode, yNextNode, localPatches, index);
3439− index += xNextNode.b || 0;
3440−
3441− xIndex += 2;
3442− yIndex += 2;
3443− continue;
3444− }
3445−
3446− break;
3447− }
3448−
3449− // eat up any remaining nodes with removeNode and insertNode
3450−
3451− while (xIndex < xLen)
3452− {
3453− index++;
3454− var x = xKids[xIndex];
3455− var xNode = x.b;
3456− _VirtualDom_removeNode(changes, localPatches, x.a, xNode, index);
3457− index += xNode.b || 0;
3458− xIndex++;
3459− }
3460−
3461− while (yIndex < yLen)
3462− {
3463− var endInserts = endInserts || [];
3464− var y = yKids[yIndex];
3465− _VirtualDom_insertNode(changes, localPatches, y.a, y.b, undefined, endInserts);
3466− yIndex++;
3467− }
3468−
3469− if (localPatches.length > 0 || inserts.length > 0 || endInserts)
3470− {
3471− _VirtualDom_pushPatch(patches, 8, rootIndex, {
3472− w: localPatches,
3473− x: inserts,
3474− y: endInserts
3475− });
3476− }
3477−}
3478−
3479−
3480−
3481−// CHANGES FROM KEYED DIFF
3482−
3483−
3484−var _VirtualDom_POSTFIX = '_elmW6BL';
3485−
3486−
3487−function _VirtualDom_insertNode(changes, localPatches, key, vnode, yIndex, inserts)
3488−{
3489− var entry = changes[key];
3490−
3491− // never seen this key before
3492− if (!entry)
3493− {
3494− entry = {
3495− c: 0,
3496− z: vnode,
3497− r: yIndex,
3498− s: undefined
3499− };
3500−
3501− inserts.push({ r: yIndex, A: entry });
3502− changes[key] = entry;
3503−
3504− return;
3505− }
3506−
3507− // this key was removed earlier, a match!
3508− if (entry.c === 1)
3509− {
3510− inserts.push({ r: yIndex, A: entry });
3511−
3512− entry.c = 2;
3513− var subPatches = [];
3514− _VirtualDom_diffHelp(entry.z, vnode, subPatches, entry.r);
3515− entry.r = yIndex;
3516− entry.s.s = {
3517− w: subPatches,
3518− A: entry
3519− };
3520−
3521− return;
3522− }
3523−
3524− // this key has already been inserted or moved, a duplicate!
3525− _VirtualDom_insertNode(changes, localPatches, key + _VirtualDom_POSTFIX, vnode, yIndex, inserts);
3526−}
3527−
3528−
3529−function _VirtualDom_removeNode(changes, localPatches, key, vnode, index)
3530−{
3531− var entry = changes[key];
3532−
3533− // never seen this key before
3534− if (!entry)
3535− {
3536− var patch = _VirtualDom_pushPatch(localPatches, 9, index, undefined);
3537−
3538− changes[key] = {
3539− c: 1,
3540− z: vnode,
3541− r: index,
3542− s: patch
3543− };
3544−
3545− return;
3546− }
3547−
3548− // this key was inserted earlier, a match!
3549− if (entry.c === 0)
3550− {
3551− entry.c = 2;
3552− var subPatches = [];
3553− _VirtualDom_diffHelp(vnode, entry.z, subPatches, index);
3554−
3555− _VirtualDom_pushPatch(localPatches, 9, index, {
3556− w: subPatches,
3557− A: entry
3558− });
3559−
3560− return;
3561− }
3562−
3563− // this key has already been removed or moved, a duplicate!
3564− _VirtualDom_removeNode(changes, localPatches, key + _VirtualDom_POSTFIX, vnode, index);
3565−}
3566−
3567−
3568−
3569−// ADD DOM NODES
3570−//
3571−// Each DOM node has an "index" assigned in order of traversal. It is important
3572−// to minimize our crawl over the actual DOM, so these indexes (along with the
3573−// descendantsCount of virtual nodes) let us skip touching entire subtrees of
3574−// the DOM if we know there are no patches there.
3575−
3576−
3577−function _VirtualDom_addDomNodes(domNode, vNode, patches, eventNode)
3578−{
3579− _VirtualDom_addDomNodesHelp(domNode, vNode, patches, 0, 0, vNode.b, eventNode);
3580−}
3581−
3582−
3583−// assumes `patches` is non-empty and indexes increase monotonically.
3584−function _VirtualDom_addDomNodesHelp(domNode, vNode, patches, i, low, high, eventNode)
3585−{
3586− var patch = patches[i];
3587− var index = patch.r;
3588−
3589− while (index === low)
3590− {
3591− var patchType = patch.$;
3592−
3593− if (patchType === 1)
3594− {
3595− _VirtualDom_addDomNodes(domNode, vNode.k, patch.s, eventNode);
3596− }
3597− else if (patchType === 8)
3598− {
3599− patch.t = domNode;
3600− patch.u = eventNode;
3601−
3602− var subPatches = patch.s.w;
3603− if (subPatches.length > 0)
3604− {
3605− _VirtualDom_addDomNodesHelp(domNode, vNode, subPatches, 0, low, high, eventNode);
3606− }
3607− }
3608− else if (patchType === 9)
3609− {
3610− patch.t = domNode;
3611− patch.u = eventNode;
3612−
3613− var data = patch.s;
3614− if (data)
3615− {
3616− data.A.s = domNode;
3617− var subPatches = data.w;
3618− if (subPatches.length > 0)
3619− {
3620− _VirtualDom_addDomNodesHelp(domNode, vNode, subPatches, 0, low, high, eventNode);
3621− }
3622− }
3623− }
3624− else
3625− {
3626− patch.t = domNode;
3627− patch.u = eventNode;
3628− }
3629−
3630− i++;
3631−
3632− if (!(patch = patches[i]) || (index = patch.r) > high)
3633− {
3634− return i;
3635− }
3636− }
3637−
3638− var tag = vNode.$;
3639−
3640− if (tag === 4)
3641− {
3642− var subNode = vNode.k;
3643−
3644− while (subNode.$ === 4)
3645− {
3646− subNode = subNode.k;
3647− }
3648−
3649− return _VirtualDom_addDomNodesHelp(domNode, subNode, patches, i, low + 1, high, _VirtualDom_getMeta(domNode).elm_event_node_ref);
3650− }
3651−
3652− // tag must be 1 or 2 at this point
3653−
3654− var vKids = vNode.e;
3655− var childNodes = _Lynx_getChildren(domNode);
3656− for (var j = 0; j < vKids.length; j++)
3657− {
3658− low++;
3659− var vKid = tag === 1 ? vKids[j] : vKids[j].b;
3660− var nextLow = low + (vKid.b || 0);
3661− if (low <= index && index <= nextLow)
3662− {
3663− i = _VirtualDom_addDomNodesHelp(childNodes[j], vKid, patches, i, low, nextLow, eventNode);
3664− if (!(patch = patches[i]) || (index = patch.r) > high)
3665− {
3666− return i;
3667− }
3668− }
3669− low = nextLow;
3670− }
3671− return i;
3672−}
3673−
3674−
3675−
3676−// APPLY PATCHES
3677−
3678−
3679−function _VirtualDom_applyPatches(rootDomNode, oldVirtualNode, patches, eventNode)
3680−{
3681− if (patches.length === 0)
3682− {
3683− return rootDomNode;
3684− }
3685−
3686− _VirtualDom_addDomNodes(rootDomNode, oldVirtualNode, patches, eventNode);
3687− return _VirtualDom_applyPatchesHelp(rootDomNode, patches);
3688−}
3689−
3690−function _VirtualDom_applyPatchesHelp(rootDomNode, patches)
3691−{
3692− for (var i = 0; i < patches.length; i++)
3693− {
3694− var patch = patches[i];
3695− var localDomNode = patch.t
3696− var newNode = _VirtualDom_applyPatch(localDomNode, patch);
3697− if (localDomNode === rootDomNode)
3698− {
3699− rootDomNode = newNode;
3700− }
3701− }
3702− return rootDomNode;
3703−}
3704−
3705−function _VirtualDom_applyPatch(domNode, patch)
3706−{
3707− switch (patch.$)
3708− {
3709− case 0:
3710− return _VirtualDom_applyPatchRedraw(domNode, patch.s, patch.u);
3711−
3712− case 4:
3713− _VirtualDom_applyFacts(domNode, patch.u, patch.s);
3714− return domNode;
3715−
3716− case 3:
3717− _Lynx_setAttribute(domNode, 'text', patch.s);
3718− return domNode;
3719−
3720− case 1:
3721− return _VirtualDom_applyPatchesHelp(domNode, patch.s);
3722−
3723− case 2:
3724− if (_VirtualDom_getMeta(domNode).elm_event_node_ref)
3725− {
3726− _VirtualDom_getMeta(domNode).elm_event_node_ref.j = patch.s;
3727− }
3728− else
3729− {
3730− _VirtualDom_getMeta(domNode).elm_event_node_ref = { j: patch.s, p: patch.u };
3731− }
3732− return domNode;
3733−
3734− case 6:
3735− var data = patch.s;
3736− for (var i = 0; i < data.i; i++)
3737− {
3738− var children = _Lynx_getChildren(domNode);
3739− _Lynx_removeElement(domNode, children[data.v]);
3740− }
3741− return domNode;
3742−
3743− case 7:
3744− var data = patch.s;
3745− var kids = data.e;
3746− for (var i = data.v; i < kids.length; i++)
3747− {
3748− _Lynx_appendChild(domNode, _VirtualDom_render(kids[i], patch.u));
3749− }
3750− return domNode;
3751−
3752− case 9:
3753− var data = patch.s;
3754− if (!data)
3755− {
3756− _Lynx_removeElement(_Lynx_getParent(domNode), domNode);
3757− return domNode;
3758− }
3759− var entry = data.A;
3760− if (typeof entry.r !== 'undefined')
3761− {
3762− _Lynx_removeElement(_Lynx_getParent(domNode), domNode);
3763− }
3764− entry.s = _VirtualDom_applyPatchesHelp(domNode, data.w);
3765− return domNode;
3766−
3767− case 8:
3768− return _VirtualDom_applyPatchReorder(domNode, patch);
3769−
3770− case 5:
3771− return patch.s(domNode);
3772−
3773− default:
3774− throw new Error('VirtualDom: unknown patch type');
3775− }
3776−}
3777−
3778−
3779−function _VirtualDom_applyPatchRedraw(domNode, vNode, eventNode)
3780−{
3781− var parentNode = _Lynx_getParent(domNode);
3782− var newNode = _VirtualDom_render(vNode, eventNode);
3783−
3784− if (!_VirtualDom_getMeta(newNode).elm_event_node_ref)
3785− {
3786− _VirtualDom_getMeta(newNode).elm_event_node_ref = _VirtualDom_getMeta(domNode).elm_event_node_ref;
3787− }
3788−
3789− if (parentNode && newNode !== domNode)
3790− {
3791− _Lynx_replaceElement(parentNode, newNode, domNode);
3792− }
3793− return newNode;
3794−}
3795−
3796−
3797−function _VirtualDom_applyPatchReorder(domNode, patch)
3798−{
3799− var data = patch.s;
3800−
3801− // remove end inserts
3802− var endInsertNodes = _VirtualDom_applyPatchReorderEndInsertsHelp(data.y, patch);
3803−
3804− // removals
3805− domNode = _VirtualDom_applyPatchesHelp(domNode, data.w);
3806−
3807− // inserts
3808− var inserts = data.x;
3809− for (var i = 0; i < inserts.length; i++)
3810− {
3811− var insert = inserts[i];
3812− var entry = insert.A;
3813− var node = entry.c === 2
3814− ? entry.s
3815− : _VirtualDom_render(entry.z, patch.u);
3816− var children = _Lynx_getChildren(domNode);
3817− _Lynx_insertBefore(domNode, node, children[insert.r]);
3818− }
3819−
3820− // add end inserts
3821− if (endInsertNodes)
3822− {
3823− for (var i = 0; i < endInsertNodes.length; i++)
3824− {
3825− _Lynx_appendChild(domNode, endInsertNodes[i]);
3826− }
3827− }
3828−
3829− return domNode;
3830−}
3831−
3832−
3833−function _VirtualDom_applyPatchReorderEndInsertsHelp(endInserts, patch)
3834−{
3835− if (!endInserts)
3836− {
3837− return;
3838− }
3839−
3840− var nodes = [];
3841− for (var i = 0; i < endInserts.length; i++)
3842− {
3843− var insert = endInserts[i];
3844− var entry = insert.A;
3845− nodes.push(entry.c === 2
3846− ? entry.s
3847− : _VirtualDom_render(entry.z, patch.u)
3848− );
3849− }
3850− return nodes;
3851−}
3852−
3853−
3854−function _VirtualDom_dekey(keyedNode)
3855−{
3856− var keyedKids = keyedNode.e;
3857− var len = keyedKids.length;
3858− var kids = new Array(len);
3859− for (var i = 0; i < len; i++)
3860− {
3861− kids[i] = keyedKids[i].b;
3862− }
3863−
3864− return {
3865− $: 1,
3866− c: keyedNode.c,
3867− d: keyedNode.d,
3868− e: kids,
3869− f: keyedNode.f,
3870− b: keyedNode.b
3871− };
3872−}
3873−
3874−
3875−
3876−// LYNXJS PAPI WRAPPERS
3877−var _Browser_lynx = (typeof globalThis !== 'undefined' ? globalThis : scope);
3878−function _Browser_newPage() {
3879− return _Browser_lynx['page'];
3880−}
3881−function _Browser_flush() {
3882− var fn = _Browser_lynx['_' + '_FlushElementTree'];
3883− if (typeof fn === 'function') { fn(); }
3884−}
3885−
3886−
3887−
3888−// ELEMENT
3889−
3890−
3891−var _Browser_element = F4(function(impl, flagDecoder, debugMetadata, args)
3892−{
3893− return _Platform_initialize(
3894− flagDecoder,
3895− args,
3896− impl.init,
3897− impl.update,
3898− impl.subscriptions,
3899− function(sendToApp, initialModel) {
3900− var view = impl.view;
3901− var domNode = args['node'] || _Browser_newPage();
3902− var currNode = view(initialModel);
3903− var currDomNode = _VirtualDom_render(currNode, sendToApp);
3904− _VirtualDom_appendChild(domNode, currDomNode);
3905− _Browser_flush();
3906−
3907− return _Browser_makeAnimator(initialModel, function(model)
3908− {
3909− var nextNode = view(model);
3910− var patches = _VirtualDom_diff(currNode, nextNode);
3911− currDomNode = _VirtualDom_applyPatches(currDomNode, currNode, patches, sendToApp);
3912− currNode = nextNode;
3913− _Browser_flush();
3914− });
3915− }
3916− );
3917−});
3918−
3919−
3920−
3921−// DOCUMENT
3922−
3923−
3924−var _Browser_document = F4(function(impl, flagDecoder, debugMetadata, args)
3925−{
3926− return _Platform_initialize(
3927− flagDecoder,
3928− args,
3929− impl.init,
3930− impl.update,
3931− impl.subscriptions,
3932− function(sendToApp, initialModel) {
3933− var view = impl.view;
3934− var rootNode = args['node'] || _Browser_newPage();
3935− var doc = view(initialModel);
3936− var currNode = _VirtualDom_node('view')(_List_Nil)(doc.body);
3937− var currDomNode = _VirtualDom_render(currNode, sendToApp);
3938− _VirtualDom_appendChild(rootNode, currDomNode);
3939− _Browser_flush();
3940−
3941− return _Browser_makeAnimator(initialModel, function(model)
3942− {
3943− var doc = view(model);
3944− var nextNode = _VirtualDom_node('view')(_List_Nil)(doc.body);
3945− var patches = _VirtualDom_diff(currNode, nextNode);
3946− currDomNode = _VirtualDom_applyPatches(currDomNode, currNode, patches, sendToApp);
3947− currNode = nextNode;
3948− _Browser_flush();
3949− });
3950− }
3951− );
3952−});
3953−
3954−
3955−
3956−// ANIMATION
3957−
3958−
3959−var _Browser_cancelAnimationFrame =
3960− function(id) { clearTimeout(id); };
3961−
3962−var _Browser_requestAnimationFrame =
3963− function(callback) { return setTimeout(callback, 1000 / 60); };
3964−
3965−
3966−function _Browser_makeAnimator(model, draw)
3967−{
3968− draw(model);
3969−
3970− var state = 0;
3971−
3972− function updateIfNeeded()
3973− {
3974− state = state === 1
3975− ? 0
3976− : ( _Browser_requestAnimationFrame(updateIfNeeded), draw(model), 1 );
3977− }
3978−
3979− return function(nextModel, isSync)
3980− {
3981− model = nextModel;
3982−
3983− isSync
3984− ? ( draw(model),
3985− state === 2 && (state = 1)
3986− )
3987− : ( state === 0 && _Browser_requestAnimationFrame(updateIfNeeded),
3988− state = 2
3989− );
3990− };
3991−}
3992−
3993−
3994−
3995−// APPLICATION
3996−
3997−
3998−function _Browser_application(impl)
3999−{
4000− return _Browser_document({
4001− init: function(flags)
4002− {
4003− return A3(impl.init, flags, _Browser_fakeUrl(), {});
4004− },
4005− view: impl.view,
4006− update: impl.update,
4007− subscriptions: impl.subscriptions
4008− });
4009−}
4010−
4011−function _Browser_fakeUrl()
4012−{
4013− return {
4014− protocol: { $: 0 },
4015− host: 'localhost',
4016− port_: { $: 1 },
4017− path: '/',
4018− query: { $: 1 },
4019− fragment: { $: 1 }
4020− };
4021−}
4022−
4023−
4024−
4025−var _Browser_go = F2(function(key, n)
4026−{
4027− return A2($elm$core$Task$perform, $elm$core$Basics$never, _Scheduler_binding(function() {
4028− // no-op in LynxJS
4029− key();
4030− }));
4031−});
4032−
4033−var _Browser_pushUrl = F2(function(key, url)
4034−{
4035− return A2($elm$core$Task$perform, $elm$core$Basics$never, _Scheduler_binding(function() {
4036− // no-op in LynxJS
4037− key();
4038− }));
4039−});
4040−
4041−var _Browser_replaceUrl = F2(function(key, url)
4042−{
4043− return A2($elm$core$Task$perform, $elm$core$Basics$never, _Scheduler_binding(function() {
4044− // no-op in LynxJS
4045− key();
4046− }));
4047−});
4048−
4049−
4050−
4051−// GLOBAL EVENTS
4052−
4053−
4054−var _Browser_fakeNode = { addEventListener: function() {}, removeEventListener: function() {} };
4055−var _Browser_doc = typeof document !== 'undefined' ? document : _Browser_fakeNode;
4056−var _Browser_window = typeof window !== 'undefined' ? window : _Browser_fakeNode;
4057−
4058−var _Browser_on = F3(function(node, eventName, sendToSelf)
4059−{
4060− return _Scheduler_spawn(_Scheduler_binding(function(callback)
4061− {
4062− return function() {};
4063− }));
4064−});
4065−
4066−var _Browser_decodeEvent = F2(function(decoder, event)
4067−{
4068− var result = _Json_runHelp(decoder, event);
4069− return $elm$core$Result$isOk(result) ? $elm$core$Maybe$Just(result.a) : $elm$core$Maybe$Nothing;
4070−});
4071−
4072−
4073−
4074−// PAGE VISIBILITY
4075−
4076−
4077−function _Browser_visibilityInfo()
4078−{
4079− return (typeof _VirtualDom_doc.hidden !== 'undefined')
4080− ? { hidden: 'hidden', change: 'visibilitychange' }
4081− :
4082− (typeof _VirtualDom_doc.mozHidden !== 'undefined')
4083− ? { hidden: 'mozHidden', change: 'mozvisibilitychange' }
4084− :
4085− (typeof _VirtualDom_doc.msHidden !== 'undefined')
4086− ? { hidden: 'msHidden', change: 'msvisibilitychange' }
4087− :
4088− (typeof _VirtualDom_doc.webkitHidden !== 'undefined')
4089− ? { hidden: 'webkitHidden', change: 'webkitvisibilitychange' }
4090− : { hidden: 'hidden', change: 'visibilitychange' };
4091−}
4092−
4093−
4094−
4095−// ANIMATION FRAMES
4096−
4097−
4098−function _Browser_rAF()
4099−{
4100− return _Scheduler_binding(function(callback)
4101− {
4102− var id = _Browser_requestAnimationFrame(function() {
4103− callback(_Scheduler_succeed(Date.now()));
4104− });
4105−
4106− return function() {
4107− _Browser_cancelAnimationFrame(id);
4108− };
4109− });
4110−}
4111−
4112−
4113−function _Browser_now()
4114−{
4115− return _Scheduler_binding(function(callback)
4116− {
4117− callback(_Scheduler_succeed(Date.now()));
4118− });
4119−}
4120−
4121−
4122−
4123−// DOM STUFF
4124−
4125−
4126−function _Browser_withNode(id, doStuff)
4127−{
4128− return _Scheduler_binding(function(callback)
4129− {
4130− callback(_Scheduler_fail($lynxjs_elm$browser$Browser$Dom$NotFound(id)));
4131− });
4132−}
4133−
4134−
4135−function _Browser_withWindow(doStuff)
4136−{
4137− return _Scheduler_binding(function(callback)
4138− {
4139− _Browser_requestAnimationFrame(function() {
4140− callback(_Scheduler_succeed(doStuff()));
4141− });
4142− });
4143−}
4144−
4145−
4146−// FOCUS and BLUR
4147−
4148−
4149−var _Browser_call = F2(function(functionName, id)
4150−{
4151− return _Browser_withNode(id, function(node) {
4152− node[functionName]();
4153− return _Utils_Tuple0;
4154− });
4155−});
4156−
4157−
4158−
4159−// WINDOW VIEWPORT
4160−
4161−
4162−function _Browser_getViewport()
4163−{
4164− return {
4165− scene: _Browser_getScene(),
4166− viewport: {
4167− x: _Browser_window.pageXOffset,
4168− y: _Browser_window.pageYOffset,
4169− width: _Browser_doc.documentElement.clientWidth,
4170− height: _Browser_doc.documentElement.clientHeight
4171− }
4172− };
4173−}
4174−
4175−function _Browser_getScene()
4176−{
4177− var body = _Browser_doc.body;
4178− var elem = _Browser_doc.documentElement;
4179− return {
4180− width: Math.max(body.scrollWidth, body.offsetWidth, elem.scrollWidth, elem.offsetWidth, elem.clientWidth),
4181− height: Math.max(body.scrollHeight, body.offsetHeight, elem.scrollHeight, elem.offsetHeight, elem.clientHeight)
4182− };
4183−}
4184−
4185−var _Browser_setViewport = F2(function(x, y)
4186−{
4187− return _Browser_withWindow(function()
4188− {
4189− _Browser_window.scroll(x, y);
4190− return _Utils_Tuple0;
4191− });
4192−});
4193−
4194−
4195−
4196−// ELEMENT VIEWPORT
4197−
4198−
4199−function _Browser_getViewportOf(id)
4200−{
4201− return _Browser_withNode(id, function(node)
4202− {
4203− return {
4204− scene: {
4205− width: node.scrollWidth,
4206− height: node.scrollHeight
4207− },
4208− viewport: {
4209− x: node.scrollLeft,
4210− y: node.scrollTop,
4211− width: node.clientWidth,
4212− height: node.clientHeight
4213− }
4214− };
4215− });
4216−}
4217−
4218−
4219−var _Browser_setViewportOf = F3(function(id, x, y)
4220−{
4221− return _Browser_withNode(id, function(node)
4222− {
4223− node.scrollLeft = x;
4224− node.scrollTop = y;
4225− return _Utils_Tuple0;
4226− });
4227−});
4228−
4229−
4230−
4231−// ELEMENT
4232−
4233−
4234−function _Browser_getElement(id)
4235−{
4236− return _Browser_withNode(id, function(node)
4237− {
4238− var rect = node.getBoundingClientRect();
4239− var x = _Browser_window.pageXOffset;
4240− var y = _Browser_window.pageYOffset;
4241− return {
4242− scene: _Browser_getScene(),
4243− viewport: {
4244− x: x,
4245− y: y,
4246− width: _Browser_doc.documentElement.clientWidth,
4247− height: _Browser_doc.documentElement.clientHeight
4248− },
4249− element: {
4250− x: x + rect.left,
4251− y: y + rect.top,
4252− width: rect.width,
4253− height: rect.height
4254− }
4255− };
4256− });
4257−}
4258−
4259−
4260−
4261−// LOAD and RELOAD
4262−
4263−
4264−function _Browser_reload(skipCache)
4265−{
4266− return A2($elm$core$Task$perform, $elm$core$Basics$never, _Scheduler_binding(function(callback)
4267− {
4268− // no-op in LynxJS
4269− }));
4270−}
4271−
4272−function _Browser_load(url)
4273−{
4274− return A2($elm$core$Task$perform, $elm$core$Basics$never, _Scheduler_binding(function(callback)
4275− {
4276− try
4277− {
4278− // no-op in LynxJS
4279− }
4280− catch(err)
4281− {
4282− // Only Firefox can throw a NS_ERROR_MALFORMED_URI exception here.
4283− // Other browsers reload the page, so let's be consistent about that.
4284− // no-op in LynxJS
4285− }
4286− }));
4287−}
4288−
4289−
4290−
4291−var _Bitwise_and = F2(function(a, b)
4292−{
4293− return a & b;
4294−});
4295−
4296−var _Bitwise_or = F2(function(a, b)
4297−{
4298− return a | b;
4299−});
4300−
4301−var _Bitwise_xor = F2(function(a, b)
4302−{
4303− return a ^ b;
4304−});
4305−
4306−function _Bitwise_complement(a)
4307−{
4308− return ~a;
4309−};
4310−
4311−var _Bitwise_shiftLeftBy = F2(function(offset, a)
4312−{
4313− return a << offset;
4314−});
4315−
4316−var _Bitwise_shiftRightBy = F2(function(offset, a)
4317−{
4318− return a >> offset;
4319−});
4320−
4321−var _Bitwise_shiftRightZfBy = F2(function(offset, a)
4322−{
4323− return a >>> offset;
4324−});
4325−
4326−
4327−
4328−function _Time_now(millisToPosix)
4329−{
4330− return _Scheduler_binding(function(callback)
4331− {
4332− callback(_Scheduler_succeed(millisToPosix(Date.now())));
4333− });
4334−}
4335−
4336−var _Time_setInterval = F2(function(interval, task)
4337−{
4338− return _Scheduler_binding(function(callback)
4339− {
4340− var id = setInterval(function() { _Scheduler_rawSpawn(task); }, interval);
4341− return function() { clearInterval(id); };
4342− });
4343−});
4344−
4345−function _Time_here()
4346−{
4347− return _Scheduler_binding(function(callback)
4348− {
4349− callback(_Scheduler_succeed(
4350− A2($elm$time$Time$customZone, -(new Date().getTimezoneOffset()), _List_Nil)
4351− ));
4352− });
4353−}
4354−
4355−
4356−function _Time_getZoneName()
4357−{
4358− return _Scheduler_binding(function(callback)
4359− {
4360− try
4361− {
4362− var name = $elm$time$Time$Name(Intl.DateTimeFormat().resolvedOptions().timeZone);
4363− }
4364− catch (e)
4365− {
4366− var name = $elm$time$Time$Offset(new Date().getTimezoneOffset());
4367− }
4368− callback(_Scheduler_succeed(name));
4369− });
4370−}
4371−
4372−
4373−// BYTES
4374−
4375−function _Bytes_width(bytes)
4376−{
4377− return bytes.byteLength;
4378−}
4379−
4380−var _Bytes_getHostEndianness = F2(function(le, be)
4381−{
4382− return _Scheduler_binding(function(callback)
4383− {
4384− callback(_Scheduler_succeed(new Uint8Array(new Uint32Array([1]))[0] === 1 ? le : be));
4385− });
4386−});
4387−
4388−
4389−// ENCODERS
4390−
4391−function _Bytes_encode(encoder)
4392−{
4393− var mutableBytes = new DataView(new ArrayBuffer($elm$bytes$Bytes$Encode$getWidth(encoder)));
4394− $elm$bytes$Bytes$Encode$write(encoder)(mutableBytes)(0);
4395− return mutableBytes;
4396−}
4397−
4398−
4399−// SIGNED INTEGERS
4400−
4401−var _Bytes_write_i8 = F3(function(mb, i, n) { mb.setInt8(i, n); return i + 1; });
4402−var _Bytes_write_i16 = F4(function(mb, i, n, isLE) { mb.setInt16(i, n, isLE); return i + 2; });
4403−var _Bytes_write_i32 = F4(function(mb, i, n, isLE) { mb.setInt32(i, n, isLE); return i + 4; });
4404−
4405−
4406−// UNSIGNED INTEGERS
4407−
4408−var _Bytes_write_u8 = F3(function(mb, i, n) { mb.setUint8(i, n); return i + 1 ;});
4409−var _Bytes_write_u16 = F4(function(mb, i, n, isLE) { mb.setUint16(i, n, isLE); return i + 2; });
4410−var _Bytes_write_u32 = F4(function(mb, i, n, isLE) { mb.setUint32(i, n, isLE); return i + 4; });
4411−
4412−
4413−// FLOATS
4414−
4415−var _Bytes_write_f32 = F4(function(mb, i, n, isLE) { mb.setFloat32(i, n, isLE); return i + 4; });
4416−var _Bytes_write_f64 = F4(function(mb, i, n, isLE) { mb.setFloat64(i, n, isLE); return i + 8; });
4417−
4418−
4419−// BYTES
4420−
4421−var _Bytes_write_bytes = F3(function(mb, offset, bytes)
4422−{
4423− for (var i = 0, len = bytes.byteLength, limit = len - 4; i <= limit; i += 4)
4424− {
4425− mb.setUint32(offset + i, bytes.getUint32(i));
4426− }
4427− for (; i < len; i++)
4428− {
4429− mb.setUint8(offset + i, bytes.getUint8(i));
4430− }
4431− return offset + len;
4432−});
4433−
4434−
4435−// STRINGS
4436−
4437−function _Bytes_getStringWidth(string)
4438−{
4439− for (var width = 0, i = 0; i < string.length; i++)
4440− {
4441− var code = string.charCodeAt(i);
4442− width +=
4443− (code < 0x80) ? 1 :
4444− (code < 0x800) ? 2 :
4445− (code < 0xD800 || 0xDBFF < code) ? 3 : (i++, 4);
4446− }
4447− return width;
4448−}
4449−
4450−var _Bytes_write_string = F3(function(mb, offset, string)
4451−{
4452− for (var i = 0; i < string.length; i++)
4453− {
4454− var code = string.charCodeAt(i);
4455− offset +=
4456− (code < 0x80)
4457− ? (mb.setUint8(offset, code)
4458− , 1
4459− )
4460− :
4461− (code < 0x800)
4462− ? (mb.setUint16(offset, 0xC080 /* 0b1100000010000000 */
4463− | (code >>> 6 & 0x1F /* 0b00011111 */) << 8
4464− | code & 0x3F /* 0b00111111 */)
4465− , 2
4466− )
4467− :
4468− (code < 0xD800 || 0xDBFF < code)
4469− ? (mb.setUint16(offset, 0xE080 /* 0b1110000010000000 */
4470− | (code >>> 12 & 0xF /* 0b00001111 */) << 8
4471− | code >>> 6 & 0x3F /* 0b00111111 */)
4472− , mb.setUint8(offset + 2, 0x80 /* 0b10000000 */
4473− | code & 0x3F /* 0b00111111 */)
4474− , 3
4475− )
4476− :
4477− (code = (code - 0xD800) * 0x400 + string.charCodeAt(++i) - 0xDC00 + 0x10000
4478− , mb.setUint32(offset, 0xF0808080 /* 0b11110000100000001000000010000000 */
4479− | (code >>> 18 & 0x7 /* 0b00000111 */) << 24
4480− | (code >>> 12 & 0x3F /* 0b00111111 */) << 16
4481− | (code >>> 6 & 0x3F /* 0b00111111 */) << 8
4482− | code & 0x3F /* 0b00111111 */)
4483− , 4
4484− );
4485− }
4486− return offset;
4487−});
4488−
4489−
4490−// DECODER
4491−
4492−var _Bytes_decode = F2(function(decoder, bytes)
4493−{
4494− try {
4495− return $elm$core$Maybe$Just(A2(decoder, bytes, 0).b);
4496− } catch(e) {
4497− return $elm$core$Maybe$Nothing;
4498− }
4499−});
4500−
4501−var _Bytes_read_i8 = F2(function( bytes, offset) { return _Utils_Tuple2(offset + 1, bytes.getInt8(offset)); });
4502−var _Bytes_read_i16 = F3(function(isLE, bytes, offset) { return _Utils_Tuple2(offset + 2, bytes.getInt16(offset, isLE)); });
4503−var _Bytes_read_i32 = F3(function(isLE, bytes, offset) { return _Utils_Tuple2(offset + 4, bytes.getInt32(offset, isLE)); });
4504−var _Bytes_read_u8 = F2(function( bytes, offset) { return _Utils_Tuple2(offset + 1, bytes.getUint8(offset)); });
4505−var _Bytes_read_u16 = F3(function(isLE, bytes, offset) { return _Utils_Tuple2(offset + 2, bytes.getUint16(offset, isLE)); });
4506−var _Bytes_read_u32 = F3(function(isLE, bytes, offset) { return _Utils_Tuple2(offset + 4, bytes.getUint32(offset, isLE)); });
4507−var _Bytes_read_f32 = F3(function(isLE, bytes, offset) { return _Utils_Tuple2(offset + 4, bytes.getFloat32(offset, isLE)); });
4508−var _Bytes_read_f64 = F3(function(isLE, bytes, offset) { return _Utils_Tuple2(offset + 8, bytes.getFloat64(offset, isLE)); });
4509−
4510−var _Bytes_read_bytes = F3(function(len, bytes, offset)
4511−{
4512− return _Utils_Tuple2(offset + len, new DataView(bytes.buffer, bytes.byteOffset + offset, len));
4513−});
4514−
4515−var _Bytes_read_string = F3(function(len, bytes, offset)
4516−{
4517− var string = '';
4518− var end = offset + len;
4519− for (; offset < end;)
4520− {
4521− var byte = bytes.getUint8(offset++);
4522− string +=
4523− (byte < 128)
4524− ? String.fromCharCode(byte)
4525− :
4526− ((byte & 0xE0 /* 0b11100000 */) === 0xC0 /* 0b11000000 */)
4527− ? String.fromCharCode((byte & 0x1F /* 0b00011111 */) << 6 | bytes.getUint8(offset++) & 0x3F /* 0b00111111 */)
4528− :
4529− ((byte & 0xF0 /* 0b11110000 */) === 0xE0 /* 0b11100000 */)
4530− ? String.fromCharCode(
4531− (byte & 0xF /* 0b00001111 */) << 12
4532− | (bytes.getUint8(offset++) & 0x3F /* 0b00111111 */) << 6
4533− | bytes.getUint8(offset++) & 0x3F /* 0b00111111 */
4534− )
4535− :
4536− (byte =
4537− ((byte & 0x7 /* 0b00000111 */) << 18
4538− | (bytes.getUint8(offset++) & 0x3F /* 0b00111111 */) << 12
4539− | (bytes.getUint8(offset++) & 0x3F /* 0b00111111 */) << 6
4540− | bytes.getUint8(offset++) & 0x3F /* 0b00111111 */
4541− ) - 0x10000
4542− , String.fromCharCode(Math.floor(byte / 0x400) + 0xD800, byte % 0x400 + 0xDC00)
4543− );
4544− }
4545− return _Utils_Tuple2(offset, string);
4546−});
4547−
4548−var _Bytes_decodeFailure = F2(function() { throw 0; });
4549−
4550−
4551−
4552−// SEND REQUEST
4553−
4554−var _Http_toTask = F3(function(router, toTask, request)
4555−{
4556− return _Scheduler_binding(function(callback)
4557− {
4558− function done(response) {
4559− callback(toTask(request.expect.a(response)));
4560− }
4561−
4562− var controller = new AbortController();
4563− var isAborted = false;
4564−
4565− // Timeout
4566− var timeoutMs = request.timeout.a || 0;
4567− var timeoutId = 0;
4568− if (timeoutMs > 0)
4569− {
4570− timeoutId = setTimeout(function() {
4571− isAborted = true;
4572− controller.abort();
4573− done($lynxjs_elm$http$Http$Timeout_);
4574− }, timeoutMs);
4575− }
4576−
4577− // Headers
4578− var headers = {};
4579− for (var h = request.headers; h.b; h = h.b) // WHILE_CONS
4580− {
4581− headers[h.a.a] = h.a.b;
4582− }
4583− if (request.body.a)
4584− {
4585− headers['Content-Type'] = request.body.a;
4586− }
4587−
4588− // Options
4589− var options = {
4590− method: request.method,
4591− headers: headers,
4592− signal: controller.signal
4593− };
4594− if (request.body.b)
4595− {
4596− options.body = request.body.b;
4597− }
4598−
4599− // Validate URL
4600− var url = request.url;
4601− try { new URL(url, 'http://localhost'); }
4602− catch (e) { return done($lynxjs_elm$http$Http$BadUrl_(url)); }
4603−
4604− // Fetch
4605− fetch(url, options).then(function(response)
4606− {
4607− var bodyPromise = request.expect.b === 'arraybuffer'
4608− ? response.arrayBuffer()
4609− : response.text();
4610−
4611− return bodyPromise.then(function(body)
4612− {
4613− if (timeoutId) { clearTimeout(timeoutId); }
4614−
4615− var metadata = {
4616− url: response.url || url,
4617− statusCode: response.status,
4618− statusText: response.statusText,
4619− headers: _Http_parseHeaders(response.headers)
4620− };
4621−
4622− var toBody = request.expect.c;
4623− done(A2(
4624− 200 <= response.status && response.status < 300 ? $lynxjs_elm$http$Http$GoodStatus_ : $lynxjs_elm$http$Http$BadStatus_,
4625− metadata,
4626− toBody(body)
4627− ));
4628− });
4629− }).catch(function(e)
4630− {
4631− if (timeoutId) { clearTimeout(timeoutId); }
4632− if (!isAborted)
4633− {
4634− done($lynxjs_elm$http$Http$NetworkError_);
4635− }
4636− });
4637−
4638− // Tracker
4639− $elm$core$Maybe$isJust(request.tracker) && _Http_track(router, controller, request.tracker.a);
4640−
4641− return function() { isAborted = true; controller.abort(); };
4642− });
4643−});
4644−
4645−
4646−// HEADERS
4647−
4648−function _Http_parseHeaders(headers)
4649−{
4650− var dict = $elm$core$Dict$empty;
4651− headers.forEach(function(value, key)
4652− {
4653− dict = A3($elm$core$Dict$update, key, function(oldValue) {
4654− return $elm$core$Maybe$Just($elm$core$Maybe$isJust(oldValue)
4655− ? value + ', ' + oldValue.a
4656− : value
4657− );
4658− }, dict);
4659− });
4660− return dict;
4661−}
4662−
4663−
4664−// EXPECT
4665−
4666−var _Http_expect = F3(function(type, toBody, toValue)
4667−{
4668− return {
4669− $: 0,
4670− b: type,
4671− c: toBody,
4672− a: toValue
4673− };
4674−});
4675−
4676−var _Http_mapExpect = F2(function(func, expect)
4677−{
4678− return {
4679− $: 0,
4680− b: expect.b,
4681− c: expect.c,
4682− a: function(x) { return func(expect.a(x)); }
4683− };
4684−});
4685−
4686−function _Http_toDataView(arrayBuffer)
4687−{
4688− return _Bytes_wrap(new DataView(arrayBuffer));
4689−}
4690−
4691−
4692−// BODY and PARTS
4693−
4694−var _Http_emptyBody = { $: 0 };
4695−var _Http_pair = F2(function(a, b) { return { $: 0, a: a, b: b }; });
4696−
4697−function _Http_toFormData(parts)
4698−{
4699− for (var formData = new FormData(); parts.b; parts = parts.b) // WHILE_CONS
4700− {
4701− var part = parts.a;
4702− formData.append(part.a, part.b);
4703− }
4704− return formData;
4705−}
4706−
4707−var _Http_bytesToBlob = F2(function(mime, bytes)
4708−{
4709− return new Blob([bytes], { type: mime });
4710−});
4711−
4712−
4713−// PROGRESS
4714−
4715−function _Http_track(router, controller, tracker)
4716−{
4717− // fetch does not support upload/download progress events natively.
4718− // Progress subscriptions will not fire, but the tracker still enables
4719− // cancellation via Http.cancel.
4720−}
4721−
4722−
4723−
4724−// NOBLE/CURVES BUNDLE (https://github.com/paulmillr/noble-curves, MIT License)
4725−// Embedded: @noble/curves 2.0.1 - secp256k1, ed25519, x25519, P-256
4726−
4727−var _Crypto_noble=(()=>{var ie=Object.defineProperty;var Nn=Object.getOwnPropertyDescriptor;var Dn=Object.getOwnPropertyNames;var Zn=Object.prototype.hasOwnProperty;var Cn=(e,t)=>{for(var r in t)ie(e,r,{get:t[r],enumerable:!0})},Vn=(e,t,r,n)=>{if(t&&typeof t=="object"||typeof t=="function")for(let o of Dn(t))!Zn.call(e,o)&&o!==r&&ie(e,o,{get:()=>t[o],enumerable:!(n=Nn(t,o))||n.enumerable});return e};var Mn=e=>Vn(ie({},"a",{value:!0}),e);var Er={};Cn(Er,{bytesToHex:()=>nt,ed25519:()=>Ln,hexToBytes:()=>rt,p256:()=>qn,secp256k1:()=>Sn,x25519:()=>Un});function wt(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&e.constructor.name==="Uint8Array"}function ot(e,t=""){if(!Number.isSafeInteger(e)||e<0){let r=t&&`"${t}" `;throw new Error(`${r}expected integer >= 0, got ${e}`)}}function N(e,t,r=""){let n=wt(e),o=e?.length,f=t!==void 0;if(!n||f&&o!==t){let i=r&&`"${r}" `,c=f?` of length ${t}`:"",s=n?`length=${o}`:`type=${typeof e}`;throw new Error(i+"expected Uint8Array"+c+", got "+s)}return e}function Wt(e){if(typeof e!="function"||typeof e.create!="function")throw new Error("Hash must wrapped by utils.createHasher");ot(e.outputLen),ot(e.blockLen)}function Rt(e,t=!0){if(e.destroyed)throw new Error("Hash instance has been destroyed");if(t&&e.finished)throw new Error("Hash#digest() has already been called")}function Ve(e,t){N(e,void 0,"digestInto() output");let r=t.outputLen;if(e.length<r)throw new Error('"digestInto() output" expected to be of length >='+r)}function at(...e){for(let t=0;t<e.length;t++)e[t].fill(0)}function $t(e){return new DataView(e.buffer,e.byteOffset,e.byteLength)}function et(e,t){return e<<32-t|e>>>t}var Me=typeof Uint8Array.from([]).toHex=="function"&&typeof Uint8Array.fromHex=="function",kn=Array.from({length:256},(e,t)=>t.toString(16).padStart(2,"0"));function nt(e){if(N(e),Me)return e.toHex();let t="";for(let r=0;r<e.length;r++)t+=kn[e[r]];return t}var ct={_0:48,_9:57,A:65,F:70,a:97,f:102};function Ce(e){if(e>=ct._0&&e<=ct._9)return e-ct._0;if(e>=ct.A&&e<=ct.F)return e-(ct.A-10);if(e>=ct.a&&e<=ct.f)return e-(ct.a-10)}function rt(e){if(typeof e!="string")throw new Error("hex string expected, got "+typeof e);if(Me)return Uint8Array.fromHex(e);let t=e.length,r=t/2;if(t%2)throw new Error("hex string expected, got unpadded hex of length "+t);let n=new Uint8Array(r);for(let o=0,f=0;o<r;o++,f+=2){let i=Ce(e.charCodeAt(f)),c=Ce(e.charCodeAt(f+1));if(i===void 0||c===void 0){let s=e[f]+e[f+1];throw new Error('hex string expected, got non-hex character "'+s+'" at index '+f)}n[o]=i*16+c}return n}function tt(...e){let t=0;for(let n=0;n<e.length;n++){let o=e[n];N(o),t+=o.length}let r=new Uint8Array(t);for(let n=0,o=0;n<e.length;n++){let f=e[n];r.set(f,o),o+=f.length}return r}function ce(e,t={}){let r=(o,f)=>e(f).update(o).digest(),n=e(void 0);return r.outputLen=n.outputLen,r.blockLen=n.blockLen,r.create=o=>e(o),Object.assign(r,t),Object.freeze(r)}function ht(e=32){let t=typeof globalThis=="object"?globalThis.crypto:null;if(typeof t?.getRandomValues!="function")throw new Error("crypto.getRandomValues must be defined");return t.getRandomValues(new Uint8Array(e))}var ae=e=>({oid:Uint8Array.from([6,9,96,134,72,1,101,3,4,2,e])});function ke(e,t,r){return e&t^~e&r}function Ye(e,t,r){return e&t^e&r^t&r}var Ct=class{blockLen;outputLen;padOffset;isLE;buffer;view;finished=!1;length=0;pos=0;destroyed=!1;constructor(t,r,n,o){this.blockLen=t,this.outputLen=r,this.padOffset=n,this.isLE=o,this.buffer=new Uint8Array(t),this.view=$t(this.buffer)}update(t){Rt(this),N(t);let{view:r,buffer:n,blockLen:o}=this,f=t.length;for(let i=0;i<f;){let c=Math.min(o-this.pos,f-i);if(c===o){let s=$t(t);for(;o<=f-i;i+=o)this.process(s,i);continue}n.set(t.subarray(i,i+c),this.pos),this.pos+=c,i+=c,this.pos===o&&(this.process(r,0),this.pos=0)}return this.length+=t.length,this.roundClean(),this}digestInto(t){Rt(this),Ve(t,this),this.finished=!0;let{buffer:r,view:n,blockLen:o,isLE:f}=this,{pos:i}=this;r[i++]=128,at(this.buffer.subarray(i)),this.padOffset>o-i&&(this.process(n,0),i=0);for(let x=i;x<o;x++)r[x]=0;n.setBigUint64(o-8,BigInt(this.length*8),f),this.process(n,0);let c=$t(t),s=this.outputLen;if(s%4)throw new Error("_sha2: outputLen must be aligned to 32bit");let l=s/4,h=this.get();if(l>h.length)throw new Error("_sha2: outputLen bigger than state");for(let x=0;x<l;x++)c.setUint32(4*x,h[x],f)}digest(){let{buffer:t,outputLen:r}=this;this.digestInto(t);let n=t.slice(0,r);return this.destroy(),n}_cloneInto(t){t||=new this.constructor,t.set(...this.get());let{blockLen:r,buffer:n,length:o,finished:f,destroyed:i,pos:c}=this;return t.destroyed=i,t.finished=f,t.length=o,t.pos=c,o%r&&t.buffer.set(n),t}clone(){return this._cloneInto()}},dt=Uint32Array.from([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]);var Q=Uint32Array.from([1779033703,4089235720,3144134277,2227873595,1013904242,4271175723,2773480762,1595750129,1359893119,2917565137,2600822924,725511199,528734635,4215389547,1541459225,327033209]);var Pt=BigInt(4294967295),Xe=BigInt(32);function Yn(e,t=!1){return t?{h:Number(e&Pt),l:Number(e>>Xe&Pt)}:{h:Number(e>>Xe&Pt)|0,l:Number(e&Pt)|0}}function Ge(e,t=!1){let r=e.length,n=new Uint32Array(r),o=new Uint32Array(r);for(let f=0;f<r;f++){let{h:i,l:c}=Yn(e[f],t);[n[f],o[f]]=[i,c]}return[n,o]}var de=(e,t,r)=>e>>>r,ue=(e,t,r)=>e<<32-r|t>>>r,Bt=(e,t,r)=>e>>>r|t<<32-r,Et=(e,t,r)=>e<<32-r|t>>>r,Vt=(e,t,r)=>e<<64-r|t>>>r-32,Mt=(e,t,r)=>e>>>r-32|t<<64-r;function ft(e,t,r,n){let o=(t>>>0)+(n>>>0);return{h:e+r+(o/2**32|0)|0,l:o|0}}var Ke=(e,t,r)=>(e>>>0)+(t>>>0)+(r>>>0),je=(e,t,r,n)=>t+r+n+(e/2**32|0)|0,ze=(e,t,r,n)=>(e>>>0)+(t>>>0)+(r>>>0)+(n>>>0),We=(e,t,r,n,o)=>t+r+n+o+(e/2**32|0)|0,$e=(e,t,r,n,o)=>(e>>>0)+(t>>>0)+(r>>>0)+(n>>>0)+(o>>>0),Pe=(e,t,r,n,o,f)=>t+r+n+o+f+(e/2**32|0)|0;var Gn=Uint32Array.from([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),bt=new Uint32Array(64),le=class extends Ct{constructor(t){super(64,t,8,!1)}get(){let{A:t,B:r,C:n,D:o,E:f,F:i,G:c,H:s}=this;return[t,r,n,o,f,i,c,s]}set(t,r,n,o,f,i,c,s){this.A=t|0,this.B=r|0,this.C=n|0,this.D=o|0,this.E=f|0,this.F=i|0,this.G=c|0,this.H=s|0}process(t,r){for(let x=0;x<16;x++,r+=4)bt[x]=t.getUint32(r,!1);for(let x=16;x<64;x++){let v=bt[x-15],E=bt[x-2],w=et(v,7)^et(v,18)^v>>>3,L=et(E,17)^et(E,19)^E>>>10;bt[x]=L+bt[x-7]+w+bt[x-16]|0}let{A:n,B:o,C:f,D:i,E:c,F:s,G:l,H:h}=this;for(let x=0;x<64;x++){let v=et(c,6)^et(c,11)^et(c,25),E=h+v+ke(c,s,l)+Gn[x]+bt[x]|0,L=(et(n,2)^et(n,13)^et(n,22))+Ye(n,o,f)|0;h=l,l=s,s=c,c=i+E|0,i=f,f=o,o=n,n=E+L|0}n=n+this.A|0,o=o+this.B|0,f=f+this.C|0,i=i+this.D|0,c=c+this.E|0,s=s+this.F|0,l=l+this.G|0,h=h+this.H|0,this.set(n,o,f,i,c,s,l,h)}roundClean(){at(bt)}destroy(){this.set(0,0,0,0,0,0,0,0),at(this.buffer)}},he=class extends le{A=dt[0]|0;B=dt[1]|0;C=dt[2]|0;D=dt[3]|0;E=dt[4]|0;F=dt[5]|0;G=dt[6]|0;H=dt[7]|0;constructor(){super(32)}};var Qe=Ge(["0x428a2f98d728ae22","0x7137449123ef65cd","0xb5c0fbcfec4d3b2f","0xe9b5dba58189dbbc","0x3956c25bf348b538","0x59f111f1b605d019","0x923f82a4af194f9b","0xab1c5ed5da6d8118","0xd807aa98a3030242","0x12835b0145706fbe","0x243185be4ee4b28c","0x550c7dc3d5ffb4e2","0x72be5d74f27b896f","0x80deb1fe3b1696b1","0x9bdc06a725c71235","0xc19bf174cf692694","0xe49b69c19ef14ad2","0xefbe4786384f25e3","0x0fc19dc68b8cd5b5","0x240ca1cc77ac9c65","0x2de92c6f592b0275","0x4a7484aa6ea6e483","0x5cb0a9dcbd41fbd4","0x76f988da831153b5","0x983e5152ee66dfab","0xa831c66d2db43210","0xb00327c898fb213f","0xbf597fc7beef0ee4","0xc6e00bf33da88fc2","0xd5a79147930aa725","0x06ca6351e003826f","0x142929670a0e6e70","0x27b70a8546d22ffc","0x2e1b21385c26c926","0x4d2c6dfc5ac42aed","0x53380d139d95b3df","0x650a73548baf63de","0x766a0abb3c77b2a8","0x81c2c92e47edaee6","0x92722c851482353b","0xa2bfe8a14cf10364","0xa81a664bbc423001","0xc24b8b70d0f89791","0xc76c51a30654be30","0xd192e819d6ef5218","0xd69906245565a910","0xf40e35855771202a","0x106aa07032bbd1b8","0x19a4c116b8d2d0c8","0x1e376c085141ab53","0x2748774cdf8eeb99","0x34b0bcb5e19b48a8","0x391c0cb3c5c95a63","0x4ed8aa4ae3418acb","0x5b9cca4f7763e373","0x682e6ff3d6b2b8a3","0x748f82ee5defb2fc","0x78a5636f43172f60","0x84c87814a1f0ab72","0x8cc702081a6439ec","0x90befffa23631e28","0xa4506cebde82bde9","0xbef9a3f7b2c67915","0xc67178f2e372532b","0xca273eceea26619c","0xd186b8c721c0c207","0xeada7dd6cde0eb1e","0xf57d4f7fee6ed178","0x06f067aa72176fba","0x0a637dc5a2c898a6","0x113f9804bef90dae","0x1b710b35131c471b","0x28db77f523047d84","0x32caab7b40c72493","0x3c9ebe0a15c9bebc","0x431d67c49c100d4c","0x4cc5d4becb3e42b6","0x597f299cfc657e2a","0x5fcb6fab3ad6faec","0x6c44198c4a475817"].map(e=>BigInt(e))),Kn=Qe[0],jn=Qe[1],xt=new Uint32Array(80),mt=new Uint32Array(80),be=class extends Ct{constructor(t){super(128,t,16,!1)}get(){let{Ah:t,Al:r,Bh:n,Bl:o,Ch:f,Cl:i,Dh:c,Dl:s,Eh:l,El:h,Fh:x,Fl:v,Gh:E,Gl:w,Hh:L,Hl:y}=this;return[t,r,n,o,f,i,c,s,l,h,x,v,E,w,L,y]}set(t,r,n,o,f,i,c,s,l,h,x,v,E,w,L,y){this.Ah=t|0,this.Al=r|0,this.Bh=n|0,this.Bl=o|0,this.Ch=f|0,this.Cl=i|0,this.Dh=c|0,this.Dl=s|0,this.Eh=l|0,this.El=h|0,this.Fh=x|0,this.Fl=v|0,this.Gh=E|0,this.Gl=w|0,this.Hh=L|0,this.Hl=y|0}process(t,r){for(let p=0;p<16;p++,r+=4)xt[p]=t.getUint32(r),mt[p]=t.getUint32(r+=4);for(let p=16;p<80;p++){let T=xt[p-15]|0,D=mt[p-15]|0,V=Bt(T,D,1)^Bt(T,D,8)^de(T,D,7),k=Et(T,D,1)^Et(T,D,8)^ue(T,D,7),B=xt[p-2]|0,b=mt[p-2]|0,q=Bt(B,b,19)^Vt(B,b,61)^de(B,b,6),Z=Et(B,b,19)^Mt(B,b,61)^ue(B,b,6),O=ze(k,Z,mt[p-7],mt[p-16]),u=We(O,V,q,xt[p-7],xt[p-16]);xt[p]=u|0,mt[p]=O|0}let{Ah:n,Al:o,Bh:f,Bl:i,Ch:c,Cl:s,Dh:l,Dl:h,Eh:x,El:v,Fh:E,Fl:w,Gh:L,Gl:y,Hh:g,Hl:R}=this;for(let p=0;p<80;p++){let T=Bt(x,v,14)^Bt(x,v,18)^Vt(x,v,41),D=Et(x,v,14)^Et(x,v,18)^Mt(x,v,41),V=x&E^~x&L,k=v&w^~v&y,B=$e(R,D,k,jn[p],mt[p]),b=Pe(B,g,T,V,Kn[p],xt[p]),q=B|0,Z=Bt(n,o,28)^Vt(n,o,34)^Vt(n,o,39),O=Et(n,o,28)^Mt(n,o,34)^Mt(n,o,39),u=n&f^n&c^f&c,a=o&i^o&s^i&s;g=L|0,R=y|0,L=E|0,y=w|0,E=x|0,w=v|0,{h:x,l:v}=ft(l|0,h|0,b|0,q|0),l=c|0,h=s|0,c=f|0,s=i|0,f=n|0,i=o|0;let d=Ke(q,O,a);n=je(d,b,Z,u),o=d|0}({h:n,l:o}=ft(this.Ah|0,this.Al|0,n|0,o|0)),{h:f,l:i}=ft(this.Bh|0,this.Bl|0,f|0,i|0),{h:c,l:s}=ft(this.Ch|0,this.Cl|0,c|0,s|0),{h:l,l:h}=ft(this.Dh|0,this.Dl|0,l|0,h|0),{h:x,l:v}=ft(this.Eh|0,this.El|0,x|0,v|0),{h:E,l:w}=ft(this.Fh|0,this.Fl|0,E|0,w|0),{h:L,l:y}=ft(this.Gh|0,this.Gl|0,L|0,y|0),{h:g,l:R}=ft(this.Hh|0,this.Hl|0,g|0,R|0),this.set(n,o,f,i,c,s,l,h,x,v,E,w,L,y,g,R)}roundClean(){at(xt,mt)}destroy(){at(this.buffer),this.set(0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0)}},xe=class extends be{Ah=Q[0]|0;Al=Q[1]|0;Bh=Q[2]|0;Bl=Q[3]|0;Ch=Q[4]|0;Cl=Q[5]|0;Dh=Q[6]|0;Dl=Q[7]|0;Eh=Q[8]|0;El=Q[9]|0;Fh=Q[10]|0;Fl=Q[11]|0;Gh=Q[12]|0;Gl=Q[13]|0;Hh=Q[14]|0;Hl=Q[15]|0;constructor(){super(64)}};var Qt=ce(()=>new he,ae(1));var me=ce(()=>new xe,ae(3));var pe=BigInt(0),ge=BigInt(1);function ut(e,t=""){if(typeof e!="boolean"){let r=t&&`"${t}" `;throw new Error(r+"expected boolean, got type="+typeof e)}return e}function Je(e){if(typeof e=="bigint"){if(!Jt(e))throw new Error("positive bigint expected, got "+e)}else ot(e);return e}function kt(e){let t=Je(e).toString(16);return t.length&1?"0"+t:t}function Fe(e){if(typeof e!="string")throw new Error("hex string expected, got "+typeof e);return e===""?pe:BigInt("0x"+e)}function Ot(e){return Fe(nt(e))}function st(e){return Fe(nt(vt(N(e)).reverse()))}function Ft(e,t){ot(t),e=Je(e);let r=rt(e.toString(16).padStart(t*2,"0"));if(r.length!==t)throw new Error("number too large");return r}function Yt(e,t){return Ft(e,t).reverse()}function vt(e){return Uint8Array.from(e)}var Jt=e=>typeof e=="bigint"&&pe<=e;function zn(e,t,r){return Jt(e)&&Jt(t)&&Jt(r)&&t<=e&&e<r}function gt(e,t,r,n){if(!zn(t,r,n))throw new Error("expected valid "+e+": "+r+" <= n < "+n+", got "+t)}function ye(e){let t;for(t=0;e>pe;e>>=ge,t+=1);return t}var Xt=e=>(ge<<BigInt(e))-ge;function tn(e,t,r){if(ot(e,"hashLen"),ot(t,"qByteLen"),typeof r!="function")throw new Error("hmacFn must be a function");let n=y=>new Uint8Array(y),o=Uint8Array.of(),f=Uint8Array.of(0),i=Uint8Array.of(1),c=1e3,s=n(e),l=n(e),h=0,x=()=>{s.fill(1),l.fill(0),h=0},v=(...y)=>r(l,tt(s,...y)),E=(y=o)=>{l=v(f,y),s=v(),y.length!==0&&(l=v(i,y),s=v())},w=()=>{if(h++>=c)throw new Error("drbg: tried max amount of iterations");let y=0,g=[];for(;y<t;){s=v();let R=s.slice();g.push(R),y+=s.length}return tt(...g)};return(y,g)=>{x(),E(y);let R;for(;!(R=g(w()));)E();return x(),R}}function it(e,t={},r={}){if(!e||typeof e!="object")throw new Error("expected valid options object");function n(f,i,c){let s=e[f];if(c&&s===void 0)return;let l=typeof s;if(l!==i||s===null)throw new Error(`param "${f}" is invalid: expected ${i}, got ${l}`)}let o=(f,i)=>Object.entries(f).forEach(([c,s])=>n(c,s,i));o(t,!1),o(r,!0)}function Ht(e){let t=new WeakMap;return(r,...n)=>{let o=t.get(r);if(o!==void 0)return o;let f=e(r,...n);return t.set(r,f),f}}var J=BigInt(0),$=BigInt(1),St=BigInt(2),rn=BigInt(3),on=BigInt(4),fn=BigInt(5),Wn=BigInt(7),sn=BigInt(8),$n=BigInt(9),cn=BigInt(16);function j(e,t){let r=e%t;return r>=J?r:t+r}function G(e,t,r){let n=e;for(;t-- >J;)n*=n,n%=r;return n}function en(e,t){if(e===J)throw new Error("invert: expected non-zero number");if(t<=J)throw new Error("invert: expected positive modulus, got "+t);let r=j(e,t),n=t,o=J,f=$,i=$,c=J;for(;r!==J;){let l=n/r,h=n%r,x=o-i*l,v=f-c*l;n=r,r=h,o=i,f=c,i=x,c=v}if(n!==$)throw new Error("invert: does not exist");return j(o,t)}function Be(e,t,r){if(!e.eql(e.sqr(t),r))throw new Error("Cannot find square root")}function an(e,t){let r=(e.ORDER+$)/on,n=e.pow(t,r);return Be(e,n,t),n}function Pn(e,t){let r=(e.ORDER-fn)/sn,n=e.mul(t,St),o=e.pow(n,r),f=e.mul(t,o),i=e.mul(e.mul(f,St),o),c=e.mul(f,e.sub(i,e.ONE));return Be(e,c,t),c}function Qn(e){let t=Tt(e),r=dn(e),n=r(t,t.neg(t.ONE)),o=r(t,n),f=r(t,t.neg(n)),i=(e+Wn)/cn;return(c,s)=>{let l=c.pow(s,i),h=c.mul(l,n),x=c.mul(l,o),v=c.mul(l,f),E=c.eql(c.sqr(h),s),w=c.eql(c.sqr(x),s);l=c.cmov(l,h,E),h=c.cmov(v,x,w);let L=c.eql(c.sqr(h),s),y=c.cmov(l,h,L);return Be(c,y,s),y}}function dn(e){if(e<rn)throw new Error("sqrt is not defined for small field");let t=e-$,r=0;for(;t%St===J;)t/=St,r++;let n=St,o=Tt(e);for(;nn(o,n)===1;)if(n++>1e3)throw new Error("Cannot find square root: probably non-prime P");if(r===1)return an;let f=o.pow(n,t),i=(t+$)/St;return function(s,l){if(s.is0(l))return l;if(nn(s,l)!==1)throw new Error("Cannot find square root");let h=r,x=s.mul(s.ONE,f),v=s.pow(l,t),E=s.pow(l,i);for(;!s.eql(v,s.ONE);){if(s.is0(v))return s.ZERO;let w=1,L=s.sqr(v);for(;!s.eql(L,s.ONE);)if(w++,L=s.sqr(L),w===h)throw new Error("Cannot find square root");let y=$<<BigInt(h-w-1),g=s.pow(x,y);h=w,x=s.sqr(g),v=s.mul(v,x),E=s.mul(E,g)}return E}}function Jn(e){return e%on===rn?an:e%sn===fn?Pn:e%cn===$n?Qn(e):dn(e)}var un=(e,t)=>(j(e,t)&$)===$,Fn=["create","isValid","is0","neg","inv","sqrt","sqr","eql","add","sub","mul","pow","div","addN","subN","mulN","sqrN"];function Ee(e){let t={ORDER:"bigint",BYTES:"number",BITS:"number"},r=Fn.reduce((n,o)=>(n[o]="function",n),t);return it(e,r),e}function tr(e,t,r){if(r<J)throw new Error("invalid exponent, negatives unsupported");if(r===J)return e.ONE;if(r===$)return t;let n=e.ONE,o=t;for(;r>J;)r&$&&(n=e.mul(n,o)),o=e.sqr(o),r>>=$;return n}function Gt(e,t,r=!1){let n=new Array(t.length).fill(r?e.ZERO:void 0),o=t.reduce((i,c,s)=>e.is0(c)?i:(n[s]=i,e.mul(i,c)),e.ONE),f=e.inv(o);return t.reduceRight((i,c,s)=>e.is0(c)?i:(n[s]=e.mul(i,n[s]),e.mul(i,c)),f),n}function nn(e,t){let r=(e.ORDER-$)/St,n=e.pow(t,r),o=e.eql(n,e.ONE),f=e.eql(n,e.ZERO),i=e.eql(n,e.neg(e.ONE));if(!o&&!f&&!i)throw new Error("invalid Legendre symbol result");return o?1:f?0:-1}function er(e,t){t!==void 0&&ot(t);let r=t!==void 0?t:e.toString(2).length,n=Math.ceil(r/8);return{nBitLength:r,nByteLength:n}}var we=class{ORDER;BITS;BYTES;isLE;ZERO=J;ONE=$;_lengths;_sqrt;_mod;constructor(t,r={}){if(t<=J)throw new Error("invalid field: expected ORDER > 0, got "+t);let n;this.isLE=!1,r!=null&&typeof r=="object"&&(typeof r.BITS=="number"&&(n=r.BITS),typeof r.sqrt=="function"&&(this.sqrt=r.sqrt),typeof r.isLE=="boolean"&&(this.isLE=r.isLE),r.allowedLengths&&(this._lengths=r.allowedLengths?.slice()),typeof r.modFromBytes=="boolean"&&(this._mod=r.modFromBytes));let{nBitLength:o,nByteLength:f}=er(t,n);if(f>2048)throw new Error("invalid field: expected ORDER of <= 2048 bytes");this.ORDER=t,this.BITS=o,this.BYTES=f,this._sqrt=void 0,Object.preventExtensions(this)}create(t){return j(t,this.ORDER)}isValid(t){if(typeof t!="bigint")throw new Error("invalid field element: expected bigint, got "+typeof t);return J<=t&&t<this.ORDER}is0(t){return t===J}isValidNot0(t){return!this.is0(t)&&this.isValid(t)}isOdd(t){return(t&$)===$}neg(t){return j(-t,this.ORDER)}eql(t,r){return t===r}sqr(t){return j(t*t,this.ORDER)}add(t,r){return j(t+r,this.ORDER)}sub(t,r){return j(t-r,this.ORDER)}mul(t,r){return j(t*r,this.ORDER)}pow(t,r){return tr(this,t,r)}div(t,r){return j(t*en(r,this.ORDER),this.ORDER)}sqrN(t){return t*t}addN(t,r){return t+r}subN(t,r){return t-r}mulN(t,r){return t*r}inv(t){return en(t,this.ORDER)}sqrt(t){return this._sqrt||(this._sqrt=Jn(this.ORDER)),this._sqrt(this,t)}toBytes(t){return this.isLE?Yt(t,this.BYTES):Ft(t,this.BYTES)}fromBytes(t,r=!1){N(t);let{_lengths:n,BYTES:o,isLE:f,ORDER:i,_mod:c}=this;if(n){if(!n.includes(t.length)||t.length>o)throw new Error("Field.fromBytes: expected "+n+" bytes, got "+t.length);let l=new Uint8Array(o);l.set(t,f?0:l.length-t.length),t=l}if(t.length!==o)throw new Error("Field.fromBytes: expected "+o+" bytes, got "+t.length);let s=f?st(t):Ot(t);if(c&&(s=j(s,i)),!r&&!this.isValid(s))throw new Error("invalid field element: outside of range 0..ORDER");return s}invertBatch(t){return Gt(this,t)}cmov(t,r,n){return n?r:t}};function Tt(e,t={}){return new we(e,t)}function ln(e){if(typeof e!="bigint")throw new Error("field order must be bigint");let t=e.toString(2).length;return Math.ceil(t/8)}function ve(e){let t=ln(e);return t+Math.ceil(t/2)}function Se(e,t,r=!1){N(e);let n=e.length,o=ln(t),f=ve(t);if(n<16||n<f||n>1024)throw new Error("expected "+f+"-1024 bytes of input, got "+n);let i=r?st(e):Ot(e),c=j(i,t-$)+$;return r?Yt(c,o):Ft(c,o)}var Lt=BigInt(0),_t=BigInt(1);function Kt(e,t){let r=t.negate();return e?r:t}function It(e,t){let r=Gt(e.Fp,t.map(n=>n.Z));return t.map((n,o)=>e.fromAffine(n.toAffine(r[o])))}function mn(e,t){if(!Number.isSafeInteger(e)||e<=0||e>t)throw new Error("invalid window size, expected [1.."+t+"], got W="+e)}function _e(e,t){mn(e,t);let r=Math.ceil(t/e)+1,n=2**(e-1),o=2**e,f=Xt(e),i=BigInt(e);return{windows:r,windowSize:n,mask:f,maxNumber:o,shiftBy:i}}function hn(e,t,r){let{windowSize:n,mask:o,maxNumber:f,shiftBy:i}=r,c=Number(e&o),s=e>>i;c>n&&(c-=f,s+=_t);let l=t*n,h=l+Math.abs(c)-1,x=c===0,v=c<0,E=t%2!==0;return{nextN:s,offset:h,isZero:x,isNeg:v,isNegF:E,offsetF:l}}var Ie=new WeakMap,gn=new WeakMap;function Ae(e){return gn.get(e)||1}function bn(e){if(e!==Lt)throw new Error("invalid wNAF")}var Ut=class{BASE;ZERO;Fn;bits;constructor(t,r){this.BASE=t.BASE,this.ZERO=t.ZERO,this.Fn=t.Fn,this.bits=r}_unsafeLadder(t,r,n=this.ZERO){let o=t;for(;r>Lt;)r&_t&&(n=n.add(o)),o=o.double(),r>>=_t;return n}precomputeWindow(t,r){let{windows:n,windowSize:o}=_e(r,this.bits),f=[],i=t,c=i;for(let s=0;s<n;s++){c=i,f.push(c);for(let l=1;l<o;l++)c=c.add(i),f.push(c);i=c.double()}return f}wNAF(t,r,n){if(!this.Fn.isValid(n))throw new Error("invalid scalar");let o=this.ZERO,f=this.BASE,i=_e(t,this.bits);for(let c=0;c<i.windows;c++){let{nextN:s,offset:l,isZero:h,isNeg:x,isNegF:v,offsetF:E}=hn(n,c,i);n=s,h?f=f.add(Kt(v,r[E])):o=o.add(Kt(x,r[l]))}return bn(n),{p:o,f}}wNAFUnsafe(t,r,n,o=this.ZERO){let f=_e(t,this.bits);for(let i=0;i<f.windows&&n!==Lt;i++){let{nextN:c,offset:s,isZero:l,isNeg:h}=hn(n,i,f);if(n=c,!l){let x=r[s];o=o.add(h?x.negate():x)}}return bn(n),o}getPrecomputes(t,r,n){let o=Ie.get(r);return o||(o=this.precomputeWindow(r,t),t!==1&&(typeof n=="function"&&(o=n(o)),Ie.set(r,o))),o}cached(t,r,n){let o=Ae(t);return this.wNAF(o,this.getPrecomputes(o,t,n),r)}unsafe(t,r,n,o){let f=Ae(t);return f===1?this._unsafeLadder(t,r,o):this.wNAFUnsafe(f,this.getPrecomputes(f,t,n),r,o)}createCache(t,r){mn(r,this.bits),gn.set(t,r),Ie.delete(t)}hasCache(t){return Ae(t)!==1}};function pn(e,t,r,n){let o=t,f=e.ZERO,i=e.ZERO;for(;r>Lt||n>Lt;)r&_t&&(f=f.add(o)),n&_t&&(i=i.add(o)),o=o.double(),r>>=_t,n>>=_t;return{p1:f,p2:i}}function xn(e,t,r){if(t){if(t.ORDER!==e)throw new Error("Field.ORDER must match order: Fp == p, Fn == n");return Ee(t),t}else return Tt(e,{isLE:r})}function te(e,t,r={},n){if(n===void 0&&(n=e==="edwards"),!t||typeof t!="object")throw new Error(`expected valid ${e} CURVE object`);for(let s of["p","n","h"]){let l=t[s];if(!(typeof l=="bigint"&&l>Lt))throw new Error(`CURVE.${s} must be positive bigint`)}let o=xn(t.p,r.Fp,n),f=xn(t.n,r.Fn,n),c=["Gx","Gy","a",e==="weierstrass"?"b":"d"];for(let s of c)if(!o.isValid(t[s]))throw new Error(`CURVE.${s} must be valid field element of CURVE.Fp`);return t=Object.freeze(Object.assign({},t)),{CURVE:t,Fp:o,Fn:f}}function qt(e,t){return function(n){let o=e(n);return{secretKey:o,publicKey:t(o)}}}var ee=class{oHash;iHash;blockLen;outputLen;finished=!1;destroyed=!1;constructor(t,r){if(Wt(t),N(r,void 0,"key"),this.iHash=t.create(),typeof this.iHash.update!="function")throw new Error("Expected instance of class which extends utils.Hash");this.blockLen=this.iHash.blockLen,this.outputLen=this.iHash.outputLen;let n=this.blockLen,o=new Uint8Array(n);o.set(r.length>n?t.create().update(r).digest():r);for(let f=0;f<o.length;f++)o[f]^=54;this.iHash.update(o),this.oHash=t.create();for(let f=0;f<o.length;f++)o[f]^=106;this.oHash.update(o),at(o)}update(t){return Rt(this),this.iHash.update(t),this}digestInto(t){Rt(this),N(t,this.outputLen,"output"),this.finished=!0,this.iHash.digestInto(t),this.oHash.update(t),this.oHash.digestInto(t),this.destroy()}digest(){let t=new Uint8Array(this.oHash.outputLen);return this.digestInto(t),t}_cloneInto(t){t||=Object.create(Object.getPrototypeOf(this),{});let{oHash:r,iHash:n,finished:o,destroyed:f,blockLen:i,outputLen:c}=this;return t=t,t.finished=o,t.destroyed=f,t.blockLen=i,t.outputLen=c,t.oHash=r._cloneInto(t.oHash),t.iHash=n._cloneInto(t.iHash),t}clone(){return this._cloneInto()}destroy(){this.destroyed=!0,this.oHash.destroy(),this.iHash.destroy()}},Re=(e,t,r)=>new ee(e,t).update(r).digest();Re.create=(e,t)=>new ee(e,t);var yn=(e,t)=>(e+(e>=0?t:-t)/wn)/t;function nr(e,t,r){let[[n,o],[f,i]]=t,c=yn(i*e,r),s=yn(-o*e,r),l=e-c*n-s*f,h=-c*o-s*i,x=l<lt,v=h<lt;x&&(l=-l),v&&(h=-h);let E=Xt(Math.ceil(ye(r)/2))+Nt;if(l<lt||l>=E||h<lt||h>=E)throw new Error("splitScalar (endomorphism): failed, k="+e);return{k1neg:x,k1:l,k2neg:v,k2:h}}function He(e){if(!["compact","recovered","der"].includes(e))throw new Error('Signature format must be "compact", "recovered", or "der"');return e}function Oe(e,t){let r={};for(let n of Object.keys(t))r[n]=e[n]===void 0?t[n]:e[n];return ut(r.lowS,"lowS"),ut(r.prehash,"prehash"),r.format!==void 0&&He(r.format),r}var Te=class extends Error{constructor(t=""){super(t)}},pt={Err:Te,_tlv:{encode:(e,t)=>{let{Err:r}=pt;if(e<0||e>256)throw new r("tlv.encode: wrong tag");if(t.length&1)throw new r("tlv.encode: unpadded data");let n=t.length/2,o=kt(n);if(o.length/2&128)throw new r("tlv.encode: long form length too big");let f=n>127?kt(o.length/2|128):"";return kt(e)+f+o+t},decode(e,t){let{Err:r}=pt,n=0;if(e<0||e>256)throw new r("tlv.encode: wrong tag");if(t.length<2||t[n++]!==e)throw new r("tlv.decode: wrong tlv");let o=t[n++],f=!!(o&128),i=0;if(!f)i=o;else{let s=o&127;if(!s)throw new r("tlv.decode(long): indefinite length not supported");if(s>4)throw new r("tlv.decode(long): byte length is too big");let l=t.subarray(n,n+s);if(l.length!==s)throw new r("tlv.decode: length bytes not complete");if(l[0]===0)throw new r("tlv.decode(long): zero leftmost byte");for(let h of l)i=i<<8|h;if(n+=s,i<128)throw new r("tlv.decode(long): not minimal encoding")}let c=t.subarray(n,n+i);if(c.length!==i)throw new r("tlv.decode: wrong value length");return{v:c,l:t.subarray(n+i)}}},_int:{encode(e){let{Err:t}=pt;if(e<lt)throw new t("integer: negative integers are not allowed");let r=kt(e);if(Number.parseInt(r[0],16)&8&&(r="00"+r),r.length&1)throw new t("unexpected DER parsing assertion: unpadded hex");return r},decode(e){let{Err:t}=pt;if(e[0]&128)throw new t("invalid signature integer: negative");if(e[0]===0&&!(e[1]&128))throw new t("invalid signature integer: unnecessary leading zero");return Ot(e)}},toSig(e){let{Err:t,_int:r,_tlv:n}=pt,o=N(e,void 0,"signature"),{v:f,l:i}=n.decode(48,o);if(i.length)throw new t("invalid signature: left bytes after parsing");let{v:c,l:s}=n.decode(2,f),{v:l,l:h}=n.decode(2,s);if(h.length)throw new t("invalid signature: left bytes after parsing");return{r:r.decode(c),s:r.decode(l)}},hexFromSig(e){let{_tlv:t,_int:r}=pt,n=t.encode(2,r.encode(e.r)),o=t.encode(2,r.encode(e.s)),f=n+o;return t.encode(48,f)}},lt=BigInt(0),Nt=BigInt(1),wn=BigInt(2),ne=BigInt(3),rr=BigInt(4);function re(e,t={}){let r=te("weierstrass",e,t),{Fp:n,Fn:o}=r,f=r.CURVE,{h:i,n:c}=f;it(t,{},{allowInfinityPoint:"boolean",clearCofactor:"function",isTorsionFree:"function",fromBytes:"function",toBytes:"function",endo:"object"});let{endo:s}=t;if(s&&(!n.is0(f.a)||typeof s.beta!="bigint"||!Array.isArray(s.basises)))throw new Error('invalid endo: expected "beta": bigint and "basises": array');let l=En(n,o);function h(){if(!n.isOdd)throw new Error("compression is not supported: Field does not have .isOdd()")}function x(O,u,a){let{x:d,y:m}=u.toAffine(),I=n.toBytes(d);if(ut(a,"isCompressed"),a){h();let S=!n.isOdd(m);return tt(Bn(S),I)}else return tt(Uint8Array.of(4),I,n.toBytes(m))}function v(O){N(O,void 0,"Point");let{publicKey:u,publicKeyUncompressed:a}=l,d=O.length,m=O[0],I=O.subarray(1);if(d===u&&(m===2||m===3)){let S=n.fromBytes(I);if(!n.isValid(S))throw new Error("bad point: is not on curve, wrong x");let A=L(S),_;try{_=n.sqrt(A)}catch(K){let M=K instanceof Error?": "+K.message:"";throw new Error("bad point: is not on curve, sqrt error"+M)}h();let H=n.isOdd(_);return(m&1)===1!==H&&(_=n.neg(_)),{x:S,y:_}}else if(d===a&&m===4){let S=n.BYTES,A=n.fromBytes(I.subarray(0,S)),_=n.fromBytes(I.subarray(S,S*2));if(!y(A,_))throw new Error("bad point: is not on curve");return{x:A,y:_}}else throw new Error(`bad point: got length ${d}, expected compressed=${u} or uncompressed=${a}`)}let E=t.toBytes||x,w=t.fromBytes||v;function L(O){let u=n.sqr(O),a=n.mul(u,O);return n.add(n.add(a,n.mul(O,f.a)),f.b)}function y(O,u){let a=n.sqr(u),d=L(O);return n.eql(a,d)}if(!y(f.Gx,f.Gy))throw new Error("bad curve params: generator point");let g=n.mul(n.pow(f.a,ne),rr),R=n.mul(n.sqr(f.b),BigInt(27));if(n.is0(n.add(g,R)))throw new Error("bad curve params: a or b");function p(O,u,a=!1){if(!n.isValid(u)||a&&n.is0(u))throw new Error(`bad point coordinate ${O}`);return u}function T(O){if(!(O instanceof b))throw new Error("Weierstrass Point expected")}function D(O){if(!s||!s.basises)throw new Error("no endo");return nr(O,s.basises,o.ORDER)}let V=Ht((O,u)=>{let{X:a,Y:d,Z:m}=O;if(n.eql(m,n.ONE))return{x:a,y:d};let I=O.is0();u==null&&(u=I?n.ONE:n.inv(m));let S=n.mul(a,u),A=n.mul(d,u),_=n.mul(m,u);if(I)return{x:n.ZERO,y:n.ZERO};if(!n.eql(_,n.ONE))throw new Error("invZ was invalid");return{x:S,y:A}}),k=Ht(O=>{if(O.is0()){if(t.allowInfinityPoint&&!n.is0(O.Y))return;throw new Error("bad point: ZERO")}let{x:u,y:a}=O.toAffine();if(!n.isValid(u)||!n.isValid(a))throw new Error("bad point: x or y not field elements");if(!y(u,a))throw new Error("bad point: equation left != right");if(!O.isTorsionFree())throw new Error("bad point: not in prime-order subgroup");return!0});function B(O,u,a,d,m){return a=new b(n.mul(a.X,O),a.Y,a.Z),u=Kt(d,u),a=Kt(m,a),u.add(a)}class b{static BASE=new b(f.Gx,f.Gy,n.ONE);static ZERO=new b(n.ZERO,n.ONE,n.ZERO);static Fp=n;static Fn=o;X;Y;Z;constructor(u,a,d){this.X=p("x",u),this.Y=p("y",a,!0),this.Z=p("z",d),Object.freeze(this)}static CURVE(){return f}static fromAffine(u){let{x:a,y:d}=u||{};if(!u||!n.isValid(a)||!n.isValid(d))throw new Error("invalid affine point");if(u instanceof b)throw new Error("projective point not allowed");return n.is0(a)&&n.is0(d)?b.ZERO:new b(a,d,n.ONE)}static fromBytes(u){let a=b.fromAffine(w(N(u,void 0,"point")));return a.assertValidity(),a}static fromHex(u){return b.fromBytes(rt(u))}get x(){return this.toAffine().x}get y(){return this.toAffine().y}precompute(u=8,a=!0){return Z.createCache(this,u),a||this.multiply(ne),this}assertValidity(){k(this)}hasEvenY(){let{y:u}=this.toAffine();if(!n.isOdd)throw new Error("Field doesn't support isOdd");return!n.isOdd(u)}equals(u){T(u);let{X:a,Y:d,Z:m}=this,{X:I,Y:S,Z:A}=u,_=n.eql(n.mul(a,A),n.mul(I,m)),H=n.eql(n.mul(d,A),n.mul(S,m));return _&&H}negate(){return new b(this.X,n.neg(this.Y),this.Z)}double(){let{a:u,b:a}=f,d=n.mul(a,ne),{X:m,Y:I,Z:S}=this,A=n.ZERO,_=n.ZERO,H=n.ZERO,U=n.mul(m,m),K=n.mul(I,I),M=n.mul(S,S),C=n.mul(m,I);return C=n.add(C,C),H=n.mul(m,S),H=n.add(H,H),A=n.mul(u,H),_=n.mul(d,M),_=n.add(A,_),A=n.sub(K,_),_=n.add(K,_),_=n.mul(A,_),A=n.mul(C,A),H=n.mul(d,H),M=n.mul(u,M),C=n.sub(U,M),C=n.mul(u,C),C=n.add(C,H),H=n.add(U,U),U=n.add(H,U),U=n.add(U,M),U=n.mul(U,C),_=n.add(_,U),M=n.mul(I,S),M=n.add(M,M),U=n.mul(M,C),A=n.sub(A,U),H=n.mul(M,K),H=n.add(H,H),H=n.add(H,H),new b(A,_,H)}add(u){T(u);let{X:a,Y:d,Z:m}=this,{X:I,Y:S,Z:A}=u,_=n.ZERO,H=n.ZERO,U=n.ZERO,K=f.a,M=n.mul(f.b,ne),C=n.mul(a,I),Y=n.mul(d,S),z=n.mul(m,A),F=n.add(a,d),X=n.add(I,S);F=n.mul(F,X),X=n.add(C,Y),F=n.sub(F,X),X=n.add(a,m);let W=n.add(I,A);return X=n.mul(X,W),W=n.add(C,z),X=n.sub(X,W),W=n.add(d,m),_=n.add(S,A),W=n.mul(W,_),_=n.add(Y,z),W=n.sub(W,_),U=n.mul(K,X),_=n.mul(M,z),U=n.add(_,U),_=n.sub(Y,U),U=n.add(Y,U),H=n.mul(_,U),Y=n.add(C,C),Y=n.add(Y,C),z=n.mul(K,z),X=n.mul(M,X),Y=n.add(Y,z),z=n.sub(C,z),z=n.mul(K,z),X=n.add(X,z),C=n.mul(Y,X),H=n.add(H,C),C=n.mul(W,X),_=n.mul(F,_),_=n.sub(_,C),C=n.mul(F,Y),U=n.mul(W,U),U=n.add(U,C),new b(_,H,U)}subtract(u){return this.add(u.negate())}is0(){return this.equals(b.ZERO)}multiply(u){let{endo:a}=t;if(!o.isValidNot0(u))throw new Error("invalid scalar: out of range");let d,m,I=S=>Z.cached(this,S,A=>It(b,A));if(a){let{k1neg:S,k1:A,k2neg:_,k2:H}=D(u),{p:U,f:K}=I(A),{p:M,f:C}=I(H);m=K.add(C),d=B(a.beta,U,M,S,_)}else{let{p:S,f:A}=I(u);d=S,m=A}return It(b,[d,m])[0]}multiplyUnsafe(u){let{endo:a}=t,d=this;if(!o.isValid(u))throw new Error("invalid scalar: out of range");if(u===lt||d.is0())return b.ZERO;if(u===Nt)return d;if(Z.hasCache(this))return this.multiply(u);if(a){let{k1neg:m,k1:I,k2neg:S,k2:A}=D(u),{p1:_,p2:H}=pn(b,d,I,A);return B(a.beta,_,H,m,S)}else return Z.unsafe(d,u)}toAffine(u){return V(this,u)}isTorsionFree(){let{isTorsionFree:u}=t;return i===Nt?!0:u?u(b,this):Z.unsafe(this,c).is0()}clearCofactor(){let{clearCofactor:u}=t;return i===Nt?this:u?u(b,this):this.multiplyUnsafe(i)}isSmallOrder(){return this.multiplyUnsafe(i).is0()}toBytes(u=!0){return ut(u,"isCompressed"),this.assertValidity(),E(b,this,u)}toHex(u=!0){return nt(this.toBytes(u))}toString(){return`<Point ${this.is0()?"ZERO":this.toHex()}>`}}let q=o.BITS,Z=new Ut(b,t.endo?Math.ceil(q/2):q);return b.BASE.precompute(8),b}function Bn(e){return Uint8Array.of(e?2:3)}function En(e,t){return{secretKey:t.BYTES,publicKey:1+e.BYTES,publicKeyUncompressed:1+2*e.BYTES,publicKeyHasPrefix:!0,signature:2*t.BYTES}}function or(e,t={}){let{Fn:r}=e,n=t.randomBytes||ht,o=Object.assign(En(e.Fp,r),{seed:ve(r.ORDER)});function f(E){try{let w=r.fromBytes(E);return r.isValidNot0(w)}catch{return!1}}function i(E,w){let{publicKey:L,publicKeyUncompressed:y}=o;try{let g=E.length;return w===!0&&g!==L||w===!1&&g!==y?!1:!!e.fromBytes(E)}catch{return!1}}function c(E=n(o.seed)){return Se(N(E,o.seed,"seed"),r.ORDER)}function s(E,w=!0){return e.BASE.multiply(r.fromBytes(E)).toBytes(w)}function l(E){let{secretKey:w,publicKey:L,publicKeyUncompressed:y}=o;if(!wt(E)||"_lengths"in r&&r._lengths||w===L)return;let g=N(E,void 0,"key").length;return g===L||g===y}function h(E,w,L=!0){if(l(E)===!0)throw new Error("first arg must be private key");if(l(w)===!1)throw new Error("second arg must be public key");let y=r.fromBytes(E);return e.fromBytes(w).multiply(y).toBytes(L)}let x={isValidSecretKey:f,isValidPublicKey:i,randomSecretKey:c},v=qt(c,s);return Object.freeze({getPublicKey:s,getSharedSecret:h,keygen:v,Point:e,utils:x,lengths:o})}function oe(e,t,r={}){Wt(t),it(r,{},{hmac:"function",lowS:"boolean",randomBytes:"function",bits2int:"function",bits2int_modN:"function"}),r=Object.assign({},r);let n=r.randomBytes||ht,o=r.hmac||((a,d)=>Re(t,a,d)),{Fp:f,Fn:i}=e,{ORDER:c,BITS:s}=i,{keygen:l,getPublicKey:h,getSharedSecret:x,utils:v,lengths:E}=or(e,r),w={prehash:!0,lowS:typeof r.lowS=="boolean"?r.lowS:!0,format:"compact",extraEntropy:!1},L=c*wn<f.ORDER;function y(a){let d=c>>Nt;return a>d}function g(a,d){if(!i.isValidNot0(d))throw new Error(`invalid signature ${a}: out of range 1..Point.Fn.ORDER`);return d}function R(){if(L)throw new Error('"recovered" sig type is not supported for cofactor >2 curves')}function p(a,d){He(d);let m=E.signature,I=d==="compact"?m:d==="recovered"?m+1:void 0;return N(a,I)}class T{r;s;recovery;constructor(d,m,I){if(this.r=g("r",d),this.s=g("s",m),I!=null){if(R(),![0,1,2,3].includes(I))throw new Error("invalid recovery id");this.recovery=I}Object.freeze(this)}static fromBytes(d,m=w.format){p(d,m);let I;if(m==="der"){let{r:H,s:U}=pt.toSig(N(d));return new T(H,U)}m==="recovered"&&(I=d[0],m="compact",d=d.subarray(1));let S=E.signature/2,A=d.subarray(0,S),_=d.subarray(S,S*2);return new T(i.fromBytes(A),i.fromBytes(_),I)}static fromHex(d,m){return this.fromBytes(rt(d),m)}assertRecovery(){let{recovery:d}=this;if(d==null)throw new Error("invalid recovery id: must be present");return d}addRecoveryBit(d){return new T(this.r,this.s,d)}recoverPublicKey(d){let{r:m,s:I}=this,S=this.assertRecovery(),A=S===2||S===3?m+c:m;if(!f.isValid(A))throw new Error("invalid recovery id: sig.r+curve.n != R.x");let _=f.toBytes(A),H=e.fromBytes(tt(Bn((S&1)===0),_)),U=i.inv(A),K=V(N(d,void 0,"msgHash")),M=i.create(-K*U),C=i.create(I*U),Y=e.BASE.multiplyUnsafe(M).add(H.multiplyUnsafe(C));if(Y.is0())throw new Error("invalid recovery: point at infinify");return Y.assertValidity(),Y}hasHighS(){return y(this.s)}toBytes(d=w.format){if(He(d),d==="der")return rt(pt.hexFromSig(this));let{r:m,s:I}=this,S=i.toBytes(m),A=i.toBytes(I);return d==="recovered"?(R(),tt(Uint8Array.of(this.assertRecovery()),S,A)):tt(S,A)}toHex(d){return nt(this.toBytes(d))}}let D=r.bits2int||function(d){if(d.length>8192)throw new Error("input is too large");let m=Ot(d),I=d.length*8-s;return I>0?m>>BigInt(I):m},V=r.bits2int_modN||function(d){return i.create(D(d))},k=Xt(s);function B(a){return gt("num < 2^"+s,a,lt,k),i.toBytes(a)}function b(a,d){return N(a,void 0,"message"),d?N(t(a),void 0,"prehashed message"):a}function q(a,d,m){let{lowS:I,prehash:S,extraEntropy:A}=Oe(m,w);a=b(a,S);let _=V(a),H=i.fromBytes(d);if(!i.isValidNot0(H))throw new Error("invalid private key");let U=[B(H),B(_)];if(A!=null&&A!==!1){let Y=A===!0?n(E.secretKey):A;U.push(N(Y,void 0,"extraEntropy"))}let K=tt(...U),M=_;function C(Y){let z=D(Y);if(!i.isValidNot0(z))return;let F=i.inv(z),X=e.BASE.multiply(z).toAffine(),W=i.create(X.x);if(W===lt)return;let At=i.create(F*i.create(M+W*H));if(At===lt)return;let zt=(X.x===W?0:2)|Number(X.y&Nt),Zt=At;return I&&y(At)&&(Zt=i.neg(At),zt^=1),new T(W,Zt,L?void 0:zt)}return{seed:K,k2sig:C}}function Z(a,d,m={}){let{seed:I,k2sig:S}=q(a,d,m);return tn(t.outputLen,i.BYTES,o)(I,S).toBytes(m.format)}function O(a,d,m,I={}){let{lowS:S,prehash:A,format:_}=Oe(I,w);if(m=N(m,void 0,"publicKey"),d=b(d,A),!wt(a)){let H=a instanceof T?", use sig.toBytes()":"";throw new Error("verify expects Uint8Array signature"+H)}p(a,_);try{let H=T.fromBytes(a,_),U=e.fromBytes(m);if(S&&H.hasHighS())return!1;let{r:K,s:M}=H,C=V(d),Y=i.inv(M),z=i.create(C*Y),F=i.create(K*Y),X=e.BASE.multiplyUnsafe(z).add(U.multiplyUnsafe(F));return X.is0()?!1:i.create(X.x)===K}catch{return!1}}function u(a,d,m={}){let{prehash:I}=Oe(m,w);return d=b(d,I),T.fromBytes(a,"recovered").recoverPublicKey(d).toBytes()}return Object.freeze({keygen:l,getPublicKey:h,getSharedSecret:x,utils:v,lengths:E,Point:e,sign:Z,verify:O,recoverPublicKey:u,Signature:T,hash:t})}var Ue={p:BigInt("0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f"),n:BigInt("0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141"),h:BigInt(1),a:BigInt(0),b:BigInt(7),Gx:BigInt("0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"),Gy:BigInt("0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8")},sr={beta:BigInt("0x7ae96a2b657c07106e64479eac3434e99cf0497512f58995c1396c28719501ee"),basises:[[BigInt("0x3086d221a7d46bcde86c90e49284eb15"),-BigInt("0xe4437ed6010e88286f547fa90abfe4c3")],[BigInt("0x114ca50f7a8e2f3f657c1108d9d44cfd8"),BigInt("0x3086d221a7d46bcde86c90e49284eb15")]]};var vn=BigInt(2);function ir(e){let t=Ue.p,r=BigInt(3),n=BigInt(6),o=BigInt(11),f=BigInt(22),i=BigInt(23),c=BigInt(44),s=BigInt(88),l=e*e*e%t,h=l*l*e%t,x=G(h,r,t)*h%t,v=G(x,r,t)*h%t,E=G(v,vn,t)*l%t,w=G(E,o,t)*E%t,L=G(w,f,t)*w%t,y=G(L,c,t)*L%t,g=G(y,s,t)*y%t,R=G(g,c,t)*L%t,p=G(R,r,t)*h%t,T=G(p,i,t)*w%t,D=G(T,n,t)*l%t,V=G(D,vn,t);if(!Le.eql(Le.sqr(V),e))throw new Error("Cannot find square root");return V}var Le=Tt(Ue.p,{sqrt:ir}),cr=re(Ue,{Fp:Le,endo:sr}),Sn=oe(cr,Qt);var yt=BigInt(0),P=BigInt(1),qe=BigInt(2),ar=BigInt(8);function dr(e,t,r,n){let o=e.sqr(r),f=e.sqr(n),i=e.add(e.mul(t.a,o),f),c=e.add(e.ONE,e.mul(t.d,e.mul(o,f)));return e.eql(i,c)}function _n(e,t={}){let r=te("edwards",e,t,t.FpFnLE),{Fp:n,Fn:o}=r,f=r.CURVE,{h:i}=f;it(t,{},{uvRatio:"function"});let c=qe<<BigInt(o.BYTES*8)-P,s=y=>n.create(y),l=t.uvRatio||((y,g)=>{try{return{isValid:!0,value:n.sqrt(n.div(y,g))}}catch{return{isValid:!1,value:yt}}});if(!dr(n,f,f.Gx,f.Gy))throw new Error("bad curve params: generator point");function h(y,g,R=!1){let p=R?P:yt;return gt("coordinate "+y,g,p,c),g}function x(y){if(!(y instanceof w))throw new Error("EdwardsPoint expected")}let v=Ht((y,g)=>{let{X:R,Y:p,Z:T}=y,D=y.is0();g==null&&(g=D?ar:n.inv(T));let V=s(R*g),k=s(p*g),B=n.mul(T,g);if(D)return{x:yt,y:P};if(B!==P)throw new Error("invZ was invalid");return{x:V,y:k}}),E=Ht(y=>{let{a:g,d:R}=f;if(y.is0())throw new Error("bad point: ZERO");let{X:p,Y:T,Z:D,T:V}=y,k=s(p*p),B=s(T*T),b=s(D*D),q=s(b*b),Z=s(k*g),O=s(b*s(Z+B)),u=s(q+s(R*s(k*B)));if(O!==u)throw new Error("bad point: equation left != right (1)");let a=s(p*T),d=s(D*V);if(a!==d)throw new Error("bad point: equation left != right (2)");return!0});class w{static BASE=new w(f.Gx,f.Gy,P,s(f.Gx*f.Gy));static ZERO=new w(yt,P,P,yt);static Fp=n;static Fn=o;X;Y;Z;T;constructor(g,R,p,T){this.X=h("x",g),this.Y=h("y",R),this.Z=h("z",p,!0),this.T=h("t",T),Object.freeze(this)}static CURVE(){return f}static fromAffine(g){if(g instanceof w)throw new Error("extended point not allowed");let{x:R,y:p}=g||{};return h("x",R),h("y",p),new w(R,p,P,s(R*p))}static fromBytes(g,R=!1){let p=n.BYTES,{a:T,d:D}=f;g=vt(N(g,p,"point")),ut(R,"zip215");let V=vt(g),k=g[p-1];V[p-1]=k&-129;let B=st(V),b=R?c:n.ORDER;gt("point.y",B,yt,b);let q=s(B*B),Z=s(q-P),O=s(D*q-T),{isValid:u,value:a}=l(Z,O);if(!u)throw new Error("bad point: invalid y coordinate");let d=(a&P)===P,m=(k&128)!==0;if(!R&&a===yt&&m)throw new Error("bad point: x=0 and x_0=1");return m!==d&&(a=s(-a)),w.fromAffine({x:a,y:B})}static fromHex(g,R=!1){return w.fromBytes(rt(g),R)}get x(){return this.toAffine().x}get y(){return this.toAffine().y}precompute(g=8,R=!0){return L.createCache(this,g),R||this.multiply(qe),this}assertValidity(){E(this)}equals(g){x(g);let{X:R,Y:p,Z:T}=this,{X:D,Y:V,Z:k}=g,B=s(R*k),b=s(D*T),q=s(p*k),Z=s(V*T);return B===b&&q===Z}is0(){return this.equals(w.ZERO)}negate(){return new w(s(-this.X),this.Y,this.Z,s(-this.T))}double(){let{a:g}=f,{X:R,Y:p,Z:T}=this,D=s(R*R),V=s(p*p),k=s(qe*s(T*T)),B=s(g*D),b=R+p,q=s(s(b*b)-D-V),Z=B+V,O=Z-k,u=B-V,a=s(q*O),d=s(Z*u),m=s(q*u),I=s(O*Z);return new w(a,d,I,m)}add(g){x(g);let{a:R,d:p}=f,{X:T,Y:D,Z:V,T:k}=this,{X:B,Y:b,Z:q,T:Z}=g,O=s(T*B),u=s(D*b),a=s(k*p*Z),d=s(V*q),m=s((T+D)*(B+b)-O-u),I=d-a,S=d+a,A=s(u-R*O),_=s(m*I),H=s(S*A),U=s(m*A),K=s(I*S);return new w(_,H,K,U)}subtract(g){return this.add(g.negate())}multiply(g){if(!o.isValidNot0(g))throw new Error("invalid scalar: expected 1 <= sc < curve.n");let{p:R,f:p}=L.cached(this,g,T=>It(w,T));return It(w,[R,p])[0]}multiplyUnsafe(g,R=w.ZERO){if(!o.isValid(g))throw new Error("invalid scalar: expected 0 <= sc < curve.n");return g===yt?w.ZERO:this.is0()||g===P?this:L.unsafe(this,g,p=>It(w,p),R)}isSmallOrder(){return this.multiplyUnsafe(i).is0()}isTorsionFree(){return L.unsafe(this,f.n).is0()}toAffine(g){return v(this,g)}clearCofactor(){return i===P?this:this.multiplyUnsafe(i)}toBytes(){let{x:g,y:R}=this.toAffine(),p=n.toBytes(R);return p[p.length-1]|=g&P?128:0,p}toHex(){return nt(this.toBytes())}toString(){return`<Point ${this.is0()?"ZERO":this.toHex()}>`}}let L=new Ut(w,o.BITS);return w.BASE.precompute(8),w}function In(e,t,r={}){if(typeof t!="function")throw new Error('"hash" function param is required');it(r,{},{adjustScalarBytes:"function",randomBytes:"function",domain:"function",prehash:"function",mapToCurve:"function"});let{prehash:n}=r,{BASE:o,Fp:f,Fn:i}=e,c=r.randomBytes||ht,s=r.adjustScalarBytes||(B=>B),l=r.domain||((B,b,q)=>{if(ut(q,"phflag"),b.length||q)throw new Error("Contexts/pre-hash are not supported");return B});function h(B){return i.create(st(B))}function x(B){let b=p.secretKey;N(B,p.secretKey,"secretKey");let q=N(t(B),2*b,"hashedSecretKey"),Z=s(q.slice(0,b)),O=q.slice(b,2*b),u=h(Z);return{head:Z,prefix:O,scalar:u}}function v(B){let{head:b,prefix:q,scalar:Z}=x(B),O=o.multiply(Z),u=O.toBytes();return{head:b,prefix:q,scalar:Z,point:O,pointBytes:u}}function E(B){return v(B).pointBytes}function w(B=Uint8Array.of(),...b){let q=tt(...b);return h(t(l(q,N(B,void 0,"context"),!!n)))}function L(B,b,q={}){B=N(B,void 0,"message"),n&&(B=n(B));let{prefix:Z,scalar:O,pointBytes:u}=v(b),a=w(q.context,Z,B),d=o.multiply(a).toBytes(),m=w(q.context,d,u,B),I=i.create(a+m*O);if(!i.isValid(I))throw new Error("sign failed: invalid s");let S=tt(d,i.toBytes(I));return N(S,p.signature,"result")}let y={zip215:!0};function g(B,b,q,Z=y){let{context:O,zip215:u}=Z,a=p.signature;B=N(B,a,"signature"),b=N(b,void 0,"message"),q=N(q,p.publicKey,"publicKey"),u!==void 0&&ut(u,"zip215"),n&&(b=n(b));let d=a/2,m=B.subarray(0,d),I=st(B.subarray(d,a)),S,A,_;try{S=e.fromBytes(q,u),A=e.fromBytes(m,u),_=o.multiplyUnsafe(I)}catch{return!1}if(!u&&S.isSmallOrder())return!1;let H=w(O,A.toBytes(),S.toBytes(),b);return A.add(S.multiplyUnsafe(H)).subtract(_).clearCofactor().is0()}let R=f.BYTES,p={secretKey:R,publicKey:R,signature:2*R,seed:R};function T(B=c(p.seed)){return N(B,p.seed,"seed")}function D(B){return wt(B)&&B.length===i.BYTES}function V(B,b){try{return!!e.fromBytes(B,b)}catch{return!1}}let k={getExtendedPublicKey:v,randomSecretKey:T,isValidSecretKey:D,isValidPublicKey:V,toMontgomery(B){let{y:b}=e.fromBytes(B),q=p.publicKey,Z=q===32;if(!Z&&q!==57)throw new Error("only defined for 25519 and 448");let O=Z?f.div(P+b,P-b):f.div(b-P,b+P);return f.toBytes(O)},toMontgomerySecret(B){let b=p.secretKey;N(B,b);let q=t(B.subarray(0,b));return s(q).subarray(0,b)}};return Object.freeze({keygen:qt(T,E),getPublicKey:E,sign:L,verify:g,utils:k,Point:e,lengths:p})}var jt=BigInt(0),Dt=BigInt(1),fe=BigInt(2);function ur(e){return it(e,{adjustScalarBytes:"function",powPminus2:"function"}),Object.freeze({...e})}function An(e){let t=ur(e),{P:r,type:n,adjustScalarBytes:o,powPminus2:f,randomBytes:i}=t,c=n==="x25519";if(!c&&n!=="x448")throw new Error("invalid type");let s=i||ht,l=c?255:448,h=c?32:56,x=BigInt(c?9:5),v=BigInt(c?121665:39081),E=c?fe**BigInt(254):fe**BigInt(447),w=c?BigInt(8)*fe**BigInt(251)-Dt:BigInt(4)*fe**BigInt(445)-Dt,L=E+w+Dt,y=a=>j(a,r),g=R(x);function R(a){return Yt(y(a),h)}function p(a){let d=vt(N(a,h,"uCoordinate"));return c&&(d[31]&=127),y(st(d))}function T(a){return st(o(vt(N(a,h,"scalar"))))}function D(a,d){let m=q(p(d),T(a));if(m===jt)throw new Error("invalid private or public key received");return R(m)}function V(a){return D(a,g)}let k=V,B=D;function b(a,d,m){let I=y(a*(d-m));return d=y(d-I),m=y(m+I),{x_2:d,x_3:m}}function q(a,d){gt("u",a,jt,r),gt("scalar",d,E,L);let m=d,I=a,S=Dt,A=jt,_=a,H=Dt,U=jt;for(let M=BigInt(l-1);M>=jt;M--){let C=m>>M&Dt;U^=C,{x_2:S,x_3:_}=b(U,S,_),{x_2:A,x_3:H}=b(U,A,H),U=C;let Y=S+A,z=y(Y*Y),F=S-A,X=y(F*F),W=z-X,At=_+H,zt=_-H,Zt=y(zt*Y),Ne=y(At*F),De=Zt+Ne,Ze=Zt-Ne;_=y(De*De),H=y(I*y(Ze*Ze)),S=y(z*X),A=y(W*(z+y(v*W)))}({x_2:S,x_3:_}=b(U,S,_)),{x_2:A,x_3:H}=b(U,A,H);let K=f(A);return y(S*K)}let Z={secretKey:h,publicKey:h,seed:h},O=(a=s(h))=>(N(a,Z.seed,"seed"),a),u={randomSecretKey:O};return Object.freeze({keygen:qt(O,k),getSharedSecret:B,getPublicKey:k,scalarMult:D,scalarMultBase:V,utils:u,GuBytes:g.slice(),lengths:Z})}var lr=BigInt(1),Rn=BigInt(2),hr=BigInt(3),br=BigInt(5),xr=BigInt(8),se=BigInt("0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffed"),mr={p:se,n:BigInt("0x1000000000000000000000000000000014def9dea2f79cd65812631a5cf5d3ed"),h:xr,a:BigInt("0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffec"),d:BigInt("0x52036cee2b6ffe738cc740797779e89800700a4d4141d8ab75eb4dca135978a3"),Gx:BigInt("0x216936d3cd6e53fec0a4e231fdd6dc5c692cc7609525a7b2c9562d608f25d51a"),Gy:BigInt("0x6666666666666666666666666666666666666666666666666666666666666658")};function Hn(e){let t=BigInt(10),r=BigInt(20),n=BigInt(40),o=BigInt(80),f=se,c=e*e%f*e%f,s=G(c,Rn,f)*c%f,l=G(s,lr,f)*e%f,h=G(l,br,f)*l%f,x=G(h,t,f)*h%f,v=G(x,r,f)*x%f,E=G(v,n,f)*v%f,w=G(E,o,f)*E%f,L=G(w,o,f)*E%f,y=G(L,t,f)*h%f;return{pow_p_5_8:G(y,Rn,f)*e%f,b2:c}}function Tn(e){return e[0]&=248,e[31]&=127,e[31]|=64,e}var On=BigInt("19681161376707505956807079304988542015446066515923890162744021073123829784752");function gr(e,t){let r=se,n=j(t*t*t,r),o=j(n*n*t,r),f=Hn(e*o).pow_p_5_8,i=j(e*n*f,r),c=j(t*i*i,r),s=i,l=j(i*On,r),h=c===e,x=c===j(-e,r),v=c===j(-e*On,r);return h&&(i=s),(x||v)&&(i=l),un(i,r)&&(i=j(-i,r)),{isValid:h||x,value:i}}var pr=_n(mr,{uvRatio:gr});function yr(e){return In(pr,me,Object.assign({adjustScalarBytes:Tn},e))}var Ln=yr({});var Un=(()=>{let e=se;return An({P:e,type:"x25519",powPminus2:t=>{let{pow_p_5_8:r,b2:n}=Hn(t);return j(G(r,hr,e)*n,e)},adjustScalarBytes:Tn})})();var wr={p:BigInt("0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff"),n:BigInt("0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551"),h:BigInt(1),a:BigInt("0xffffffff00000001000000000000000000000000fffffffffffffffffffffffc"),b:BigInt("0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604b"),Gx:BigInt("0x6b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296"),Gy:BigInt("0x4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5")};var Br=re(wr),qn=oe(Br,Qt);return Mn(Er);})();
4728−/*! Bundled license information:
4729−
4730−@noble/hashes/utils.js:
4731− (*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
4732−
4733−@noble/curves/utils.js:
4734−@noble/curves/abstract/modular.js:
4735−@noble/curves/abstract/curve.js:
4736−@noble/curves/abstract/weierstrass.js:
4737−@noble/curves/secp256k1.js:
4738−@noble/curves/abstract/edwards.js:
4739−@noble/curves/abstract/montgomery.js:
4740−@noble/curves/ed25519.js:
4741−@noble/curves/nist.js:
4742− (*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
4743−*/
4744−/*! Bundled license information:
4745−
4746−@noble/hashes/utils.js:
4747− (*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
4748−
4749−@noble/curves/utils.js:
4750−@noble/curves/abstract/modular.js:
4751−@noble/curves/abstract/curve.js:
4752−@noble/curves/abstract/weierstrass.js:
4753−@noble/curves/secp256k1.js:
4754−@noble/curves/abstract/edwards.js:
4755−@noble/curves/ed25519.js:
4756−@noble/curves/nist.js:
4757− (*! noble-curves - MIT License (c) 2022 Paul Miller (paulmillr.com) *)
4758−*/
4759−
4760−
4761−// UTILS
4762−
4763−var _Crypto_toHex = _Crypto_noble.bytesToHex;
4764−var _Crypto_fromHex = _Crypto_noble.hexToBytes;
4765−
4766−
4767−// DER CODEC (for ECDSA signatures: secp256k1, P-256)
4768−
4769−function _Crypto_bigintToBytes(n, len)
4770−{
4771− var hex = n.toString(16);
4772− if (hex.length % 2) { hex = '0' + hex; }
4773− while (hex.length < len * 2) { hex = '00' + hex; }
4774− return _Crypto_fromHex(hex);
4775−}
4776−
4777−function _Crypto_bytesToBigint(bytes)
4778−{
4779− return BigInt('0x' + _Crypto_toHex(bytes));
4780−}
4781−
4782−function _Crypto_compactToDer(compactHex, byteLen)
4783−{
4784− var bytes = _Crypto_fromHex(compactHex);
4785− var r = bytes.slice(0, byteLen);
4786− var s = bytes.slice(byteLen);
4787− function encodeInt(b) {
4788− // Strip leading zeros, then add back if high bit set
4789− var i = 0;
4790− while (i < b.length - 1 && b[i] === 0) { i++; }
4791− b = b.slice(i);
4792− if (b[0] & 0x80) {
4793− var padded = new Uint8Array(b.length + 1);
4794− padded[0] = 0;
4795− padded.set(b, 1);
4796− b = padded;
4797− }
4798− return b;
4799− }
4800− var rEnc = encodeInt(r);
4801− var sEnc = encodeInt(s);
4802− var totalLen = 2 + rEnc.length + 2 + sEnc.length;
4803− var der = new Uint8Array(2 + totalLen);
4804− var pos = 0;
4805− der[pos++] = 0x30;
4806− der[pos++] = totalLen;
4807− der[pos++] = 0x02;
4808− der[pos++] = rEnc.length;
4809− der.set(rEnc, pos); pos += rEnc.length;
4810− der[pos++] = 0x02;
4811− der[pos++] = sEnc.length;
4812− der.set(sEnc, pos);
4813− return _Crypto_toHex(der);
4814−}
4815−
4816−function _Crypto_derToCompact(derHex, byteLen)
4817−{
4818− var der = _Crypto_fromHex(derHex);
4819− if (der[0] !== 0x30) { return null; }
4820− var pos = 2;
4821− if (der[pos] !== 0x02) { return null; }
4822− var rLen = der[pos + 1]; pos += 2;
4823− var rBytes = der.slice(pos, pos + rLen); pos += rLen;
4824− if (der[pos] !== 0x02) { return null; }
4825− var sLen = der[pos + 1]; pos += 2;
4826− var sBytes = der.slice(pos, pos + sLen);
4827− // Remove leading zero padding
4828− while (rBytes.length > byteLen) { rBytes = rBytes.slice(1); }
4829− while (sBytes.length > byteLen) { sBytes = sBytes.slice(1); }
4830− // Pad to fixed width
4831− var r = new Uint8Array(byteLen);
4832− var s = new Uint8Array(byteLen);
4833− r.set(rBytes, byteLen - rBytes.length);
4834− s.set(sBytes, byteLen - sBytes.length);
4835− var compact = new Uint8Array(byteLen * 2);
4836− compact.set(r, 0);
4837− compact.set(s, byteLen);
4838− return _Crypto_toHex(compact);
4839−}
4840−
4841−
4842−// SECP256K1
4843−
4844−function _Crypto_secp256k1PrivateKeyFromHex(hex)
4845−{
4846− try {
4847− _Crypto_noble.secp256k1.getPublicKey(_Crypto_fromHex(hex));
4848− return $elm$core$Maybe$Just(hex);
4849− } catch (e) {
4850− return $elm$core$Maybe$Nothing;
4851− }
4852−}
4853−
4854−function _Crypto_secp256k1PrivateKeyToHex(key)
4855−{
4856− return key;
4857−}
4858−
4859−function _Crypto_secp256k1PublicKeyFromHex(hex)
4860−{
4861− try {
4862− _Crypto_noble.secp256k1.Point.fromHex(hex);
4863− return $elm$core$Maybe$Just(hex);
4864− } catch (e) {
4865− return $elm$core$Maybe$Nothing;
4866− }
4867−}
4868−
4869−function _Crypto_secp256k1PublicKeyToHex(key)
4870−{
4871− return key;
4872−}
4873−
4874−function _Crypto_secp256k1SignatureFromCompactHex(hex)
4875−{
4876− try {
4877− var bytes = _Crypto_fromHex(hex);
4878− if (bytes.length !== 64) { return $elm$core$Maybe$Nothing; }
4879− _Crypto_noble.secp256k1.Signature.fromHex(hex);
4880− return $elm$core$Maybe$Just(hex);
4881− } catch (e) {
4882− return $elm$core$Maybe$Nothing;
4883− }
4884−}
4885−
4886−function _Crypto_secp256k1SignatureFromDerHex(hex)
4887−{
4888− try {
4889− var compact = _Crypto_derToCompact(hex, 32);
4890− if (!compact) { return $elm$core$Maybe$Nothing; }
4891− _Crypto_noble.secp256k1.Signature.fromHex(compact);
4892− return $elm$core$Maybe$Just(compact);
4893− } catch (e) {
4894− return $elm$core$Maybe$Nothing;
4895− }
4896−}
4897−
4898−function _Crypto_secp256k1SignatureToCompactHex(sig)
4899−{
4900− return sig;
4901−}
4902−
4903−function _Crypto_secp256k1SignatureToDerHex(compactHex)
4904−{
4905− return _Crypto_compactToDer(compactHex, 32);
4906−}
4907−
4908−function _Crypto_secp256k1GetPublicKey(privKey)
4909−{
4910− return _Crypto_toHex(_Crypto_noble.secp256k1.getPublicKey(_Crypto_fromHex(privKey)));
4911−}
4912−
4913−var _Crypto_secp256k1Sign = F2(function(msgHash, privKey)
4914−{
4915− var sig = _Crypto_noble.secp256k1.sign(_Crypto_fromHex(msgHash), _Crypto_fromHex(privKey));
4916− return _Crypto_toHex(sig);
4917−});
4918−
4919−var _Crypto_secp256k1Verify = F3(function(sig, msgHash, pubKey)
4920−{
4921− try {
4922− return _Crypto_noble.secp256k1.verify(
4923− _Crypto_fromHex(sig),
4924− _Crypto_fromHex(msgHash),
4925− _Crypto_fromHex(pubKey)
4926− );
4927− } catch (e) {
4928− return false;
4929− }
4930−});
4931−
4932−var _Crypto_secp256k1GetSharedSecret = F2(function(privKey, pubKey)
4933−{
4934− return _Crypto_toHex(_Crypto_noble.secp256k1.getSharedSecret(_Crypto_fromHex(privKey), _Crypto_fromHex(pubKey)));
4935−});
4936−
4937−
4938−// ED25519
4939−
4940−function _Crypto_ed25519PrivateKeyFromHex(hex)
4941−{
4942− try {
4943− _Crypto_noble.ed25519.getPublicKey(_Crypto_fromHex(hex));
4944− return $elm$core$Maybe$Just(hex);
4945− } catch (e) {
4946− return $elm$core$Maybe$Nothing;
4947− }
4948−}
4949−
4950−function _Crypto_ed25519PrivateKeyToHex(key)
4951−{
4952− return key;
4953−}
4954−
4955−function _Crypto_ed25519PublicKeyFromHex(hex)
4956−{
4957− try {
4958− _Crypto_noble.ed25519.Point.fromHex(hex);
4959− return $elm$core$Maybe$Just(hex);
4960− } catch (e) {
4961− return $elm$core$Maybe$Nothing;
4962− }
4963−}
4964−
4965−function _Crypto_ed25519PublicKeyToHex(key)
4966−{
4967− return key;
4968−}
4969−
4970−function _Crypto_ed25519SignatureFromHex(hex)
4971−{
4972− try {
4973− var bytes = _Crypto_fromHex(hex);
4974− if (bytes.length !== 64) { return $elm$core$Maybe$Nothing; }
4975− return $elm$core$Maybe$Just(hex);
4976− } catch (e) {
4977− return $elm$core$Maybe$Nothing;
4978− }
4979−}
4980−
4981−function _Crypto_ed25519SignatureToHex(sig)
4982−{
4983− return sig;
4984−}
4985−
4986−function _Crypto_ed25519GetPublicKey(privKey)
4987−{
4988− return _Crypto_toHex(_Crypto_noble.ed25519.getPublicKey(_Crypto_fromHex(privKey)));
4989−}
4990−
4991−var _Crypto_ed25519Sign = F2(function(msg, privKey)
4992−{
4993− return _Crypto_toHex(_Crypto_noble.ed25519.sign(_Crypto_fromHex(msg), _Crypto_fromHex(privKey)));
4994−});
4995−
4996−var _Crypto_ed25519Verify = F3(function(sig, msg, pubKey)
4997−{
4998− try {
4999− return _Crypto_noble.ed25519.verify(
5000− _Crypto_fromHex(sig),
5001− _Crypto_fromHex(msg),
5002− _Crypto_fromHex(pubKey)
5003− );
5004− } catch (e) {
5005− return false;
5006− }
5007−});
5008−
5009−
5010−// X25519 (Diffie-Hellman)
5011−
5012−function _Crypto_x25519PrivateKeyFromHex(hex)
5013−{
5014− try {
5015− var bytes = _Crypto_fromHex(hex);
5016− if (bytes.length !== 32) { return $elm$core$Maybe$Nothing; }
5017− _Crypto_noble.x25519.getPublicKey(bytes);
5018− return $elm$core$Maybe$Just(hex);
5019− } catch (e) {
5020− return $elm$core$Maybe$Nothing;
5021− }
5022−}
5023−
5024−function _Crypto_x25519PrivateKeyToHex(key)
5025−{
5026− return key;
5027−}
5028−
5029−function _Crypto_x25519PublicKeyFromHex(hex)
5030−{
5031− try {
5032− var bytes = _Crypto_fromHex(hex);
5033− if (bytes.length !== 32) { return $elm$core$Maybe$Nothing; }
5034− return $elm$core$Maybe$Just(hex);
5035− } catch (e) {
5036− return $elm$core$Maybe$Nothing;
5037− }
5038−}
5039−
5040−function _Crypto_x25519PublicKeyToHex(key)
5041−{
5042− return key;
5043−}
5044−
5045−function _Crypto_x25519GetPublicKey(privKey)
5046−{
5047− return _Crypto_toHex(_Crypto_noble.x25519.getPublicKey(_Crypto_fromHex(privKey)));
5048−}
5049−
5050−var _Crypto_x25519GetSharedSecret = F2(function(privKey, pubKey)
5051−{
5052− return _Crypto_toHex(_Crypto_noble.x25519.getSharedSecret(_Crypto_fromHex(privKey), _Crypto_fromHex(pubKey)));
5053−});
5054−
5055−
5056−// P-256 (secp256r1)
5057−
5058−function _Crypto_p256PrivateKeyFromHex(hex)
5059−{
5060− try {
5061− _Crypto_noble.p256.getPublicKey(_Crypto_fromHex(hex));
5062− return $elm$core$Maybe$Just(hex);
5063− } catch (e) {
5064− return $elm$core$Maybe$Nothing;
5065− }
5066−}
5067−
5068−function _Crypto_p256PrivateKeyToHex(key)
5069−{
5070− return key;
5071−}
5072−
5073−function _Crypto_p256PublicKeyFromHex(hex)
5074−{
5075− try {
5076− _Crypto_noble.p256.Point.fromHex(hex);
5077− return $elm$core$Maybe$Just(hex);
5078− } catch (e) {
5079− return $elm$core$Maybe$Nothing;
5080− }
5081−}
5082−
5083−function _Crypto_p256PublicKeyToHex(key)
5084−{
5085− return key;
5086−}
5087−
5088−function _Crypto_p256SignatureFromCompactHex(hex)
5089−{
5090− try {
5091− var bytes = _Crypto_fromHex(hex);
5092− if (bytes.length !== 64) { return $elm$core$Maybe$Nothing; }
5093− _Crypto_noble.p256.Signature.fromHex(hex);
5094− return $elm$core$Maybe$Just(hex);
5095− } catch (e) {
5096− return $elm$core$Maybe$Nothing;
5097− }
5098−}
5099−
5100−function _Crypto_p256SignatureFromDerHex(hex)
5101−{
5102− try {
5103− var compact = _Crypto_derToCompact(hex, 32);
5104− if (!compact) { return $elm$core$Maybe$Nothing; }
5105− _Crypto_noble.p256.Signature.fromHex(compact);
5106− return $elm$core$Maybe$Just(compact);
5107− } catch (e) {
5108− return $elm$core$Maybe$Nothing;
5109− }
5110−}
5111−
5112−function _Crypto_p256SignatureToCompactHex(sig)
5113−{
5114− return sig;
5115−}
5116−
5117−function _Crypto_p256SignatureToDerHex(compactHex)
5118−{
5119− return _Crypto_compactToDer(compactHex, 32);
5120−}
5121−
5122−function _Crypto_p256GetPublicKey(privKey)
5123−{
5124− return _Crypto_toHex(_Crypto_noble.p256.getPublicKey(_Crypto_fromHex(privKey)));
5125−}
5126−
5127−var _Crypto_p256Sign = F2(function(msgHash, privKey)
5128−{
5129− var sig = _Crypto_noble.p256.sign(_Crypto_fromHex(msgHash), _Crypto_fromHex(privKey));
5130− return _Crypto_toHex(sig);
5131−});
5132−
5133−var _Crypto_p256Verify = F3(function(sig, msgHash, pubKey)
5134−{
5135− try {
5136− return _Crypto_noble.p256.verify(
5137− _Crypto_fromHex(sig),
5138− _Crypto_fromHex(msgHash),
5139− _Crypto_fromHex(pubKey)
5140− );
5141− } catch (e) {
5142− return false;
5143− }
5144−});
5145−
5146−var _Crypto_p256GetSharedSecret = F2(function(privKey, pubKey)
5147−{
5148− return _Crypto_toHex(_Crypto_noble.p256.getSharedSecret(_Crypto_fromHex(privKey), _Crypto_fromHex(pubKey)));
5149−});
5150−
5151−
5152−// NOBLE/CIPHERS BUNDLE (https://github.com/paulmillr/noble-ciphers, MIT License)
5153−// Embedded: @noble/ciphers - AES-GCM, ChaCha20-Poly1305, XChaCha20-Poly1305
5154−
5155−var _Crypto_ciphers =(()=>{var _t=Object.defineProperty;var de=Object.getOwnPropertyDescriptor;var we=Object.getOwnPropertyNames;var be=Object.prototype.hasOwnProperty;var xe=(e,t)=>{for(var n in t)_t(e,n,{get:t[n],enumerable:!0})},me=(e,t,n,r)=>{if(t&&typeof t=="object"||typeof t=="function")for(let o of we(t))!be.call(e,o)&&o!==n&&_t(e,o,{get:()=>t[o],enumerable:!(r=de(t,o))||r.enumerable});return e};var Le=e=>me(_t({},"_"+"_esModule",{value:!0}),e);var Ge={};xe(Ge,{cbc:()=>ne,cfb:()=>re,chacha20poly1305:()=>pe,ctr:()=>Gt,ecb:()=>ee,gcm:()=>oe,managedNonce:()=>Vt,randomBytes:()=>xt,siv:()=>se,xchacha20poly1305:()=>ye});function Ee(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&e.constructor.name==="Uint8Array"}function yt(e){if(typeof e!="boolean")throw new Error(`boolean expected, not ${e}`)}function ht(e){if(!Number.isSafeInteger(e)||e<0)throw new Error("positive integer expected, got "+e)}function E(e,t,n=""){let r=Ee(e),o=e?.length,s=t!==void 0;if(!r||s&&o!==t){let i=n&&`"${n}" `,c=s?` of length ${t}`:"",f=r?`length=${o}`:`type=${typeof e}`;throw new Error(i+"expected Uint8Array"+c+", got "+f)}return e}function et(e,t=!0){if(e.destroyed)throw new Error("Hash instance has been destroyed");if(t&&e.finished)throw new Error("Hash#digest() has already been called")}function gt(e,t){E(e,void 0,"output");let n=t.outputLen;if(e.length<n)throw new Error("digestInto() expects output buffer of length at least "+n)}function dt(e){return new Uint8Array(e.buffer,e.byteOffset,e.byteLength)}function A(e){return new Uint32Array(e.buffer,e.byteOffset,Math.floor(e.byteLength/4))}function B(...e){for(let t=0;t<e.length;t++)e[t].fill(0)}function at(e){return new DataView(e.buffer,e.byteOffset,e.byteLength)}var Ue=new Uint8Array(new Uint32Array([287454020]).buffer)[0]===68;function vt(e,t){return e.buffer===t.buffer&&e.byteOffset<t.byteOffset+t.byteLength&&t.byteOffset<e.byteOffset+e.byteLength}function pt(e,t){if(vt(e,t)&&e.byteOffset<t.byteOffset)throw new Error("complex overlap of input and output is not supported")}function Ct(...e){let t=0;for(let r=0;r<e.length;r++){let o=e[r];E(o),t+=o.length}let n=new Uint8Array(t);for(let r=0,o=0;r<e.length;r++){let s=e[r];n.set(s,o),o+=s.length}return n}function jt(e,t){if(t==null||typeof t!="object")throw new Error("options must be defined");return Object.assign(e,t)}function wt(e,t){if(e.length!==t.length)return!1;let n=0;for(let r=0;r<e.length;r++)n|=e[r]^t[r];return n===0}var D=(e,t)=>{function n(r,...o){if(E(r,void 0,"key"),!Ue)throw new Error("Non little-endian hardware is not yet supported");if(e.nonceLength!==void 0){let h=o[0];E(h,e.varSizeNonce?void 0:e.nonceLength,"nonce")}let s=e.tagLength;s&&o[1]!==void 0&&E(o[1],void 0,"AAD");let i=t(r,...o),c=(h,a)=>{if(a!==void 0){if(h!==2)throw new Error("cipher output not supported");E(a,void 0,"output")}},f=!1;return{encrypt(h,a){if(f)throw new Error("cannot encrypt() twice with same key + nonce");return f=!0,E(h),c(i.encrypt.length,a),i.encrypt(h,a)},decrypt(h,a){if(E(h),s&&h.length<s)throw new Error('"ciphertext" expected length bigger than tagLength='+s);return c(i.decrypt.length,a),i.decrypt(h,a)}}}return Object.assign(n,e),n};function Q(e,t,n=!0){if(t===void 0)return new Uint8Array(e);if(t.length!==e)throw new Error('"output" expected Uint8Array of length '+e+", got: "+t.length);if(n&&!Z(t))throw new Error("invalid output, must be aligned");return t}function bt(e,t,n){yt(n);let r=new Uint8Array(16),o=at(r);return o.setBigUint64(0,BigInt(t),n),o.setBigUint64(8,BigInt(e),n),r}function Z(e){return e.byteOffset%4===0}function z(e){return Uint8Array.from(e)}function xt(e=32){let t=typeof globalThis=="object"?globalThis.crypto:null;if(typeof t?.getRandomValues!="function")throw new Error("crypto.getRandomValues must be defined");return t.getRandomValues(new Uint8Array(e))}function Vt(e,t=xt){let{nonceLength:n}=e;ht(n);let r=(o,s)=>{let i=Ct(o,s);return s.fill(0),i};return((o,...s)=>({encrypt(i){E(i);let c=t(n),f=e(o,c,...s).encrypt(i);return f instanceof Promise?f.then(l=>r(c,l)):r(c,f)},decrypt(i){E(i);let c=i.subarray(0,n),f=i.subarray(n);return e(o,c,...s).decrypt(f)}}))}var tt=16,Tt=new Uint8Array(16),G=A(Tt),Ae=225,Be=(e,t,n,r)=>{let o=r&1;return{s3:n<<31|r>>>1,s2:t<<31|n>>>1,s1:e<<31|t>>>1,s0:e>>>1^Ae<<24&-(o&1)}},F=e=>(e>>>0&255)<<24|(e>>>8&255)<<16|(e>>>16&255)<<8|e>>>24&255|0;function _e(e){e.reverse();let t=e[15]&1,n=0;for(let r=0;r<e.length;r++){let o=e[r];e[r]=o>>>1|n,n=(o&1)<<7}return e[0]^=-t&225,e}var ve=e=>e>64*1024?8:e>1024?4:2,mt=class{blockLen=tt;outputLen=tt;s0=0;s1=0;s2=0;s3=0;finished=!1;t;W;windowSize;constructor(t,n){E(t,16,"key"),t=z(t);let r=at(t),o=r.getUint32(0,!1),s=r.getUint32(4,!1),i=r.getUint32(8,!1),c=r.getUint32(12,!1),f=[];for(let u=0;u<128;u++)f.push({s0:F(o),s1:F(s),s2:F(i),s3:F(c)}),{s0:o,s1:s,s2:i,s3:c}=Be(o,s,i,c);let l=ve(n||1024);if(![1,2,4,8].includes(l))throw new Error("ghash: invalid window size, expected 2, 4 or 8");this.W=l;let a=128/l,p=this.windowSize=2**l,g=[];for(let u=0;u<a;u++)for(let y=0;y<p;y++){let d=0,w=0,b=0,U=0;for(let L=0;L<l;L++){if(!(y>>>l-L-1&1))continue;let{s0:v,s1:j,s2:V,s3:_}=f[l*u+L];d^=v,w^=j,b^=V,U^=_}g.push({s0:d,s1:w,s2:b,s3:U})}this.t=g}_updateBlock(t,n,r,o){t^=this.s0,n^=this.s1,r^=this.s2,o^=this.s3;let{W:s,t:i,windowSize:c}=this,f=0,l=0,h=0,a=0,p=(1<<s)-1,g=0;for(let u of[t,n,r,o])for(let y=0;y<4;y++){let d=u>>>8*y&255;for(let w=8/s-1;w>=0;w--){let b=d>>>s*w&p,{s0:U,s1:L,s2:C,s3:v}=i[g*c+b];f^=U,l^=L,h^=C,a^=v,g+=1}}this.s0=f,this.s1=l,this.s2=h,this.s3=a}update(t){et(this),E(t),t=z(t);let n=A(t),r=Math.floor(t.length/tt),o=t.length%tt;for(let s=0;s<r;s++)this._updateBlock(n[s*4+0],n[s*4+1],n[s*4+2],n[s*4+3]);return o&&(Tt.set(t.subarray(r*tt)),this._updateBlock(G[0],G[1],G[2],G[3]),B(G)),this}destroy(){let{t}=this;for(let n of t)n.s0=0,n.s1=0,n.s2=0,n.s3=0}digestInto(t){et(this),gt(t,this),this.finished=!0;let{s0:n,s1:r,s2:o,s3:s}=this,i=A(t);return i[0]=n,i[1]=r,i[2]=o,i[3]=s,t}digest(){let t=new Uint8Array(tt);return this.digestInto(t),this.destroy(),t}},St=class extends mt{constructor(t,n){E(t);let r=_e(z(t));super(r,n),B(r)}update(t){et(this),E(t),t=z(t);let n=A(t),r=t.length%tt,o=Math.floor(t.length/tt);for(let s=0;s<o;s++)this._updateBlock(F(n[s*4+3]),F(n[s*4+2]),F(n[s*4+1]),F(n[s*4+0]));return r&&(Tt.set(t.subarray(o*tt)),this._updateBlock(F(G[3]),F(G[2]),F(G[1]),F(G[0])),B(G)),this}digestInto(t){et(this),gt(t,this),this.finished=!0;let{s0:n,s1:r,s2:o,s3:s}=this,i=A(t);return i[0]=n,i[1]=r,i[2]=o,i[3]=s,t.reverse()}};function Ht(e){let t=(r,o)=>e(o,r.length).update(r).digest(),n=e(new Uint8Array(16),0);return t.outputLen=n.outputLen,t.blockLen=n.blockLen,t.create=(r,o)=>e(r,o),t}var It=Ht((e,t)=>new mt(e,t)),Ce=Ht((e,t)=>new St(e,t));var R=16,Nt=4,Lt=new Uint8Array(R);var Se=283;function $t(e){if(![16,24,32].includes(e.length))throw new Error('"aes key" expected Uint8Array of length 16/24/32, got length='+e.length)}function Mt(e){return e<<1^Se&-(e>>7)}function ut(e,t){let n=0;for(;t>0;t>>=1)n^=e&-(t&1),e=Mt(e);return n}var Te=(e,t,n=1)=>{if(!Number.isSafeInteger(n))throw new Error("incBytes: wrong carry "+n);E(e);for(let r=0;r<e.length;r++){let o=t?r:e.length-1-r;n=n+(e[o]&255)|0,e[o]=n&255,n>>>=8}},kt=(()=>{let e=new Uint8Array(256);for(let n=0,r=1;n<256;n++,r^=Mt(r))e[n]=r;let t=new Uint8Array(256);t[0]=99;for(let n=0;n<255;n++){let r=e[255-n];r|=r<<8,t[e[n]]=(r^r>>4^r>>5^r>>6^r>>7^99)&255}return B(e),t})(),Ie=kt.map((e,t)=>kt.indexOf(t)),Ke=e=>e<<24|e>>>8,Kt=e=>e<<8|e>>>24;function Zt(e,t){if(e.length!==256)throw new Error("Wrong sbox length");let n=new Uint32Array(256).map((l,h)=>t(e[h])),r=n.map(Kt),o=r.map(Kt),s=o.map(Kt),i=new Uint32Array(256*256),c=new Uint32Array(256*256),f=new Uint16Array(256*256);for(let l=0;l<256;l++)for(let h=0;h<256;h++){let a=l*256+h;i[a]=n[l]^r[h],c[a]=o[l]^s[h],f[a]=e[l]<<8|e[h]}return{sbox:e,sbox2:f,T0:n,T1:r,T2:o,T3:s,T01:i,T23:c}}var Pt=Zt(kt,e=>ut(e,3)<<24|e<<16|e<<8|ut(e,2)),Ft=Zt(Ie,e=>ut(e,11)<<24|ut(e,13)<<16|ut(e,9)<<8|ut(e,14)),Oe=(()=>{let e=new Uint8Array(16);for(let t=0,n=1;t<16;t++,n=Mt(n))e[t]=n;return e})();function rt(e){E(e);let t=e.length;$t(e);let{sbox2:n}=Pt,r=[];Z(e)||r.push(e=z(e));let o=A(e),s=o.length,i=f=>J(n,f,f,f,f),c=new Uint32Array(t+28);c.set(o);for(let f=s;f<c.length;f++){let l=c[f-1];f%s===0?l=i(Ke(l))^Oe[f/s-1]:s>6&&f%s===4&&(l=i(l)),c[f]=c[f-s]^l}return B(...r),c}function Yt(e){let t=rt(e),n=t.slice(),r=t.length,{sbox2:o}=Pt,{T0:s,T1:i,T2:c,T3:f}=Ft;for(let l=0;l<r;l+=4)for(let h=0;h<4;h++)n[l+h]=t[r-l-4+h];B(t);for(let l=4;l<r-4;l++){let h=n[l],a=J(o,h,h,h,h);n[l]=s[a&255]^i[a>>>8&255]^c[a>>>16&255]^f[a>>>24]}return n}function nt(e,t,n,r,o,s){return e[n<<8&65280|r>>>8&255]^t[o>>>8&65280|s>>>24&255]}function J(e,t,n,r,o){return e[t&255|n&65280]|e[r>>>16&255|o>>>16&65280]<<16}function Y(e,t,n,r,o){let{sbox2:s,T01:i,T23:c}=Pt,f=0;t^=e[f++],n^=e[f++],r^=e[f++],o^=e[f++];let l=e.length/4-2;for(let u=0;u<l;u++){let y=e[f++]^nt(i,c,t,n,r,o),d=e[f++]^nt(i,c,n,r,o,t),w=e[f++]^nt(i,c,r,o,t,n),b=e[f++]^nt(i,c,o,t,n,r);t=y,n=d,r=w,o=b}let h=e[f++]^J(s,t,n,r,o),a=e[f++]^J(s,n,r,o,t),p=e[f++]^J(s,r,o,t,n),g=e[f++]^J(s,o,t,n,r);return{s0:h,s1:a,s2:p,s3:g}}function Qt(e,t,n,r,o){let{sbox2:s,T01:i,T23:c}=Ft,f=0;t^=e[f++],n^=e[f++],r^=e[f++],o^=e[f++];let l=e.length/4-2;for(let u=0;u<l;u++){let y=e[f++]^nt(i,c,t,o,r,n),d=e[f++]^nt(i,c,n,t,o,r),w=e[f++]^nt(i,c,r,n,t,o),b=e[f++]^nt(i,c,o,r,n,t);t=y,n=d,r=w,o=b}let h=e[f++]^J(s,t,o,r,n),a=e[f++]^J(s,n,t,o,r),p=e[f++]^J(s,r,n,t,o),g=e[f++]^J(s,o,r,n,t);return{s0:h,s1:a,s2:p,s3:g}}function ke(e,t,n,r){E(t,R,"nonce"),E(n);let o=n.length;r=Q(o,r),pt(n,r);let s=t,i=A(s),{s0:c,s1:f,s2:l,s3:h}=Y(e,i[0],i[1],i[2],i[3]),a=A(n),p=A(r);for(let u=0;u+4<=a.length;u+=4)p[u+0]=a[u+0]^c,p[u+1]=a[u+1]^f,p[u+2]=a[u+2]^l,p[u+3]=a[u+3]^h,Te(s,!1,1),{s0:c,s1:f,s2:l,s3:h}=Y(e,i[0],i[1],i[2],i[3]);let g=R*Math.floor(a.length/Nt);if(g<o){let u=new Uint32Array([c,f,l,h]),y=dt(u);for(let d=g,w=0;d<o;d++,w++)r[d]=n[d]^y[w];B(u)}return r}function Et(e,t,n,r,o){E(n,R,"nonce"),E(r),o=Q(r.length,o);let s=n,i=A(s),c=at(s),f=A(r),l=A(o),h=t?0:12,a=r.length,p=c.getUint32(h,t),{s0:g,s1:u,s2:y,s3:d}=Y(e,i[0],i[1],i[2],i[3]);for(let b=0;b+4<=f.length;b+=4)l[b+0]=f[b+0]^g,l[b+1]=f[b+1]^u,l[b+2]=f[b+2]^y,l[b+3]=f[b+3]^d,p=p+1>>>0,c.setUint32(h,p,t),{s0:g,s1:u,s2:y,s3:d}=Y(e,i[0],i[1],i[2],i[3]);let w=R*Math.floor(f.length/Nt);if(w<a){let b=new Uint32Array([g,u,y,d]),U=dt(b);for(let L=w,C=0;L<a;L++,C++)o[L]=r[L]^U[C];B(b)}return o}var Gt=D({blockSize:16,nonceLength:16},function(t,n){function r(o,s){if(E(o),s!==void 0&&(E(s),!Z(s)))throw new Error("unaligned destination");let i=rt(t),c=z(n),f=[i,c];Z(o)||f.push(o=z(o));let l=ke(i,c,o,s);return B(...f),l}return{encrypt:(o,s)=>r(o,s),decrypt:(o,s)=>r(o,s)}});function Jt(e){if(E(e),e.length%R!==0)throw new Error("aes-(cbc/ecb).decrypt ciphertext should consist of blocks with size "+R)}function Xt(e,t,n){E(e);let r=e.length,o=r%R;if(!t&&o!==0)throw new Error("aec/(cbc-ecb): unpadded plaintext with disabled padding");Z(e)||(e=z(e));let s=A(e);if(t){let c=R-o;c||(c=R),r=r+c}n=Q(r,n),pt(e,n);let i=A(n);return{b:s,o:i,out:n}}function Dt(e,t){if(!t)return e;let n=e.length;if(!n)throw new Error("aes/pcks5: empty ciphertext not allowed");let r=e[n-1];if(r<=0||r>16)throw new Error("aes/pcks5: wrong padding");let o=e.subarray(0,-r);for(let s=0;s<r;s++)if(e[n-s-1]!==r)throw new Error("aes/pcks5: wrong padding");return o}function te(e){let t=new Uint8Array(16),n=A(t);t.set(e);let r=R-e.length;for(let o=R-r;o<R;o++)t[o]=r;return n}var ee=D({blockSize:16},function(t,n={}){let r=!n.disablePadding;return{encrypt(o,s){let{b:i,o:c,out:f}=Xt(o,r,s),l=rt(t),h=0;for(;h+4<=i.length;){let{s0:a,s1:p,s2:g,s3:u}=Y(l,i[h+0],i[h+1],i[h+2],i[h+3]);c[h++]=a,c[h++]=p,c[h++]=g,c[h++]=u}if(r){let a=te(o.subarray(h*4)),{s0:p,s1:g,s2:u,s3:y}=Y(l,a[0],a[1],a[2],a[3]);c[h++]=p,c[h++]=g,c[h++]=u,c[h++]=y}return B(l),f},decrypt(o,s){Jt(o);let i=Yt(t);s=Q(o.length,s);let c=[i];Z(o)||c.push(o=z(o)),pt(o,s);let f=A(o),l=A(s);for(let h=0;h+4<=f.length;){let{s0:a,s1:p,s2:g,s3:u}=Qt(i,f[h+0],f[h+1],f[h+2],f[h+3]);l[h++]=a,l[h++]=p,l[h++]=g,l[h++]=u}return B(...c),Dt(s,r)}}}),ne=D({blockSize:16,nonceLength:16},function(t,n,r={}){let o=!r.disablePadding;return{encrypt(s,i){let c=rt(t),{b:f,o:l,out:h}=Xt(s,o,i),a=n,p=[c];Z(a)||p.push(a=z(a));let g=A(a),u=g[0],y=g[1],d=g[2],w=g[3],b=0;for(;b+4<=f.length;)u^=f[b+0],y^=f[b+1],d^=f[b+2],w^=f[b+3],{s0:u,s1:y,s2:d,s3:w}=Y(c,u,y,d,w),l[b++]=u,l[b++]=y,l[b++]=d,l[b++]=w;if(o){let U=te(s.subarray(b*4));u^=U[0],y^=U[1],d^=U[2],w^=U[3],{s0:u,s1:y,s2:d,s3:w}=Y(c,u,y,d,w),l[b++]=u,l[b++]=y,l[b++]=d,l[b++]=w}return B(...p),h},decrypt(s,i){Jt(s);let c=Yt(t),f=n,l=[c];Z(f)||l.push(f=z(f));let h=A(f);i=Q(s.length,i),Z(s)||l.push(s=z(s)),pt(s,i);let a=A(s),p=A(i),g=h[0],u=h[1],y=h[2],d=h[3];for(let w=0;w+4<=a.length;){let b=g,U=u,L=y,C=d;g=a[w+0],u=a[w+1],y=a[w+2],d=a[w+3];let{s0:v,s1:j,s2:V,s3:_}=Qt(c,g,u,y,d);p[w++]=v^b,p[w++]=j^U,p[w++]=V^L,p[w++]=_^C}return B(...l),Dt(i,o)}}}),re=D({blockSize:16,nonceLength:16},function(t,n){function r(o,s,i){E(o);let c=o.length;if(i=Q(c,i),vt(o,i))throw new Error("overlapping src and dst not supported.");let f=rt(t),l=n,h=[f];Z(l)||h.push(l=z(l)),Z(o)||h.push(o=z(o));let a=A(o),p=A(i),g=s?p:a,u=A(l),y=u[0],d=u[1],w=u[2],b=u[3];for(let L=0;L+4<=a.length;){let{s0:C,s1:v,s2:j,s3:V}=Y(f,y,d,w,b);p[L+0]=a[L+0]^C,p[L+1]=a[L+1]^v,p[L+2]=a[L+2]^j,p[L+3]=a[L+3]^V,y=g[L++],d=g[L++],w=g[L++],b=g[L++]}let U=R*Math.floor(a.length/Nt);if(U<c){({s0:y,s1:d,s2:w,s3:b}=Y(f,y,d,w,b));let L=dt(new Uint32Array([y,d,w,b]));for(let C=U,v=0;C<c;C++,v++)i[C]=o[C]^L[v];B(L)}return B(...h),i}return{encrypt:(o,s)=>r(o,!0,s),decrypt:(o,s)=>r(o,!1,s)}});function Ne(e,t,n,r,o){let s=o?o.length:0,i=e.create(n,r.length+s);o&&i.update(o);let c=bt(8*r.length,8*s,t);i.update(r),i.update(c);let f=i.digest();return B(c),f}var oe=D({blockSize:16,nonceLength:12,tagLength:16,varSizeNonce:!0},function(t,n,r){if(n.length<8)throw new Error("aes/gcm: invalid nonce length");let o=16;function s(c,f,l){let h=Ne(It,!1,c,l,r);for(let a=0;a<f.length;a++)h[a]^=f[a];return h}function i(){let c=rt(t),f=Lt.slice(),l=Lt.slice();if(Et(c,!1,l,l,f),n.length===12)l.set(n);else{let a=Lt.slice();at(a).setBigUint64(8,BigInt(n.length*8),!1);let g=It.create(f).update(n).update(a);g.digestInto(l),g.destroy()}let h=Et(c,!1,l,Lt);return{xk:c,authKey:f,counter:l,tagMask:h}}return{encrypt(c){let{xk:f,authKey:l,counter:h,tagMask:a}=i(),p=new Uint8Array(c.length+o),g=[f,l,h,a];Z(c)||g.push(c=z(c)),Et(f,!1,h,c,p.subarray(0,c.length));let u=s(l,a,p.subarray(0,p.length-o));return g.push(u),p.set(u,c.length),B(...g),p},decrypt(c){let{xk:f,authKey:l,counter:h,tagMask:a}=i(),p=[f,l,a,h];Z(c)||p.push(c=z(c));let g=c.subarray(0,-o),u=c.subarray(-o),y=s(l,a,g);if(p.push(y),!wt(y,u))throw new Error("aes/gcm: invalid ghash tag");let d=Et(f,!1,h,g);return B(...p),d}}});function Me(e){return e instanceof Uint32Array||ArrayBuffer.isView(e)&&e.constructor.name==="Uint32Array"}function Ot(e,t){if(E(t,16,"block"),!Me(e))throw new Error("_encryptBlock accepts result of expandKeyLE");let n=A(t),{s0:r,s1:o,s2:s,s3:i}=Y(e,n[0],n[1],n[2],n[3]);return n[0]=r,n[1]=o,n[2]=s,n[3]=i,t}function qt(e){let t=0;for(let n=R-1;n>=0;n--){let r=(e[n]&128)>>>7;e[n]=e[n]<<1|t,t=r}return t&&(e[R-1]^=135),e}function Ut(e,t){if(e.length!==t.length)throw new Error("xorBlock: blocks must have same length");for(let n=0;n<e.length;n++)e[n]=e[n]^t[n];return e}var At=class{buffer;destroyed;k1;k2;xk;constructor(t){E(t),$t(t),this.xk=rt(t),this.buffer=new Uint8Array(0),this.destroyed=!1;let n=new Uint8Array(R);Ot(this.xk,n),this.k1=qt(n),this.k2=qt(new Uint8Array(this.k1))}update(t){let{destroyed:n,buffer:r}=this;if(n)throw new Error("CMAC instance was destroyed");E(t);let o=new Uint8Array(r.length+t.length);return o.set(r),o.set(t,r.length),this.buffer=o,this}digest(){if(this.destroyed)throw new Error("CMAC instance was destroyed");let{buffer:t}=this,n=t.length,r=Math.ceil(n/R),o;r===0?(r=1,o=!1):o=n%R===0;let s=(r-1)*R,i=t.subarray(s),c;if(o)c=Ut(new Uint8Array(i),this.k1);else{let l=new Uint8Array(R);l.set(i),l[i.length]=128,c=Ut(l,this.k2)}let f=new Uint8Array(R);for(let l=0;l<r-1;l++){let h=t.subarray(l*R,(l+1)*R);Ut(f,h),Ot(this.xk,f)}return Ut(f,c),Ot(this.xk,f),B(c),f}destroy(){let{buffer:t,destroyed:n,xk:r,k1:o,k2:s}=this;n||(this.destroyed=!0,B(t,r,o,s))}},Pe=(e,t)=>new At(e).update(t).digest();Pe.create=e=>new At(e);var se=()=>{throw new Error('"siv" from v1 is now "gcmsiv"')};var ie=e=>Uint8Array.from(e.split(""),t=>t.charCodeAt(0)),ze=ie("expand 16-byte k"),Re=ie("expand 32-byte k"),We=A(ze),je=A(Re);function x(e,t){return e<<t|e>>>32-t}function zt(e){return e.byteOffset%4===0}var Bt=64,Ve=16,fe=2**32-1,ce=Uint32Array.of();function He(e,t,n,r,o,s,i,c){let f=o.length,l=new Uint8Array(Bt),h=A(l),a=zt(o)&&zt(s),p=a?A(o):ce,g=a?A(s):ce;for(let u=0;u<f;i++){if(e(t,n,r,h,i,c),i>=fe)throw new Error("arx: counter overflow");let y=Math.min(Bt,f-u);if(a&&y===Bt){let d=u/4;if(u%4!==0)throw new Error("arx: invalid block position");for(let w=0,b;w<Ve;w++)b=d+w,g[b]=p[b]^h[w];u+=Bt;continue}for(let d=0,w;d<y;d++)w=u+d,s[w]=o[w]^l[d];u+=y}}function Rt(e,t){let{allowShortKeys:n,extendNonceFn:r,counterLength:o,counterRight:s,rounds:i}=jt({allowShortKeys:!1,counterLength:8,counterRight:!1,rounds:20},t);if(typeof e!="function")throw new Error("core must be a function");return ht(o),ht(i),yt(s),yt(n),(c,f,l,h,a=0)=>{E(c,void 0,"key"),E(f,void 0,"nonce"),E(l,void 0,"data");let p=l.length;if(h===void 0&&(h=new Uint8Array(p)),E(h,void 0,"output"),ht(a),a<0||a>=fe)throw new Error("arx: counter overflow");if(h.length<p)throw new Error(`arx: output (${h.length}) is shorter than data (${p})`);let g=[],u=c.length,y,d;if(u===32)g.push(y=z(c)),d=je;else if(u===16&&n)y=new Uint8Array(32),y.set(c),y.set(c,16),d=We,g.push(y);else throw E(c,32,"arx key"),new Error("invalid key size");zt(f)||g.push(f=z(f));let w=A(y);if(r){if(f.length!==24)throw new Error("arx: extended nonce must be 24 bytes");r(d,w,A(f.subarray(0,16)),w),f=f.subarray(16)}let b=16-o;if(b!==f.length)throw new Error(`arx: nonce must be ${b} or 16 bytes`);if(b!==12){let L=new Uint8Array(12);L.set(f,s?0:12-f.length),f=L,g.push(f)}let U=A(f);return He(e,d,w,U,l,h,a,i),B(...g),h}}function $(e,t){return e[t++]&255|(e[t++]&255)<<8}var Wt=class{blockLen=16;outputLen=16;buffer=new Uint8Array(16);r=new Uint16Array(10);h=new Uint16Array(10);pad=new Uint16Array(8);pos=0;finished=!1;constructor(t){t=z(E(t,32,"key"));let n=$(t,0),r=$(t,2),o=$(t,4),s=$(t,6),i=$(t,8),c=$(t,10),f=$(t,12),l=$(t,14);this.r[0]=n&8191,this.r[1]=(n>>>13|r<<3)&8191,this.r[2]=(r>>>10|o<<6)&7939,this.r[3]=(o>>>7|s<<9)&8191,this.r[4]=(s>>>4|i<<12)&255,this.r[5]=i>>>1&8190,this.r[6]=(i>>>14|c<<2)&8191,this.r[7]=(c>>>11|f<<5)&8065,this.r[8]=(f>>>8|l<<8)&8191,this.r[9]=l>>>5&127;for(let h=0;h<8;h++)this.pad[h]=$(t,16+2*h)}process(t,n,r=!1){let o=r?0:2048,{h:s,r:i}=this,c=i[0],f=i[1],l=i[2],h=i[3],a=i[4],p=i[5],g=i[6],u=i[7],y=i[8],d=i[9],w=$(t,n+0),b=$(t,n+2),U=$(t,n+4),L=$(t,n+6),C=$(t,n+8),v=$(t,n+10),j=$(t,n+12),V=$(t,n+14),_=s[0]+(w&8191),S=s[1]+((w>>>13|b<<3)&8191),T=s[2]+((b>>>10|U<<6)&8191),I=s[3]+((U>>>7|L<<9)&8191),K=s[4]+((L>>>4|C<<12)&8191),O=s[5]+(C>>>1&8191),k=s[6]+((C>>>14|v<<2)&8191),N=s[7]+((v>>>11|j<<5)&8191),M=s[8]+((j>>>8|V<<8)&8191),P=s[9]+(V>>>5|o),m=0,H=m+_*c+S*(5*d)+T*(5*y)+I*(5*u)+K*(5*g);m=H>>>13,H&=8191,H+=O*(5*p)+k*(5*a)+N*(5*h)+M*(5*l)+P*(5*f),m+=H>>>13,H&=8191;let q=m+_*f+S*c+T*(5*d)+I*(5*y)+K*(5*u);m=q>>>13,q&=8191,q+=O*(5*g)+k*(5*p)+N*(5*a)+M*(5*h)+P*(5*l),m+=q>>>13,q&=8191;let W=m+_*l+S*f+T*c+I*(5*d)+K*(5*y);m=W>>>13,W&=8191,W+=O*(5*u)+k*(5*g)+N*(5*p)+M*(5*a)+P*(5*h),m+=W>>>13,W&=8191;let X=m+_*h+S*l+T*f+I*c+K*(5*d);m=X>>>13,X&=8191,X+=O*(5*y)+k*(5*u)+N*(5*g)+M*(5*p)+P*(5*a),m+=X>>>13,X&=8191;let ot=m+_*a+S*h+T*l+I*f+K*c;m=ot>>>13,ot&=8191,ot+=O*(5*d)+k*(5*y)+N*(5*u)+M*(5*g)+P*(5*p),m+=ot>>>13,ot&=8191;let st=m+_*p+S*a+T*h+I*l+K*f;m=st>>>13,st&=8191,st+=O*c+k*(5*d)+N*(5*y)+M*(5*u)+P*(5*g),m+=st>>>13,st&=8191;let ct=m+_*g+S*p+T*a+I*h+K*l;m=ct>>>13,ct&=8191,ct+=O*f+k*c+N*(5*d)+M*(5*y)+P*(5*u),m+=ct>>>13,ct&=8191;let it=m+_*u+S*g+T*p+I*a+K*h;m=it>>>13,it&=8191,it+=O*l+k*f+N*c+M*(5*d)+P*(5*y),m+=it>>>13,it&=8191;let ft=m+_*y+S*u+T*g+I*p+K*a;m=ft>>>13,ft&=8191,ft+=O*h+k*l+N*f+M*c+P*(5*d),m+=ft>>>13,ft&=8191;let lt=m+_*d+S*y+T*u+I*g+K*p;m=lt>>>13,lt&=8191,lt+=O*a+k*h+N*l+M*f+P*c,m+=lt>>>13,lt&=8191,m=(m<<2)+m|0,m=m+H|0,H=m&8191,m=m>>>13,q+=m,s[0]=H,s[1]=q,s[2]=W,s[3]=X,s[4]=ot,s[5]=st,s[6]=ct,s[7]=it,s[8]=ft,s[9]=lt}finalize(){let{h:t,pad:n}=this,r=new Uint16Array(10),o=t[1]>>>13;t[1]&=8191;for(let c=2;c<10;c++)t[c]+=o,o=t[c]>>>13,t[c]&=8191;t[0]+=o*5,o=t[0]>>>13,t[0]&=8191,t[1]+=o,o=t[1]>>>13,t[1]&=8191,t[2]+=o,r[0]=t[0]+5,o=r[0]>>>13,r[0]&=8191;for(let c=1;c<10;c++)r[c]=t[c]+o,o=r[c]>>>13,r[c]&=8191;r[9]-=8192;let s=(o^1)-1;for(let c=0;c<10;c++)r[c]&=s;s=~s;for(let c=0;c<10;c++)t[c]=t[c]&s|r[c];t[0]=(t[0]|t[1]<<13)&65535,t[1]=(t[1]>>>3|t[2]<<10)&65535,t[2]=(t[2]>>>6|t[3]<<7)&65535,t[3]=(t[3]>>>9|t[4]<<4)&65535,t[4]=(t[4]>>>12|t[5]<<1|t[6]<<14)&65535,t[5]=(t[6]>>>2|t[7]<<11)&65535,t[6]=(t[7]>>>5|t[8]<<8)&65535,t[7]=(t[8]>>>8|t[9]<<5)&65535;let i=t[0]+n[0];t[0]=i&65535;for(let c=1;c<8;c++)i=(t[c]+n[c]|0)+(i>>>16)|0,t[c]=i&65535;B(r)}update(t){et(this),E(t),t=z(t);let{buffer:n,blockLen:r}=this,o=t.length;for(let s=0;s<o;){let i=Math.min(r-this.pos,o-s);if(i===r){for(;r<=o-s;s+=r)this.process(t,s);continue}n.set(t.subarray(s,s+i),this.pos),this.pos+=i,s+=i,this.pos===r&&(this.process(n,0,!1),this.pos=0)}return this}destroy(){B(this.h,this.r,this.buffer,this.pad)}digestInto(t){et(this),gt(t,this),this.finished=!0;let{buffer:n,h:r}=this,{pos:o}=this;if(o){for(n[o++]=1;o<16;o++)n[o]=0;this.process(n,0,!0)}this.finalize();let s=0;for(let i=0;i<8;i++)t[s++]=r[i]>>>0,t[s++]=r[i]>>>8;return t}digest(){let{buffer:t,outputLen:n}=this;this.digestInto(t);let r=t.slice(0,n);return this.destroy(),r}};function qe(e){let t=(r,o)=>e(o).update(r).digest(),n=e(new Uint8Array(32));return t.outputLen=n.outputLen,t.blockLen=n.blockLen,t.create=r=>e(r),t}var le=qe(e=>new Wt(e));function ue(e,t,n,r,o,s=20){let i=e[0],c=e[1],f=e[2],l=e[3],h=t[0],a=t[1],p=t[2],g=t[3],u=t[4],y=t[5],d=t[6],w=t[7],b=o,U=n[0],L=n[1],C=n[2],v=i,j=c,V=f,_=l,S=h,T=a,I=p,K=g,O=u,k=y,N=d,M=w,P=b,m=U,H=L,q=C;for(let X=0;X<s;X+=2)v=v+S|0,P=x(P^v,16),O=O+P|0,S=x(S^O,12),v=v+S|0,P=x(P^v,8),O=O+P|0,S=x(S^O,7),j=j+T|0,m=x(m^j,16),k=k+m|0,T=x(T^k,12),j=j+T|0,m=x(m^j,8),k=k+m|0,T=x(T^k,7),V=V+I|0,H=x(H^V,16),N=N+H|0,I=x(I^N,12),V=V+I|0,H=x(H^V,8),N=N+H|0,I=x(I^N,7),_=_+K|0,q=x(q^_,16),M=M+q|0,K=x(K^M,12),_=_+K|0,q=x(q^_,8),M=M+q|0,K=x(K^M,7),v=v+T|0,q=x(q^v,16),N=N+q|0,T=x(T^N,12),v=v+T|0,q=x(q^v,8),N=N+q|0,T=x(T^N,7),j=j+I|0,P=x(P^j,16),M=M+P|0,I=x(I^M,12),j=j+I|0,P=x(P^j,8),M=M+P|0,I=x(I^M,7),V=V+K|0,m=x(m^V,16),O=O+m|0,K=x(K^O,12),V=V+K|0,m=x(m^V,8),O=O+m|0,K=x(K^O,7),_=_+S|0,H=x(H^_,16),k=k+H|0,S=x(S^k,12),_=_+S|0,H=x(H^_,8),k=k+H|0,S=x(S^k,7);let W=0;r[W++]=i+v|0,r[W++]=c+j|0,r[W++]=f+V|0,r[W++]=l+_|0,r[W++]=h+S|0,r[W++]=a+T|0,r[W++]=p+I|0,r[W++]=g+K|0,r[W++]=u+O|0,r[W++]=y+k|0,r[W++]=d+N|0,r[W++]=w+M|0,r[W++]=b+P|0,r[W++]=U+m|0,r[W++]=L+H|0,r[W++]=C+q|0}function $e(e,t,n,r){let o=e[0],s=e[1],i=e[2],c=e[3],f=t[0],l=t[1],h=t[2],a=t[3],p=t[4],g=t[5],u=t[6],y=t[7],d=n[0],w=n[1],b=n[2],U=n[3];for(let C=0;C<20;C+=2)o=o+f|0,d=x(d^o,16),p=p+d|0,f=x(f^p,12),o=o+f|0,d=x(d^o,8),p=p+d|0,f=x(f^p,7),s=s+l|0,w=x(w^s,16),g=g+w|0,l=x(l^g,12),s=s+l|0,w=x(w^s,8),g=g+w|0,l=x(l^g,7),i=i+h|0,b=x(b^i,16),u=u+b|0,h=x(h^u,12),i=i+h|0,b=x(b^i,8),u=u+b|0,h=x(h^u,7),c=c+a|0,U=x(U^c,16),y=y+U|0,a=x(a^y,12),c=c+a|0,U=x(U^c,8),y=y+U|0,a=x(a^y,7),o=o+l|0,U=x(U^o,16),u=u+U|0,l=x(l^u,12),o=o+l|0,U=x(U^o,8),u=u+U|0,l=x(l^u,7),s=s+h|0,d=x(d^s,16),y=y+d|0,h=x(h^y,12),s=s+h|0,d=x(d^s,8),y=y+d|0,h=x(h^y,7),i=i+a|0,w=x(w^i,16),p=p+w|0,a=x(a^p,12),i=i+a|0,w=x(w^i,8),p=p+w|0,a=x(a^p,7),c=c+f|0,b=x(b^c,16),g=g+b|0,f=x(f^g,12),c=c+f|0,b=x(b^c,8),g=g+b|0,f=x(f^g,7);let L=0;r[L++]=o,r[L++]=s,r[L++]=i,r[L++]=c,r[L++]=d,r[L++]=w,r[L++]=b,r[L++]=U}var Ze=Rt(ue,{counterRight:!1,counterLength:4,allowShortKeys:!1}),Fe=Rt(ue,{counterRight:!1,counterLength:8,extendNonceFn:$e,allowShortKeys:!1});var Ye=new Uint8Array(16),he=(e,t)=>{e.update(t);let n=t.length%16;n&&e.update(Ye.subarray(n))},Qe=new Uint8Array(32);function ae(e,t,n,r,o){o!==void 0&&E(o,void 0,"AAD");let s=e(t,n,Qe),i=bt(r.length,o?o.length:0,!0),c=le.create(s);o&&he(c,o),he(c,r),c.update(i);let f=c.digest();return B(s,i),f}var ge=e=>(t,n,r)=>({encrypt(s,i){let c=s.length;i=Q(c+16,i,!1),i.set(s);let f=i.subarray(0,-16);e(t,n,f,f,1);let l=ae(e,t,n,f,r);return i.set(l,c),B(l),i},decrypt(s,i){i=Q(s.length-16,i,!1);let c=s.subarray(0,-16),f=s.subarray(-16),l=ae(e,t,n,c,r);if(!wt(f,l))throw new Error("invalid tag");return i.set(s.subarray(0,-16)),e(t,n,i,i,1),B(l),i}}),pe=D({blockSize:64,nonceLength:12,tagLength:16},ge(Ze)),ye=D({blockSize:64,nonceLength:24,tagLength:16},ge(Fe));return Le(Ge);})();
5156−/*! Bundled license information:
5157−
5158−@noble/ciphers/utils.js:
5159− (*! noble-ciphers - MIT License (c) 2023 Paul Miller (paulmillr.com) *)
5160−*/
5161−
5162−
5163−// CIPHER FUNCTIONS
5164−
5165−function _Crypto_cipherKeyFromHex(hex)
5166−{
5167− try {
5168− var bytes = _Crypto_fromHex(hex);
5169− if (bytes.length !== 32) { return $elm$core$Maybe$Nothing; }
5170− return $elm$core$Maybe$Just(hex);
5171− } catch (e) {
5172− return $elm$core$Maybe$Nothing;
5173− }
5174−}
5175−
5176−function _Crypto_cipherKeyToHex(key)
5177−{
5178− return key;
5179−}
5180−
5181−function _Crypto_cipherNonce12FromHex(hex)
5182−{
5183− try {
5184− var bytes = _Crypto_fromHex(hex);
5185− if (bytes.length !== 12) { return $elm$core$Maybe$Nothing; }
5186− return $elm$core$Maybe$Just(hex);
5187− } catch (e) {
5188− return $elm$core$Maybe$Nothing;
5189− }
5190−}
5191−
5192−function _Crypto_cipherNonce12ToHex(nonce)
5193−{
5194− return nonce;
5195−}
5196−
5197−function _Crypto_cipherNonce24FromHex(hex)
5198−{
5199− try {
5200− var bytes = _Crypto_fromHex(hex);
5201− if (bytes.length !== 24) { return $elm$core$Maybe$Nothing; }
5202− return $elm$core$Maybe$Just(hex);
5203− } catch (e) {
5204− return $elm$core$Maybe$Nothing;
5205− }
5206−}
5207−
5208−function _Crypto_cipherNonce24ToHex(nonce)
5209−{
5210− return nonce;
5211−}
5212−
5213−
5214−// AES-256-GCM
5215−
5216−var _Crypto_aesGcmEncrypt = F3(function(key, nonce, plaintext)
5217−{
5218− var cipher = _Crypto_ciphers.gcm(_Crypto_fromHex(key), _Crypto_fromHex(nonce));
5219− return _Crypto_toHex(cipher.encrypt(_Crypto_fromHex(plaintext)));
5220−});
5221−
5222−var _Crypto_aesGcmDecrypt = F3(function(key, nonce, ciphertext)
5223−{
5224− try {
5225− var cipher = _Crypto_ciphers.gcm(_Crypto_fromHex(key), _Crypto_fromHex(nonce));
5226− return $elm$core$Maybe$Just(_Crypto_toHex(cipher.decrypt(_Crypto_fromHex(ciphertext))));
5227− } catch (e) {
5228− return $elm$core$Maybe$Nothing;
5229− }
5230−});
5231−
5232−
5233−// CHACHA20-POLY1305
5234−
5235−var _Crypto_chacha20Encrypt = F3(function(key, nonce, plaintext)
5236−{
5237− var cipher = _Crypto_ciphers.chacha20poly1305(_Crypto_fromHex(key), _Crypto_fromHex(nonce));
5238− return _Crypto_toHex(cipher.encrypt(_Crypto_fromHex(plaintext)));
5239−});
5240−
5241−var _Crypto_chacha20Decrypt = F3(function(key, nonce, ciphertext)
5242−{
5243− try {
5244− var cipher = _Crypto_ciphers.chacha20poly1305(_Crypto_fromHex(key), _Crypto_fromHex(nonce));
5245− return $elm$core$Maybe$Just(_Crypto_toHex(cipher.decrypt(_Crypto_fromHex(ciphertext))));
5246− } catch (e) {
5247− return $elm$core$Maybe$Nothing;
5248− }
5249−});
5250−
5251−
5252−// XCHACHA20-POLY1305
5253−
5254−var _Crypto_xchacha20Encrypt = F3(function(key, nonce, plaintext)
5255−{
5256− var cipher = _Crypto_ciphers.xchacha20poly1305(_Crypto_fromHex(key), _Crypto_fromHex(nonce));
5257− return _Crypto_toHex(cipher.encrypt(_Crypto_fromHex(plaintext)));
5258−});
5259−
5260−var _Crypto_xchacha20Decrypt = F3(function(key, nonce, ciphertext)
5261−{
5262− try {
5263− var cipher = _Crypto_ciphers.xchacha20poly1305(_Crypto_fromHex(key), _Crypto_fromHex(nonce));
5264− return $elm$core$Maybe$Just(_Crypto_toHex(cipher.decrypt(_Crypto_fromHex(ciphertext))));
5265− } catch (e) {
5266− return $elm$core$Maybe$Nothing;
5267− }
5268−});
5269−function _Utils_cmp(x, y, ord) {if (typeof x !== 'object') {return x === y ? 0 : x < y ? -1 : 1; }if (x instanceof String) {var a = x.valueOf(); var b = y.valueOf();return a === b ? 0 : a < b ? -1 : 1; }if (typeof x.$ === 'undefined' || (typeof x.$ === 'string' && x.$[0] === '#')) {var keys = Object.keys(x).sort();for (var i = 0; i < keys.length; i++) {var k = keys[i];if (k === '$') continue;ord = _Utils_cmp(x[k], y[k]);if (ord) return ord; }return 0; }if (x.$ === '::' || x.$ === '[]') {for (; x.b && y.b && !(ord = _Utils_cmp(x.a, y.a)); x = x.b, y = y.b) {}return ord || (x.b ? 1 : y.b ? -1 : 0); }if (x.$ !== y.$) {return x.$ < y.$ ? -1 : 1; }var keys = Object.keys(x);for (var i = 0; i < keys.length; i++) {var k = keys[i];if (k === '$') continue;ord = _Utils_cmp(x[k], y[k]);if (ord) return ord; }return 0; }
5270−var $elm$core$List$cons = _List_cons;
5271−var $elm$core$Elm$JsArray$foldr = _JsArray_foldr;
5272−var $elm$core$Array$foldr = F3(
5273− function (func, baseCase, _v0) {
5274− var tree = _v0.c;
5275− var tail = _v0.d;
5276− var helper = F2(
5277− function (node, acc) {
5278− if (node.$ === 'SubTree') {
5279− var subTree = node.a;
5280− return A3($elm$core$Elm$JsArray$foldr, helper, acc, subTree);
5281− } else {
5282− var values = node.a;
5283− return A3($elm$core$Elm$JsArray$foldr, func, acc, values);
5284− }
5285− });
5286− return A3(
5287− $elm$core$Elm$JsArray$foldr,
5288− helper,
5289− A3($elm$core$Elm$JsArray$foldr, func, baseCase, tail),
5290− tree);
5291− });
5292−var $elm$core$Array$toList = function (array) {
5293− return A3($elm$core$Array$foldr, $elm$core$List$cons, _List_Nil, array);
5294−};
5295−var $elm$core$Dict$foldr = F3(
5296− function (func, acc, t) {
5297− foldr:
5298− while (true) {
5299− if (t.$ === 'RBEmpty_elm_builtin') {
5300− return acc;
5301− } else {
5302− var key = t.b;
5303− var value = t.c;
5304− var left = t.d;
5305− var right = t.e;
5306− var $temp$func = func,
5307− $temp$acc = A3(
5308− func,
5309− key,
5310− value,
5311− A3($elm$core$Dict$foldr, func, acc, right)),
5312− $temp$t = left;
5313− func = $temp$func;
5314− acc = $temp$acc;
5315− t = $temp$t;
5316− continue foldr;
5317− }
5318− }
5319− });
5320−var $elm$core$Dict$toList = function (dict) {
5321− return A3(
5322− $elm$core$Dict$foldr,
5323− F3(
5324− function (key, value, list) {
5325− return A2(
5326− $elm$core$List$cons,
5327− _Utils_Tuple2(key, value),
5328− list);
5329− }),
5330− _List_Nil,
5331− dict);
5332−};
5333−var $elm$core$Dict$keys = function (dict) {
5334− return A3(
5335− $elm$core$Dict$foldr,
5336− F3(
5337− function (key, value, keyList) {
5338− return A2($elm$core$List$cons, key, keyList);
5339− }),
5340− _List_Nil,
5341− dict);
5342−};
5343−var $elm$core$Set$toList = function (_v0) {
5344− var dict = _v0.a;
5345− return $elm$core$Dict$keys(dict);
5346−};
5347−var $elm$core$Basics$EQ = {$: 'EQ'};
5348−var $elm$core$Basics$GT = {$: 'GT'};
5349−var $elm$core$Basics$LT = {$: 'LT'};
5350−var $elm$core$Result$Err = function (a) {
5351− return {$: 'Err', a: a};
5352−};
5353−var $elm$json$Json$Decode$Failure = F2(
5354− function (a, b) {
5355− return {$: 'Failure', a: a, b: b};
5356− });
5357−var $elm$json$Json$Decode$Field = F2(
5358− function (a, b) {
5359− return {$: 'Field', a: a, b: b};
5360− });
5361−var $elm$json$Json$Decode$Index = F2(
5362− function (a, b) {
5363− return {$: 'Index', a: a, b: b};
5364− });
5365−var $elm$core$Result$Ok = function (a) {
5366− return {$: 'Ok', a: a};
5367−};
5368−var $elm$json$Json$Decode$OneOf = function (a) {
5369− return {$: 'OneOf', a: a};
5370−};
5371−var $elm$core$Basics$False = {$: 'False'};
5372−var $elm$core$Basics$add = _Basics_add;
5373−var $elm$core$Maybe$Just = function (a) {
5374− return {$: 'Just', a: a};
5375−};
5376−var $elm$core$Maybe$Nothing = {$: 'Nothing'};
5377−var $elm$core$String$all = _String_all;
5378−var $elm$core$Basics$and = _Basics_and;
5379−var $elm$core$Basics$append = _Utils_append;
5380−var $elm$json$Json$Encode$encode = _Json_encode;
5381−var $elm$core$String$fromInt = _String_fromNumber;
5382−var $elm$core$String$join = F2(
5383− function (sep, chunks) {
5384− return A2(
5385− _String_join,
5386− sep,
5387− _List_toArray(chunks));
5388− });
5389−var $elm$core$String$split = F2(
5390− function (sep, string) {
5391− return _List_fromArray(
5392− A2(_String_split, sep, string));
5393− });
5394−var $elm$json$Json$Decode$indent = function (str) {
5395− return A2(
5396− $elm$core$String$join,
5397− '\n ',
5398− A2($elm$core$String$split, '\n', str));
5399−};
5400−var $elm$core$List$foldl = F3(
5401− function (func, acc, list) {
5402− foldl:
5403− while (true) {
5404− if (!list.b) {
5405− return acc;
5406− } else {
5407− var x = list.a;
5408− var xs = list.b;
5409− var $temp$func = func,
5410− $temp$acc = A2(func, x, acc),
5411− $temp$list = xs;
5412− func = $temp$func;
5413− acc = $temp$acc;
5414− list = $temp$list;
5415− continue foldl;
5416− }
5417− }
5418− });
5419−var $elm$core$List$length = function (xs) {
5420− return A3(
5421− $elm$core$List$foldl,
5422− F2(
5423− function (_v0, i) {
5424− return i + 1;
5425− }),
5426− 0,
5427− xs);
5428−};
5429−var $elm$core$List$map2 = _List_map2;
5430−var $elm$core$Basics$le = _Utils_le;
5431−var $elm$core$Basics$sub = _Basics_sub;
5432−var $elm$core$List$rangeHelp = F3(
5433− function (lo, hi, list) {
5434− rangeHelp:
5435− while (true) {
5436− if (_Utils_cmp(lo, hi) < 1) {
5437− var $temp$lo = lo,
5438− $temp$hi = hi - 1,
5439− $temp$list = A2($elm$core$List$cons, hi, list);
5440− lo = $temp$lo;
5441− hi = $temp$hi;
5442− list = $temp$list;
5443− continue rangeHelp;
5444− } else {
5445− return list;
5446− }
5447− }
5448− });
5449−var $elm$core$List$range = F2(
5450− function (lo, hi) {
5451− return A3($elm$core$List$rangeHelp, lo, hi, _List_Nil);
5452− });
5453−var $elm$core$List$indexedMap = F2(
5454− function (f, xs) {
5455− return A3(
5456− $elm$core$List$map2,
5457− f,
5458− A2(
5459− $elm$core$List$range,
5460− 0,
5461− $elm$core$List$length(xs) - 1),
5462− xs);
5463− });
5464−var $elm$core$Char$toCode = _Char_toCode;
5465−var $elm$core$Char$isLower = function (_char) {
5466− var code = $elm$core$Char$toCode(_char);
5467− return (97 <= code) && (code <= 122);
5468−};
5469−var $elm$core$Char$isUpper = function (_char) {
5470− var code = $elm$core$Char$toCode(_char);
5471− return (code <= 90) && (65 <= code);
5472−};
5473−var $elm$core$Basics$or = _Basics_or;
5474−var $elm$core$Char$isAlpha = function (_char) {
5475− return $elm$core$Char$isLower(_char) || $elm$core$Char$isUpper(_char);
5476−};
5477−var $elm$core$Char$isDigit = function (_char) {
5478− var code = $elm$core$Char$toCode(_char);
5479− return (code <= 57) && (48 <= code);
5480−};
5481−var $elm$core$Char$isAlphaNum = function (_char) {
5482− return $elm$core$Char$isLower(_char) || ($elm$core$Char$isUpper(_char) || $elm$core$Char$isDigit(_char));
5483−};
5484−var $elm$core$List$reverse = function (list) {
5485− return A3($elm$core$List$foldl, $elm$core$List$cons, _List_Nil, list);
5486−};
5487−var $elm$core$String$uncons = _String_uncons;
5488−var $elm$json$Json$Decode$errorOneOf = F2(
5489− function (i, error) {
5490− return '\n\n(' + ($elm$core$String$fromInt(i + 1) + (') ' + $elm$json$Json$Decode$indent(
5491− $elm$json$Json$Decode$errorToString(error))));
5492− });
5493−var $elm$json$Json$Decode$errorToString = function (error) {
5494− return A2($elm$json$Json$Decode$errorToStringHelp, error, _List_Nil);
5495−};
5496−var $elm$json$Json$Decode$errorToStringHelp = F2(
5497− function (error, context) {
5498− errorToStringHelp:
5499− while (true) {
5500− switch (error.$) {
5501− case 'Field':
5502− var f = error.a;
5503− var err = error.b;
5504− var isSimple = function () {
5505− var _v1 = $elm$core$String$uncons(f);
5506− if (_v1.$ === 'Nothing') {
5507− return false;
5508− } else {
5509− var _v2 = _v1.a;
5510− var _char = _v2.a;
5511− var rest = _v2.b;
5512− return $elm$core$Char$isAlpha(_char) && A2($elm$core$String$all, $elm$core$Char$isAlphaNum, rest);
5513− }
5514− }();
5515− var fieldName = isSimple ? ('.' + f) : ('[\'' + (f + '\']'));
5516− var $temp$error = err,
5517− $temp$context = A2($elm$core$List$cons, fieldName, context);
5518− error = $temp$error;
5519− context = $temp$context;
5520− continue errorToStringHelp;
5521− case 'Index':
5522− var i = error.a;
5523− var err = error.b;
5524− var indexName = '[' + ($elm$core$String$fromInt(i) + ']');
5525− var $temp$error = err,
5526− $temp$context = A2($elm$core$List$cons, indexName, context);
5527− error = $temp$error;
5528− context = $temp$context;
5529− continue errorToStringHelp;
5530− case 'OneOf':
5531− var errors = error.a;
5532− if (!errors.b) {
5533− return 'Ran into a Json.Decode.oneOf with no possibilities' + function () {
5534− if (!context.b) {
5535− return '!';
5536− } else {
5537− return ' at json' + A2(
5538− $elm$core$String$join,
5539− '',
5540− $elm$core$List$reverse(context));
5541− }
5542− }();
5543− } else {
5544− if (!errors.b.b) {
5545− var err = errors.a;
5546− var $temp$error = err,
5547− $temp$context = context;
5548− error = $temp$error;
5549− context = $temp$context;
5550− continue errorToStringHelp;
5551− } else {
5552− var starter = function () {
5553− if (!context.b) {
5554− return 'Json.Decode.oneOf';
5555− } else {
5556− return 'The Json.Decode.oneOf at json' + A2(
5557− $elm$core$String$join,
5558− '',
5559− $elm$core$List$reverse(context));
5560− }
5561− }();
5562− var introduction = starter + (' failed in the following ' + ($elm$core$String$fromInt(
5563− $elm$core$List$length(errors)) + ' ways:'));
5564− return A2(
5565− $elm$core$String$join,
5566− '\n\n',
5567− A2(
5568− $elm$core$List$cons,
5569− introduction,
5570− A2($elm$core$List$indexedMap, $elm$json$Json$Decode$errorOneOf, errors)));
5571− }
5572− }
5573− default:
5574− var msg = error.a;
5575− var json = error.b;
5576− var introduction = function () {
5577− if (!context.b) {
5578− return 'Problem with the given value:\n\n';
5579− } else {
5580− return 'Problem with the value at json' + (A2(
5581− $elm$core$String$join,
5582− '',
5583− $elm$core$List$reverse(context)) + ':\n\n ');
5584− }
5585− }();
5586− return introduction + ($elm$json$Json$Decode$indent(
5587− A2($elm$json$Json$Encode$encode, 4, json)) + ('\n\n' + msg));
5588− }
5589− }
5590− });
5591−var $elm$core$Array$branchFactor = 32;
5592−var $elm$core$Array$Array_elm_builtin = F4(
5593− function (a, b, c, d) {
5594− return {$: 'Array_elm_builtin', a: a, b: b, c: c, d: d};
5595− });
5596−var $elm$core$Elm$JsArray$empty = _JsArray_empty;
5597−var $elm$core$Basics$ceiling = _Basics_ceiling;
5598−var $elm$core$Basics$fdiv = _Basics_fdiv;
5599−var $elm$core$Basics$logBase = F2(
5600− function (base, number) {
5601− return _Basics_log(number) / _Basics_log(base);
5602− });
5603−var $elm$core$Basics$toFloat = _Basics_toFloat;
5604−var $elm$core$Array$shiftStep = $elm$core$Basics$ceiling(
5605− A2($elm$core$Basics$logBase, 2, $elm$core$Array$branchFactor));
5606−var $elm$core$Array$empty = A4($elm$core$Array$Array_elm_builtin, 0, $elm$core$Array$shiftStep, $elm$core$Elm$JsArray$empty, $elm$core$Elm$JsArray$empty);
5607−var $elm$core$Elm$JsArray$initialize = _JsArray_initialize;
5608−var $elm$core$Array$Leaf = function (a) {
5609− return {$: 'Leaf', a: a};
5610−};
5611−var $elm$core$Basics$apL = F2(
5612− function (f, x) {
5613− return f(x);
5614− });
5615−var $elm$core$Basics$apR = F2(
5616− function (x, f) {
5617− return f(x);
5618− });
5619−var $elm$core$Basics$eq = _Utils_equal;
5620−var $elm$core$Basics$floor = _Basics_floor;
5621−var $elm$core$Elm$JsArray$length = _JsArray_length;
5622−var $elm$core$Basics$gt = _Utils_gt;
5623−var $elm$core$Basics$max = F2(
5624− function (x, y) {
5625− return (_Utils_cmp(x, y) > 0) ? x : y;
5626− });
5627−var $elm$core$Basics$mul = _Basics_mul;
5628−var $elm$core$Array$SubTree = function (a) {
5629− return {$: 'SubTree', a: a};
5630−};
5631−var $elm$core$Elm$JsArray$initializeFromList = _JsArray_initializeFromList;
5632−var $elm$core$Array$compressNodes = F2(
5633− function (nodes, acc) {
5634− compressNodes:
5635− while (true) {
5636− var _v0 = A2($elm$core$Elm$JsArray$initializeFromList, $elm$core$Array$branchFactor, nodes);
5637− var node = _v0.a;
5638− var remainingNodes = _v0.b;
5639− var newAcc = A2(
5640− $elm$core$List$cons,
5641− $elm$core$Array$SubTree(node),
5642− acc);
5643− if (!remainingNodes.b) {
5644− return $elm$core$List$reverse(newAcc);
5645− } else {
5646− var $temp$nodes = remainingNodes,
5647− $temp$acc = newAcc;
5648− nodes = $temp$nodes;
5649− acc = $temp$acc;
5650− continue compressNodes;
5651− }
5652− }
5653− });
5654−var $elm$core$Tuple$first = function (_v0) {
5655− var x = _v0.a;
5656− return x;
5657−};
5658−var $elm$core$Array$treeFromBuilder = F2(
5659− function (nodeList, nodeListSize) {
5660− treeFromBuilder:
5661− while (true) {
5662− var newNodeSize = $elm$core$Basics$ceiling(nodeListSize / $elm$core$Array$branchFactor);
5663− if (newNodeSize === 1) {
5664− return A2($elm$core$Elm$JsArray$initializeFromList, $elm$core$Array$branchFactor, nodeList).a;
5665− } else {
5666− var $temp$nodeList = A2($elm$core$Array$compressNodes, nodeList, _List_Nil),
5667− $temp$nodeListSize = newNodeSize;
5668− nodeList = $temp$nodeList;
5669− nodeListSize = $temp$nodeListSize;
5670− continue treeFromBuilder;
5671− }
5672− }
5673− });
5674−var $elm$core$Array$builderToArray = F2(
5675− function (reverseNodeList, builder) {
5676− if (!builder.nodeListSize) {
5677− return A4(
5678− $elm$core$Array$Array_elm_builtin,
5679− $elm$core$Elm$JsArray$length(builder.tail),
5680− $elm$core$Array$shiftStep,
5681− $elm$core$Elm$JsArray$empty,
5682− builder.tail);
5683− } else {
5684− var treeLen = builder.nodeListSize * $elm$core$Array$branchFactor;
5685− var depth = $elm$core$Basics$floor(
5686− A2($elm$core$Basics$logBase, $elm$core$Array$branchFactor, treeLen - 1));
5687− var correctNodeList = reverseNodeList ? $elm$core$List$reverse(builder.nodeList) : builder.nodeList;
5688− var tree = A2($elm$core$Array$treeFromBuilder, correctNodeList, builder.nodeListSize);
5689− return A4(
5690− $elm$core$Array$Array_elm_builtin,
5691− $elm$core$Elm$JsArray$length(builder.tail) + treeLen,
5692− A2($elm$core$Basics$max, 5, depth * $elm$core$Array$shiftStep),
5693− tree,
5694− builder.tail);
5695− }
5696− });
5697−var $elm$core$Basics$idiv = _Basics_idiv;
5698−var $elm$core$Basics$lt = _Utils_lt;
5699−var $elm$core$Array$initializeHelp = F5(
5700− function (fn, fromIndex, len, nodeList, tail) {
5701− initializeHelp:
5702− while (true) {
5703− if (fromIndex < 0) {
5704− return A2(
5705− $elm$core$Array$builderToArray,
5706− false,
5707− {nodeList: nodeList, nodeListSize: (len / $elm$core$Array$branchFactor) | 0, tail: tail});
5708− } else {
5709− var leaf = $elm$core$Array$Leaf(
5710− A3($elm$core$Elm$JsArray$initialize, $elm$core$Array$branchFactor, fromIndex, fn));
5711− var $temp$fn = fn,
5712− $temp$fromIndex = fromIndex - $elm$core$Array$branchFactor,
5713− $temp$len = len,
5714− $temp$nodeList = A2($elm$core$List$cons, leaf, nodeList),
5715− $temp$tail = tail;
5716− fn = $temp$fn;
5717− fromIndex = $temp$fromIndex;
5718− len = $temp$len;
5719− nodeList = $temp$nodeList;
5720− tail = $temp$tail;
5721− continue initializeHelp;
5722− }
5723− }
5724− });
5725−var $elm$core$Basics$remainderBy = _Basics_remainderBy;
5726−var $elm$core$Array$initialize = F2(
5727− function (len, fn) {
5728− if (len <= 0) {
5729− return $elm$core$Array$empty;
5730− } else {
5731− var tailLen = len % $elm$core$Array$branchFactor;
5732− var tail = A3($elm$core$Elm$JsArray$initialize, tailLen, len - tailLen, fn);
5733− var initialFromIndex = (len - tailLen) - $elm$core$Array$branchFactor;
5734− return A5($elm$core$Array$initializeHelp, fn, initialFromIndex, len, _List_Nil, tail);
5735− }
5736− });
5737−var $elm$core$Basics$True = {$: 'True'};
5738−var $elm$core$Result$isOk = function (result) {
5739− if (result.$ === 'Ok') {
5740− return true;
5741− } else {
5742− return false;
5743− }
5744−};
5745−var $elm$json$Json$Decode$map = _Json_map1;
5746−var $elm$json$Json$Decode$map2 = _Json_map2;
5747−var $elm$json$Json$Decode$succeed = _Json_succeed;
5748−var $lynxjs_elm$virtual_dom$VirtualDom$toHandlerInt = function (handler) {
5749− switch (handler.$) {
5750− case 'Normal':
5751− return 0;
5752− case 'MayStopPropagation':
5753− return 1;
5754− case 'MayPreventDefault':
5755− return 2;
5756− default:
5757− return 3;
5758− }
5759−};
5760−var $elm$core$Basics$identity = function (x) {
5761− return x;
5762−};
5763−var $lynxjs_elm$browser$Browser$Dom$NotFound = function (a) {
5764− return {$: 'NotFound', a: a};
5765−};
5766−var $elm$core$Basics$never = function (_v0) {
5767− never:
5768− while (true) {
5769− var nvr = _v0.a;
5770− var $temp$_v0 = nvr;
5771− _v0 = $temp$_v0;
5772− continue never;
5773− }
5774−};
5775−var $elm$core$Task$Perform = function (a) {
5776− return {$: 'Perform', a: a};
5777−};
5778−var $elm$core$Task$succeed = _Scheduler_succeed;
5779−var $elm$core$Task$init = $elm$core$Task$succeed(_Utils_Tuple0);
5780−var $elm$core$List$foldrHelper = F4(
5781− function (fn, acc, ctr, ls) {
5782− if (!ls.b) {
5783− return acc;
5784− } else {
5785− var a = ls.a;
5786− var r1 = ls.b;
5787− if (!r1.b) {
5788− return A2(fn, a, acc);
5789− } else {
5790− var b = r1.a;
5791− var r2 = r1.b;
5792− if (!r2.b) {
5793− return A2(
5794− fn,
5795− a,
5796− A2(fn, b, acc));
5797− } else {
5798− var c = r2.a;
5799− var r3 = r2.b;
5800− if (!r3.b) {
5801− return A2(
5802− fn,
5803− a,
5804− A2(
5805− fn,
5806− b,
5807− A2(fn, c, acc)));
5808− } else {
5809− var d = r3.a;
5810− var r4 = r3.b;
5811− var res = (ctr > 500) ? A3(
5812− $elm$core$List$foldl,
5813− fn,
5814− acc,
5815− $elm$core$List$reverse(r4)) : A4($elm$core$List$foldrHelper, fn, acc, ctr + 1, r4);
5816− return A2(
5817− fn,
5818− a,
5819− A2(
5820− fn,
5821− b,
5822− A2(
5823− fn,
5824− c,
5825− A2(fn, d, res))));
5826− }
5827− }
5828− }
5829− }
5830− });
5831−var $elm$core$List$foldr = F3(
5832− function (fn, acc, ls) {
5833− return A4($elm$core$List$foldrHelper, fn, acc, 0, ls);
5834− });
5835−var $elm$core$List$map = F2(
5836− function (f, xs) {
5837− return A3(
5838− $elm$core$List$foldr,
5839− F2(
5840− function (x, acc) {
5841− return A2(
5842− $elm$core$List$cons,
5843− f(x),
5844− acc);
5845− }),
5846− _List_Nil,
5847− xs);
5848− });
5849−var $elm$core$Task$andThen = _Scheduler_andThen;
5850−var $elm$core$Task$map = F2(
5851− function (func, taskA) {
5852− return A2(
5853− $elm$core$Task$andThen,
5854− function (a) {
5855− return $elm$core$Task$succeed(
5856− func(a));
5857− },
5858− taskA);
5859− });
5860−var $elm$core$Task$map2 = F3(
5861− function (func, taskA, taskB) {
5862− return A2(
5863− $elm$core$Task$andThen,
5864− function (a) {
5865− return A2(
5866− $elm$core$Task$andThen,
5867− function (b) {
5868− return $elm$core$Task$succeed(
5869− A2(func, a, b));
5870− },
5871− taskB);
5872− },
5873− taskA);
5874− });
5875−var $elm$core$Task$sequence = function (tasks) {
5876− return A3(
5877− $elm$core$List$foldr,
5878− $elm$core$Task$map2($elm$core$List$cons),
5879− $elm$core$Task$succeed(_List_Nil),
5880− tasks);
5881−};
5882−var $elm$core$Platform$sendToApp = _Platform_sendToApp;
5883−var $elm$core$Task$spawnCmd = F2(
5884− function (router, _v0) {
5885− var task = _v0.a;
5886− return _Scheduler_spawn(
5887− A2(
5888− $elm$core$Task$andThen,
5889− $elm$core$Platform$sendToApp(router),
5890− task));
5891− });
5892−var $elm$core$Task$onEffects = F3(
5893− function (router, commands, state) {
5894− return A2(
5895− $elm$core$Task$map,
5896− function (_v0) {
5897− return _Utils_Tuple0;
5898− },
5899− $elm$core$Task$sequence(
5900− A2(
5901− $elm$core$List$map,
5902− $elm$core$Task$spawnCmd(router),
5903− commands)));
5904− });
5905−var $elm$core$Task$onSelfMsg = F3(
5906− function (_v0, _v1, _v2) {
5907− return $elm$core$Task$succeed(_Utils_Tuple0);
5908− });
5909−var $elm$core$Task$cmdMap = F2(
5910− function (tagger, _v0) {
5911− var task = _v0.a;
5912− return $elm$core$Task$Perform(
5913− A2($elm$core$Task$map, tagger, task));
5914− });
5915−_Platform_effectManagers['Task'] = _Platform_createManager($elm$core$Task$init, $elm$core$Task$onEffects, $elm$core$Task$onSelfMsg, $elm$core$Task$cmdMap);
5916−var $elm$core$Task$command = _Platform_leaf('Task');
5917−var $elm$core$Task$perform = F2(
5918− function (toMessage, task) {
5919− return $elm$core$Task$command(
5920− $elm$core$Task$Perform(
5921− A2($elm$core$Task$map, toMessage, task)));
5922− });
5923−var $lynxjs_elm$browser$Browser$element = _Browser_element;
5924−var $author$project$Main$GotSeed = function (a) {
5925− return {$: 'GotSeed', a: a};
5926−};
5927−var $author$project$Main$SetupScreen = {$: 'SetupScreen'};
5928−var $elm$core$Dict$RBEmpty_elm_builtin = {$: 'RBEmpty_elm_builtin'};
5929−var $elm$core$Dict$empty = $elm$core$Dict$RBEmpty_elm_builtin;
5930−var $elm$core$Set$Set_elm_builtin = function (a) {
5931− return {$: 'Set_elm_builtin', a: a};
5932−};
5933−var $elm$core$Set$empty = $elm$core$Set$Set_elm_builtin($elm$core$Dict$empty);
5934−var $elm$random$Random$Generate = function (a) {
5935− return {$: 'Generate', a: a};
5936−};
5937−var $elm$random$Random$Seed = F2(
5938− function (a, b) {
5939− return {$: 'Seed', a: a, b: b};
5940− });
5941−var $elm$core$Bitwise$shiftRightZfBy = _Bitwise_shiftRightZfBy;
5942−var $elm$random$Random$next = function (_v0) {
5943− var state0 = _v0.a;
5944− var incr = _v0.b;
5945− return A2($elm$random$Random$Seed, ((state0 * 1664525) + incr) >>> 0, incr);
5946−};
5947−var $elm$random$Random$initialSeed = function (x) {
5948− var _v0 = $elm$random$Random$next(
5949− A2($elm$random$Random$Seed, 0, 1013904223));
5950− var state1 = _v0.a;
5951− var incr = _v0.b;
5952− var state2 = (state1 + x) >>> 0;
5953− return $elm$random$Random$next(
5954− A2($elm$random$Random$Seed, state2, incr));
5955−};
5956−var $elm$time$Time$Name = function (a) {
5957− return {$: 'Name', a: a};
5958−};
5959−var $elm$time$Time$Offset = function (a) {
5960− return {$: 'Offset', a: a};
5961−};
5962−var $elm$time$Time$Zone = F2(
5963− function (a, b) {
5964− return {$: 'Zone', a: a, b: b};
5965− });
5966−var $elm$time$Time$customZone = $elm$time$Time$Zone;
5967−var $elm$time$Time$Posix = function (a) {
5968− return {$: 'Posix', a: a};
5969−};
5970−var $elm$time$Time$millisToPosix = $elm$time$Time$Posix;
5971−var $elm$time$Time$now = _Time_now($elm$time$Time$millisToPosix);
5972−var $elm$time$Time$posixToMillis = function (_v0) {
5973− var millis = _v0.a;
5974− return millis;
5975−};
5976−var $elm$random$Random$init = A2(
5977− $elm$core$Task$andThen,
5978− function (time) {
5979− return $elm$core$Task$succeed(
5980− $elm$random$Random$initialSeed(
5981− $elm$time$Time$posixToMillis(time)));
5982− },
5983− $elm$time$Time$now);
5984−var $elm$random$Random$step = F2(
5985− function (_v0, seed) {
5986− var generator = _v0.a;
5987− return generator(seed);
5988− });
5989−var $elm$random$Random$onEffects = F3(
5990− function (router, commands, seed) {
5991− if (!commands.b) {
5992− return $elm$core$Task$succeed(seed);
5993− } else {
5994− var generator = commands.a.a;
5995− var rest = commands.b;
5996− var _v1 = A2($elm$random$Random$step, generator, seed);
5997− var value = _v1.a;
5998− var newSeed = _v1.b;
5999− return A2(
6000− $elm$core$Task$andThen,
6001− function (_v2) {
6002− return A3($elm$random$Random$onEffects, router, rest, newSeed);
6003− },
6004− A2($elm$core$Platform$sendToApp, router, value));
6005− }
6006− });
6007−var $elm$random$Random$onSelfMsg = F3(
6008− function (_v0, _v1, seed) {
6009− return $elm$core$Task$succeed(seed);
6010− });
6011−var $elm$random$Random$Generator = function (a) {
6012− return {$: 'Generator', a: a};
6013−};
6014−var $elm$random$Random$map = F2(
6015− function (func, _v0) {
6016− var genA = _v0.a;
6017− return $elm$random$Random$Generator(
6018− function (seed0) {
6019− var _v1 = genA(seed0);
6020− var a = _v1.a;
6021− var seed1 = _v1.b;
6022− return _Utils_Tuple2(
6023− func(a),
6024− seed1);
6025− });
6026− });
6027−var $elm$random$Random$cmdMap = F2(
6028− function (func, _v0) {
6029− var generator = _v0.a;
6030− return $elm$random$Random$Generate(
6031− A2($elm$random$Random$map, func, generator));
6032− });
6033−_Platform_effectManagers['Random'] = _Platform_createManager($elm$random$Random$init, $elm$random$Random$onEffects, $elm$random$Random$onSelfMsg, $elm$random$Random$cmdMap);
6034−var $elm$random$Random$command = _Platform_leaf('Random');
6035−var $elm$random$Random$generate = F2(
6036− function (tagger, generator) {
6037− return $elm$random$Random$command(
6038− $elm$random$Random$Generate(
6039− A2($elm$random$Random$map, tagger, generator)));
6040− });
6041−var $elm$core$String$fromList = _String_fromList;
6042−var $elm$random$Random$listHelp = F4(
6043− function (revList, n, gen, seed) {
6044− listHelp:
6045− while (true) {
6046− if (n < 1) {
6047− return _Utils_Tuple2(revList, seed);
6048− } else {
6049− var _v0 = gen(seed);
6050− var value = _v0.a;
6051− var newSeed = _v0.b;
6052− var $temp$revList = A2($elm$core$List$cons, value, revList),
6053− $temp$n = n - 1,
6054− $temp$gen = gen,
6055− $temp$seed = newSeed;
6056− revList = $temp$revList;
6057− n = $temp$n;
6058− gen = $temp$gen;
6059− seed = $temp$seed;
6060− continue listHelp;
6061− }
6062− }
6063− });
6064−var $elm$random$Random$list = F2(
6065− function (n, _v0) {
6066− var gen = _v0.a;
6067− return $elm$random$Random$Generator(
6068− function (seed) {
6069− return A4($elm$random$Random$listHelp, _List_Nil, n, gen, seed);
6070− });
6071− });
6072−var $elm$core$Char$fromCode = _Char_fromCode;
6073−var $elm$core$Bitwise$and = _Bitwise_and;
6074−var $elm$core$Basics$negate = function (n) {
6075− return -n;
6076−};
6077−var $elm$core$Bitwise$xor = _Bitwise_xor;
6078−var $elm$random$Random$peel = function (_v0) {
6079− var state = _v0.a;
6080− var word = (state ^ (state >>> ((state >>> 28) + 4))) * 277803737;
6081− return ((word >>> 22) ^ word) >>> 0;
6082−};
6083−var $elm$random$Random$int = F2(
6084− function (a, b) {
6085− return $elm$random$Random$Generator(
6086− function (seed0) {
6087− var _v0 = (_Utils_cmp(a, b) < 0) ? _Utils_Tuple2(a, b) : _Utils_Tuple2(b, a);
6088− var lo = _v0.a;
6089− var hi = _v0.b;
6090− var range = (hi - lo) + 1;
6091− if (!((range - 1) & range)) {
6092− return _Utils_Tuple2(
6093− (((range - 1) & $elm$random$Random$peel(seed0)) >>> 0) + lo,
6094− $elm$random$Random$next(seed0));
6095− } else {
6096− var threshhold = (((-range) >>> 0) % range) >>> 0;
6097− var accountForBias = function (seed) {
6098− accountForBias:
6099− while (true) {
6100− var x = $elm$random$Random$peel(seed);
6101− var seedN = $elm$random$Random$next(seed);
6102− if (_Utils_cmp(x, threshhold) < 0) {
6103− var $temp$seed = seedN;
6104− seed = $temp$seed;
6105− continue accountForBias;
6106− } else {
6107− return _Utils_Tuple2((x % range) + lo, seedN);
6108− }
6109− }
6110− };
6111− return accountForBias(seed0);
6112− }
6113− });
6114− });
6115−var $author$project$Main$randomHexChar = A2(
6116− $elm$random$Random$map,
6117− function (n) {
6118− return (n < 10) ? $elm$core$Char$fromCode(n + 48) : $elm$core$Char$fromCode((n - 10) + 97);
6119− },
6120− A2($elm$random$Random$int, 0, 15));
6121−var $author$project$Main$randomHexString = function (byteCount) {
6122− return A2(
6123− $elm$random$Random$map,
6124− $elm$core$String$fromList,
6125− A2($elm$random$Random$list, byteCount * 2, $author$project$Main$randomHexChar));
6126−};
6127−var $author$project$Main$init = function (_v0) {
6128− return _Utils_Tuple2(
6129− {activeChat: '', addIdentity: '', addName: '', authToken: '', connecting: false, contacts: $elm$core$Dict$empty, currentTime: 0, displayName: '', draft: '', edPriv: $elm$core$Maybe$Nothing, edPub: $elm$core$Maybe$Nothing, error: $elm$core$Maybe$Nothing, messages: $elm$core$Dict$empty, screen: $author$project$Main$SetupScreen, seenIds: $elm$core$Set$empty, sending: false, serverUrl: 'http://localhost:8080', xPriv: $elm$core$Maybe$Nothing, xPub: $elm$core$Maybe$Nothing},
6130− A2(
6131− $elm$random$Random$generate,
6132− $author$project$Main$GotSeed,
6133− $author$project$Main$randomHexString(32)));
6134−};
6135−var $author$project$Main$GotTime = function (a) {
6136− return {$: 'GotTime', a: a};
6137−};
6138−var $author$project$Main$PollTick = function (a) {
6139− return {$: 'PollTick', a: a};
6140−};
6141−var $elm$core$Platform$Sub$batch = _Platform_batch;
6142−var $elm$time$Time$Every = F2(
6143− function (a, b) {
6144− return {$: 'Every', a: a, b: b};
6145− });
6146−var $elm$time$Time$State = F2(
6147− function (taggers, processes) {
6148− return {processes: processes, taggers: taggers};
6149− });
6150−var $elm$time$Time$init = $elm$core$Task$succeed(
6151− A2($elm$time$Time$State, $elm$core$Dict$empty, $elm$core$Dict$empty));
6152−var $elm$core$Basics$compare = _Utils_compare;
6153−var $elm$core$Dict$get = F2(
6154− function (targetKey, dict) {
6155− get:
6156− while (true) {
6157− if (dict.$ === 'RBEmpty_elm_builtin') {
6158− return $elm$core$Maybe$Nothing;
6159− } else {
6160− var key = dict.b;
6161− var value = dict.c;
6162− var left = dict.d;
6163− var right = dict.e;
6164− var _v1 = A2($elm$core$Basics$compare, targetKey, key);
6165− switch (_v1.$) {
6166− case 'LT':
6167− var $temp$targetKey = targetKey,
6168− $temp$dict = left;
6169− targetKey = $temp$targetKey;
6170− dict = $temp$dict;
6171− continue get;
6172− case 'EQ':
6173− return $elm$core$Maybe$Just(value);
6174− default:
6175− var $temp$targetKey = targetKey,
6176− $temp$dict = right;
6177− targetKey = $temp$targetKey;
6178− dict = $temp$dict;
6179− continue get;
6180− }
6181− }
6182− }
6183− });
6184−var $elm$core$Dict$Black = {$: 'Black'};
6185−var $elm$core$Dict$RBNode_elm_builtin = F5(
6186− function (a, b, c, d, e) {
6187− return {$: 'RBNode_elm_builtin', a: a, b: b, c: c, d: d, e: e};
6188− });
6189−var $elm$core$Dict$Red = {$: 'Red'};
6190−var $elm$core$Dict$balance = F5(
6191− function (color, key, value, left, right) {
6192− if ((right.$ === 'RBNode_elm_builtin') && (right.a.$ === 'Red')) {
6193− var _v1 = right.a;
6194− var rK = right.b;
6195− var rV = right.c;
6196− var rLeft = right.d;
6197− var rRight = right.e;
6198− if ((left.$ === 'RBNode_elm_builtin') && (left.a.$ === 'Red')) {
6199− var _v3 = left.a;
6200− var lK = left.b;
6201− var lV = left.c;
6202− var lLeft = left.d;
6203− var lRight = left.e;
6204− return A5(
6205− $elm$core$Dict$RBNode_elm_builtin,
6206− $elm$core$Dict$Red,
6207− key,
6208− value,
6209− A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Black, lK, lV, lLeft, lRight),
6210− A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Black, rK, rV, rLeft, rRight));
6211− } else {
6212− return A5(
6213− $elm$core$Dict$RBNode_elm_builtin,
6214− color,
6215− rK,
6216− rV,
6217− A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Red, key, value, left, rLeft),
6218− rRight);
6219− }
6220− } else {
6221− if ((((left.$ === 'RBNode_elm_builtin') && (left.a.$ === 'Red')) && (left.d.$ === 'RBNode_elm_builtin')) && (left.d.a.$ === 'Red')) {
6222− var _v5 = left.a;
6223− var lK = left.b;
6224− var lV = left.c;
6225− var _v6 = left.d;
6226− var _v7 = _v6.a;
6227− var llK = _v6.b;
6228− var llV = _v6.c;
6229− var llLeft = _v6.d;
6230− var llRight = _v6.e;
6231− var lRight = left.e;
6232− return A5(
6233− $elm$core$Dict$RBNode_elm_builtin,
6234− $elm$core$Dict$Red,
6235− lK,
6236− lV,
6237− A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Black, llK, llV, llLeft, llRight),
6238− A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Black, key, value, lRight, right));
6239− } else {
6240− return A5($elm$core$Dict$RBNode_elm_builtin, color, key, value, left, right);
6241− }
6242− }
6243− });
6244−var $elm$core$Dict$insertHelp = F3(
6245− function (key, value, dict) {
6246− if (dict.$ === 'RBEmpty_elm_builtin') {
6247− return A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Red, key, value, $elm$core$Dict$RBEmpty_elm_builtin, $elm$core$Dict$RBEmpty_elm_builtin);
6248− } else {
6249− var nColor = dict.a;
6250− var nKey = dict.b;
6251− var nValue = dict.c;
6252− var nLeft = dict.d;
6253− var nRight = dict.e;
6254− var _v1 = A2($elm$core$Basics$compare, key, nKey);
6255− switch (_v1.$) {
6256− case 'LT':
6257− return A5(
6258− $elm$core$Dict$balance,
6259− nColor,
6260− nKey,
6261− nValue,
6262− A3($elm$core$Dict$insertHelp, key, value, nLeft),
6263− nRight);
6264− case 'EQ':
6265− return A5($elm$core$Dict$RBNode_elm_builtin, nColor, nKey, value, nLeft, nRight);
6266− default:
6267− return A5(
6268− $elm$core$Dict$balance,
6269− nColor,
6270− nKey,
6271− nValue,
6272− nLeft,
6273− A3($elm$core$Dict$insertHelp, key, value, nRight));
6274− }
6275− }
6276− });
6277−var $elm$core$Dict$insert = F3(
6278− function (key, value, dict) {
6279− var _v0 = A3($elm$core$Dict$insertHelp, key, value, dict);
6280− if ((_v0.$ === 'RBNode_elm_builtin') && (_v0.a.$ === 'Red')) {
6281− var _v1 = _v0.a;
6282− var k = _v0.b;
6283− var v = _v0.c;
6284− var l = _v0.d;
6285− var r = _v0.e;
6286− return A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Black, k, v, l, r);
6287− } else {
6288− var x = _v0;
6289− return x;
6290− }
6291− });
6292−var $elm$time$Time$addMySub = F2(
6293− function (_v0, state) {
6294− var interval = _v0.a;
6295− var tagger = _v0.b;
6296− var _v1 = A2($elm$core$Dict$get, interval, state);
6297− if (_v1.$ === 'Nothing') {
6298− return A3(
6299− $elm$core$Dict$insert,
6300− interval,
6301− _List_fromArray(
6302− [tagger]),
6303− state);
6304− } else {
6305− var taggers = _v1.a;
6306− return A3(
6307− $elm$core$Dict$insert,
6308− interval,
6309− A2($elm$core$List$cons, tagger, taggers),
6310− state);
6311− }
6312− });
6313−var $elm$core$Process$kill = _Scheduler_kill;
6314−var $elm$core$Dict$foldl = F3(
6315− function (func, acc, dict) {
6316− foldl:
6317− while (true) {
6318− if (dict.$ === 'RBEmpty_elm_builtin') {
6319− return acc;
6320− } else {
6321− var key = dict.b;
6322− var value = dict.c;
6323− var left = dict.d;
6324− var right = dict.e;
6325− var $temp$func = func,
6326− $temp$acc = A3(
6327− func,
6328− key,
6329− value,
6330− A3($elm$core$Dict$foldl, func, acc, left)),
6331− $temp$dict = right;
6332− func = $temp$func;
6333− acc = $temp$acc;
6334− dict = $temp$dict;
6335− continue foldl;
6336− }
6337− }
6338− });
6339−var $elm$core$Dict$merge = F6(
6340− function (leftStep, bothStep, rightStep, leftDict, rightDict, initialResult) {
6341− var stepState = F3(
6342− function (rKey, rValue, _v0) {
6343− stepState:
6344− while (true) {
6345− var list = _v0.a;
6346− var result = _v0.b;
6347− if (!list.b) {
6348− return _Utils_Tuple2(
6349− list,
6350− A3(rightStep, rKey, rValue, result));
6351− } else {
6352− var _v2 = list.a;
6353− var lKey = _v2.a;
6354− var lValue = _v2.b;
6355− var rest = list.b;
6356− if (_Utils_cmp(lKey, rKey) < 0) {
6357− var $temp$rKey = rKey,
6358− $temp$rValue = rValue,
6359− $temp$_v0 = _Utils_Tuple2(
6360− rest,
6361− A3(leftStep, lKey, lValue, result));
6362− rKey = $temp$rKey;
6363− rValue = $temp$rValue;
6364− _v0 = $temp$_v0;
6365− continue stepState;
6366− } else {
6367− if (_Utils_cmp(lKey, rKey) > 0) {
6368− return _Utils_Tuple2(
6369− list,
6370− A3(rightStep, rKey, rValue, result));
6371− } else {
6372− return _Utils_Tuple2(
6373− rest,
6374− A4(bothStep, lKey, lValue, rValue, result));
6375− }
6376− }
6377− }
6378− }
6379− });
6380− var _v3 = A3(
6381− $elm$core$Dict$foldl,
6382− stepState,
6383− _Utils_Tuple2(
6384− $elm$core$Dict$toList(leftDict),
6385− initialResult),
6386− rightDict);
6387− var leftovers = _v3.a;
6388− var intermediateResult = _v3.b;
6389− return A3(
6390− $elm$core$List$foldl,
6391− F2(
6392− function (_v4, result) {
6393− var k = _v4.a;
6394− var v = _v4.b;
6395− return A3(leftStep, k, v, result);
6396− }),
6397− intermediateResult,
6398− leftovers);
6399− });
6400−var $elm$core$Platform$sendToSelf = _Platform_sendToSelf;
6401−var $elm$time$Time$setInterval = _Time_setInterval;
6402−var $elm$core$Process$spawn = _Scheduler_spawn;
6403−var $elm$time$Time$spawnHelp = F3(
6404− function (router, intervals, processes) {
6405− if (!intervals.b) {
6406− return $elm$core$Task$succeed(processes);
6407− } else {
6408− var interval = intervals.a;
6409− var rest = intervals.b;
6410− var spawnTimer = $elm$core$Process$spawn(
6411− A2(
6412− $elm$time$Time$setInterval,
6413− interval,
6414− A2($elm$core$Platform$sendToSelf, router, interval)));
6415− var spawnRest = function (id) {
6416− return A3(
6417− $elm$time$Time$spawnHelp,
6418− router,
6419− rest,
6420− A3($elm$core$Dict$insert, interval, id, processes));
6421− };
6422− return A2($elm$core$Task$andThen, spawnRest, spawnTimer);
6423− }
6424− });
6425−var $elm$time$Time$onEffects = F3(
6426− function (router, subs, _v0) {
6427− var processes = _v0.processes;
6428− var rightStep = F3(
6429− function (_v6, id, _v7) {
6430− var spawns = _v7.a;
6431− var existing = _v7.b;
6432− var kills = _v7.c;
6433− return _Utils_Tuple3(
6434− spawns,
6435− existing,
6436− A2(
6437− $elm$core$Task$andThen,
6438− function (_v5) {
6439− return kills;
6440− },
6441− $elm$core$Process$kill(id)));
6442− });
6443− var newTaggers = A3($elm$core$List$foldl, $elm$time$Time$addMySub, $elm$core$Dict$empty, subs);
6444− var leftStep = F3(
6445− function (interval, taggers, _v4) {
6446− var spawns = _v4.a;
6447− var existing = _v4.b;
6448− var kills = _v4.c;
6449− return _Utils_Tuple3(
6450− A2($elm$core$List$cons, interval, spawns),
6451− existing,
6452− kills);
6453− });
6454− var bothStep = F4(
6455− function (interval, taggers, id, _v3) {
6456− var spawns = _v3.a;
6457− var existing = _v3.b;
6458− var kills = _v3.c;
6459− return _Utils_Tuple3(
6460− spawns,
6461− A3($elm$core$Dict$insert, interval, id, existing),
6462− kills);
6463− });
6464− var _v1 = A6(
6465− $elm$core$Dict$merge,
6466− leftStep,
6467− bothStep,
6468− rightStep,
6469− newTaggers,
6470− processes,
6471− _Utils_Tuple3(
6472− _List_Nil,
6473− $elm$core$Dict$empty,
6474− $elm$core$Task$succeed(_Utils_Tuple0)));
6475− var spawnList = _v1.a;
6476− var existingDict = _v1.b;
6477− var killTask = _v1.c;
6478− return A2(
6479− $elm$core$Task$andThen,
6480− function (newProcesses) {
6481− return $elm$core$Task$succeed(
6482− A2($elm$time$Time$State, newTaggers, newProcesses));
6483− },
6484− A2(
6485− $elm$core$Task$andThen,
6486− function (_v2) {
6487− return A3($elm$time$Time$spawnHelp, router, spawnList, existingDict);
6488− },
6489− killTask));
6490− });
6491−var $elm$time$Time$onSelfMsg = F3(
6492− function (router, interval, state) {
6493− var _v0 = A2($elm$core$Dict$get, interval, state.taggers);
6494− if (_v0.$ === 'Nothing') {
6495− return $elm$core$Task$succeed(state);
6496− } else {
6497− var taggers = _v0.a;
6498− var tellTaggers = function (time) {
6499− return $elm$core$Task$sequence(
6500− A2(
6501− $elm$core$List$map,
6502− function (tagger) {
6503− return A2(
6504− $elm$core$Platform$sendToApp,
6505− router,
6506− tagger(time));
6507− },
6508− taggers));
6509− };
6510− return A2(
6511− $elm$core$Task$andThen,
6512− function (_v1) {
6513− return $elm$core$Task$succeed(state);
6514− },
6515− A2($elm$core$Task$andThen, tellTaggers, $elm$time$Time$now));
6516− }
6517− });
6518−var $elm$core$Basics$composeL = F3(
6519− function (g, f, x) {
6520− return g(
6521− f(x));
6522− });
6523−var $elm$time$Time$subMap = F2(
6524− function (f, _v0) {
6525− var interval = _v0.a;
6526− var tagger = _v0.b;
6527− return A2(
6528− $elm$time$Time$Every,
6529− interval,
6530− A2($elm$core$Basics$composeL, f, tagger));
6531− });
6532−_Platform_effectManagers['Time'] = _Platform_createManager($elm$time$Time$init, $elm$time$Time$onEffects, $elm$time$Time$onSelfMsg, 0, $elm$time$Time$subMap);
6533−var $elm$time$Time$subscription = _Platform_leaf('Time');
6534−var $elm$time$Time$every = F2(
6535− function (interval, tagger) {
6536− return $elm$time$Time$subscription(
6537− A2($elm$time$Time$Every, interval, tagger));
6538− });
6539−var $elm$core$Basics$neq = _Utils_notEqual;
6540−var $elm$core$Platform$Sub$none = $elm$core$Platform$Sub$batch(_List_Nil);
6541−var $author$project$Main$subscriptions = function (model) {
6542− return $elm$core$Platform$Sub$batch(
6543− _List_fromArray(
6544− [
6545− (model.authToken !== '') ? A2($elm$time$Time$every, 3000, $author$project$Main$PollTick) : $elm$core$Platform$Sub$none,
6546− A2($elm$time$Time$every, 60000, $author$project$Main$GotTime)
6547− ]));
6548−};
6549−var $author$project$Main$ChatScreen = {$: 'ChatScreen'};
6550−var $author$project$Main$ContactsScreen = {$: 'ContactsScreen'};
6551−var $author$project$Main$GotSendEntropy = function (a) {
6552− return {$: 'GotSendEntropy', a: a};
6553−};
6554−var $author$project$Main$GotAck = function (a) {
6555− return {$: 'GotAck', a: a};
6556−};
6557−var $elm$json$Json$Decode$bool = _Json_decodeBool;
6558−var $elm$json$Json$Decode$decodeString = _Json_runOnString;
6559−var $elm$bytes$Bytes$Encode$getWidth = function (builder) {
6560− switch (builder.$) {
6561− case 'I8':
6562− return 1;
6563− case 'I16':
6564− return 2;
6565− case 'I32':
6566− return 4;
6567− case 'U8':
6568− return 1;
6569− case 'U16':
6570− return 2;
6571− case 'U32':
6572− return 4;
6573− case 'F32':
6574− return 4;
6575− case 'F64':
6576− return 8;
6577− case 'Seq':
6578− var w = builder.a;
6579− return w;
6580− case 'Utf8':
6581− var w = builder.a;
6582− return w;
6583− default:
6584− var bs = builder.a;
6585− return _Bytes_width(bs);
6586− }
6587−};
6588−var $elm$bytes$Bytes$LE = {$: 'LE'};
6589−var $elm$bytes$Bytes$Encode$write = F3(
6590− function (builder, mb, offset) {
6591− switch (builder.$) {
6592− case 'I8':
6593− var n = builder.a;
6594− return A3(_Bytes_write_i8, mb, offset, n);
6595− case 'I16':
6596− var e = builder.a;
6597− var n = builder.b;
6598− return A4(
6599− _Bytes_write_i16,
6600− mb,
6601− offset,
6602− n,
6603− _Utils_eq(e, $elm$bytes$Bytes$LE));
6604− case 'I32':
6605− var e = builder.a;
6606− var n = builder.b;
6607− return A4(
6608− _Bytes_write_i32,
6609− mb,
6610− offset,
6611− n,
6612− _Utils_eq(e, $elm$bytes$Bytes$LE));
6613− case 'U8':
6614− var n = builder.a;
6615− return A3(_Bytes_write_u8, mb, offset, n);
6616− case 'U16':
6617− var e = builder.a;
6618− var n = builder.b;
6619− return A4(
6620− _Bytes_write_u16,
6621− mb,
6622− offset,
6623− n,
6624− _Utils_eq(e, $elm$bytes$Bytes$LE));
6625− case 'U32':
6626− var e = builder.a;
6627− var n = builder.b;
6628− return A4(
6629− _Bytes_write_u32,
6630− mb,
6631− offset,
6632− n,
6633− _Utils_eq(e, $elm$bytes$Bytes$LE));
6634− case 'F32':
6635− var e = builder.a;
6636− var n = builder.b;
6637− return A4(
6638− _Bytes_write_f32,
6639− mb,
6640− offset,
6641− n,
6642− _Utils_eq(e, $elm$bytes$Bytes$LE));
6643− case 'F64':
6644− var e = builder.a;
6645− var n = builder.b;
6646− return A4(
6647− _Bytes_write_f64,
6648− mb,
6649− offset,
6650− n,
6651− _Utils_eq(e, $elm$bytes$Bytes$LE));
6652− case 'Seq':
6653− var bs = builder.b;
6654− return A3($elm$bytes$Bytes$Encode$writeSequence, bs, mb, offset);
6655− case 'Utf8':
6656− var s = builder.b;
6657− return A3(_Bytes_write_string, mb, offset, s);
6658− default:
6659− var bs = builder.a;
6660− return A3(_Bytes_write_bytes, mb, offset, bs);
6661− }
6662− });
6663−var $elm$bytes$Bytes$Encode$writeSequence = F3(
6664− function (builders, mb, offset) {
6665− writeSequence:
6666− while (true) {
6667− if (!builders.b) {
6668− return offset;
6669− } else {
6670− var b = builders.a;
6671− var bs = builders.b;
6672− var $temp$builders = bs,
6673− $temp$mb = mb,
6674− $temp$offset = A3($elm$bytes$Bytes$Encode$write, b, mb, offset);
6675− builders = $temp$builders;
6676− mb = $temp$mb;
6677− offset = $temp$offset;
6678− continue writeSequence;
6679− }
6680− }
6681− });
6682−var $lynxjs_elm$http$Http$BadStatus_ = F2(
6683− function (a, b) {
6684− return {$: 'BadStatus_', a: a, b: b};
6685− });
6686−var $lynxjs_elm$http$Http$BadUrl_ = function (a) {
6687− return {$: 'BadUrl_', a: a};
6688−};
6689−var $lynxjs_elm$http$Http$GoodStatus_ = F2(
6690− function (a, b) {
6691− return {$: 'GoodStatus_', a: a, b: b};
6692− });
6693−var $lynxjs_elm$http$Http$NetworkError_ = {$: 'NetworkError_'};
6694−var $lynxjs_elm$http$Http$Timeout_ = {$: 'Timeout_'};
6695−var $elm$core$Maybe$isJust = function (maybe) {
6696− if (maybe.$ === 'Just') {
6697− return true;
6698− } else {
6699− return false;
6700− }
6701−};
6702−var $elm$core$Dict$getMin = function (dict) {
6703− getMin:
6704− while (true) {
6705− if ((dict.$ === 'RBNode_elm_builtin') && (dict.d.$ === 'RBNode_elm_builtin')) {
6706− var left = dict.d;
6707− var $temp$dict = left;
6708− dict = $temp$dict;
6709− continue getMin;
6710− } else {
6711− return dict;
6712− }
6713− }
6714−};
6715−var $elm$core$Dict$moveRedLeft = function (dict) {
6716− if (((dict.$ === 'RBNode_elm_builtin') && (dict.d.$ === 'RBNode_elm_builtin')) && (dict.e.$ === 'RBNode_elm_builtin')) {
6717− if ((dict.e.d.$ === 'RBNode_elm_builtin') && (dict.e.d.a.$ === 'Red')) {
6718− var clr = dict.a;
6719− var k = dict.b;
6720− var v = dict.c;
6721− var _v1 = dict.d;
6722− var lClr = _v1.a;
6723− var lK = _v1.b;
6724− var lV = _v1.c;
6725− var lLeft = _v1.d;
6726− var lRight = _v1.e;
6727− var _v2 = dict.e;
6728− var rClr = _v2.a;
6729− var rK = _v2.b;
6730− var rV = _v2.c;
6731− var rLeft = _v2.d;
6732− var _v3 = rLeft.a;
6733− var rlK = rLeft.b;
6734− var rlV = rLeft.c;
6735− var rlL = rLeft.d;
6736− var rlR = rLeft.e;
6737− var rRight = _v2.e;
6738− return A5(
6739− $elm$core$Dict$RBNode_elm_builtin,
6740− $elm$core$Dict$Red,
6741− rlK,
6742− rlV,
6743− A5(
6744− $elm$core$Dict$RBNode_elm_builtin,
6745− $elm$core$Dict$Black,
6746− k,
6747− v,
6748− A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Red, lK, lV, lLeft, lRight),
6749− rlL),
6750− A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Black, rK, rV, rlR, rRight));
6751− } else {
6752− var clr = dict.a;
6753− var k = dict.b;
6754− var v = dict.c;
6755− var _v4 = dict.d;
6756− var lClr = _v4.a;
6757− var lK = _v4.b;
6758− var lV = _v4.c;
6759− var lLeft = _v4.d;
6760− var lRight = _v4.e;
6761− var _v5 = dict.e;
6762− var rClr = _v5.a;
6763− var rK = _v5.b;
6764− var rV = _v5.c;
6765− var rLeft = _v5.d;
6766− var rRight = _v5.e;
6767− if (clr.$ === 'Black') {
6768− return A5(
6769− $elm$core$Dict$RBNode_elm_builtin,
6770− $elm$core$Dict$Black,
6771− k,
6772− v,
6773− A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Red, lK, lV, lLeft, lRight),
6774− A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Red, rK, rV, rLeft, rRight));
6775− } else {
6776− return A5(
6777− $elm$core$Dict$RBNode_elm_builtin,
6778− $elm$core$Dict$Black,
6779− k,
6780− v,
6781− A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Red, lK, lV, lLeft, lRight),
6782− A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Red, rK, rV, rLeft, rRight));
6783− }
6784− }
6785− } else {
6786− return dict;
6787− }
6788−};
6789−var $elm$core$Dict$moveRedRight = function (dict) {
6790− if (((dict.$ === 'RBNode_elm_builtin') && (dict.d.$ === 'RBNode_elm_builtin')) && (dict.e.$ === 'RBNode_elm_builtin')) {
6791− if ((dict.d.d.$ === 'RBNode_elm_builtin') && (dict.d.d.a.$ === 'Red')) {
6792− var clr = dict.a;
6793− var k = dict.b;
6794− var v = dict.c;
6795− var _v1 = dict.d;
6796− var lClr = _v1.a;
6797− var lK = _v1.b;
6798− var lV = _v1.c;
6799− var _v2 = _v1.d;
6800− var _v3 = _v2.a;
6801− var llK = _v2.b;
6802− var llV = _v2.c;
6803− var llLeft = _v2.d;
6804− var llRight = _v2.e;
6805− var lRight = _v1.e;
6806− var _v4 = dict.e;
6807− var rClr = _v4.a;
6808− var rK = _v4.b;
6809− var rV = _v4.c;
6810− var rLeft = _v4.d;
6811− var rRight = _v4.e;
6812− return A5(
6813− $elm$core$Dict$RBNode_elm_builtin,
6814− $elm$core$Dict$Red,
6815− lK,
6816− lV,
6817− A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Black, llK, llV, llLeft, llRight),
6818− A5(
6819− $elm$core$Dict$RBNode_elm_builtin,
6820− $elm$core$Dict$Black,
6821− k,
6822− v,
6823− lRight,
6824− A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Red, rK, rV, rLeft, rRight)));
6825− } else {
6826− var clr = dict.a;
6827− var k = dict.b;
6828− var v = dict.c;
6829− var _v5 = dict.d;
6830− var lClr = _v5.a;
6831− var lK = _v5.b;
6832− var lV = _v5.c;
6833− var lLeft = _v5.d;
6834− var lRight = _v5.e;
6835− var _v6 = dict.e;
6836− var rClr = _v6.a;
6837− var rK = _v6.b;
6838− var rV = _v6.c;
6839− var rLeft = _v6.d;
6840− var rRight = _v6.e;
6841− if (clr.$ === 'Black') {
6842− return A5(
6843− $elm$core$Dict$RBNode_elm_builtin,
6844− $elm$core$Dict$Black,
6845− k,
6846− v,
6847− A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Red, lK, lV, lLeft, lRight),
6848− A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Red, rK, rV, rLeft, rRight));
6849− } else {
6850− return A5(
6851− $elm$core$Dict$RBNode_elm_builtin,
6852− $elm$core$Dict$Black,
6853− k,
6854− v,
6855− A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Red, lK, lV, lLeft, lRight),
6856− A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Red, rK, rV, rLeft, rRight));
6857− }
6858− }
6859− } else {
6860− return dict;
6861− }
6862−};
6863−var $elm$core$Dict$removeHelpPrepEQGT = F7(
6864− function (targetKey, dict, color, key, value, left, right) {
6865− if ((left.$ === 'RBNode_elm_builtin') && (left.a.$ === 'Red')) {
6866− var _v1 = left.a;
6867− var lK = left.b;
6868− var lV = left.c;
6869− var lLeft = left.d;
6870− var lRight = left.e;
6871− return A5(
6872− $elm$core$Dict$RBNode_elm_builtin,
6873− color,
6874− lK,
6875− lV,
6876− lLeft,
6877− A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Red, key, value, lRight, right));
6878− } else {
6879− _v2$2:
6880− while (true) {
6881− if ((right.$ === 'RBNode_elm_builtin') && (right.a.$ === 'Black')) {
6882− if (right.d.$ === 'RBNode_elm_builtin') {
6883− if (right.d.a.$ === 'Black') {
6884− var _v3 = right.a;
6885− var _v4 = right.d;
6886− var _v5 = _v4.a;
6887− return $elm$core$Dict$moveRedRight(dict);
6888− } else {
6889− break _v2$2;
6890− }
6891− } else {
6892− var _v6 = right.a;
6893− var _v7 = right.d;
6894− return $elm$core$Dict$moveRedRight(dict);
6895− }
6896− } else {
6897− break _v2$2;
6898− }
6899− }
6900− return dict;
6901− }
6902− });
6903−var $elm$core$Dict$removeMin = function (dict) {
6904− if ((dict.$ === 'RBNode_elm_builtin') && (dict.d.$ === 'RBNode_elm_builtin')) {
6905− var color = dict.a;
6906− var key = dict.b;
6907− var value = dict.c;
6908− var left = dict.d;
6909− var lColor = left.a;
6910− var lLeft = left.d;
6911− var right = dict.e;
6912− if (lColor.$ === 'Black') {
6913− if ((lLeft.$ === 'RBNode_elm_builtin') && (lLeft.a.$ === 'Red')) {
6914− var _v3 = lLeft.a;
6915− return A5(
6916− $elm$core$Dict$RBNode_elm_builtin,
6917− color,
6918− key,
6919− value,
6920− $elm$core$Dict$removeMin(left),
6921− right);
6922− } else {
6923− var _v4 = $elm$core$Dict$moveRedLeft(dict);
6924− if (_v4.$ === 'RBNode_elm_builtin') {
6925− var nColor = _v4.a;
6926− var nKey = _v4.b;
6927− var nValue = _v4.c;
6928− var nLeft = _v4.d;
6929− var nRight = _v4.e;
6930− return A5(
6931− $elm$core$Dict$balance,
6932− nColor,
6933− nKey,
6934− nValue,
6935− $elm$core$Dict$removeMin(nLeft),
6936− nRight);
6937− } else {
6938− return $elm$core$Dict$RBEmpty_elm_builtin;
6939− }
6940− }
6941− } else {
6942− return A5(
6943− $elm$core$Dict$RBNode_elm_builtin,
6944− color,
6945− key,
6946− value,
6947− $elm$core$Dict$removeMin(left),
6948− right);
6949− }
6950− } else {
6951− return $elm$core$Dict$RBEmpty_elm_builtin;
6952− }
6953−};
6954−var $elm$core$Dict$removeHelp = F2(
6955− function (targetKey, dict) {
6956− if (dict.$ === 'RBEmpty_elm_builtin') {
6957− return $elm$core$Dict$RBEmpty_elm_builtin;
6958− } else {
6959− var color = dict.a;
6960− var key = dict.b;
6961− var value = dict.c;
6962− var left = dict.d;
6963− var right = dict.e;
6964− if (_Utils_cmp(targetKey, key) < 0) {
6965− if ((left.$ === 'RBNode_elm_builtin') && (left.a.$ === 'Black')) {
6966− var _v4 = left.a;
6967− var lLeft = left.d;
6968− if ((lLeft.$ === 'RBNode_elm_builtin') && (lLeft.a.$ === 'Red')) {
6969− var _v6 = lLeft.a;
6970− return A5(
6971− $elm$core$Dict$RBNode_elm_builtin,
6972− color,
6973− key,
6974− value,
6975− A2($elm$core$Dict$removeHelp, targetKey, left),
6976− right);
6977− } else {
6978− var _v7 = $elm$core$Dict$moveRedLeft(dict);
6979− if (_v7.$ === 'RBNode_elm_builtin') {
6980− var nColor = _v7.a;
6981− var nKey = _v7.b;
6982− var nValue = _v7.c;
6983− var nLeft = _v7.d;
6984− var nRight = _v7.e;
6985− return A5(
6986− $elm$core$Dict$balance,
6987− nColor,
6988− nKey,
6989− nValue,
6990− A2($elm$core$Dict$removeHelp, targetKey, nLeft),
6991− nRight);
6992− } else {
6993− return $elm$core$Dict$RBEmpty_elm_builtin;
6994− }
6995− }
6996− } else {
6997− return A5(
6998− $elm$core$Dict$RBNode_elm_builtin,
6999− color,
7000− key,
7001− value,
7002− A2($elm$core$Dict$removeHelp, targetKey, left),
7003− right);
7004− }
7005− } else {
7006− return A2(
7007− $elm$core$Dict$removeHelpEQGT,
7008− targetKey,
7009− A7($elm$core$Dict$removeHelpPrepEQGT, targetKey, dict, color, key, value, left, right));
7010− }
7011− }
7012− });
7013−var $elm$core$Dict$removeHelpEQGT = F2(
7014− function (targetKey, dict) {
7015− if (dict.$ === 'RBNode_elm_builtin') {
7016− var color = dict.a;
7017− var key = dict.b;
7018− var value = dict.c;
7019− var left = dict.d;
7020− var right = dict.e;
7021− if (_Utils_eq(targetKey, key)) {
7022− var _v1 = $elm$core$Dict$getMin(right);
7023− if (_v1.$ === 'RBNode_elm_builtin') {
7024− var minKey = _v1.b;
7025− var minValue = _v1.c;
7026− return A5(
7027− $elm$core$Dict$balance,
7028− color,
7029− minKey,
7030− minValue,
7031− left,
7032− $elm$core$Dict$removeMin(right));
7033− } else {
7034− return $elm$core$Dict$RBEmpty_elm_builtin;
7035− }
7036− } else {
7037− return A5(
7038− $elm$core$Dict$balance,
7039− color,
7040− key,
7041− value,
7042− left,
7043− A2($elm$core$Dict$removeHelp, targetKey, right));
7044− }
7045− } else {
7046− return $elm$core$Dict$RBEmpty_elm_builtin;
7047− }
7048− });
7049−var $elm$core$Dict$remove = F2(
7050− function (key, dict) {
7051− var _v0 = A2($elm$core$Dict$removeHelp, key, dict);
7052− if ((_v0.$ === 'RBNode_elm_builtin') && (_v0.a.$ === 'Red')) {
7053− var _v1 = _v0.a;
7054− var k = _v0.b;
7055− var v = _v0.c;
7056− var l = _v0.d;
7057− var r = _v0.e;
7058− return A5($elm$core$Dict$RBNode_elm_builtin, $elm$core$Dict$Black, k, v, l, r);
7059− } else {
7060− var x = _v0;
7061− return x;
7062− }
7063− });
7064−var $elm$core$Dict$update = F3(
7065− function (targetKey, alter, dictionary) {
7066− var _v0 = alter(
7067− A2($elm$core$Dict$get, targetKey, dictionary));
7068− if (_v0.$ === 'Just') {
7069− var value = _v0.a;
7070− return A3($elm$core$Dict$insert, targetKey, value, dictionary);
7071− } else {
7072− return A2($elm$core$Dict$remove, targetKey, dictionary);
7073− }
7074− });
7075−var $elm$core$Basics$composeR = F3(
7076− function (f, g, x) {
7077− return g(
7078− f(x));
7079− });
7080−var $lynxjs_elm$http$Http$expectStringResponse = F2(
7081− function (toMsg, toResult) {
7082− return A3(
7083− _Http_expect,
7084− '',
7085− $elm$core$Basics$identity,
7086− A2($elm$core$Basics$composeR, toResult, toMsg));
7087− });
7088−var $elm$core$Result$mapError = F2(
7089− function (f, result) {
7090− if (result.$ === 'Ok') {
7091− var v = result.a;
7092− return $elm$core$Result$Ok(v);
7093− } else {
7094− var e = result.a;
7095− return $elm$core$Result$Err(
7096− f(e));
7097− }
7098− });
7099−var $lynxjs_elm$http$Http$BadBody = function (a) {
7100− return {$: 'BadBody', a: a};
7101−};
7102−var $lynxjs_elm$http$Http$BadStatus = function (a) {
7103− return {$: 'BadStatus', a: a};
7104−};
7105−var $lynxjs_elm$http$Http$BadUrl = function (a) {
7106− return {$: 'BadUrl', a: a};
7107−};
7108−var $lynxjs_elm$http$Http$NetworkError = {$: 'NetworkError'};
7109−var $lynxjs_elm$http$Http$Timeout = {$: 'Timeout'};
7110−var $lynxjs_elm$http$Http$resolve = F2(
7111− function (toResult, response) {
7112− switch (response.$) {
7113− case 'BadUrl_':
7114− var url = response.a;
7115− return $elm$core$Result$Err(
7116− $lynxjs_elm$http$Http$BadUrl(url));
7117− case 'Timeout_':
7118− return $elm$core$Result$Err($lynxjs_elm$http$Http$Timeout);
7119− case 'NetworkError_':
7120− return $elm$core$Result$Err($lynxjs_elm$http$Http$NetworkError);
7121− case 'BadStatus_':
7122− var metadata = response.a;
7123− return $elm$core$Result$Err(
7124− $lynxjs_elm$http$Http$BadStatus(metadata.statusCode));
7125− default:
7126− var body = response.b;
7127− return A2(
7128− $elm$core$Result$mapError,
7129− $lynxjs_elm$http$Http$BadBody,
7130− toResult(body));
7131− }
7132− });
7133−var $lynxjs_elm$http$Http$expectJson = F2(
7134− function (toMsg, decoder) {
7135− return A2(
7136− $lynxjs_elm$http$Http$expectStringResponse,
7137− toMsg,
7138− $lynxjs_elm$http$Http$resolve(
7139− function (string) {
7140− return A2(
7141− $elm$core$Result$mapError,
7142− $elm$json$Json$Decode$errorToString,
7143− A2($elm$json$Json$Decode$decodeString, decoder, string));
7144− }));
7145− });
7146−var $elm$json$Json$Decode$field = _Json_decodeField;
7147−var $lynxjs_elm$http$Http$Header = F2(
7148− function (a, b) {
7149− return {$: 'Header', a: a, b: b};
7150− });
7151−var $lynxjs_elm$http$Http$header = $lynxjs_elm$http$Http$Header;
7152−var $lynxjs_elm$http$Http$jsonBody = function (value) {
7153− return A2(
7154− _Http_pair,
7155− 'application/json',
7156− A2($elm$json$Json$Encode$encode, 0, value));
7157−};
7158−var $elm$json$Json$Encode$list = F2(
7159− function (func, entries) {
7160− return _Json_wrap(
7161− A3(
7162− $elm$core$List$foldl,
7163− _Json_addEntry(func),
7164− _Json_emptyArray(_Utils_Tuple0),
7165− entries));
7166− });
7167−var $elm$json$Json$Encode$object = function (pairs) {
7168− return _Json_wrap(
7169− A3(
7170− $elm$core$List$foldl,
7171− F2(
7172− function (_v0, obj) {
7173− var k = _v0.a;
7174− var v = _v0.b;
7175− return A3(_Json_addField, k, v, obj);
7176− }),
7177− _Json_emptyObject(_Utils_Tuple0),
7178− pairs));
7179−};
7180−var $lynxjs_elm$http$Http$Request = function (a) {
7181− return {$: 'Request', a: a};
7182−};
7183−var $lynxjs_elm$http$Http$State = F2(
7184− function (reqs, subs) {
7185− return {reqs: reqs, subs: subs};
7186− });
7187−var $lynxjs_elm$http$Http$init = $elm$core$Task$succeed(
7188− A2($lynxjs_elm$http$Http$State, $elm$core$Dict$empty, _List_Nil));
7189−var $lynxjs_elm$http$Http$updateReqs = F3(
7190− function (router, cmds, reqs) {
7191− updateReqs:
7192− while (true) {
7193− if (!cmds.b) {
7194− return $elm$core$Task$succeed(reqs);
7195− } else {
7196− var cmd = cmds.a;
7197− var otherCmds = cmds.b;
7198− if (cmd.$ === 'Cancel') {
7199− var tracker = cmd.a;
7200− var _v2 = A2($elm$core$Dict$get, tracker, reqs);
7201− if (_v2.$ === 'Nothing') {
7202− var $temp$router = router,
7203− $temp$cmds = otherCmds,
7204− $temp$reqs = reqs;
7205− router = $temp$router;
7206− cmds = $temp$cmds;
7207− reqs = $temp$reqs;
7208− continue updateReqs;
7209− } else {
7210− var pid = _v2.a;
7211− return A2(
7212− $elm$core$Task$andThen,
7213− function (_v3) {
7214− return A3(
7215− $lynxjs_elm$http$Http$updateReqs,
7216− router,
7217− otherCmds,
7218− A2($elm$core$Dict$remove, tracker, reqs));
7219− },
7220− $elm$core$Process$kill(pid));
7221− }
7222− } else {
7223− var req = cmd.a;
7224− return A2(
7225− $elm$core$Task$andThen,
7226− function (pid) {
7227− var _v4 = req.tracker;
7228− if (_v4.$ === 'Nothing') {
7229− return A3($lynxjs_elm$http$Http$updateReqs, router, otherCmds, reqs);
7230− } else {
7231− var tracker = _v4.a;
7232− return A3(
7233− $lynxjs_elm$http$Http$updateReqs,
7234− router,
7235− otherCmds,
7236− A3($elm$core$Dict$insert, tracker, pid, reqs));
7237− }
7238− },
7239− $elm$core$Process$spawn(
7240− A3(
7241− _Http_toTask,
7242− router,
7243− $elm$core$Platform$sendToApp(router),
7244− req)));
7245− }
7246− }
7247− }
7248− });
7249−var $lynxjs_elm$http$Http$onEffects = F4(
7250− function (router, cmds, subs, state) {
7251− return A2(
7252− $elm$core$Task$andThen,
7253− function (reqs) {
7254− return $elm$core$Task$succeed(
7255− A2($lynxjs_elm$http$Http$State, reqs, subs));
7256− },
7257− A3($lynxjs_elm$http$Http$updateReqs, router, cmds, state.reqs));
7258− });
7259−var $elm$core$List$maybeCons = F3(
7260− function (f, mx, xs) {
7261− var _v0 = f(mx);
7262− if (_v0.$ === 'Just') {
7263− var x = _v0.a;
7264− return A2($elm$core$List$cons, x, xs);
7265− } else {
7266− return xs;
7267− }
7268− });
7269−var $elm$core$List$filterMap = F2(
7270− function (f, xs) {
7271− return A3(
7272− $elm$core$List$foldr,
7273− $elm$core$List$maybeCons(f),
7274− _List_Nil,
7275− xs);
7276− });
7277−var $lynxjs_elm$http$Http$maybeSend = F4(
7278− function (router, desiredTracker, progress, _v0) {
7279− var actualTracker = _v0.a;
7280− var toMsg = _v0.b;
7281− return _Utils_eq(desiredTracker, actualTracker) ? $elm$core$Maybe$Just(
7282− A2(
7283− $elm$core$Platform$sendToApp,
7284− router,
7285− toMsg(progress))) : $elm$core$Maybe$Nothing;
7286− });
7287−var $lynxjs_elm$http$Http$onSelfMsg = F3(
7288− function (router, _v0, state) {
7289− var tracker = _v0.a;
7290− var progress = _v0.b;
7291− return A2(
7292− $elm$core$Task$andThen,
7293− function (_v1) {
7294− return $elm$core$Task$succeed(state);
7295− },
7296− $elm$core$Task$sequence(
7297− A2(
7298− $elm$core$List$filterMap,
7299− A3($lynxjs_elm$http$Http$maybeSend, router, tracker, progress),
7300− state.subs)));
7301− });
7302−var $lynxjs_elm$http$Http$Cancel = function (a) {
7303− return {$: 'Cancel', a: a};
7304−};
7305−var $lynxjs_elm$http$Http$cmdMap = F2(
7306− function (func, cmd) {
7307− if (cmd.$ === 'Cancel') {
7308− var tracker = cmd.a;
7309− return $lynxjs_elm$http$Http$Cancel(tracker);
7310− } else {
7311− var r = cmd.a;
7312− return $lynxjs_elm$http$Http$Request(
7313− {
7314− allowCookiesFromOtherDomains: r.allowCookiesFromOtherDomains,
7315− body: r.body,
7316− expect: A2(_Http_mapExpect, func, r.expect),
7317− headers: r.headers,
7318− method: r.method,
7319− timeout: r.timeout,
7320− tracker: r.tracker,
7321− url: r.url
7322− });
7323− }
7324− });
7325−var $lynxjs_elm$http$Http$MySub = F2(
7326− function (a, b) {
7327− return {$: 'MySub', a: a, b: b};
7328− });
7329−var $lynxjs_elm$http$Http$subMap = F2(
7330− function (func, _v0) {
7331− var tracker = _v0.a;
7332− var toMsg = _v0.b;
7333− return A2(
7334− $lynxjs_elm$http$Http$MySub,
7335− tracker,
7336− A2($elm$core$Basics$composeR, toMsg, func));
7337− });
7338−_Platform_effectManagers['Http'] = _Platform_createManager($lynxjs_elm$http$Http$init, $lynxjs_elm$http$Http$onEffects, $lynxjs_elm$http$Http$onSelfMsg, $lynxjs_elm$http$Http$cmdMap, $lynxjs_elm$http$Http$subMap);
7339−var $lynxjs_elm$http$Http$command = _Platform_leaf('Http');
7340−var $lynxjs_elm$http$Http$subscription = _Platform_leaf('Http');
7341−var $lynxjs_elm$http$Http$request = function (r) {
7342− return $lynxjs_elm$http$Http$command(
7343− $lynxjs_elm$http$Http$Request(
7344− {allowCookiesFromOtherDomains: false, body: r.body, expect: r.expect, headers: r.headers, method: r.method, timeout: r.timeout, tracker: r.tracker, url: r.url}));
7345−};
7346−var $elm$json$Json$Encode$string = _Json_wrap;
7347−var $author$project$Main$ackMessages = F3(
7348− function (serverUrl, token, messageIds) {
7349− return $lynxjs_elm$http$Http$request(
7350− {
7351− body: $lynxjs_elm$http$Http$jsonBody(
7352− $elm$json$Json$Encode$object(
7353− _List_fromArray(
7354− [
7355− _Utils_Tuple2(
7356− 'messageIds',
7357− A2($elm$json$Json$Encode$list, $elm$json$Json$Encode$string, messageIds))
7358− ]))),
7359− expect: A2(
7360− $lynxjs_elm$http$Http$expectJson,
7361− $author$project$Main$GotAck,
7362− A2($elm$json$Json$Decode$field, 'ok', $elm$json$Json$Decode$bool)),
7363− headers: _List_fromArray(
7364− [
7365− A2($lynxjs_elm$http$Http$header, 'Authorization', 'Bearer ' + token)
7366− ]),
7367− method: 'POST',
7368− timeout: $elm$core$Maybe$Just(5000),
7369− tracker: $elm$core$Maybe$Nothing,
7370− url: serverUrl + '/messages/ack'
7371− });
7372− });
7373−var $lynxjs_elm$crypto$Crypto$X25519$getPublicKey = _Crypto_x25519GetPublicKey;
7374−var $lynxjs_elm$crypto$Crypto$X25519$getSharedSecret = _Crypto_x25519GetSharedSecret;
7375−var $lynxjs_elm$crypto$Crypto$Cipher$keyFromHex = _Crypto_cipherKeyFromHex;
7376−var $lynxjs_elm$crypto$Crypto$Cipher$nonce24FromHex = _Crypto_cipherNonce24FromHex;
7377−var $lynxjs_elm$crypto$Crypto$X25519$privateKeyFromHex = _Crypto_x25519PrivateKeyFromHex;
7378−var $lynxjs_elm$crypto$Crypto$X25519$publicKeyFromHex = _Crypto_x25519PublicKeyFromHex;
7379−var $lynxjs_elm$crypto$Crypto$Ed25519$publicKeyToHex = _Crypto_ed25519PublicKeyToHex;
7380−var $lynxjs_elm$crypto$Crypto$X25519$publicKeyToHex = _Crypto_x25519PublicKeyToHex;
7381−var $lynxjs_elm$crypto$Crypto$Ed25519$sign = _Crypto_ed25519Sign;
7382−var $lynxjs_elm$crypto$Crypto$Ed25519$signatureToHex = _Crypto_ed25519SignatureToHex;
7383−var $elm$core$Basics$modBy = _Basics_modBy;
7384−var $author$project$Main$nibbleToHex = function (n) {
7385− switch (n) {
7386− case 0:
7387− return _Utils_chr('0');
7388− case 1:
7389− return _Utils_chr('1');
7390− case 2:
7391− return _Utils_chr('2');
7392− case 3:
7393− return _Utils_chr('3');
7394− case 4:
7395− return _Utils_chr('4');
7396− case 5:
7397− return _Utils_chr('5');
7398− case 6:
7399− return _Utils_chr('6');
7400− case 7:
7401− return _Utils_chr('7');
7402− case 8:
7403− return _Utils_chr('8');
7404− case 9:
7405− return _Utils_chr('9');
7406− case 10:
7407− return _Utils_chr('a');
7408− case 11:
7409− return _Utils_chr('b');
7410− case 12:
7411− return _Utils_chr('c');
7412− case 13:
7413− return _Utils_chr('d');
7414− case 14:
7415− return _Utils_chr('e');
7416− default:
7417− return _Utils_chr('f');
7418− }
7419−};
7420−var $author$project$Main$charToHexBytes = function (c) {
7421− var code = $elm$core$Char$toCode(c);
7422− if (code < 128) {
7423− return _List_fromArray(
7424− [
7425− $author$project$Main$nibbleToHex((code / 16) | 0),
7426− $author$project$Main$nibbleToHex(
7427− A2($elm$core$Basics$modBy, 16, code))
7428− ]);
7429− } else {
7430− if (code < 2048) {
7431− var b2 = 128 + A2($elm$core$Basics$modBy, 64, code);
7432− var b1 = 192 + ((code / 64) | 0);
7433− return _List_fromArray(
7434− [
7435− $author$project$Main$nibbleToHex((b1 / 16) | 0),
7436− $author$project$Main$nibbleToHex(
7437− A2($elm$core$Basics$modBy, 16, b1)),
7438− $author$project$Main$nibbleToHex((b2 / 16) | 0),
7439− $author$project$Main$nibbleToHex(
7440− A2($elm$core$Basics$modBy, 16, b2))
7441− ]);
7442− } else {
7443− var b3 = 128 + A2($elm$core$Basics$modBy, 64, code);
7444− var b2 = 128 + A2($elm$core$Basics$modBy, 64, (code / 64) | 0);
7445− var b1 = 224 + ((code / 4096) | 0);
7446− return _List_fromArray(
7447− [
7448− $author$project$Main$nibbleToHex((b1 / 16) | 0),
7449− $author$project$Main$nibbleToHex(
7450− A2($elm$core$Basics$modBy, 16, b1)),
7451− $author$project$Main$nibbleToHex((b2 / 16) | 0),
7452− $author$project$Main$nibbleToHex(
7453− A2($elm$core$Basics$modBy, 16, b2)),
7454− $author$project$Main$nibbleToHex((b3 / 16) | 0),
7455− $author$project$Main$nibbleToHex(
7456− A2($elm$core$Basics$modBy, 16, b3))
7457− ]);
7458− }
7459− }
7460−};
7461−var $elm$core$List$append = F2(
7462− function (xs, ys) {
7463− if (!ys.b) {
7464− return xs;
7465− } else {
7466− return A3($elm$core$List$foldr, $elm$core$List$cons, ys, xs);
7467− }
7468− });
7469−var $elm$core$List$concat = function (lists) {
7470− return A3($elm$core$List$foldr, $elm$core$List$append, _List_Nil, lists);
7471−};
7472−var $elm$core$List$concatMap = F2(
7473− function (f, list) {
7474− return $elm$core$List$concat(
7475− A2($elm$core$List$map, f, list));
7476− });
7477−var $elm$core$String$foldr = _String_foldr;
7478−var $elm$core$String$toList = function (string) {
7479− return A3($elm$core$String$foldr, $elm$core$List$cons, _List_Nil, string);
7480−};
7481−var $author$project$Main$textToHex = function (str) {
7482− return $elm$core$String$fromList(
7483− A2(
7484− $elm$core$List$concatMap,
7485− $author$project$Main$charToHexBytes,
7486− $elm$core$String$toList(str)));
7487−};
7488−var $lynxjs_elm$crypto$Crypto$Cipher$xchacha20Encrypt = _Crypto_xchacha20Encrypt;
7489−var $author$project$Main$encryptMessage = F6(
7490− function (edPriv, edPub, recipientXPubHex, ephSeedHex, nonceHex, plaintextJson) {
7491− var _v0 = _Utils_Tuple3(
7492− $lynxjs_elm$crypto$Crypto$X25519$privateKeyFromHex(ephSeedHex),
7493− $lynxjs_elm$crypto$Crypto$X25519$publicKeyFromHex(recipientXPubHex),
7494− $lynxjs_elm$crypto$Crypto$Cipher$nonce24FromHex(nonceHex));
7495− if (((_v0.a.$ === 'Just') && (_v0.b.$ === 'Just')) && (_v0.c.$ === 'Just')) {
7496− var ephPriv = _v0.a.a;
7497− var recipientXPub = _v0.b.a;
7498− var nonce = _v0.c.a;
7499− var sharedSecretHex = A2($lynxjs_elm$crypto$Crypto$X25519$getSharedSecret, ephPriv, recipientXPub);
7500− var ephPub = $lynxjs_elm$crypto$Crypto$X25519$getPublicKey(ephPriv);
7501− var ephPubHex = $lynxjs_elm$crypto$Crypto$X25519$publicKeyToHex(ephPub);
7502− var _v1 = $lynxjs_elm$crypto$Crypto$Cipher$keyFromHex(sharedSecretHex);
7503− if (_v1.$ === 'Just') {
7504− var key = _v1.a;
7505− var senderPubHex = $lynxjs_elm$crypto$Crypto$Ed25519$publicKeyToHex(edPub);
7506− var plaintextHex = $author$project$Main$textToHex(plaintextJson);
7507− var ciphertextHex = A3($lynxjs_elm$crypto$Crypto$Cipher$xchacha20Encrypt, key, nonce, plaintextHex);
7508− var sig = A2($lynxjs_elm$crypto$Crypto$Ed25519$sign, ciphertextHex, edPriv);
7509− var sigHex = $lynxjs_elm$crypto$Crypto$Ed25519$signatureToHex(sig);
7510− return $elm$core$Maybe$Just(
7511− _Utils_ap(
7512− ephPubHex,
7513− _Utils_ap(
7514− nonceHex,
7515− _Utils_ap(
7516− ciphertextHex,
7517− _Utils_ap(senderPubHex, sigHex)))));
7518− } else {
7519− return $elm$core$Maybe$Nothing;
7520− }
7521− } else {
7522− return $elm$core$Maybe$Nothing;
7523− }
7524− });
7525−var $lynxjs_elm$crypto$Crypto$Ed25519$getPublicKey = _Crypto_ed25519GetPublicKey;
7526−var $author$project$Main$httpErrorToString = function (err) {
7527− switch (err.$) {
7528− case 'BadUrl':
7529− var url = err.a;
7530− return 'Bad URL: ' + url;
7531− case 'Timeout':
7532− return 'Request timed out';
7533− case 'NetworkError':
7534− return 'Network error';
7535− case 'BadStatus':
7536− var code = err.a;
7537− return 'Server error: ' + $elm$core$String$fromInt(code);
7538− default:
7539− var msg_ = err.a;
7540− return 'Bad response: ' + msg_;
7541− }
7542−};
7543−var $elm$json$Json$Encode$int = _Json_wrap;
7544−var $elm$core$List$isEmpty = function (xs) {
7545− if (!xs.b) {
7546− return true;
7547− } else {
7548− return false;
7549− }
7550−};
7551−var $elm$core$String$isEmpty = function (string) {
7552− return string === '';
7553−};
7554−var $elm$core$String$slice = _String_slice;
7555−var $elm$core$String$left = F2(
7556− function (n, string) {
7557− return (n < 1) ? '' : A3($elm$core$String$slice, 0, n, string);
7558− });
7559−var $author$project$Main$GotLookup = function (a) {
7560− return {$: 'GotLookup', a: a};
7561−};
7562−var $lynxjs_elm$http$Http$emptyBody = _Http_emptyBody;
7563−var $lynxjs_elm$http$Http$get = function (r) {
7564− return $lynxjs_elm$http$Http$request(
7565− {body: $lynxjs_elm$http$Http$emptyBody, expect: r.expect, headers: _List_Nil, method: 'GET', timeout: $elm$core$Maybe$Nothing, tracker: $elm$core$Maybe$Nothing, url: r.url});
7566−};
7567−var $author$project$Main$LookupResponse = F2(
7568− function (pubkey, online) {
7569− return {online: online, pubkey: pubkey};
7570− });
7571−var $author$project$Main$lookupDecoder = function (pubkey) {
7572− return A2(
7573− $elm$json$Json$Decode$map,
7574− $author$project$Main$LookupResponse(pubkey),
7575− A2($elm$json$Json$Decode$field, 'online', $elm$json$Json$Decode$bool));
7576−};
7577−var $author$project$Main$lookupPresence = F2(
7578− function (serverUrl, pubkey) {
7579− return $lynxjs_elm$http$Http$get(
7580− {
7581− expect: A2(
7582− $lynxjs_elm$http$Http$expectJson,
7583− $author$project$Main$GotLookup,
7584− $author$project$Main$lookupDecoder(pubkey)),
7585− url: serverUrl + ('/lookup/' + pubkey)
7586− });
7587− });
7588−var $elm$core$Maybe$map = F2(
7589− function (f, maybe) {
7590− if (maybe.$ === 'Just') {
7591− var value = maybe.a;
7592− return $elm$core$Maybe$Just(
7593− f(value));
7594− } else {
7595− return $elm$core$Maybe$Nothing;
7596− }
7597− });
7598−var $elm$core$Platform$Cmd$batch = _Platform_batch;
7599−var $elm$core$Platform$Cmd$none = $elm$core$Platform$Cmd$batch(_List_Nil);
7600−var $elm$core$String$length = _String_length;
7601−var $author$project$Main$parseIdentity = function (str) {
7602− var _v0 = A2($elm$core$String$split, '.', str);
7603− if ((_v0.b && _v0.b.b) && (!_v0.b.b.b)) {
7604− var edHex = _v0.a;
7605− var _v1 = _v0.b;
7606− var xHex = _v1.a;
7607− return (($elm$core$String$length(edHex) === 64) && ($elm$core$String$length(xHex) === 64)) ? $elm$core$Maybe$Just(
7608− _Utils_Tuple2(edHex, xHex)) : $elm$core$Maybe$Nothing;
7609− } else {
7610− return $elm$core$Maybe$Nothing;
7611− }
7612−};
7613−var $author$project$Main$GotPoll = function (a) {
7614− return {$: 'GotPoll', a: a};
7615−};
7616−var $author$project$Main$PollResponse = F2(
7617− function (events, cursor) {
7618− return {cursor: cursor, events: events};
7619− });
7620−var $elm$json$Json$Decode$list = _Json_decodeList;
7621−var $author$project$Main$PollEvent = F3(
7622− function (id, eventType, payload) {
7623− return {eventType: eventType, id: id, payload: payload};
7624− });
7625−var $elm$json$Json$Decode$map3 = _Json_map3;
7626−var $elm$json$Json$Decode$string = _Json_decodeString;
7627−var $author$project$Main$pollEventDecoder = A4(
7628− $elm$json$Json$Decode$map3,
7629− $author$project$Main$PollEvent,
7630− A2($elm$json$Json$Decode$field, 'id', $elm$json$Json$Decode$string),
7631− A2($elm$json$Json$Decode$field, 'eventType', $elm$json$Json$Decode$string),
7632− A2($elm$json$Json$Decode$field, 'payload', $elm$json$Json$Decode$string));
7633−var $author$project$Main$pollDecoder = A3(
7634− $elm$json$Json$Decode$map2,
7635− $author$project$Main$PollResponse,
7636− A2(
7637− $elm$json$Json$Decode$field,
7638− 'events',
7639− $elm$json$Json$Decode$list($author$project$Main$pollEventDecoder)),
7640− A2($elm$json$Json$Decode$field, 'cursor', $elm$json$Json$Decode$string));
7641−var $author$project$Main$pollMessages = F2(
7642− function (serverUrl, token) {
7643− return $lynxjs_elm$http$Http$request(
7644− {
7645− body: $lynxjs_elm$http$Http$emptyBody,
7646− expect: A2($lynxjs_elm$http$Http$expectJson, $author$project$Main$GotPoll, $author$project$Main$pollDecoder),
7647− headers: _List_fromArray(
7648− [
7649− A2($lynxjs_elm$http$Http$header, 'Authorization', 'Bearer ' + token)
7650− ]),
7651− method: 'GET',
7652− timeout: $elm$core$Maybe$Just(10000),
7653− tracker: $elm$core$Maybe$Nothing,
7654− url: serverUrl + '/poll'
7655− });
7656− });
7657−var $lynxjs_elm$crypto$Crypto$Ed25519$privateKeyFromHex = _Crypto_ed25519PrivateKeyFromHex;
7658−var $elm$core$Basics$ge = _Utils_ge;
7659−var $author$project$Main$decodeUtf8Help = F2(
7660− function (bytes, acc) {
7661− decodeUtf8Help:
7662− while (true) {
7663− if (!bytes.b) {
7664− return $elm$core$Maybe$Just(acc);
7665− } else {
7666− var b = bytes.a;
7667− var rest = bytes.b;
7668− if (b < 128) {
7669− var $temp$bytes = rest,
7670− $temp$acc = A2(
7671− $elm$core$List$cons,
7672− $elm$core$Char$fromCode(b),
7673− acc);
7674− bytes = $temp$bytes;
7675− acc = $temp$acc;
7676− continue decodeUtf8Help;
7677− } else {
7678− if ((b >= 192) && (b < 224)) {
7679− if (rest.b) {
7680− var b2 = rest.a;
7681− var rest2 = rest.b;
7682− var code = ((b - 192) * 64) + (b2 - 128);
7683− var $temp$bytes = rest2,
7684− $temp$acc = A2(
7685− $elm$core$List$cons,
7686− $elm$core$Char$fromCode(code),
7687− acc);
7688− bytes = $temp$bytes;
7689− acc = $temp$acc;
7690− continue decodeUtf8Help;
7691− } else {
7692− return $elm$core$Maybe$Nothing;
7693− }
7694− } else {
7695− if ((b >= 224) && (b < 240)) {
7696− if (rest.b && rest.b.b) {
7697− var b2 = rest.a;
7698− var _v3 = rest.b;
7699− var b3 = _v3.a;
7700− var rest3 = _v3.b;
7701− var code = (((b - 224) * 4096) + ((b2 - 128) * 64)) + (b3 - 128);
7702− var $temp$bytes = rest3,
7703− $temp$acc = A2(
7704− $elm$core$List$cons,
7705− $elm$core$Char$fromCode(code),
7706− acc);
7707− bytes = $temp$bytes;
7708− acc = $temp$acc;
7709− continue decodeUtf8Help;
7710− } else {
7711− return $elm$core$Maybe$Nothing;
7712− }
7713− } else {
7714− if (b >= 240) {
7715− if ((rest.b && rest.b.b) && rest.b.b.b) {
7716− var b2 = rest.a;
7717− var _v5 = rest.b;
7718− var b3 = _v5.a;
7719− var _v6 = _v5.b;
7720− var b4 = _v6.a;
7721− var rest4 = _v6.b;
7722− var code = ((((b - 240) * 262144) + ((b2 - 128) * 4096)) + ((b3 - 128) * 64)) + (b4 - 128);
7723− var $temp$bytes = rest4,
7724− $temp$acc = A2(
7725− $elm$core$List$cons,
7726− $elm$core$Char$fromCode(code),
7727− acc);
7728− bytes = $temp$bytes;
7729− acc = $temp$acc;
7730− continue decodeUtf8Help;
7731− } else {
7732− return $elm$core$Maybe$Nothing;
7733− }
7734− } else {
7735− return $elm$core$Maybe$Nothing;
7736− }
7737− }
7738− }
7739− }
7740− }
7741− }
7742− });
7743−var $author$project$Main$decodeUtf8Bytes = function (bytes) {
7744− return A2(
7745− $elm$core$Maybe$map,
7746− A2($elm$core$Basics$composeR, $elm$core$List$reverse, $elm$core$String$fromList),
7747− A2($author$project$Main$decodeUtf8Help, bytes, _List_Nil));
7748−};
7749−var $author$project$Main$hexCharToInt = function (c) {
7750− var code = $elm$core$Char$toCode(c);
7751− return ((code >= 48) && (code <= 57)) ? $elm$core$Maybe$Just(code - 48) : (((code >= 97) && (code <= 102)) ? $elm$core$Maybe$Just(code - 87) : (((code >= 65) && (code <= 70)) ? $elm$core$Maybe$Just(code - 55) : $elm$core$Maybe$Nothing));
7752−};
7753−var $elm$core$Maybe$map2 = F3(
7754− function (func, ma, mb) {
7755− if (ma.$ === 'Nothing') {
7756− return $elm$core$Maybe$Nothing;
7757− } else {
7758− var a = ma.a;
7759− if (mb.$ === 'Nothing') {
7760− return $elm$core$Maybe$Nothing;
7761− } else {
7762− var b = mb.a;
7763− return $elm$core$Maybe$Just(
7764− A2(func, a, b));
7765− }
7766− }
7767− });
7768−var $author$project$Main$pairUp = function (list) {
7769− if (list.b && list.b.b) {
7770− var a = list.a;
7771− var _v1 = list.b;
7772− var b = _v1.a;
7773− var rest = _v1.b;
7774− return A2(
7775− $elm$core$List$cons,
7776− _Utils_Tuple2(a, b),
7777− $author$project$Main$pairUp(rest));
7778− } else {
7779− return _List_Nil;
7780− }
7781−};
7782−var $author$project$Main$hexToBytes = function (hex) {
7783− var chars = $elm$core$String$toList(hex);
7784− var pairs = $author$project$Main$pairUp(chars);
7785− return A2(
7786− $elm$core$List$filterMap,
7787− function (_v0) {
7788− var hi = _v0.a;
7789− var lo = _v0.b;
7790− return A3(
7791− $elm$core$Maybe$map2,
7792− F2(
7793− function (h, l) {
7794− return (h * 16) + l;
7795− }),
7796− $author$project$Main$hexCharToInt(hi),
7797− $author$project$Main$hexCharToInt(lo));
7798− },
7799− pairs);
7800−};
7801−var $elm$core$Maybe$withDefault = F2(
7802− function (_default, maybe) {
7803− if (maybe.$ === 'Just') {
7804− var value = maybe.a;
7805− return value;
7806− } else {
7807− return _default;
7808− }
7809− });
7810−var $author$project$Main$hexToText = function (hex) {
7811− return A2(
7812− $elm$core$Maybe$withDefault,
7813− '',
7814− $author$project$Main$decodeUtf8Bytes(
7815− $author$project$Main$hexToBytes(hex)));
7816−};
7817−var $elm$core$Basics$not = _Basics_not;
7818−var $lynxjs_elm$crypto$Crypto$Ed25519$publicKeyFromHex = _Crypto_ed25519PublicKeyFromHex;
7819−var $lynxjs_elm$crypto$Crypto$Ed25519$signatureFromHex = _Crypto_ed25519SignatureFromHex;
7820−var $lynxjs_elm$crypto$Crypto$Ed25519$verify = _Crypto_ed25519Verify;
7821−var $lynxjs_elm$crypto$Crypto$Cipher$xchacha20Decrypt = _Crypto_xchacha20Decrypt;
7822−var $author$project$Main$decryptMessage = F2(
7823− function (xPriv, blob) {
7824− var hexLen = $elm$core$String$length(blob);
7825− if (hexLen < 304) {
7826− return $elm$core$Maybe$Nothing;
7827− } else {
7828− var sigHex = A3($elm$core$String$slice, hexLen - 128, hexLen, blob);
7829− var senderEdPubHex = A3($elm$core$String$slice, hexLen - 192, hexLen - 128, blob);
7830− var nonceHex = A3($elm$core$String$slice, 64, 112, blob);
7831− var ephPubHex = A3($elm$core$String$slice, 0, 64, blob);
7832− var ciphertextHex = A3($elm$core$String$slice, 112, hexLen - 192, blob);
7833− var _v0 = _Utils_Tuple2(
7834− $lynxjs_elm$crypto$Crypto$Ed25519$publicKeyFromHex(senderEdPubHex),
7835− $lynxjs_elm$crypto$Crypto$Ed25519$signatureFromHex(sigHex));
7836− if ((_v0.a.$ === 'Just') && (_v0.b.$ === 'Just')) {
7837− var senderEdPub = _v0.a.a;
7838− var sig = _v0.b.a;
7839− if (!A3($lynxjs_elm$crypto$Crypto$Ed25519$verify, sig, ciphertextHex, senderEdPub)) {
7840− return $elm$core$Maybe$Nothing;
7841− } else {
7842− var _v1 = _Utils_Tuple2(
7843− $lynxjs_elm$crypto$Crypto$X25519$publicKeyFromHex(ephPubHex),
7844− $lynxjs_elm$crypto$Crypto$Cipher$nonce24FromHex(nonceHex));
7845− if ((_v1.a.$ === 'Just') && (_v1.b.$ === 'Just')) {
7846− var ephPub = _v1.a.a;
7847− var nonce = _v1.b.a;
7848− var sharedSecretHex = A2($lynxjs_elm$crypto$Crypto$X25519$getSharedSecret, xPriv, ephPub);
7849− var _v2 = $lynxjs_elm$crypto$Crypto$Cipher$keyFromHex(sharedSecretHex);
7850− if (_v2.$ === 'Just') {
7851− var key = _v2.a;
7852− var _v3 = A3($lynxjs_elm$crypto$Crypto$Cipher$xchacha20Decrypt, key, nonce, ciphertextHex);
7853− if (_v3.$ === 'Just') {
7854− var plaintextHex = _v3.a;
7855− return $elm$core$Maybe$Just(
7856− _Utils_Tuple2(
7857− senderEdPubHex,
7858− $author$project$Main$hexToText(plaintextHex)));
7859− } else {
7860− return $elm$core$Maybe$Nothing;
7861− }
7862− } else {
7863− return $elm$core$Maybe$Nothing;
7864− }
7865− } else {
7866− return $elm$core$Maybe$Nothing;
7867− }
7868− }
7869− } else {
7870− return $elm$core$Maybe$Nothing;
7871− }
7872− }
7873− });
7874−var $author$project$Main$Envelope = F4(
7875− function (toKey, fromKey, encryptedBlob, timestamp) {
7876− return {encryptedBlob: encryptedBlob, fromKey: fromKey, timestamp: timestamp, toKey: toKey};
7877− });
7878−var $elm$json$Json$Decode$int = _Json_decodeInt;
7879−var $elm$json$Json$Decode$map4 = _Json_map4;
7880−var $author$project$Main$envelopeDecoder = A5(
7881− $elm$json$Json$Decode$map4,
7882− $author$project$Main$Envelope,
7883− A2($elm$json$Json$Decode$field, 'toKey', $elm$json$Json$Decode$string),
7884− A2($elm$json$Json$Decode$field, 'fromKey', $elm$json$Json$Decode$string),
7885− A2($elm$json$Json$Decode$field, 'encryptedBlob', $elm$json$Json$Decode$string),
7886− A2($elm$json$Json$Decode$field, 'timestamp', $elm$json$Json$Decode$int));
7887−var $elm$core$Set$insert = F2(
7888− function (key, _v0) {
7889− var dict = _v0.a;
7890− return $elm$core$Set$Set_elm_builtin(
7891− A3($elm$core$Dict$insert, key, _Utils_Tuple0, dict));
7892− });
7893−var $elm$core$Dict$member = F2(
7894− function (key, dict) {
7895− var _v0 = A2($elm$core$Dict$get, key, dict);
7896− if (_v0.$ === 'Just') {
7897− return true;
7898− } else {
7899− return false;
7900− }
7901− });
7902−var $elm$core$Set$member = F2(
7903− function (key, _v0) {
7904− var dict = _v0.a;
7905− return A2($elm$core$Dict$member, key, dict);
7906− });
7907−var $author$project$Main$MessagePayload = F4(
7908− function (type_, id, timestamp, body) {
7909− return {body: body, id: id, timestamp: timestamp, type_: type_};
7910− });
7911−var $author$project$Main$messagePayloadDecoder = A5(
7912− $elm$json$Json$Decode$map4,
7913− $author$project$Main$MessagePayload,
7914− A2($elm$json$Json$Decode$field, 'type', $elm$json$Json$Decode$string),
7915− A2($elm$json$Json$Decode$field, 'id', $elm$json$Json$Decode$string),
7916− A2($elm$json$Json$Decode$field, 'timestamp', $elm$json$Json$Decode$int),
7917− A2($elm$json$Json$Decode$field, 'body', $elm$json$Json$Decode$string));
7918−var $author$project$Main$processEvents = F4(
7919− function (xPriv, myEdHex, events, seenIds) {
7920− return A3(
7921− $elm$core$List$foldl,
7922− F2(
7923− function (event, _v0) {
7924− var msgs = _v0.a;
7925− var seen = _v0.b;
7926− var acks = _v0.c;
7927− if (event.eventType !== 'message') {
7928− return _Utils_Tuple3(msgs, seen, acks);
7929− } else {
7930− var _v1 = A2($elm$json$Json$Decode$decodeString, $author$project$Main$envelopeDecoder, event.payload);
7931− if (_v1.$ === 'Err') {
7932− return _Utils_Tuple3(
7933− msgs,
7934− seen,
7935− A2($elm$core$List$cons, event.id, acks));
7936− } else {
7937− var envelope = _v1.a;
7938− if (_Utils_eq(envelope.fromKey, myEdHex)) {
7939− return _Utils_Tuple3(
7940− msgs,
7941− seen,
7942− A2($elm$core$List$cons, event.id, acks));
7943− } else {
7944− var _v2 = A2($author$project$Main$decryptMessage, xPriv, envelope.encryptedBlob);
7945− if (_v2.$ === 'Nothing') {
7946− return _Utils_Tuple3(
7947− msgs,
7948− seen,
7949− A2($elm$core$List$cons, event.id, acks));
7950− } else {
7951− var _v3 = _v2.a;
7952− var senderPub = _v3.a;
7953− var json = _v3.b;
7954− var _v4 = A2($elm$json$Json$Decode$decodeString, $author$project$Main$messagePayloadDecoder, json);
7955− if (_v4.$ === 'Err') {
7956− return _Utils_Tuple3(
7957− msgs,
7958− seen,
7959− A2($elm$core$List$cons, event.id, acks));
7960− } else {
7961− var payload = _v4.a;
7962− return A2($elm$core$Set$member, payload.id, seen) ? _Utils_Tuple3(
7963− msgs,
7964− seen,
7965− A2($elm$core$List$cons, event.id, acks)) : _Utils_Tuple3(
7966− A2(
7967− $elm$core$List$cons,
7968− _Utils_Tuple2(
7969− senderPub,
7970− {body: payload.body, id: payload.id, outgoing: false, timestamp: payload.timestamp}),
7971− msgs),
7972− A2($elm$core$Set$insert, payload.id, seen),
7973− A2($elm$core$List$cons, event.id, acks));
7974− }
7975− }
7976− }
7977− }
7978− }
7979− }),
7980− _Utils_Tuple3(_List_Nil, seenIds, _List_Nil),
7981− events);
7982− });
7983−var $author$project$Main$GotChallenge = function (a) {
7984− return {$: 'GotChallenge', a: a};
7985−};
7986−var $author$project$Main$ChallengeResponse = function (challenge) {
7987− return {challenge: challenge};
7988−};
7989−var $author$project$Main$challengeDecoder = A2(
7990− $elm$json$Json$Decode$map,
7991− $author$project$Main$ChallengeResponse,
7992− A2($elm$json$Json$Decode$field, 'challenge', $elm$json$Json$Decode$string));
7993−var $lynxjs_elm$http$Http$post = function (r) {
7994− return $lynxjs_elm$http$Http$request(
7995− {body: r.body, expect: r.expect, headers: _List_Nil, method: 'POST', timeout: $elm$core$Maybe$Nothing, tracker: $elm$core$Maybe$Nothing, url: r.url});
7996−};
7997−var $author$project$Main$requestChallenge = F2(
7998− function (serverUrl, pubkey) {
7999− return $lynxjs_elm$http$Http$post(
8000− {
8001− body: $lynxjs_elm$http$Http$jsonBody(
8002− $elm$json$Json$Encode$object(
8003− _List_fromArray(
8004− [
8005− _Utils_Tuple2(
8006− 'pubkey',
8007− $elm$json$Json$Encode$string(pubkey))
8008− ]))),
8009− expect: A2($lynxjs_elm$http$Http$expectJson, $author$project$Main$GotChallenge, $author$project$Main$challengeDecoder),
8010− url: serverUrl + '/auth/challenge'
8011− });
8012− });
8013−var $author$project$Main$GotSendResult = function (a) {
8014− return {$: 'GotSendResult', a: a};
8015−};
8016−var $author$project$Main$SendResponse = F2(
8017− function (status, messageId) {
8018− return {messageId: messageId, status: status};
8019− });
8020−var $author$project$Main$sendDecoder = A3(
8021− $elm$json$Json$Decode$map2,
8022− $author$project$Main$SendResponse,
8023− A2($elm$json$Json$Decode$field, 'status', $elm$json$Json$Decode$string),
8024− A2($elm$json$Json$Decode$field, 'messageId', $elm$json$Json$Decode$string));
8025−var $author$project$Main$sendMessageHttp = F5(
8026− function (serverUrl, token, to, encryptedBlob, timestamp) {
8027− return $lynxjs_elm$http$Http$request(
8028− {
8029− body: $lynxjs_elm$http$Http$jsonBody(
8030− $elm$json$Json$Encode$object(
8031− _List_fromArray(
8032− [
8033− _Utils_Tuple2(
8034− 'to',
8035− $elm$json$Json$Encode$string(to)),
8036− _Utils_Tuple2(
8037− 'encryptedBlob',
8038− $elm$json$Json$Encode$string(encryptedBlob)),
8039− _Utils_Tuple2(
8040− 'timestamp',
8041− $elm$json$Json$Encode$int(timestamp))
8042− ]))),
8043− expect: A2($lynxjs_elm$http$Http$expectJson, $author$project$Main$GotSendResult, $author$project$Main$sendDecoder),
8044− headers: _List_fromArray(
8045− [
8046− A2($lynxjs_elm$http$Http$header, 'Authorization', 'Bearer ' + token)
8047− ]),
8048− method: 'POST',
8049− timeout: $elm$core$Maybe$Just(10000),
8050− tracker: $elm$core$Maybe$Nothing,
8051− url: serverUrl + '/messages/'
8052− });
8053− });
8054−var $elm$core$String$trim = _String_trim;
8055−var $author$project$Main$GotVerify = function (a) {
8056− return {$: 'GotVerify', a: a};
8057−};
8058−var $author$project$Main$VerifyResponse = F2(
8059− function (token, expiresAt) {
8060− return {expiresAt: expiresAt, token: token};
8061− });
8062−var $author$project$Main$verifyDecoder = A3(
8063− $elm$json$Json$Decode$map2,
8064− $author$project$Main$VerifyResponse,
8065− A2($elm$json$Json$Decode$field, 'token', $elm$json$Json$Decode$string),
8066− A2($elm$json$Json$Decode$field, 'expiresAt', $elm$json$Json$Decode$int));
8067−var $author$project$Main$verifyAuth = F4(
8068− function (serverUrl, pubkey, challenge, signature) {
8069− return $lynxjs_elm$http$Http$post(
8070− {
8071− body: $lynxjs_elm$http$Http$jsonBody(
8072− $elm$json$Json$Encode$object(
8073− _List_fromArray(
8074− [
8075− _Utils_Tuple2(
8076− 'pubkey',
8077− $elm$json$Json$Encode$string(pubkey)),
8078− _Utils_Tuple2(
8079− 'challenge',
8080− $elm$json$Json$Encode$string(challenge)),
8081− _Utils_Tuple2(
8082− 'signature',
8083− $elm$json$Json$Encode$string(signature))
8084− ]))),
8085− expect: A2($lynxjs_elm$http$Http$expectJson, $author$project$Main$GotVerify, $author$project$Main$verifyDecoder),
8086− url: serverUrl + '/auth/verify'
8087− });
8088− });
8089−var $author$project$Main$update = F2(
8090− function (msg, model) {
8091− switch (msg.$) {
8092− case 'GotSeed':
8093− var seedHex = msg.a;
8094− var xPriv = $lynxjs_elm$crypto$Crypto$X25519$privateKeyFromHex(seedHex);
8095− var edPriv = $lynxjs_elm$crypto$Crypto$Ed25519$privateKeyFromHex(seedHex);
8096− return _Utils_Tuple2(
8097− _Utils_update(
8098− model,
8099− {
8100− edPriv: edPriv,
8101− edPub: A2($elm$core$Maybe$map, $lynxjs_elm$crypto$Crypto$Ed25519$getPublicKey, edPriv),
8102− xPriv: xPriv,
8103− xPub: A2($elm$core$Maybe$map, $lynxjs_elm$crypto$Crypto$X25519$getPublicKey, xPriv)
8104− }),
8105− $elm$core$Platform$Cmd$none);
8106− case 'GotTime':
8107− var posix = msg.a;
8108− return _Utils_Tuple2(
8109− _Utils_update(
8110− model,
8111− {
8112− currentTime: ($elm$time$Time$posixToMillis(posix) / 1000) | 0
8113− }),
8114− $elm$core$Platform$Cmd$none);
8115− case 'Connect':
8116− var _v1 = model.edPub;
8117− if (_v1.$ === 'Just') {
8118− var pub = _v1.a;
8119− return _Utils_Tuple2(
8120− _Utils_update(
8121− model,
8122− {connecting: true, error: $elm$core$Maybe$Nothing}),
8123− A2(
8124− $author$project$Main$requestChallenge,
8125− model.serverUrl,
8126− $lynxjs_elm$crypto$Crypto$Ed25519$publicKeyToHex(pub)));
8127− } else {
8128− return _Utils_Tuple2(
8129− _Utils_update(
8130− model,
8131− {
8132− error: $elm$core$Maybe$Just('No identity keypair')
8133− }),
8134− $elm$core$Platform$Cmd$none);
8135− }
8136− case 'GotChallenge':
8137− var result = msg.a;
8138− var _v2 = _Utils_Tuple2(result, model.edPriv);
8139− if (_v2.a.$ === 'Ok') {
8140− if (_v2.b.$ === 'Just') {
8141− var resp = _v2.a.a;
8142− var priv = _v2.b.a;
8143− var sig = A2($lynxjs_elm$crypto$Crypto$Ed25519$sign, resp.challenge, priv);
8144− return _Utils_Tuple2(
8145− model,
8146− A4(
8147− $author$project$Main$verifyAuth,
8148− model.serverUrl,
8149− A2(
8150− $elm$core$Maybe$withDefault,
8151− '',
8152− A2($elm$core$Maybe$map, $lynxjs_elm$crypto$Crypto$Ed25519$publicKeyToHex, model.edPub)),
8153− resp.challenge,
8154− $lynxjs_elm$crypto$Crypto$Ed25519$signatureToHex(sig)));
8155− } else {
8156− return _Utils_Tuple2(
8157− _Utils_update(
8158− model,
8159− {connecting: false}),
8160− $elm$core$Platform$Cmd$none);
8161− }
8162− } else {
8163− var err = _v2.a.a;
8164− return _Utils_Tuple2(
8165− _Utils_update(
8166− model,
8167− {
8168− connecting: false,
8169− error: $elm$core$Maybe$Just(
8170− $author$project$Main$httpErrorToString(err))
8171− }),
8172− $elm$core$Platform$Cmd$none);
8173− }
8174− case 'GotVerify':
8175− var result = msg.a;
8176− if (result.$ === 'Ok') {
8177− var resp = result.a;
8178− return (resp.token !== '') ? _Utils_Tuple2(
8179− _Utils_update(
8180− model,
8181− {authToken: resp.token, connecting: false, screen: $author$project$Main$ContactsScreen}),
8182− $elm$core$Platform$Cmd$none) : _Utils_Tuple2(
8183− _Utils_update(
8184− model,
8185− {
8186− connecting: false,
8187− error: $elm$core$Maybe$Just('Authentication failed')
8188− }),
8189− $elm$core$Platform$Cmd$none);
8190− } else {
8191− var err = result.a;
8192− return _Utils_Tuple2(
8193− _Utils_update(
8194− model,
8195− {
8196− connecting: false,
8197− error: $elm$core$Maybe$Just(
8198− $author$project$Main$httpErrorToString(err))
8199− }),
8200− $elm$core$Platform$Cmd$none);
8201− }
8202− case 'PrepareSend':
8203− return $elm$core$String$isEmpty(
8204− $elm$core$String$trim(model.draft)) ? _Utils_Tuple2(model, $elm$core$Platform$Cmd$none) : _Utils_Tuple2(
8205− _Utils_update(
8206− model,
8207− {sending: true}),
8208− A2(
8209− $elm$random$Random$generate,
8210− $author$project$Main$GotSendEntropy,
8211− $author$project$Main$randomHexString(72)));
8212− case 'GotSendEntropy':
8213− var entropyHex = msg.a;
8214− var _v4 = _Utils_Tuple2(model.edPriv, model.edPub);
8215− if ((_v4.a.$ === 'Just') && (_v4.b.$ === 'Just')) {
8216− var edPriv = _v4.a.a;
8217− var edPub = _v4.b.a;
8218− var recipientEdHex = model.activeChat;
8219− var contact = A2($elm$core$Dict$get, recipientEdHex, model.contacts);
8220− if (contact.$ === 'Just') {
8221− var c = contact.a;
8222− var timestamp = model.currentTime;
8223− var nonceHex = A3($elm$core$String$slice, 96, 144, entropyHex);
8224− var msgId = A2($elm$core$String$left, 32, entropyHex);
8225− var outMsg = {body: model.draft, id: msgId, outgoing: true, timestamp: timestamp};
8226− var plaintextJson = A2(
8227− $elm$json$Json$Encode$encode,
8228− 0,
8229− $elm$json$Json$Encode$object(
8230− _List_fromArray(
8231− [
8232− _Utils_Tuple2(
8233− 'type',
8234− $elm$json$Json$Encode$string('text')),
8235− _Utils_Tuple2(
8236− 'id',
8237− $elm$json$Json$Encode$string(msgId)),
8238− _Utils_Tuple2(
8239− 'timestamp',
8240− $elm$json$Json$Encode$int(timestamp)),
8241− _Utils_Tuple2(
8242− 'body',
8243− $elm$json$Json$Encode$string(model.draft))
8244− ])));
8245− var existing = A2(
8246− $elm$core$Maybe$withDefault,
8247− _List_Nil,
8248− A2($elm$core$Dict$get, recipientEdHex, model.messages));
8249− var ephSeedHex = A3($elm$core$String$slice, 32, 96, entropyHex);
8250− var blob = A6($author$project$Main$encryptMessage, edPriv, edPub, c.xPubHex, ephSeedHex, nonceHex, plaintextJson);
8251− if (blob.$ === 'Just') {
8252− var blobHex = blob.a;
8253− return _Utils_Tuple2(
8254− _Utils_update(
8255− model,
8256− {
8257− draft: '',
8258− messages: A3(
8259− $elm$core$Dict$insert,
8260− recipientEdHex,
8261− _Utils_ap(
8262− existing,
8263− _List_fromArray(
8264− [outMsg])),
8265− model.messages)
8266− }),
8267− A5($author$project$Main$sendMessageHttp, model.serverUrl, model.authToken, recipientEdHex, blobHex, timestamp));
8268− } else {
8269− return _Utils_Tuple2(
8270− _Utils_update(
8271− model,
8272− {
8273− error: $elm$core$Maybe$Just('Encryption failed'),
8274− sending: false
8275− }),
8276− $elm$core$Platform$Cmd$none);
8277− }
8278− } else {
8279− return _Utils_Tuple2(
8280− _Utils_update(
8281− model,
8282− {
8283− error: $elm$core$Maybe$Just('Contact not found'),
8284− sending: false
8285− }),
8286− $elm$core$Platform$Cmd$none);
8287− }
8288− } else {
8289− return _Utils_Tuple2(
8290− _Utils_update(
8291− model,
8292− {
8293− error: $elm$core$Maybe$Just('No identity'),
8294− sending: false
8295− }),
8296− $elm$core$Platform$Cmd$none);
8297− }
8298− case 'GotSendResult':
8299− var result = msg.a;
8300− if (result.$ === 'Ok') {
8301− return _Utils_Tuple2(
8302− _Utils_update(
8303− model,
8304− {sending: false}),
8305− $elm$core$Platform$Cmd$none);
8306− } else {
8307− var err = result.a;
8308− return _Utils_Tuple2(
8309− _Utils_update(
8310− model,
8311− {
8312− error: $elm$core$Maybe$Just(
8313− $author$project$Main$httpErrorToString(err)),
8314− sending: false
8315− }),
8316− $elm$core$Platform$Cmd$none);
8317− }
8318− case 'PollTick':
8319− var posix = msg.a;
8320− var t = ($elm$time$Time$posixToMillis(posix) / 1000) | 0;
8321− return (model.authToken !== '') ? _Utils_Tuple2(
8322− _Utils_update(
8323− model,
8324− {currentTime: t}),
8325− A2($author$project$Main$pollMessages, model.serverUrl, model.authToken)) : _Utils_Tuple2(
8326− _Utils_update(
8327− model,
8328− {currentTime: t}),
8329− $elm$core$Platform$Cmd$none);
8330− case 'GotPoll':
8331− var result = msg.a;
8332− var _v8 = _Utils_Tuple3(result, model.xPriv, model.edPub);
8333− _v8$2:
8334− while (true) {
8335− if (_v8.a.$ === 'Ok') {
8336− if ((_v8.b.$ === 'Just') && (_v8.c.$ === 'Just')) {
8337− var resp = _v8.a.a;
8338− var xPriv = _v8.b.a;
8339− var edPub = _v8.c.a;
8340− var myEdHex = $lynxjs_elm$crypto$Crypto$Ed25519$publicKeyToHex(edPub);
8341− var _v9 = A4($author$project$Main$processEvents, xPriv, myEdHex, resp.events, model.seenIds);
8342− var newMessages = _v9.a;
8343− var newSeenIds = _v9.b;
8344− var ackIds = _v9.c;
8345− var ackCmd = $elm$core$List$isEmpty(ackIds) ? $elm$core$Platform$Cmd$none : A3($author$project$Main$ackMessages, model.serverUrl, model.authToken, ackIds);
8346− var updatedContacts = A3(
8347− $elm$core$List$foldl,
8348− F2(
8349− function (_v11, dict) {
8350− var from = _v11.a;
8351− var chatMsg = _v11.b;
8352− return A3(
8353− $elm$core$Dict$update,
8354− from,
8355− function (existing) {
8356− if (existing.$ === 'Just') {
8357− var c = existing.a;
8358− return $elm$core$Maybe$Just(
8359− _Utils_update(
8360− c,
8361− {lastMessage: chatMsg.body, lastTime: chatMsg.timestamp}));
8362− } else {
8363− return $elm$core$Maybe$Just(
8364− {
8365− edPubHex: from,
8366− lastMessage: chatMsg.body,
8367− lastTime: chatMsg.timestamp,
8368− name: A2($elm$core$String$left, 8, from) + '...',
8369− online: true,
8370− xPubHex: ''
8371− });
8372− }
8373− },
8374− dict);
8375− }),
8376− model.contacts,
8377− newMessages);
8378− var updatedMessages = A3(
8379− $elm$core$List$foldl,
8380− F2(
8381− function (_v10, dict) {
8382− var from = _v10.a;
8383− var chatMsg = _v10.b;
8384− var existing = A2(
8385− $elm$core$Maybe$withDefault,
8386− _List_Nil,
8387− A2($elm$core$Dict$get, from, dict));
8388− return A3(
8389− $elm$core$Dict$insert,
8390− from,
8391− _Utils_ap(
8392− existing,
8393− _List_fromArray(
8394− [chatMsg])),
8395− dict);
8396− }),
8397− model.messages,
8398− newMessages);
8399− return _Utils_Tuple2(
8400− _Utils_update(
8401− model,
8402− {contacts: updatedContacts, messages: updatedMessages, seenIds: newSeenIds}),
8403− ackCmd);
8404− } else {
8405− break _v8$2;
8406− }
8407− } else {
8408− if ((_v8.a.a.$ === 'BadStatus') && (_v8.a.a.a === 401)) {
8409− return _Utils_Tuple2(
8410− _Utils_update(
8411− model,
8412− {
8413− authToken: '',
8414− error: $elm$core$Maybe$Just('Session expired, please reconnect'),
8415− screen: $author$project$Main$SetupScreen
8416− }),
8417− $elm$core$Platform$Cmd$none);
8418− } else {
8419− break _v8$2;
8420− }
8421− }
8422− }
8423− return _Utils_Tuple2(model, $elm$core$Platform$Cmd$none);
8424− case 'GotAck':
8425− return _Utils_Tuple2(model, $elm$core$Platform$Cmd$none);
8426− case 'AddContact':
8427− if ($elm$core$String$isEmpty(model.addIdentity)) {
8428− return _Utils_Tuple2(model, $elm$core$Platform$Cmd$none);
8429− } else {
8430− var _v13 = $author$project$Main$parseIdentity(model.addIdentity);
8431− if (_v13.$ === 'Just') {
8432− var _v14 = _v13.a;
8433− var edHex = _v14.a;
8434− var xHex = _v14.b;
8435− var name = $elm$core$String$isEmpty(model.addName) ? (A2($elm$core$String$left, 8, edHex) + '...') : model.addName;
8436− var contact = {edPubHex: edHex, lastMessage: '', lastTime: 0, name: name, online: false, xPubHex: xHex};
8437− return _Utils_Tuple2(
8438− _Utils_update(
8439− model,
8440− {
8441− addIdentity: '',
8442− addName: '',
8443− contacts: A3($elm$core$Dict$insert, edHex, contact, model.contacts),
8444− screen: $author$project$Main$ContactsScreen
8445− }),
8446− A2($author$project$Main$lookupPresence, model.serverUrl, edHex));
8447− } else {
8448− return _Utils_Tuple2(
8449− _Utils_update(
8450− model,
8451− {
8452− error: $elm$core$Maybe$Just('Invalid identity. Expected: <ed25519_hex>.<x25519_hex>')
8453− }),
8454− $elm$core$Platform$Cmd$none);
8455− }
8456− }
8457− case 'GotLookup':
8458− var result = msg.a;
8459− if (result.$ === 'Ok') {
8460− var resp = result.a;
8461− return _Utils_Tuple2(
8462− _Utils_update(
8463− model,
8464− {
8465− contacts: A3(
8466− $elm$core$Dict$update,
8467− resp.pubkey,
8468− $elm$core$Maybe$map(
8469− function (c) {
8470− return _Utils_update(
8471− c,
8472− {online: resp.online});
8473− }),
8474− model.contacts)
8475− }),
8476− $elm$core$Platform$Cmd$none);
8477− } else {
8478− return _Utils_Tuple2(model, $elm$core$Platform$Cmd$none);
8479− }
8480− case 'Navigate':
8481− var screen = msg.a;
8482− return _Utils_Tuple2(
8483− _Utils_update(
8484− model,
8485− {screen: screen}),
8486− $elm$core$Platform$Cmd$none);
8487− case 'OpenChat':
8488− var edPubHex = msg.a;
8489− return _Utils_Tuple2(
8490− _Utils_update(
8491− model,
8492− {activeChat: edPubHex, draft: '', screen: $author$project$Main$ChatScreen}),
8493− $elm$core$Platform$Cmd$none);
8494− case 'UpdateDraft':
8495− var text = msg.a;
8496− return _Utils_Tuple2(
8497− _Utils_update(
8498− model,
8499− {draft: text}),
8500− $elm$core$Platform$Cmd$none);
8501− case 'UpdateServerUrl':
8502− var text = msg.a;
8503− return _Utils_Tuple2(
8504− _Utils_update(
8505− model,
8506− {serverUrl: text}),
8507− $elm$core$Platform$Cmd$none);
8508− case 'UpdateAddIdentity':
8509− var text = msg.a;
8510− return _Utils_Tuple2(
8511− _Utils_update(
8512− model,
8513− {addIdentity: text}),
8514− $elm$core$Platform$Cmd$none);
8515− case 'UpdateAddName':
8516− var text = msg.a;
8517− return _Utils_Tuple2(
8518− _Utils_update(
8519− model,
8520− {addName: text}),
8521− $elm$core$Platform$Cmd$none);
8522− case 'UpdateDisplayName':
8523− var text = msg.a;
8524− return _Utils_Tuple2(
8525− _Utils_update(
8526− model,
8527− {displayName: text}),
8528− $elm$core$Platform$Cmd$none);
8529− default:
8530− return _Utils_Tuple2(
8531− _Utils_update(
8532− model,
8533− {error: $elm$core$Maybe$Nothing}),
8534− $elm$core$Platform$Cmd$none);
8535− }
8536− });
8537−var $lynxjs_elm$ui$Lynx$Attributes$Column = {$: 'Column'};
8538−var $lynxjs_elm$virtual_dom$VirtualDom$style = _VirtualDom_style;
8539−var $lynxjs_elm$ui$Lynx$Attributes$style = $lynxjs_elm$virtual_dom$VirtualDom$style;
8540−var $lynxjs_elm$ui$Lynx$Attributes$backgroundColor = $lynxjs_elm$ui$Lynx$Attributes$style('backgroundColor');
8541−var $lynxjs_elm$ui$Lynx$Attributes$Center = {$: 'Center'};
8542−var $author$project$Main$DismissError = {$: 'DismissError'};
8543−var $lynxjs_elm$ui$Lynx$Attributes$Row = {$: 'Row'};
8544−var $lynxjs_elm$ui$Lynx$Attributes$alignmentToString = function (a) {
8545− switch (a.$) {
8546− case 'Start':
8547− return 'flex-start';
8548− case 'Center':
8549− return 'center';
8550− case 'End':
8551− return 'flex-end';
8552− case 'SpaceBetween':
8553− return 'space-between';
8554− case 'SpaceAround':
8555− return 'space-around';
8556− case 'SpaceEvenly':
8557− return 'space-evenly';
8558− case 'Stretch':
8559− return 'stretch';
8560− default:
8561− return 'baseline';
8562− }
8563−};
8564−var $lynxjs_elm$ui$Lynx$Attributes$alignItems = function (a) {
8565− return A2(
8566− $lynxjs_elm$ui$Lynx$Attributes$style,
8567− 'alignItems',
8568− $lynxjs_elm$ui$Lynx$Attributes$alignmentToString(a));
8569−};
8570−var $lynxjs_elm$ui$Lynx$Attributes$color = $lynxjs_elm$ui$Lynx$Attributes$style('color');
8571−var $lynxjs_elm$ui$Lynx$Attributes$intStyle = F2(
8572− function (key, val) {
8573− return A2(
8574− $lynxjs_elm$virtual_dom$VirtualDom$style,
8575− key,
8576− $elm$core$String$fromInt(val));
8577− });
8578−var $lynxjs_elm$ui$Lynx$Attributes$flex = $lynxjs_elm$ui$Lynx$Attributes$intStyle('flex');
8579−var $lynxjs_elm$ui$Lynx$Attributes$flexDirection = function (d) {
8580− return A2(
8581− $lynxjs_elm$ui$Lynx$Attributes$style,
8582− 'flexDirection',
8583− function () {
8584− switch (d.$) {
8585− case 'Row':
8586− return 'row';
8587− case 'Column':
8588− return 'column';
8589− case 'RowReverse':
8590− return 'row-reverse';
8591− default:
8592− return 'column-reverse';
8593− }
8594− }());
8595−};
8596−var $lynxjs_elm$ui$Lynx$Attributes$pxStyle = F2(
8597− function (key, val) {
8598− return A2(
8599− $lynxjs_elm$virtual_dom$VirtualDom$style,
8600− key,
8601− $elm$core$String$fromInt(val) + 'px');
8602− });
8603−var $lynxjs_elm$ui$Lynx$Attributes$fontSize = $lynxjs_elm$ui$Lynx$Attributes$pxStyle('fontSize');
8604−var $lynxjs_elm$virtual_dom$VirtualDom$Normal = function (a) {
8605− return {$: 'Normal', a: a};
8606−};
8607−var $lynxjs_elm$virtual_dom$VirtualDom$on = _VirtualDom_on;
8608−var $lynxjs_elm$ui$Lynx$Events$onTap = function (msg) {
8609− return A2(
8610− $lynxjs_elm$virtual_dom$VirtualDom$on,
8611− 'tap',
8612− $lynxjs_elm$virtual_dom$VirtualDom$Normal(
8613− $elm$json$Json$Decode$succeed(msg)));
8614−};
8615−var $lynxjs_elm$ui$Lynx$Attributes$paddingBottom = $lynxjs_elm$ui$Lynx$Attributes$pxStyle('paddingBottom');
8616−var $lynxjs_elm$ui$Lynx$Attributes$paddingLeft = $lynxjs_elm$ui$Lynx$Attributes$pxStyle('paddingLeft');
8617−var $lynxjs_elm$ui$Lynx$Attributes$paddingRight = $lynxjs_elm$ui$Lynx$Attributes$pxStyle('paddingRight');
8618−var $lynxjs_elm$ui$Lynx$Attributes$paddingTop = $lynxjs_elm$ui$Lynx$Attributes$pxStyle('paddingTop');
8619−var $lynxjs_elm$virtual_dom$VirtualDom$node = function (tag) {
8620− return _VirtualDom_node(
8621− _VirtualDom_noScript(tag));
8622−};
8623−var $lynxjs_elm$ui$Lynx$text = $lynxjs_elm$virtual_dom$VirtualDom$node('text');
8624−var $lynxjs_elm$virtual_dom$VirtualDom$text = _VirtualDom_text;
8625−var $lynxjs_elm$ui$Lynx$textContent = $lynxjs_elm$virtual_dom$VirtualDom$text;
8626−var $lynxjs_elm$ui$Lynx$view = $lynxjs_elm$virtual_dom$VirtualDom$node('view');
8627−var $author$project$Main$errorBanner = function (err) {
8628− return A2(
8629− $lynxjs_elm$ui$Lynx$view,
8630− _List_fromArray(
8631− [
8632− $lynxjs_elm$ui$Lynx$Events$onTap($author$project$Main$DismissError),
8633− $lynxjs_elm$ui$Lynx$Attributes$backgroundColor('#3d1114'),
8634− $lynxjs_elm$ui$Lynx$Attributes$paddingTop(10),
8635− $lynxjs_elm$ui$Lynx$Attributes$paddingBottom(10),
8636− $lynxjs_elm$ui$Lynx$Attributes$paddingLeft(16),
8637− $lynxjs_elm$ui$Lynx$Attributes$paddingRight(16),
8638− $lynxjs_elm$ui$Lynx$Attributes$flexDirection($lynxjs_elm$ui$Lynx$Attributes$Row),
8639− $lynxjs_elm$ui$Lynx$Attributes$alignItems($lynxjs_elm$ui$Lynx$Attributes$Center)
8640− ]),
8641− _List_fromArray(
8642− [
8643− A2(
8644− $lynxjs_elm$ui$Lynx$text,
8645− _List_fromArray(
8646− [
8647− $lynxjs_elm$ui$Lynx$Attributes$color('#f85149'),
8648− $lynxjs_elm$ui$Lynx$Attributes$fontSize(14),
8649− $lynxjs_elm$ui$Lynx$Attributes$flex(1)
8650− ]),
8651− _List_fromArray(
8652− [
8653− $lynxjs_elm$ui$Lynx$textContent(err)
8654− ])),
8655− A2(
8656− $lynxjs_elm$ui$Lynx$text,
8657− _List_fromArray(
8658− [
8659− $lynxjs_elm$ui$Lynx$Attributes$color('#f85149'),
8660− $lynxjs_elm$ui$Lynx$Attributes$fontSize(16)
8661− ]),
8662− _List_fromArray(
8663− [
8664− $lynxjs_elm$ui$Lynx$textContent('x')
8665− ]))
8666− ]));
8667−};
8668−var $author$project$Main$AddContact = {$: 'AddContact'};
8669−var $lynxjs_elm$ui$Lynx$Attributes$Bold = {$: 'Bold'};
8670−var $author$project$Main$Navigate = function (a) {
8671− return {$: 'Navigate', a: a};
8672−};
8673−var $author$project$Main$UpdateAddIdentity = function (a) {
8674− return {$: 'UpdateAddIdentity', a: a};
8675−};
8676−var $author$project$Main$UpdateAddName = function (a) {
8677− return {$: 'UpdateAddName', a: a};
8678−};
8679−var $lynxjs_elm$ui$Lynx$Attributes$marginBottom = $lynxjs_elm$ui$Lynx$Attributes$pxStyle('marginBottom');
8680−var $author$project$Main$fieldLabel = function (text) {
8681− return A2(
8682− $lynxjs_elm$ui$Lynx$text,
8683− _List_fromArray(
8684− [
8685− $lynxjs_elm$ui$Lynx$Attributes$color('#8b949e'),
8686− $lynxjs_elm$ui$Lynx$Attributes$fontSize(13),
8687− $lynxjs_elm$ui$Lynx$Attributes$marginBottom(6)
8688− ]),
8689− _List_fromArray(
8690− [
8691− $lynxjs_elm$ui$Lynx$textContent(text)
8692− ]));
8693−};
8694−var $lynxjs_elm$ui$Lynx$Attributes$fontWeight = function (w) {
8695− return A2(
8696− $lynxjs_elm$ui$Lynx$Attributes$style,
8697− 'fontWeight',
8698− function () {
8699− if (w.$ === 'Normal') {
8700− return 'normal';
8701− } else {
8702− return 'bold';
8703− }
8704− }());
8705−};
8706−var $lynxjs_elm$ui$Lynx$Attributes$padding = $lynxjs_elm$ui$Lynx$Attributes$pxStyle('padding');
8707−var $lynxjs_elm$ui$Lynx$Attributes$borderRadius = $lynxjs_elm$ui$Lynx$Attributes$pxStyle('borderRadius');
8708−var $author$project$Main$primaryButton = F2(
8709− function (msg, text) {
8710− return A2(
8711− $lynxjs_elm$ui$Lynx$view,
8712− _List_fromArray(
8713− [
8714− $lynxjs_elm$ui$Lynx$Events$onTap(msg),
8715− $lynxjs_elm$ui$Lynx$Attributes$backgroundColor('#58a6ff'),
8716− $lynxjs_elm$ui$Lynx$Attributes$borderRadius(8),
8717− $lynxjs_elm$ui$Lynx$Attributes$paddingTop(14),
8718− $lynxjs_elm$ui$Lynx$Attributes$paddingBottom(14),
8719− $lynxjs_elm$ui$Lynx$Attributes$alignItems($lynxjs_elm$ui$Lynx$Attributes$Center)
8720− ]),
8721− _List_fromArray(
8722− [
8723− A2(
8724− $lynxjs_elm$ui$Lynx$text,
8725− _List_fromArray(
8726− [
8727− $lynxjs_elm$ui$Lynx$Attributes$color('#ffffff'),
8728− $lynxjs_elm$ui$Lynx$Attributes$fontSize(16),
8729− $lynxjs_elm$ui$Lynx$Attributes$fontWeight($lynxjs_elm$ui$Lynx$Attributes$Bold)
8730− ]),
8731− _List_fromArray(
8732− [
8733− $lynxjs_elm$ui$Lynx$textContent(text)
8734− ]))
8735− ]));
8736− });
8737−var $lynxjs_elm$ui$Lynx$Attributes$height = $lynxjs_elm$ui$Lynx$Attributes$pxStyle('height');
8738−var $author$project$Main$spacer = function (px) {
8739− return A2(
8740− $lynxjs_elm$ui$Lynx$view,
8741− _List_fromArray(
8742− [
8743− $lynxjs_elm$ui$Lynx$Attributes$height(px)
8744− ]),
8745− _List_Nil);
8746−};
8747−var $lynxjs_elm$ui$Lynx$Attributes$borderColor = $lynxjs_elm$ui$Lynx$Attributes$style('borderColor');
8748−var $lynxjs_elm$ui$Lynx$Attributes$borderWidth = $lynxjs_elm$ui$Lynx$Attributes$pxStyle('borderWidth');
8749−var $lynxjs_elm$ui$Lynx$input = $lynxjs_elm$virtual_dom$VirtualDom$node('input');
8750−var $elm$json$Json$Decode$at = F2(
8751− function (fields, decoder) {
8752− return A3($elm$core$List$foldr, $elm$json$Json$Decode$field, decoder, fields);
8753− });
8754−var $lynxjs_elm$ui$Lynx$Events$onInput = function (tagger) {
8755− return A2(
8756− $lynxjs_elm$virtual_dom$VirtualDom$on,
8757− 'input',
8758− $lynxjs_elm$virtual_dom$VirtualDom$Normal(
8759− A2(
8760− $elm$json$Json$Decode$map,
8761− tagger,
8762− A2(
8763− $elm$json$Json$Decode$at,
8764− _List_fromArray(
8765− ['detail', 'value']),
8766− $elm$json$Json$Decode$string))));
8767−};
8768−var $lynxjs_elm$virtual_dom$VirtualDom$attribute = F2(
8769− function (key, value) {
8770− return A2(
8771− _VirtualDom_attribute,
8772− _VirtualDom_noOnOrFormAction(key),
8773− _VirtualDom_noJavaScriptOrHtmlUri(value));
8774− });
8775−var $lynxjs_elm$ui$Lynx$Attributes$attr = $lynxjs_elm$virtual_dom$VirtualDom$attribute;
8776−var $lynxjs_elm$ui$Lynx$Attributes$placeholder = $lynxjs_elm$ui$Lynx$Attributes$attr('placeholder');
8777−var $lynxjs_elm$ui$Lynx$Attributes$value = $lynxjs_elm$ui$Lynx$Attributes$attr('value');
8778−var $author$project$Main$textInput = function (config) {
8779− return A2(
8780− $lynxjs_elm$ui$Lynx$input,
8781− _List_fromArray(
8782− [
8783− $lynxjs_elm$ui$Lynx$Events$onInput(config.onInput),
8784− $lynxjs_elm$ui$Lynx$Attributes$value(config.value),
8785− $lynxjs_elm$ui$Lynx$Attributes$placeholder(config.placeholder),
8786− $lynxjs_elm$ui$Lynx$Attributes$height(44),
8787− $lynxjs_elm$ui$Lynx$Attributes$fontSize(16),
8788− $lynxjs_elm$ui$Lynx$Attributes$color('#c9d1d9'),
8789− $lynxjs_elm$ui$Lynx$Attributes$backgroundColor('#161b22'),
8790− $lynxjs_elm$ui$Lynx$Attributes$borderWidth(1),
8791− $lynxjs_elm$ui$Lynx$Attributes$borderColor('#30363d'),
8792− $lynxjs_elm$ui$Lynx$Attributes$borderRadius(8),
8793− $lynxjs_elm$ui$Lynx$Attributes$paddingLeft(12)
8794− ]),
8795− _List_Nil);
8796−};
8797−var $author$project$Main$viewAddContact = function (model) {
8798− return A2(
8799− $lynxjs_elm$ui$Lynx$view,
8800− _List_fromArray(
8801− [
8802− $lynxjs_elm$ui$Lynx$Attributes$flexDirection($lynxjs_elm$ui$Lynx$Attributes$Column),
8803− $lynxjs_elm$ui$Lynx$Attributes$flex(1)
8804− ]),
8805− _List_fromArray(
8806− [
8807− A2(
8808− $lynxjs_elm$ui$Lynx$view,
8809− _List_fromArray(
8810− [
8811− $lynxjs_elm$ui$Lynx$Attributes$flexDirection($lynxjs_elm$ui$Lynx$Attributes$Row),
8812− $lynxjs_elm$ui$Lynx$Attributes$alignItems($lynxjs_elm$ui$Lynx$Attributes$Center),
8813− $lynxjs_elm$ui$Lynx$Attributes$paddingTop(12),
8814− $lynxjs_elm$ui$Lynx$Attributes$paddingBottom(12),
8815− $lynxjs_elm$ui$Lynx$Attributes$paddingLeft(16),
8816− $lynxjs_elm$ui$Lynx$Attributes$paddingRight(16),
8817− $lynxjs_elm$ui$Lynx$Attributes$backgroundColor('#161b22')
8818− ]),
8819− _List_fromArray(
8820− [
8821− A2(
8822− $lynxjs_elm$ui$Lynx$view,
8823− _List_fromArray(
8824− [
8825− $lynxjs_elm$ui$Lynx$Events$onTap(
8826− $author$project$Main$Navigate($author$project$Main$ContactsScreen)),
8827− $lynxjs_elm$ui$Lynx$Attributes$paddingRight(12),
8828− $lynxjs_elm$ui$Lynx$Attributes$paddingTop(4),
8829− $lynxjs_elm$ui$Lynx$Attributes$paddingBottom(4)
8830− ]),
8831− _List_fromArray(
8832− [
8833− A2(
8834− $lynxjs_elm$ui$Lynx$text,
8835− _List_fromArray(
8836− [
8837− $lynxjs_elm$ui$Lynx$Attributes$color('#58a6ff'),
8838− $lynxjs_elm$ui$Lynx$Attributes$fontSize(20)
8839− ]),
8840− _List_fromArray(
8841− [
8842− $lynxjs_elm$ui$Lynx$textContent('<')
8843− ]))
8844− ])),
8845− A2(
8846− $lynxjs_elm$ui$Lynx$text,
8847− _List_fromArray(
8848− [
8849− $lynxjs_elm$ui$Lynx$Attributes$color('#c9d1d9'),
8850− $lynxjs_elm$ui$Lynx$Attributes$fontSize(18),
8851− $lynxjs_elm$ui$Lynx$Attributes$fontWeight($lynxjs_elm$ui$Lynx$Attributes$Bold)
8852− ]),
8853− _List_fromArray(
8854− [
8855− $lynxjs_elm$ui$Lynx$textContent('Add Contact')
8856− ]))
8857− ])),
8858− A2(
8859− $lynxjs_elm$ui$Lynx$view,
8860− _List_fromArray(
8861− [
8862− $lynxjs_elm$ui$Lynx$Attributes$padding(24),
8863− $lynxjs_elm$ui$Lynx$Attributes$flexDirection($lynxjs_elm$ui$Lynx$Attributes$Column)
8864− ]),
8865− _List_fromArray(
8866− [
8867− $author$project$Main$fieldLabel('Identity string'),
8868− A2(
8869− $lynxjs_elm$ui$Lynx$text,
8870− _List_fromArray(
8871− [
8872− $lynxjs_elm$ui$Lynx$Attributes$color('#6e7681'),
8873− $lynxjs_elm$ui$Lynx$Attributes$fontSize(12),
8874− $lynxjs_elm$ui$Lynx$Attributes$marginBottom(8)
8875− ]),
8876− _List_fromArray(
8877− [
8878− $lynxjs_elm$ui$Lynx$textContent('Paste the <ed25519>.<x25519> identity from your contact')
8879− ])),
8880− $author$project$Main$textInput(
8881− {onInput: $author$project$Main$UpdateAddIdentity, placeholder: 'abc123...def456.ghi789...jkl012', value: model.addIdentity}),
8882− $author$project$Main$spacer(20),
8883− $author$project$Main$fieldLabel('Name'),
8884− $author$project$Main$textInput(
8885− {onInput: $author$project$Main$UpdateAddName, placeholder: 'Display name (optional)', value: model.addName}),
8886− $author$project$Main$spacer(32),
8887− A2($author$project$Main$primaryButton, $author$project$Main$AddContact, 'Add Contact')
8888− ]))
8889− ]));
8890−};
8891−var $author$project$Main$PrepareSend = {$: 'PrepareSend'};
8892−var $lynxjs_elm$ui$Lynx$Attributes$TextCenter = {$: 'TextCenter'};
8893−var $author$project$Main$UpdateDraft = function (a) {
8894− return {$: 'UpdateDraft', a: a};
8895−};
8896−var $lynxjs_elm$ui$Lynx$Attributes$Vertical = {$: 'Vertical'};
8897−var $lynxjs_elm$ui$Lynx$Attributes$justifyContent = function (a) {
8898− return A2(
8899− $lynxjs_elm$ui$Lynx$Attributes$style,
8900− 'justifyContent',
8901− $lynxjs_elm$ui$Lynx$Attributes$alignmentToString(a));
8902−};
8903−var $lynxjs_elm$ui$Lynx$Attributes$marginLeft = $lynxjs_elm$ui$Lynx$Attributes$pxStyle('marginLeft');
8904−var $lynxjs_elm$ui$Lynx$Attributes$scrollDirection = function (d) {
8905− return A2(
8906− $lynxjs_elm$ui$Lynx$Attributes$attr,
8907− 'scroll-direction',
8908− function () {
8909− if (d.$ === 'Vertical') {
8910− return 'vertical';
8911− } else {
8912− return 'horizontal';
8913− }
8914− }());
8915−};
8916−var $lynxjs_elm$ui$Lynx$scrollView = $lynxjs_elm$virtual_dom$VirtualDom$node('scroll-view');
8917−var $lynxjs_elm$ui$Lynx$Attributes$textAlign = function (a) {
8918− return A2(
8919− $lynxjs_elm$ui$Lynx$Attributes$style,
8920− 'textAlign',
8921− function () {
8922− switch (a.$) {
8923− case 'Left':
8924− return 'left';
8925− case 'Right':
8926− return 'right';
8927− case 'TextCenter':
8928− return 'center';
8929− default:
8930− return 'justify';
8931− }
8932− }());
8933−};
8934−var $elm$core$String$right = F2(
8935− function (n, string) {
8936− return (n < 1) ? '' : A3(
8937− $elm$core$String$slice,
8938− -n,
8939− $elm$core$String$length(string),
8940− string);
8941− });
8942−var $author$project$Main$truncateKey = function (key) {
8943− return ($elm$core$String$length(key) > 16) ? (A2($elm$core$String$left, 8, key) + ('...' + A2($elm$core$String$right, 8, key))) : key;
8944−};
8945−var $lynxjs_elm$ui$Lynx$Attributes$End = {$: 'End'};
8946−var $lynxjs_elm$ui$Lynx$Attributes$Start = {$: 'Start'};
8947−var $lynxjs_elm$ui$Lynx$Attributes$maxWidth = $lynxjs_elm$ui$Lynx$Attributes$pxStyle('maxWidth');
8948−var $author$project$Main$viewMessage = function (chatMsg) {
8949− return A2(
8950− $lynxjs_elm$ui$Lynx$view,
8951− _List_fromArray(
8952− [
8953− $lynxjs_elm$ui$Lynx$Attributes$flexDirection($lynxjs_elm$ui$Lynx$Attributes$Row),
8954− $lynxjs_elm$ui$Lynx$Attributes$justifyContent(
8955− chatMsg.outgoing ? $lynxjs_elm$ui$Lynx$Attributes$End : $lynxjs_elm$ui$Lynx$Attributes$Start),
8956− $lynxjs_elm$ui$Lynx$Attributes$marginBottom(6)
8957− ]),
8958− _List_fromArray(
8959− [
8960− A2(
8961− $lynxjs_elm$ui$Lynx$view,
8962− _List_fromArray(
8963− [
8964− $lynxjs_elm$ui$Lynx$Attributes$backgroundColor(
8965− chatMsg.outgoing ? '#1a3a5c' : '#21262d'),
8966− $lynxjs_elm$ui$Lynx$Attributes$borderRadius(16),
8967− $lynxjs_elm$ui$Lynx$Attributes$paddingTop(10),
8968− $lynxjs_elm$ui$Lynx$Attributes$paddingBottom(10),
8969− $lynxjs_elm$ui$Lynx$Attributes$paddingLeft(14),
8970− $lynxjs_elm$ui$Lynx$Attributes$paddingRight(14),
8971− $lynxjs_elm$ui$Lynx$Attributes$maxWidth(280)
8972− ]),
8973− _List_fromArray(
8974− [
8975− A2(
8976− $lynxjs_elm$ui$Lynx$text,
8977− _List_fromArray(
8978− [
8979− $lynxjs_elm$ui$Lynx$Attributes$color('#c9d1d9'),
8980− $lynxjs_elm$ui$Lynx$Attributes$fontSize(15)
8981− ]),
8982− _List_fromArray(
8983− [
8984− $lynxjs_elm$ui$Lynx$textContent(chatMsg.body)
8985− ]))
8986− ]))
8987− ]));
8988−};
8989−var $lynxjs_elm$ui$Lynx$Attributes$width = $lynxjs_elm$ui$Lynx$Attributes$pxStyle('width');
8990−var $author$project$Main$viewChat = function (model) {
8991− var contactName = A2(
8992− $elm$core$Maybe$withDefault,
8993− $author$project$Main$truncateKey(model.activeChat),
8994− A2(
8995− $elm$core$Maybe$map,
8996− function ($) {
8997− return $.name;
8998− },
8999− A2($elm$core$Dict$get, model.activeChat, model.contacts)));
9000− var chatMessages = A2(
9001− $elm$core$Maybe$withDefault,
9002− _List_Nil,
9003− A2($elm$core$Dict$get, model.activeChat, model.messages));
9004− return A2(
9005− $lynxjs_elm$ui$Lynx$view,
9006− _List_fromArray(
9007− [
9008− $lynxjs_elm$ui$Lynx$Attributes$flexDirection($lynxjs_elm$ui$Lynx$Attributes$Column),
9009− $lynxjs_elm$ui$Lynx$Attributes$flex(1)
9010− ]),
9011− _List_fromArray(
9012− [
9013− A2(
9014− $lynxjs_elm$ui$Lynx$view,
9015− _List_fromArray(
9016− [
9017− $lynxjs_elm$ui$Lynx$Attributes$flexDirection($lynxjs_elm$ui$Lynx$Attributes$Row),
9018− $lynxjs_elm$ui$Lynx$Attributes$alignItems($lynxjs_elm$ui$Lynx$Attributes$Center),
9019− $lynxjs_elm$ui$Lynx$Attributes$paddingTop(12),
9020− $lynxjs_elm$ui$Lynx$Attributes$paddingBottom(12),
9021− $lynxjs_elm$ui$Lynx$Attributes$paddingLeft(16),
9022− $lynxjs_elm$ui$Lynx$Attributes$paddingRight(16),
9023− $lynxjs_elm$ui$Lynx$Attributes$backgroundColor('#161b22')
9024− ]),
9025− _List_fromArray(
9026− [
9027− A2(
9028− $lynxjs_elm$ui$Lynx$view,
9029− _List_fromArray(
9030− [
9031− $lynxjs_elm$ui$Lynx$Events$onTap(
9032− $author$project$Main$Navigate($author$project$Main$ContactsScreen)),
9033− $lynxjs_elm$ui$Lynx$Attributes$paddingRight(12),
9034− $lynxjs_elm$ui$Lynx$Attributes$paddingTop(4),
9035− $lynxjs_elm$ui$Lynx$Attributes$paddingBottom(4)
9036− ]),
9037− _List_fromArray(
9038− [
9039− A2(
9040− $lynxjs_elm$ui$Lynx$text,
9041− _List_fromArray(
9042− [
9043− $lynxjs_elm$ui$Lynx$Attributes$color('#58a6ff'),
9044− $lynxjs_elm$ui$Lynx$Attributes$fontSize(20)
9045− ]),
9046− _List_fromArray(
9047− [
9048− $lynxjs_elm$ui$Lynx$textContent('<')
9049− ]))
9050− ])),
9051− A2(
9052− $lynxjs_elm$ui$Lynx$text,
9053− _List_fromArray(
9054− [
9055− $lynxjs_elm$ui$Lynx$Attributes$color('#c9d1d9'),
9056− $lynxjs_elm$ui$Lynx$Attributes$fontSize(18),
9057− $lynxjs_elm$ui$Lynx$Attributes$fontWeight($lynxjs_elm$ui$Lynx$Attributes$Bold),
9058− $lynxjs_elm$ui$Lynx$Attributes$flex(1)
9059− ]),
9060− _List_fromArray(
9061− [
9062− $lynxjs_elm$ui$Lynx$textContent(contactName)
9063− ]))
9064− ])),
9065− A2(
9066− $lynxjs_elm$ui$Lynx$scrollView,
9067− _List_fromArray(
9068− [
9069− $lynxjs_elm$ui$Lynx$Attributes$flex(1),
9070− $lynxjs_elm$ui$Lynx$Attributes$scrollDirection($lynxjs_elm$ui$Lynx$Attributes$Vertical),
9071− $lynxjs_elm$ui$Lynx$Attributes$paddingTop(12),
9072− $lynxjs_elm$ui$Lynx$Attributes$paddingBottom(12),
9073− $lynxjs_elm$ui$Lynx$Attributes$paddingLeft(16),
9074− $lynxjs_elm$ui$Lynx$Attributes$paddingRight(16)
9075− ]),
9076− $elm$core$List$isEmpty(chatMessages) ? _List_fromArray(
9077− [
9078− A2(
9079− $lynxjs_elm$ui$Lynx$view,
9080− _List_fromArray(
9081− [
9082− $lynxjs_elm$ui$Lynx$Attributes$alignItems($lynxjs_elm$ui$Lynx$Attributes$Center),
9083− $lynxjs_elm$ui$Lynx$Attributes$paddingTop(80)
9084− ]),
9085− _List_fromArray(
9086− [
9087− A2(
9088− $lynxjs_elm$ui$Lynx$text,
9089− _List_fromArray(
9090− [
9091− $lynxjs_elm$ui$Lynx$Attributes$color('#8b949e'),
9092− $lynxjs_elm$ui$Lynx$Attributes$fontSize(14),
9093− $lynxjs_elm$ui$Lynx$Attributes$textAlign($lynxjs_elm$ui$Lynx$Attributes$TextCenter)
9094− ]),
9095− _List_fromArray(
9096− [
9097− $lynxjs_elm$ui$Lynx$textContent('No messages yet. Say hello!')
9098− ]))
9099− ]))
9100− ]) : A2($elm$core$List$map, $author$project$Main$viewMessage, chatMessages)),
9101− A2(
9102− $lynxjs_elm$ui$Lynx$view,
9103− _List_fromArray(
9104− [
9105− $lynxjs_elm$ui$Lynx$Attributes$flexDirection($lynxjs_elm$ui$Lynx$Attributes$Row),
9106− $lynxjs_elm$ui$Lynx$Attributes$alignItems($lynxjs_elm$ui$Lynx$Attributes$Center),
9107− $lynxjs_elm$ui$Lynx$Attributes$paddingTop(8),
9108− $lynxjs_elm$ui$Lynx$Attributes$paddingBottom(8),
9109− $lynxjs_elm$ui$Lynx$Attributes$paddingLeft(12),
9110− $lynxjs_elm$ui$Lynx$Attributes$paddingRight(12),
9111− $lynxjs_elm$ui$Lynx$Attributes$backgroundColor('#161b22')
9112− ]),
9113− _List_fromArray(
9114− [
9115− A2(
9116− $lynxjs_elm$ui$Lynx$input,
9117− _List_fromArray(
9118− [
9119− $lynxjs_elm$ui$Lynx$Events$onInput($author$project$Main$UpdateDraft),
9120− $lynxjs_elm$ui$Lynx$Attributes$value(model.draft),
9121− $lynxjs_elm$ui$Lynx$Attributes$placeholder('Message...'),
9122− $lynxjs_elm$ui$Lynx$Attributes$flex(1),
9123− $lynxjs_elm$ui$Lynx$Attributes$height(40),
9124− $lynxjs_elm$ui$Lynx$Attributes$fontSize(16),
9125− $lynxjs_elm$ui$Lynx$Attributes$color('#c9d1d9'),
9126− $lynxjs_elm$ui$Lynx$Attributes$backgroundColor('#0d1117'),
9127− $lynxjs_elm$ui$Lynx$Attributes$borderWidth(1),
9128− $lynxjs_elm$ui$Lynx$Attributes$borderColor('#30363d'),
9129− $lynxjs_elm$ui$Lynx$Attributes$borderRadius(20),
9130− $lynxjs_elm$ui$Lynx$Attributes$paddingLeft(16)
9131− ]),
9132− _List_Nil),
9133− A2(
9134− $lynxjs_elm$ui$Lynx$view,
9135− _List_fromArray(
9136− [
9137− $lynxjs_elm$ui$Lynx$Events$onTap($author$project$Main$PrepareSend),
9138− $lynxjs_elm$ui$Lynx$Attributes$marginLeft(8),
9139− $lynxjs_elm$ui$Lynx$Attributes$backgroundColor(
9140− (model.sending || $elm$core$String$isEmpty(
9141− $elm$core$String$trim(model.draft))) ? '#30363d' : '#58a6ff'),
9142− $lynxjs_elm$ui$Lynx$Attributes$borderRadius(20),
9143− $lynxjs_elm$ui$Lynx$Attributes$width(40),
9144− $lynxjs_elm$ui$Lynx$Attributes$height(40),
9145− $lynxjs_elm$ui$Lynx$Attributes$alignItems($lynxjs_elm$ui$Lynx$Attributes$Center),
9146− $lynxjs_elm$ui$Lynx$Attributes$justifyContent($lynxjs_elm$ui$Lynx$Attributes$Center)
9147− ]),
9148− _List_fromArray(
9149− [
9150− A2(
9151− $lynxjs_elm$ui$Lynx$text,
9152− _List_fromArray(
9153− [
9154− $lynxjs_elm$ui$Lynx$Attributes$color('#ffffff'),
9155− $lynxjs_elm$ui$Lynx$Attributes$fontSize(16),
9156− $lynxjs_elm$ui$Lynx$Attributes$fontWeight($lynxjs_elm$ui$Lynx$Attributes$Bold)
9157− ]),
9158− _List_fromArray(
9159− [
9160− $lynxjs_elm$ui$Lynx$textContent('^')
9161− ]))
9162− ]))
9163− ]))
9164− ]));
9165−};
9166−var $author$project$Main$AddContactScreen = {$: 'AddContactScreen'};
9167−var $elm$core$Dict$isEmpty = function (dict) {
9168− if (dict.$ === 'RBEmpty_elm_builtin') {
9169− return true;
9170− } else {
9171− return false;
9172− }
9173−};
9174−var $lynxjs_elm$ui$Lynx$Attributes$marginRight = $lynxjs_elm$ui$Lynx$Attributes$pxStyle('marginRight');
9175−var $elm$core$List$sortBy = _List_sortBy;
9176−var $elm$core$Dict$values = function (dict) {
9177− return A3(
9178− $elm$core$Dict$foldr,
9179− F3(
9180− function (key, value, valueList) {
9181− return A2($elm$core$List$cons, value, valueList);
9182− }),
9183− _List_Nil,
9184− dict);
9185−};
9186−var $author$project$Main$OpenChat = function (a) {
9187− return {$: 'OpenChat', a: a};
9188−};
9189−var $lynxjs_elm$ui$Lynx$Attributes$marginTop = $lynxjs_elm$ui$Lynx$Attributes$pxStyle('marginTop');
9190−var $elm$core$String$toUpper = _String_toUpper;
9191−var $author$project$Main$viewContactRow = function (contact) {
9192− return A2(
9193− $lynxjs_elm$ui$Lynx$view,
9194− _List_fromArray(
9195− [
9196− $lynxjs_elm$ui$Lynx$Events$onTap(
9197− $author$project$Main$OpenChat(contact.edPubHex)),
9198− $lynxjs_elm$ui$Lynx$Attributes$flexDirection($lynxjs_elm$ui$Lynx$Attributes$Row),
9199− $lynxjs_elm$ui$Lynx$Attributes$alignItems($lynxjs_elm$ui$Lynx$Attributes$Center),
9200− $lynxjs_elm$ui$Lynx$Attributes$paddingTop(14),
9201− $lynxjs_elm$ui$Lynx$Attributes$paddingBottom(14),
9202− $lynxjs_elm$ui$Lynx$Attributes$paddingLeft(20),
9203− $lynxjs_elm$ui$Lynx$Attributes$paddingRight(20),
9204− $lynxjs_elm$ui$Lynx$Attributes$borderColor('#21262d'),
9205− $lynxjs_elm$ui$Lynx$Attributes$borderWidth(1)
9206− ]),
9207− _List_fromArray(
9208− [
9209− A2(
9210− $lynxjs_elm$ui$Lynx$view,
9211− _List_fromArray(
9212− [
9213− $lynxjs_elm$ui$Lynx$Attributes$width(44),
9214− $lynxjs_elm$ui$Lynx$Attributes$height(44),
9215− $lynxjs_elm$ui$Lynx$Attributes$borderRadius(22),
9216− $lynxjs_elm$ui$Lynx$Attributes$backgroundColor('#30363d'),
9217− $lynxjs_elm$ui$Lynx$Attributes$alignItems($lynxjs_elm$ui$Lynx$Attributes$Center),
9218− $lynxjs_elm$ui$Lynx$Attributes$justifyContent($lynxjs_elm$ui$Lynx$Attributes$Center),
9219− $lynxjs_elm$ui$Lynx$Attributes$marginRight(12)
9220− ]),
9221− _List_fromArray(
9222− [
9223− A2(
9224− $lynxjs_elm$ui$Lynx$text,
9225− _List_fromArray(
9226− [
9227− $lynxjs_elm$ui$Lynx$Attributes$color('#58a6ff'),
9228− $lynxjs_elm$ui$Lynx$Attributes$fontSize(18),
9229− $lynxjs_elm$ui$Lynx$Attributes$fontWeight($lynxjs_elm$ui$Lynx$Attributes$Bold)
9230− ]),
9231− _List_fromArray(
9232− [
9233− $lynxjs_elm$ui$Lynx$textContent(
9234− $elm$core$String$toUpper(
9235− A2($elm$core$String$left, 1, contact.name)))
9236− ]))
9237− ])),
9238− A2(
9239− $lynxjs_elm$ui$Lynx$view,
9240− _List_fromArray(
9241− [
9242− $lynxjs_elm$ui$Lynx$Attributes$flex(1),
9243− $lynxjs_elm$ui$Lynx$Attributes$flexDirection($lynxjs_elm$ui$Lynx$Attributes$Column)
9244− ]),
9245− _List_fromArray(
9246− [
9247− A2(
9248− $lynxjs_elm$ui$Lynx$view,
9249− _List_fromArray(
9250− [
9251− $lynxjs_elm$ui$Lynx$Attributes$flexDirection($lynxjs_elm$ui$Lynx$Attributes$Row),
9252− $lynxjs_elm$ui$Lynx$Attributes$alignItems($lynxjs_elm$ui$Lynx$Attributes$Center)
9253− ]),
9254− _List_fromArray(
9255− [
9256− A2(
9257− $lynxjs_elm$ui$Lynx$text,
9258− _List_fromArray(
9259− [
9260− $lynxjs_elm$ui$Lynx$Attributes$color('#c9d1d9'),
9261− $lynxjs_elm$ui$Lynx$Attributes$fontSize(16),
9262− $lynxjs_elm$ui$Lynx$Attributes$fontWeight($lynxjs_elm$ui$Lynx$Attributes$Bold),
9263− $lynxjs_elm$ui$Lynx$Attributes$flex(1)
9264− ]),
9265− _List_fromArray(
9266− [
9267− $lynxjs_elm$ui$Lynx$textContent(contact.name)
9268− ])),
9269− contact.online ? A2(
9270− $lynxjs_elm$ui$Lynx$view,
9271− _List_fromArray(
9272− [
9273− $lynxjs_elm$ui$Lynx$Attributes$width(8),
9274− $lynxjs_elm$ui$Lynx$Attributes$height(8),
9275− $lynxjs_elm$ui$Lynx$Attributes$borderRadius(4),
9276− $lynxjs_elm$ui$Lynx$Attributes$backgroundColor('#3fb950'),
9277− $lynxjs_elm$ui$Lynx$Attributes$marginLeft(8)
9278− ]),
9279− _List_Nil) : A2($lynxjs_elm$ui$Lynx$view, _List_Nil, _List_Nil)
9280− ])),
9281− $elm$core$String$isEmpty(contact.lastMessage) ? A2($lynxjs_elm$ui$Lynx$view, _List_Nil, _List_Nil) : A2(
9282− $lynxjs_elm$ui$Lynx$text,
9283− _List_fromArray(
9284− [
9285− $lynxjs_elm$ui$Lynx$Attributes$color('#8b949e'),
9286− $lynxjs_elm$ui$Lynx$Attributes$fontSize(14),
9287− $lynxjs_elm$ui$Lynx$Attributes$marginTop(2)
9288− ]),
9289− _List_fromArray(
9290− [
9291− $lynxjs_elm$ui$Lynx$textContent(
9292− A2($elm$core$String$left, 50, contact.lastMessage))
9293− ]))
9294− ]))
9295− ]));
9296−};
9297−var $author$project$Main$viewContacts = function (model) {
9298− return A2(
9299− $lynxjs_elm$ui$Lynx$view,
9300− _List_fromArray(
9301− [
9302− $lynxjs_elm$ui$Lynx$Attributes$flexDirection($lynxjs_elm$ui$Lynx$Attributes$Column),
9303− $lynxjs_elm$ui$Lynx$Attributes$flex(1)
9304− ]),
9305− _List_fromArray(
9306− [
9307− A2(
9308− $lynxjs_elm$ui$Lynx$view,
9309− _List_fromArray(
9310− [
9311− $lynxjs_elm$ui$Lynx$Attributes$flexDirection($lynxjs_elm$ui$Lynx$Attributes$Row),
9312− $lynxjs_elm$ui$Lynx$Attributes$alignItems($lynxjs_elm$ui$Lynx$Attributes$Center),
9313− $lynxjs_elm$ui$Lynx$Attributes$paddingTop(16),
9314− $lynxjs_elm$ui$Lynx$Attributes$paddingBottom(16),
9315− $lynxjs_elm$ui$Lynx$Attributes$paddingLeft(20),
9316− $lynxjs_elm$ui$Lynx$Attributes$paddingRight(20),
9317− $lynxjs_elm$ui$Lynx$Attributes$backgroundColor('#161b22')
9318− ]),
9319− _List_fromArray(
9320− [
9321− A2(
9322− $lynxjs_elm$ui$Lynx$text,
9323− _List_fromArray(
9324− [
9325− $lynxjs_elm$ui$Lynx$Attributes$fontSize(20),
9326− $lynxjs_elm$ui$Lynx$Attributes$fontWeight($lynxjs_elm$ui$Lynx$Attributes$Bold),
9327− $lynxjs_elm$ui$Lynx$Attributes$color('#c9d1d9'),
9328− $lynxjs_elm$ui$Lynx$Attributes$flex(1)
9329− ]),
9330− _List_fromArray(
9331− [
9332− $lynxjs_elm$ui$Lynx$textContent('Messages')
9333− ])),
9334− A2(
9335− $lynxjs_elm$ui$Lynx$view,
9336− _List_fromArray(
9337− [
9338− $lynxjs_elm$ui$Lynx$Events$onTap(
9339− $author$project$Main$Navigate($author$project$Main$AddContactScreen)),
9340− $lynxjs_elm$ui$Lynx$Attributes$paddingLeft(12),
9341− $lynxjs_elm$ui$Lynx$Attributes$paddingRight(4)
9342− ]),
9343− _List_fromArray(
9344− [
9345− A2(
9346− $lynxjs_elm$ui$Lynx$text,
9347− _List_fromArray(
9348− [
9349− $lynxjs_elm$ui$Lynx$Attributes$color('#58a6ff'),
9350− $lynxjs_elm$ui$Lynx$Attributes$fontSize(28)
9351− ]),
9352− _List_fromArray(
9353− [
9354− $lynxjs_elm$ui$Lynx$textContent('+')
9355− ]))
9356− ]))
9357− ])),
9358− $elm$core$Dict$isEmpty(model.contacts) ? A2(
9359− $lynxjs_elm$ui$Lynx$view,
9360− _List_fromArray(
9361− [
9362− $lynxjs_elm$ui$Lynx$Attributes$flex(1),
9363− $lynxjs_elm$ui$Lynx$Attributes$alignItems($lynxjs_elm$ui$Lynx$Attributes$Center),
9364− $lynxjs_elm$ui$Lynx$Attributes$justifyContent($lynxjs_elm$ui$Lynx$Attributes$Center),
9365− $lynxjs_elm$ui$Lynx$Attributes$padding(40)
9366− ]),
9367− _List_fromArray(
9368− [
9369− A2(
9370− $lynxjs_elm$ui$Lynx$text,
9371− _List_fromArray(
9372− [
9373− $lynxjs_elm$ui$Lynx$Attributes$color('#8b949e'),
9374− $lynxjs_elm$ui$Lynx$Attributes$fontSize(16),
9375− $lynxjs_elm$ui$Lynx$Attributes$textAlign($lynxjs_elm$ui$Lynx$Attributes$TextCenter)
9376− ]),
9377− _List_fromArray(
9378− [
9379− $lynxjs_elm$ui$Lynx$textContent('No conversations yet')
9380− ])),
9381− $author$project$Main$spacer(8),
9382− A2(
9383− $lynxjs_elm$ui$Lynx$text,
9384− _List_fromArray(
9385− [
9386− $lynxjs_elm$ui$Lynx$Attributes$color('#8b949e'),
9387− $lynxjs_elm$ui$Lynx$Attributes$fontSize(14),
9388− $lynxjs_elm$ui$Lynx$Attributes$textAlign($lynxjs_elm$ui$Lynx$Attributes$TextCenter)
9389− ]),
9390− _List_fromArray(
9391− [
9392− $lynxjs_elm$ui$Lynx$textContent('Tap + to add a contact')
9393− ]))
9394− ])) : A2(
9395− $lynxjs_elm$ui$Lynx$scrollView,
9396− _List_fromArray(
9397− [
9398− $lynxjs_elm$ui$Lynx$Attributes$flex(1),
9399− $lynxjs_elm$ui$Lynx$Attributes$scrollDirection($lynxjs_elm$ui$Lynx$Attributes$Vertical)
9400− ]),
9401− A2(
9402− $elm$core$List$map,
9403− $author$project$Main$viewContactRow,
9404− A2(
9405− $elm$core$List$sortBy,
9406− function (c) {
9407− return -c.lastTime;
9408− },
9409− $elm$core$Dict$values(model.contacts)))),
9410− function () {
9411− var _v0 = model.edPub;
9412− if (_v0.$ === 'Just') {
9413− var pub = _v0.a;
9414− return A2(
9415− $lynxjs_elm$ui$Lynx$view,
9416− _List_fromArray(
9417− [
9418− $lynxjs_elm$ui$Lynx$Attributes$paddingTop(10),
9419− $lynxjs_elm$ui$Lynx$Attributes$paddingBottom(10),
9420− $lynxjs_elm$ui$Lynx$Attributes$paddingLeft(20),
9421− $lynxjs_elm$ui$Lynx$Attributes$paddingRight(20),
9422− $lynxjs_elm$ui$Lynx$Attributes$backgroundColor('#161b22'),
9423− $lynxjs_elm$ui$Lynx$Attributes$flexDirection($lynxjs_elm$ui$Lynx$Attributes$Row),
9424− $lynxjs_elm$ui$Lynx$Attributes$alignItems($lynxjs_elm$ui$Lynx$Attributes$Center)
9425− ]),
9426− _List_fromArray(
9427− [
9428− A2(
9429− $lynxjs_elm$ui$Lynx$view,
9430− _List_fromArray(
9431− [
9432− $lynxjs_elm$ui$Lynx$Attributes$width(8),
9433− $lynxjs_elm$ui$Lynx$Attributes$height(8),
9434− $lynxjs_elm$ui$Lynx$Attributes$borderRadius(4),
9435− $lynxjs_elm$ui$Lynx$Attributes$backgroundColor('#3fb950'),
9436− $lynxjs_elm$ui$Lynx$Attributes$marginRight(8)
9437− ]),
9438− _List_Nil),
9439− A2(
9440− $lynxjs_elm$ui$Lynx$text,
9441− _List_fromArray(
9442− [
9443− $lynxjs_elm$ui$Lynx$Attributes$color('#8b949e'),
9444− $lynxjs_elm$ui$Lynx$Attributes$fontSize(11),
9445− $lynxjs_elm$ui$Lynx$Attributes$flex(1)
9446− ]),
9447− _List_fromArray(
9448− [
9449− $lynxjs_elm$ui$Lynx$textContent(
9450− $author$project$Main$truncateKey(
9451− $lynxjs_elm$crypto$Crypto$Ed25519$publicKeyToHex(pub)))
9452− ]))
9453− ]));
9454− } else {
9455− return A2($lynxjs_elm$ui$Lynx$view, _List_Nil, _List_Nil);
9456− }
9457− }()
9458− ]));
9459−};
9460−var $author$project$Main$Connect = {$: 'Connect'};
9461−var $author$project$Main$UpdateDisplayName = function (a) {
9462− return {$: 'UpdateDisplayName', a: a};
9463−};
9464−var $author$project$Main$UpdateServerUrl = function (a) {
9465− return {$: 'UpdateServerUrl', a: a};
9466−};
9467−var $author$project$Main$disabledButton = function (text) {
9468− return A2(
9469− $lynxjs_elm$ui$Lynx$view,
9470− _List_fromArray(
9471− [
9472− $lynxjs_elm$ui$Lynx$Attributes$backgroundColor('#30363d'),
9473− $lynxjs_elm$ui$Lynx$Attributes$borderRadius(8),
9474− $lynxjs_elm$ui$Lynx$Attributes$paddingTop(14),
9475− $lynxjs_elm$ui$Lynx$Attributes$paddingBottom(14),
9476− $lynxjs_elm$ui$Lynx$Attributes$alignItems($lynxjs_elm$ui$Lynx$Attributes$Center)
9477− ]),
9478− _List_fromArray(
9479− [
9480− A2(
9481− $lynxjs_elm$ui$Lynx$text,
9482− _List_fromArray(
9483− [
9484− $lynxjs_elm$ui$Lynx$Attributes$color('#8b949e'),
9485− $lynxjs_elm$ui$Lynx$Attributes$fontSize(16)
9486− ]),
9487− _List_fromArray(
9488− [
9489− $lynxjs_elm$ui$Lynx$textContent(text)
9490− ]))
9491− ]));
9492−};
9493−var $author$project$Main$viewSetup = function (model) {
9494− var identityStr = function () {
9495− var _v0 = _Utils_Tuple2(model.edPub, model.xPub);
9496− if ((_v0.a.$ === 'Just') && (_v0.b.$ === 'Just')) {
9497− var edP = _v0.a.a;
9498− var xP = _v0.b.a;
9499− return $lynxjs_elm$crypto$Crypto$Ed25519$publicKeyToHex(edP) + ('.' + $lynxjs_elm$crypto$Crypto$X25519$publicKeyToHex(xP));
9500− } else {
9501− return '';
9502− }
9503− }();
9504− return A2(
9505− $lynxjs_elm$ui$Lynx$view,
9506− _List_fromArray(
9507− [
9508− $lynxjs_elm$ui$Lynx$Attributes$flexDirection($lynxjs_elm$ui$Lynx$Attributes$Column),
9509− $lynxjs_elm$ui$Lynx$Attributes$flex(1),
9510− $lynxjs_elm$ui$Lynx$Attributes$padding(24),
9511− $lynxjs_elm$ui$Lynx$Attributes$justifyContent($lynxjs_elm$ui$Lynx$Attributes$Center)
9512− ]),
9513− _List_fromArray(
9514− [
9515− A2(
9516− $lynxjs_elm$ui$Lynx$text,
9517− _List_fromArray(
9518− [
9519− $lynxjs_elm$ui$Lynx$Attributes$fontSize(36),
9520− $lynxjs_elm$ui$Lynx$Attributes$fontWeight($lynxjs_elm$ui$Lynx$Attributes$Bold),
9521− $lynxjs_elm$ui$Lynx$Attributes$color('#58a6ff'),
9522− $lynxjs_elm$ui$Lynx$Attributes$textAlign($lynxjs_elm$ui$Lynx$Attributes$TextCenter),
9523− $lynxjs_elm$ui$Lynx$Attributes$marginBottom(8)
9524− ]),
9525− _List_fromArray(
9526− [
9527− $lynxjs_elm$ui$Lynx$textContent('Skrepka')
9528− ])),
9529− A2(
9530− $lynxjs_elm$ui$Lynx$text,
9531− _List_fromArray(
9532− [
9533− $lynxjs_elm$ui$Lynx$Attributes$fontSize(14),
9534− $lynxjs_elm$ui$Lynx$Attributes$color('#8b949e'),
9535− $lynxjs_elm$ui$Lynx$Attributes$textAlign($lynxjs_elm$ui$Lynx$Attributes$TextCenter),
9536− $lynxjs_elm$ui$Lynx$Attributes$marginBottom(32)
9537− ]),
9538− _List_fromArray(
9539− [
9540− $lynxjs_elm$ui$Lynx$textContent('Encrypted messaging, no accounts')
9541− ])),
9542− $author$project$Main$fieldLabel('Your identity (share this with contacts)'),
9543− $elm$core$String$isEmpty(identityStr) ? A2(
9544− $lynxjs_elm$ui$Lynx$text,
9545− _List_fromArray(
9546− [
9547− $lynxjs_elm$ui$Lynx$Attributes$color('#8b949e'),
9548− $lynxjs_elm$ui$Lynx$Attributes$fontSize(14),
9549− $lynxjs_elm$ui$Lynx$Attributes$marginBottom(24)
9550− ]),
9551− _List_fromArray(
9552− [
9553− $lynxjs_elm$ui$Lynx$textContent('Generating keypair...')
9554− ])) : A2(
9555− $lynxjs_elm$ui$Lynx$view,
9556− _List_fromArray(
9557− [
9558− $lynxjs_elm$ui$Lynx$Attributes$backgroundColor('#161b22'),
9559− $lynxjs_elm$ui$Lynx$Attributes$borderRadius(8),
9560− $lynxjs_elm$ui$Lynx$Attributes$padding(12),
9561− $lynxjs_elm$ui$Lynx$Attributes$marginBottom(24)
9562− ]),
9563− _List_fromArray(
9564− [
9565− A2(
9566− $lynxjs_elm$ui$Lynx$text,
9567− _List_fromArray(
9568− [
9569− $lynxjs_elm$ui$Lynx$Attributes$color('#c9d1d9'),
9570− $lynxjs_elm$ui$Lynx$Attributes$fontSize(11)
9571− ]),
9572− _List_fromArray(
9573− [
9574− $lynxjs_elm$ui$Lynx$textContent(identityStr)
9575− ]))
9576− ])),
9577− $author$project$Main$fieldLabel('Relay server'),
9578− $author$project$Main$textInput(
9579− {onInput: $author$project$Main$UpdateServerUrl, placeholder: 'http://relay.example.com:8080', value: model.serverUrl}),
9580− $author$project$Main$spacer(24),
9581− $author$project$Main$fieldLabel('Display name (optional)'),
9582− $author$project$Main$textInput(
9583− {onInput: $author$project$Main$UpdateDisplayName, placeholder: 'Anonymous', value: model.displayName}),
9584− $author$project$Main$spacer(32),
9585− model.connecting ? $author$project$Main$disabledButton('Connecting...') : (_Utils_eq(model.edPriv, $elm$core$Maybe$Nothing) ? $author$project$Main$disabledButton('Generating keys...') : A2($author$project$Main$primaryButton, $author$project$Main$Connect, 'Connect to Server'))
9586− ]));
9587−};
9588−var $author$project$Main$view = function (model) {
9589− return A2(
9590− $lynxjs_elm$ui$Lynx$view,
9591− _List_fromArray(
9592− [
9593− $lynxjs_elm$ui$Lynx$Attributes$flexDirection($lynxjs_elm$ui$Lynx$Attributes$Column),
9594− $lynxjs_elm$ui$Lynx$Attributes$flex(1),
9595− $lynxjs_elm$ui$Lynx$Attributes$backgroundColor('#0d1117')
9596− ]),
9597− _List_fromArray(
9598− [
9599− function () {
9600− var _v0 = model.error;
9601− if (_v0.$ === 'Just') {
9602− var err = _v0.a;
9603− return $author$project$Main$errorBanner(err);
9604− } else {
9605− return A2($lynxjs_elm$ui$Lynx$view, _List_Nil, _List_Nil);
9606− }
9607− }(),
9608− function () {
9609− var _v1 = model.screen;
9610− switch (_v1.$) {
9611− case 'SetupScreen':
9612− return $author$project$Main$viewSetup(model);
9613− case 'ContactsScreen':
9614− return $author$project$Main$viewContacts(model);
9615− case 'ChatScreen':
9616− return $author$project$Main$viewChat(model);
9617− default:
9618− return $author$project$Main$viewAddContact(model);
9619− }
9620− }()
9621− ]));
9622−};
9623−var $author$project$Main$main = $lynxjs_elm$browser$Browser$element(
9624− {init: $author$project$Main$init, subscriptions: $author$project$Main$subscriptions, update: $author$project$Main$update, view: $author$project$Main$view});
9625−_Platform_export({'Main':{'init':$author$project$Main$main(
9626− $elm$json$Json$Decode$succeed(_Utils_Tuple0))(0)}});}(globalThis));
client/elm.jsondeleted−30
@@ -1,30 +0,0 @@
1−{
2− "type": "application",
3− "source-directories": [
4− "src"
5− ],
6− "elm-version": "0.19.1",
7− "dependencies": {
8− "direct": {
9− "elm/core": "1.0.5",
10− "elm/json": "1.1.4",
11− "elm/random": "1.0.0",
12− "elm/time": "1.0.0",
13− "lynxjs-elm/browser": "1.0.0",
14− "lynxjs-elm/crypto": "1.0.0",
15− "lynxjs-elm/http": "1.0.0",
16− "lynxjs-elm/ui": "1.0.0"
17− },
18− "indirect": {
19− "elm/bytes": "1.0.8",
20− "elm/file": "1.0.5",
21− "elm/url": "1.0.0",
22− "lynxjs-elm/bytes": "1.0.0",
23− "lynxjs-elm/virtual-dom": "1.0.0"
24− }
25− },
26− "test-dependencies": {
27− "direct": {},
28− "indirect": {}
29− }
30−}
client/src/Main.elmdeleted−1862
@@ -1,1862 +0,0 @@
1−module Main exposing (main)
2−
3−{-| Skrepka – Decentralized encrypted messaging client.
4−
5− Built with LynxJS-Elm for native iOS/Android.
6− Implements the Skrepka protocol v0.1 for E2E encrypted messaging.
7−
8− Crypto: Ed25519 signing, X25519 key agreement, XChaCha20-Poly1305 AEAD.
9− Uses raw X25519 shared secret as cipher key (HKDF not yet available).
10−
11−-}
12−
13−import Browser
14−import Crypto.Cipher as Cipher
15−import Crypto.Ed25519 as Ed
16−import Crypto.X25519 as X
17−import Dict exposing (Dict)
18−import Http
19−import Json.Decode as Decode exposing (Decoder)
20−import Json.Encode as Encode
21−import Lynx
22−import Lynx.Attributes as Attr
23− exposing
24− ( Alignment(..)
25− , FlexDirection(..)
26− , FontWeight(..)
27− , ScrollDirection(..)
28− , TextAlign(..)
29− )
30−import Lynx.Events exposing (onInput, onTap)
31−import Random
32−import Set exposing (Set)
33−import Time
34−
35−
36−
37−-- ============================================================================
38−-- MAIN
39−-- ============================================================================
40−
41−
42−main : Program () Model Msg
43−main =
44− Browser.element
45− { init = init
46− , view = view
47− , update = update
48− , subscriptions = subscriptions
49− }
50−
51−
52−
53−-- ============================================================================
54−-- MODEL
55−-- ============================================================================
56−
57−
58−type alias Model =
59− { edPriv : Maybe Ed.PrivateKey
60− , edPub : Maybe Ed.PublicKey
61− , xPriv : Maybe X.PrivateKey
62− , xPub : Maybe X.PublicKey
63− , serverUrl : String
64− , authToken : String
65− , screen : Screen
66− , contacts : Dict String Contact
67− , messages : Dict String (List ChatMessage)
68− , seenIds : Set String
69− , draft : String
70− , activeChat : String
71− , addIdentity : String
72− , addName : String
73− , displayName : String
74− , currentTime : Int
75− , error : Maybe String
76− , connecting : Bool
77− , sending : Bool
78− }
79−
80−
81−type Screen
82− = SetupScreen
83− | ContactsScreen
84− | ChatScreen
85− | AddContactScreen
86−
87−
88−type alias Contact =
89− { edPubHex : String
90− , xPubHex : String
91− , name : String
92− , lastMessage : String
93− , lastTime : Int
94− , online : Bool
95− }
96−
97−
98−type alias ChatMessage =
99− { id : String
100− , body : String
101− , timestamp : Int
102− , outgoing : Bool
103− }
104−
105−
106−
107−-- ============================================================================
108−-- INIT
109−-- ============================================================================
110−
111−
112−init : () -> ( Model, Cmd Msg )
113−init _ =
114− ( { edPriv = Nothing
115− , edPub = Nothing
116− , xPriv = Nothing
117− , xPub = Nothing
118− , serverUrl = "http://localhost:8080"
119− , authToken = ""
120− , screen = SetupScreen
121− , contacts = Dict.empty
122− , messages = Dict.empty
123− , seenIds = Set.empty
124− , draft = ""
125− , activeChat = ""
126− , addIdentity = ""
127− , addName = ""
128− , displayName = ""
129− , currentTime = 0
130− , error = Nothing
131− , connecting = False
132− , sending = False
133− }
134− , Random.generate GotSeed (randomHexString 32)
135− )
136−
137−
138−
139−-- ============================================================================
140−-- MSG
141−-- ============================================================================
142−
143−
144−type Msg
145− = GotSeed String
146− | GotTime Time.Posix
147− -- Auth
148− | Connect
149− | GotChallenge (Result Http.Error ChallengeResponse)
150− | GotVerify (Result Http.Error VerifyResponse)
151− -- Messaging
152− | PrepareSend
153− | GotSendEntropy String
154− | GotSendResult (Result Http.Error SendResponse)
155− -- Polling
156− | PollTick Time.Posix
157− | GotPoll (Result Http.Error PollResponse)
158− | GotAck (Result Http.Error Bool)
159− -- Contacts
160− | AddContact
161− | GotLookup (Result Http.Error LookupResponse)
162− -- Navigation
163− | Navigate Screen
164− | OpenChat String
165− -- Input
166− | UpdateDraft String
167− | UpdateServerUrl String
168− | UpdateAddIdentity String
169− | UpdateAddName String
170− | UpdateDisplayName String
171− -- Error
172− | DismissError
173−
174−
175−
176−-- ============================================================================
177−-- UPDATE
178−-- ============================================================================
179−
180−
181−update : Msg -> Model -> ( Model, Cmd Msg )
182−update msg model =
183− case msg of
184− GotSeed seedHex ->
185− let
186− edPriv =
187− Ed.privateKeyFromHex seedHex
188−
189− xPriv =
190− X.privateKeyFromHex seedHex
191− in
192− ( { model
193− | edPriv = edPriv
194− , edPub = Maybe.map Ed.getPublicKey edPriv
195− , xPriv = xPriv
196− , xPub = Maybe.map X.getPublicKey xPriv
197− }
198− , Cmd.none
199− )
200−
201− GotTime posix ->
202− ( { model | currentTime = Time.posixToMillis posix // 1000 }, Cmd.none )
203−
204− -- AUTH ----------------------------------------------------------------
205−
206− Connect ->
207− case model.edPub of
208− Just pub ->
209− ( { model | connecting = True, error = Nothing }
210− , requestChallenge model.serverUrl (Ed.publicKeyToHex pub)
211− )
212−
213− Nothing ->
214− ( { model | error = Just "No identity keypair" }, Cmd.none )
215−
216− GotChallenge result ->
217− case ( result, model.edPriv ) of
218− ( Ok resp, Just priv ) ->
219− let
220− sig =
221− Ed.sign resp.challenge priv
222− in
223− ( model
224− , verifyAuth model.serverUrl
225− (Maybe.withDefault "" (Maybe.map Ed.publicKeyToHex model.edPub))
226− resp.challenge
227− (Ed.signatureToHex sig)
228− )
229−
230− ( Err err, _ ) ->
231− ( { model | connecting = False, error = Just (httpErrorToString err) }
232− , Cmd.none
233− )
234−
235− _ ->
236− ( { model | connecting = False }, Cmd.none )
237−
238− GotVerify result ->
239− case result of
240− Ok resp ->
241− if resp.token /= "" then
242− ( { model
243− | authToken = resp.token
244− , connecting = False
245− , screen = ContactsScreen
246− }
247− , Cmd.none
248− )
249−
250− else
251− ( { model
252− | connecting = False
253− , error = Just "Authentication failed"
254− }
255− , Cmd.none
256− )
257−
258− Err err ->
259− ( { model | connecting = False, error = Just (httpErrorToString err) }
260− , Cmd.none
261− )
262−
263− -- MESSAGING -----------------------------------------------------------
264−
265− PrepareSend ->
266− if String.isEmpty (String.trim model.draft) then
267− ( model, Cmd.none )
268−
269− else
270− ( { model | sending = True }
271− -- 16 bytes msg ID + 32 bytes ephemeral X25519 key + 24 bytes nonce = 72
272− , Random.generate GotSendEntropy (randomHexString 72)
273− )
274−
275− GotSendEntropy entropyHex ->
276− case ( model.edPriv, model.edPub ) of
277− ( Just edPriv, Just edPub ) ->
278− let
279− recipientEdHex =
280− model.activeChat
281−
282− contact =
283− Dict.get recipientEdHex model.contacts
284− in
285− case contact of
286− Just c ->
287− let
288− msgId =
289− String.left 32 entropyHex
290−
291− ephSeedHex =
292− String.slice 32 96 entropyHex
293−
294− nonceHex =
295− String.slice 96 144 entropyHex
296−
297− timestamp =
298− model.currentTime
299−
300− plaintextJson =
301− Encode.encode 0
302− (Encode.object
303− [ ( "type", Encode.string "text" )
304− , ( "id", Encode.string msgId )
305− , ( "timestamp", Encode.int timestamp )
306− , ( "body", Encode.string model.draft )
307− ]
308− )
309−
310− blob =
311− encryptMessage edPriv edPub c.xPubHex ephSeedHex nonceHex plaintextJson
312−
313− outMsg =
314− { id = msgId
315− , body = model.draft
316− , timestamp = timestamp
317− , outgoing = True
318− }
319−
320− existing =
321− Dict.get recipientEdHex model.messages
322− |> Maybe.withDefault []
323− in
324− case blob of
325− Just blobHex ->
326− ( { model
327− | draft = ""
328− , messages =
329− Dict.insert recipientEdHex
330− (existing ++ [ outMsg ])
331− model.messages
332− }
333− , sendMessageHttp model.serverUrl
334− model.authToken
335− recipientEdHex
336− blobHex
337− timestamp
338− )
339−
340− Nothing ->
341− ( { model
342− | sending = False
343− , error = Just "Encryption failed"
344− }
345− , Cmd.none
346− )
347−
348− Nothing ->
349− ( { model | sending = False, error = Just "Contact not found" }
350− , Cmd.none
351− )
352−
353− _ ->
354− ( { model | sending = False, error = Just "No identity" }, Cmd.none )
355−
356− GotSendResult result ->
357− case result of
358− Ok _ ->
359− ( { model | sending = False }, Cmd.none )
360−
361− Err err ->
362− ( { model | sending = False, error = Just (httpErrorToString err) }
363− , Cmd.none
364− )
365−
366− -- POLLING -------------------------------------------------------------
367−
368− PollTick posix ->
369− let
370− t =
371− Time.posixToMillis posix // 1000
372− in
373− if model.authToken /= "" then
374− ( { model | currentTime = t }
375− , pollMessages model.serverUrl model.authToken
376− )
377−
378− else
379− ( { model | currentTime = t }, Cmd.none )
380−
381− GotPoll result ->
382− case ( result, model.xPriv, model.edPub ) of
383− ( Ok resp, Just xPriv, Just edPub ) ->
384− let
385− myEdHex =
386− Ed.publicKeyToHex edPub
387−
388− ( newMessages, newSeenIds, ackIds ) =
389− processEvents xPriv myEdHex resp.events model.seenIds
390−
391− updatedMessages =
392− List.foldl
393− (\( from, chatMsg ) dict ->
394− let
395− existing =
396− Dict.get from dict |> Maybe.withDefault []
397− in
398− Dict.insert from (existing ++ [ chatMsg ]) dict
399− )
400− model.messages
401− newMessages
402−
403− updatedContacts =
404− List.foldl
405− (\( from, chatMsg ) dict ->
406− Dict.update from
407− (\existing ->
408− case existing of
409− Just c ->
410− Just
411− { c
412− | lastMessage = chatMsg.body
413− , lastTime = chatMsg.timestamp
414− }
415−
416− Nothing ->
417− Just
418− { edPubHex = from
419− , xPubHex = ""
420− , name = String.left 8 from ++ "..."
421− , lastMessage = chatMsg.body
422− , lastTime = chatMsg.timestamp
423− , online = True
424− }
425− )
426− dict
427− )
428− model.contacts
429− newMessages
430−
431− ackCmd =
432− if List.isEmpty ackIds then
433− Cmd.none
434−
435− else
436− ackMessages model.serverUrl model.authToken ackIds
437− in
438− ( { model
439− | messages = updatedMessages
440− , seenIds = newSeenIds
441− , contacts = updatedContacts
442− }
443− , ackCmd
444− )
445−
446− ( Err (Http.BadStatus 401), _, _ ) ->
447− ( { model
448− | authToken = ""
449− , screen = SetupScreen
450− , error = Just "Session expired, please reconnect"
451− }
452− , Cmd.none
453− )
454−
455− _ ->
456− ( model, Cmd.none )
457−
458− GotAck _ ->
459− ( model, Cmd.none )
460−
461− -- CONTACTS ------------------------------------------------------------
462−
463− AddContact ->
464− if String.isEmpty model.addIdentity then
465− ( model, Cmd.none )
466−
467− else
468− case parseIdentity model.addIdentity of
469− Just ( edHex, xHex ) ->
470− let
471− name =
472− if String.isEmpty model.addName then
473− String.left 8 edHex ++ "..."
474−
475− else
476− model.addName
477−
478− contact =
479− { edPubHex = edHex
480− , xPubHex = xHex
481− , name = name
482− , lastMessage = ""
483− , lastTime = 0
484− , online = False
485− }
486− in
487− ( { model
488− | contacts = Dict.insert edHex contact model.contacts
489− , addIdentity = ""
490− , addName = ""
491− , screen = ContactsScreen
492− }
493− , lookupPresence model.serverUrl edHex
494− )
495−
496− Nothing ->
497− ( { model | error = Just "Invalid identity. Expected: <ed25519_hex>.<x25519_hex>" }
498− , Cmd.none
499− )
500−
501− GotLookup result ->
502− case result of
503− Ok resp ->
504− ( { model
505− | contacts =
506− Dict.update resp.pubkey
507− (Maybe.map (\c -> { c | online = resp.online }))
508− model.contacts
509− }
510− , Cmd.none
511− )
512−
513− Err _ ->
514− ( model, Cmd.none )
515−
516− -- NAVIGATION ----------------------------------------------------------
517−
518− Navigate screen ->
519− ( { model | screen = screen }, Cmd.none )
520−
521− OpenChat edPubHex ->
522− ( { model | screen = ChatScreen, activeChat = edPubHex, draft = "" }, Cmd.none )
523−
524− -- INPUT ---------------------------------------------------------------
525−
526− UpdateDraft text ->
527− ( { model | draft = text }, Cmd.none )
528−
529− UpdateServerUrl text ->
530− ( { model | serverUrl = text }, Cmd.none )
531−
532− UpdateAddIdentity text ->
533− ( { model | addIdentity = text }, Cmd.none )
534−
535− UpdateAddName text ->
536− ( { model | addName = text }, Cmd.none )
537−
538− UpdateDisplayName text ->
539− ( { model | displayName = text }, Cmd.none )
540−
541− -- ERROR ---------------------------------------------------------------
542−
543− DismissError ->
544− ( { model | error = Nothing }, Cmd.none )
545−
546−
547−
548−-- ============================================================================
549−-- SUBSCRIPTIONS
550−-- ============================================================================
551−
552−
553−subscriptions : Model -> Sub Msg
554−subscriptions model =
555− Sub.batch
556− [ if model.authToken /= "" then
557− Time.every 3000 PollTick
558−
559− else
560− Sub.none
561− , Time.every 60000 GotTime
562− ]
563−
564−
565−
566−-- ============================================================================
567−-- VIEW
568−-- ============================================================================
569−
570−
571−view : Model -> Lynx.Node Msg
572−view model =
573− Lynx.view
574− [ Attr.flexDirection Column
575− , Attr.flex 1
576− , Attr.backgroundColor "#0d1117"
577− ]
578− [ case model.error of
579− Just err ->
580− errorBanner err
581−
582− Nothing ->
583− Lynx.view [] []
584− , case model.screen of
585− SetupScreen ->
586− viewSetup model
587−
588− ContactsScreen ->
589− viewContacts model
590−
591− ChatScreen ->
592− viewChat model
593−
594− AddContactScreen ->
595− viewAddContact model
596− ]
597−
598−
599−
600−-- Error Banner ----------------------------------------------------------------
601−
602−
603−errorBanner : String -> Lynx.Node Msg
604−errorBanner err =
605− Lynx.view
606− [ onTap DismissError
607− , Attr.backgroundColor "#3d1114"
608− , Attr.paddingTop 10
609− , Attr.paddingBottom 10
610− , Attr.paddingLeft 16
611− , Attr.paddingRight 16
612− , Attr.flexDirection Row
613− , Attr.alignItems Center
614− ]
615− [ Lynx.text [ Attr.color "#f85149", Attr.fontSize 14, Attr.flex 1 ]
616− [ Lynx.textContent err ]
617− , Lynx.text [ Attr.color "#f85149", Attr.fontSize 16 ]
618− [ Lynx.textContent "x" ]
619− ]
620−
621−
622−
623−-- Setup Screen ----------------------------------------------------------------
624−
625−
626−viewSetup : Model -> Lynx.Node Msg
627−viewSetup model =
628− let
629− identityStr =
630− case ( model.edPub, model.xPub ) of
631− ( Just edP, Just xP ) ->
632− Ed.publicKeyToHex edP ++ "." ++ X.publicKeyToHex xP
633−
634− _ ->
635− ""
636− in
637− Lynx.view
638− [ Attr.flexDirection Column
639− , Attr.flex 1
640− , Attr.padding 24
641− , Attr.justifyContent Center
642− ]
643− [ Lynx.text
644− [ Attr.fontSize 36
645− , Attr.fontWeight Bold
646− , Attr.color "#58a6ff"
647− , Attr.textAlign TextCenter
648− , Attr.marginBottom 8
649− ]
650− [ Lynx.textContent "Skrepka" ]
651− , Lynx.text
652− [ Attr.fontSize 14
653− , Attr.color "#8b949e"
654− , Attr.textAlign TextCenter
655− , Attr.marginBottom 32
656− ]
657− [ Lynx.textContent "Encrypted messaging, no accounts" ]
658−
659− -- Identity
660− , fieldLabel "Your identity (share this with contacts)"
661− , if String.isEmpty identityStr then
662− Lynx.text
663− [ Attr.color "#8b949e"
664− , Attr.fontSize 14
665− , Attr.marginBottom 24
666− ]
667− [ Lynx.textContent "Generating keypair..." ]
668−
669− else
670− Lynx.view
671− [ Attr.backgroundColor "#161b22"
672− , Attr.borderRadius 8
673− , Attr.padding 12
674− , Attr.marginBottom 24
675− ]
676− [ Lynx.text [ Attr.color "#c9d1d9", Attr.fontSize 11 ]
677− [ Lynx.textContent identityStr ]
678− ]
679−
680− -- Server URL
681− , fieldLabel "Relay server"
682− , textInput
683− { onInput = UpdateServerUrl
684− , value = model.serverUrl
685− , placeholder = "http://relay.example.com:8080"
686− }
687− , spacer 24
688−
689− -- Display name
690− , fieldLabel "Display name (optional)"
691− , textInput
692− { onInput = UpdateDisplayName
693− , value = model.displayName
694− , placeholder = "Anonymous"
695− }
696− , spacer 32
697−
698− -- Connect button
699− , if model.connecting then
700− disabledButton "Connecting..."
701−
702− else if model.edPriv == Nothing then
703− disabledButton "Generating keys..."
704−
705− else
706− primaryButton Connect "Connect to Server"
707− ]
708−
709−
710−
711−-- Contacts Screen -------------------------------------------------------------
712−
713−
714−viewContacts : Model -> Lynx.Node Msg
715−viewContacts model =
716− Lynx.view
717− [ Attr.flexDirection Column, Attr.flex 1 ]
718− [ -- Header
719− Lynx.view
720− [ Attr.flexDirection Row
721− , Attr.alignItems Center
722− , Attr.paddingTop 16
723− , Attr.paddingBottom 16
724− , Attr.paddingLeft 20
725− , Attr.paddingRight 20
726− , Attr.backgroundColor "#161b22"
727− ]
728− [ Lynx.text
729− [ Attr.fontSize 20
730− , Attr.fontWeight Bold
731− , Attr.color "#c9d1d9"
732− , Attr.flex 1
733− ]
734− [ Lynx.textContent "Messages" ]
735− , Lynx.view
736− [ onTap (Navigate AddContactScreen)
737− , Attr.paddingLeft 12
738− , Attr.paddingRight 4
739− ]
740− [ Lynx.text [ Attr.color "#58a6ff", Attr.fontSize 28 ]
741− [ Lynx.textContent "+" ]
742− ]
743− ]
744−
745− -- Conversation list
746− , if Dict.isEmpty model.contacts then
747− Lynx.view
748− [ Attr.flex 1
749− , Attr.alignItems Center
750− , Attr.justifyContent Center
751− , Attr.padding 40
752− ]
753− [ Lynx.text
754− [ Attr.color "#8b949e"
755− , Attr.fontSize 16
756− , Attr.textAlign TextCenter
757− ]
758− [ Lynx.textContent "No conversations yet" ]
759− , spacer 8
760− , Lynx.text
761− [ Attr.color "#8b949e"
762− , Attr.fontSize 14
763− , Attr.textAlign TextCenter
764− ]
765− [ Lynx.textContent "Tap + to add a contact" ]
766− ]
767−
768− else
769− Lynx.scrollView
770− [ Attr.flex 1, Attr.scrollDirection Vertical ]
771− (Dict.values model.contacts
772− |> List.sortBy (\c -> negate c.lastTime)
773− |> List.map viewContactRow
774− )
775−
776− -- Identity bar
777− , case model.edPub of
778− Just pub ->
779− Lynx.view
780− [ Attr.paddingTop 10
781− , Attr.paddingBottom 10
782− , Attr.paddingLeft 20
783− , Attr.paddingRight 20
784− , Attr.backgroundColor "#161b22"
785− , Attr.flexDirection Row
786− , Attr.alignItems Center
787− ]
788− [ Lynx.view
789− [ Attr.width 8
790− , Attr.height 8
791− , Attr.borderRadius 4
792− , Attr.backgroundColor "#3fb950"
793− , Attr.marginRight 8
794− ]
795− []
796− , Lynx.text [ Attr.color "#8b949e", Attr.fontSize 11, Attr.flex 1 ]
797− [ Lynx.textContent (truncateKey (Ed.publicKeyToHex pub)) ]
798− ]
799−
800− Nothing ->
801− Lynx.view [] []
802− ]
803−
804−
805−viewContactRow : Contact -> Lynx.Node Msg
806−viewContactRow contact =
807− Lynx.view
808− [ onTap (OpenChat contact.edPubHex)
809− , Attr.flexDirection Row
810− , Attr.alignItems Center
811− , Attr.paddingTop 14
812− , Attr.paddingBottom 14
813− , Attr.paddingLeft 20
814− , Attr.paddingRight 20
815− , Attr.borderColor "#21262d"
816− , Attr.borderWidth 1
817− ]
818− [ -- Avatar
819− Lynx.view
820− [ Attr.width 44
821− , Attr.height 44
822− , Attr.borderRadius 22
823− , Attr.backgroundColor "#30363d"
824− , Attr.alignItems Center
825− , Attr.justifyContent Center
826− , Attr.marginRight 12
827− ]
828− [ Lynx.text [ Attr.color "#58a6ff", Attr.fontSize 18, Attr.fontWeight Bold ]
829− [ Lynx.textContent (String.left 1 contact.name |> String.toUpper) ]
830− ]
831−
832− -- Name + last message
833− , Lynx.view [ Attr.flex 1, Attr.flexDirection Column ]
834− [ Lynx.view [ Attr.flexDirection Row, Attr.alignItems Center ]
835− [ Lynx.text
836− [ Attr.color "#c9d1d9"
837− , Attr.fontSize 16
838− , Attr.fontWeight Bold
839− , Attr.flex 1
840− ]
841− [ Lynx.textContent contact.name ]
842− , if contact.online then
843− Lynx.view
844− [ Attr.width 8
845− , Attr.height 8
846− , Attr.borderRadius 4
847− , Attr.backgroundColor "#3fb950"
848− , Attr.marginLeft 8
849− ]
850− []
851−
852− else
853− Lynx.view [] []
854− ]
855− , if String.isEmpty contact.lastMessage then
856− Lynx.view [] []
857−
858− else
859− Lynx.text
860− [ Attr.color "#8b949e"
861− , Attr.fontSize 14
862− , Attr.marginTop 2
863− ]
864− [ Lynx.textContent (String.left 50 contact.lastMessage) ]
865− ]
866− ]
867−
868−
869−
870−-- Chat Screen -----------------------------------------------------------------
871−
872−
873−viewChat : Model -> Lynx.Node Msg
874−viewChat model =
875− let
876− contactName =
877− Dict.get model.activeChat model.contacts
878− |> Maybe.map .name
879− |> Maybe.withDefault (truncateKey model.activeChat)
880−
881− chatMessages =
882− Dict.get model.activeChat model.messages
883− |> Maybe.withDefault []
884− in
885− Lynx.view
886− [ Attr.flexDirection Column, Attr.flex 1 ]
887− [ -- Header
888− Lynx.view
889− [ Attr.flexDirection Row
890− , Attr.alignItems Center
891− , Attr.paddingTop 12
892− , Attr.paddingBottom 12
893− , Attr.paddingLeft 16
894− , Attr.paddingRight 16
895− , Attr.backgroundColor "#161b22"
896− ]
897− [ Lynx.view
898− [ onTap (Navigate ContactsScreen)
899− , Attr.paddingRight 12
900− , Attr.paddingTop 4
901− , Attr.paddingBottom 4
902− ]
903− [ Lynx.text [ Attr.color "#58a6ff", Attr.fontSize 20 ]
904− [ Lynx.textContent "<" ]
905− ]
906− , Lynx.text
907− [ Attr.color "#c9d1d9"
908− , Attr.fontSize 18
909− , Attr.fontWeight Bold
910− , Attr.flex 1
911− ]
912− [ Lynx.textContent contactName ]
913− ]
914−
915− -- Messages
916− , Lynx.scrollView
917− [ Attr.flex 1
918− , Attr.scrollDirection Vertical
919− , Attr.paddingTop 12
920− , Attr.paddingBottom 12
921− , Attr.paddingLeft 16
922− , Attr.paddingRight 16
923− ]
924− (if List.isEmpty chatMessages then
925− [ Lynx.view
926− [ Attr.alignItems Center
927− , Attr.paddingTop 80
928− ]
929− [ Lynx.text
930− [ Attr.color "#8b949e"
931− , Attr.fontSize 14
932− , Attr.textAlign TextCenter
933− ]
934− [ Lynx.textContent "No messages yet. Say hello!" ]
935− ]
936− ]
937−
938− else
939− List.map viewMessage chatMessages
940− )
941−
942− -- Input bar
943− , Lynx.view
944− [ Attr.flexDirection Row
945− , Attr.alignItems Center
946− , Attr.paddingTop 8
947− , Attr.paddingBottom 8
948− , Attr.paddingLeft 12
949− , Attr.paddingRight 12
950− , Attr.backgroundColor "#161b22"
951− ]
952− [ Lynx.input
953− [ onInput UpdateDraft
954− , Attr.value model.draft
955− , Attr.placeholder "Message..."
956− , Attr.flex 1
957− , Attr.height 40
958− , Attr.fontSize 16
959− , Attr.color "#c9d1d9"
960− , Attr.backgroundColor "#0d1117"
961− , Attr.borderWidth 1
962− , Attr.borderColor "#30363d"
963− , Attr.borderRadius 20
964− , Attr.paddingLeft 16
965− ]
966− []
967− , Lynx.view
968− [ onTap PrepareSend
969− , Attr.marginLeft 8
970− , Attr.backgroundColor
971− (if model.sending || String.isEmpty (String.trim model.draft) then
972− "#30363d"
973−
974− else
975− "#58a6ff"
976− )
977− , Attr.borderRadius 20
978− , Attr.width 40
979− , Attr.height 40
980− , Attr.alignItems Center
981− , Attr.justifyContent Center
982− ]
983− [ Lynx.text [ Attr.color "#ffffff", Attr.fontSize 16, Attr.fontWeight Bold ]
984− [ Lynx.textContent "^" ]
985− ]
986− ]
987− ]
988−
989−
990−viewMessage : ChatMessage -> Lynx.Node Msg
991−viewMessage chatMsg =
992− Lynx.view
993− [ Attr.flexDirection Row
994− , Attr.justifyContent
995− (if chatMsg.outgoing then
996− End
997−
998− else
999− Start
1000− )
1001− , Attr.marginBottom 6
1002− ]
1003− [ Lynx.view
1004− [ Attr.backgroundColor
1005− (if chatMsg.outgoing then
1006− "#1a3a5c"
1007−
1008− else
1009− "#21262d"
1010− )
1011− , Attr.borderRadius 16
1012− , Attr.paddingTop 10
1013− , Attr.paddingBottom 10
1014− , Attr.paddingLeft 14
1015− , Attr.paddingRight 14
1016− , Attr.maxWidth 280
1017− ]
1018− [ Lynx.text [ Attr.color "#c9d1d9", Attr.fontSize 15 ]
1019− [ Lynx.textContent chatMsg.body ]
1020− ]
1021− ]
1022−
1023−
1024−
1025−-- Add Contact Screen ----------------------------------------------------------
1026−
1027−
1028−viewAddContact : Model -> Lynx.Node Msg
1029−viewAddContact model =
1030− Lynx.view
1031− [ Attr.flexDirection Column, Attr.flex 1 ]
1032− [ -- Header
1033− Lynx.view
1034− [ Attr.flexDirection Row
1035− , Attr.alignItems Center
1036− , Attr.paddingTop 12
1037− , Attr.paddingBottom 12
1038− , Attr.paddingLeft 16
1039− , Attr.paddingRight 16
1040− , Attr.backgroundColor "#161b22"
1041− ]
1042− [ Lynx.view
1043− [ onTap (Navigate ContactsScreen)
1044− , Attr.paddingRight 12
1045− , Attr.paddingTop 4
1046− , Attr.paddingBottom 4
1047− ]
1048− [ Lynx.text [ Attr.color "#58a6ff", Attr.fontSize 20 ]
1049− [ Lynx.textContent "<" ]
1050− ]
1051− , Lynx.text
1052− [ Attr.color "#c9d1d9"
1053− , Attr.fontSize 18
1054− , Attr.fontWeight Bold
1055− ]
1056− [ Lynx.textContent "Add Contact" ]
1057− ]
1058−
1059− -- Form
1060− , Lynx.view
1061− [ Attr.padding 24, Attr.flexDirection Column ]
1062− [ fieldLabel "Identity string"
1063− , Lynx.text
1064− [ Attr.color "#6e7681"
1065− , Attr.fontSize 12
1066− , Attr.marginBottom 8
1067− ]
1068− [ Lynx.textContent "Paste the <ed25519>.<x25519> identity from your contact" ]
1069− , textInput
1070− { onInput = UpdateAddIdentity
1071− , value = model.addIdentity
1072− , placeholder = "abc123...def456.ghi789...jkl012"
1073− }
1074− , spacer 20
1075− , fieldLabel "Name"
1076− , textInput
1077− { onInput = UpdateAddName
1078− , value = model.addName
1079− , placeholder = "Display name (optional)"
1080− }
1081− , spacer 32
1082− , primaryButton AddContact "Add Contact"
1083− ]
1084− ]
1085−
1086−
1087−
1088−-- ============================================================================
1089−-- UI COMPONENTS
1090−-- ============================================================================
1091−
1092−
1093−fieldLabel : String -> Lynx.Node msg
1094−fieldLabel text =
1095− Lynx.text
1096− [ Attr.color "#8b949e"
1097− , Attr.fontSize 13
1098− , Attr.marginBottom 6
1099− ]
1100− [ Lynx.textContent text ]
1101−
1102−
1103−textInput : { onInput : String -> msg, value : String, placeholder : String } -> Lynx.Node msg
1104−textInput config =
1105− Lynx.input
1106− [ onInput config.onInput
1107− , Attr.value config.value
1108− , Attr.placeholder config.placeholder
1109− , Attr.height 44
1110− , Attr.fontSize 16
1111− , Attr.color "#c9d1d9"
1112− , Attr.backgroundColor "#161b22"
1113− , Attr.borderWidth 1
1114− , Attr.borderColor "#30363d"
1115− , Attr.borderRadius 8
1116− , Attr.paddingLeft 12
1117− ]
1118− []
1119−
1120−
1121−primaryButton : msg -> String -> Lynx.Node msg
1122−primaryButton msg text =
1123− Lynx.view
1124− [ onTap msg
1125− , Attr.backgroundColor "#58a6ff"
1126− , Attr.borderRadius 8
1127− , Attr.paddingTop 14
1128− , Attr.paddingBottom 14
1129− , Attr.alignItems Center
1130− ]
1131− [ Lynx.text [ Attr.color "#ffffff", Attr.fontSize 16, Attr.fontWeight Bold ]
1132− [ Lynx.textContent text ]
1133− ]
1134−
1135−
1136−disabledButton : String -> Lynx.Node msg
1137−disabledButton text =
1138− Lynx.view
1139− [ Attr.backgroundColor "#30363d"
1140− , Attr.borderRadius 8
1141− , Attr.paddingTop 14
1142− , Attr.paddingBottom 14
1143− , Attr.alignItems Center
1144− ]
1145− [ Lynx.text [ Attr.color "#8b949e", Attr.fontSize 16 ]
1146− [ Lynx.textContent text ]
1147− ]
1148−
1149−
1150−spacer : Int -> Lynx.Node msg
1151−spacer px =
1152− Lynx.view [ Attr.height px ] []
1153−
1154−
1155−
1156−-- ============================================================================
1157−-- HTTP API
1158−-- ============================================================================
1159−
1160−
1161−requestChallenge : String -> String -> Cmd Msg
1162−requestChallenge serverUrl pubkey =
1163− Http.post
1164− { url = serverUrl ++ "/auth/challenge"
1165− , body =
1166− Http.jsonBody
1167− (Encode.object [ ( "pubkey", Encode.string pubkey ) ])
1168− , expect = Http.expectJson GotChallenge challengeDecoder
1169− }
1170−
1171−
1172−verifyAuth : String -> String -> String -> String -> Cmd Msg
1173−verifyAuth serverUrl pubkey challenge signature =
1174− Http.post
1175− { url = serverUrl ++ "/auth/verify"
1176− , body =
1177− Http.jsonBody
1178− (Encode.object
1179− [ ( "pubkey", Encode.string pubkey )
1180− , ( "challenge", Encode.string challenge )
1181− , ( "signature", Encode.string signature )
1182− ]
1183− )
1184− , expect = Http.expectJson GotVerify verifyDecoder
1185− }
1186−
1187−
1188−pollMessages : String -> String -> Cmd Msg
1189−pollMessages serverUrl token =
1190− Http.request
1191− { method = "GET"
1192− , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
1193− , url = serverUrl ++ "/poll"
1194− , body = Http.emptyBody
1195− , expect = Http.expectJson GotPoll pollDecoder
1196− , timeout = Just 10000
1197− , tracker = Nothing
1198− }
1199−
1200−
1201−sendMessageHttp : String -> String -> String -> String -> Int -> Cmd Msg
1202−sendMessageHttp serverUrl token to encryptedBlob timestamp =
1203− Http.request
1204− { method = "POST"
1205− , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
1206− , url = serverUrl ++ "/messages/"
1207− , body =
1208− Http.jsonBody
1209− (Encode.object
1210− [ ( "to", Encode.string to )
1211− , ( "encryptedBlob", Encode.string encryptedBlob )
1212− , ( "timestamp", Encode.int timestamp )
1213− ]
1214− )
1215− , expect = Http.expectJson GotSendResult sendDecoder
1216− , timeout = Just 10000
1217− , tracker = Nothing
1218− }
1219−
1220−
1221−ackMessages : String -> String -> List String -> Cmd Msg
1222−ackMessages serverUrl token messageIds =
1223− Http.request
1224− { method = "POST"
1225− , headers = [ Http.header "Authorization" ("Bearer " ++ token) ]
1226− , url = serverUrl ++ "/messages/ack"
1227− , body =
1228− Http.jsonBody
1229− (Encode.object
1230− [ ( "messageIds", Encode.list Encode.string messageIds ) ]
1231− )
1232− , expect = Http.expectJson GotAck (Decode.field "ok" Decode.bool)
1233− , timeout = Just 5000
1234− , tracker = Nothing
1235− }
1236−
1237−
1238−lookupPresence : String -> String -> Cmd Msg
1239−lookupPresence serverUrl pubkey =
1240− Http.get
1241− { url = serverUrl ++ "/lookup/" ++ pubkey
1242− , expect = Http.expectJson GotLookup (lookupDecoder pubkey)
1243− }
1244−
1245−
1246−
1247−-- ============================================================================
1248−-- PROTOCOL / CRYPTO
1249−-- ============================================================================
1250−
1251−
1252−{-| Encrypt a message per Skrepka protocol section 3.
1253−
1254−Uses X25519 ECDH for key agreement and XChaCha20-Poly1305 for encryption.
1255−The raw X25519 shared secret is used directly as the cipher key (HKDF
1256−not available; shared secret is already 32 uniformly random bytes).
1257−
1258−Blob format (hex):
1259−ephemeral\_x25519\_pub (64 hex) ++ nonce (48 hex)
1260−++ ciphertext (var) ++ sender\_ed25519\_pub (64 hex) ++ signature (128 hex)
1261−
1262−-}
1263−encryptMessage :
1264− Ed.PrivateKey
1265− -> Ed.PublicKey
1266− -> String
1267− -> String
1268− -> String
1269− -> String
1270− -> Maybe String
1271−encryptMessage edPriv edPub recipientXPubHex ephSeedHex nonceHex plaintextJson =
1272− case ( X.privateKeyFromHex ephSeedHex, X.publicKeyFromHex recipientXPubHex, Cipher.nonce24FromHex nonceHex ) of
1273− ( Just ephPriv, Just recipientXPub, Just nonce ) ->
1274− let
1275− ephPub =
1276− X.getPublicKey ephPriv
1277−
1278− ephPubHex =
1279− X.publicKeyToHex ephPub
1280−
1281− -- X25519 key agreement
1282− sharedSecretHex =
1283− X.getSharedSecret ephPriv recipientXPub
1284− in
1285− case Cipher.keyFromHex sharedSecretHex of
1286− Just key ->
1287− let
1288− -- Encrypt plaintext
1289− plaintextHex =
1290− textToHex plaintextJson
1291−
1292− ciphertextHex =
1293− Cipher.xchacha20Encrypt key nonce plaintextHex
1294−
1295− -- Sign the ciphertext
1296− sig =
1297− Ed.sign ciphertextHex edPriv
1298−
1299− senderPubHex =
1300− Ed.publicKeyToHex edPub
1301−
1302− sigHex =
1303− Ed.signatureToHex sig
1304− in
1305− Just
1306− (ephPubHex
1307− ++ nonceHex
1308− ++ ciphertextHex
1309− ++ senderPubHex
1310− ++ sigHex
1311− )
1312−
1313− Nothing ->
1314− Nothing
1315−
1316− _ ->
1317− Nothing
1318−
1319−
1320−{-| Decrypt a message per Skrepka protocol section 3.
1321−Returns (senderEdPubHex, plaintextJson) on success.
1322−-}
1323−decryptMessage : X.PrivateKey -> String -> Maybe ( String, String )
1324−decryptMessage xPriv blob =
1325− let
1326− hexLen =
1327− String.length blob
1328− in
1329− if hexLen < 304 then
1330− -- Minimum: 64 + 48 + 0 + 64 + 128 = 304 hex chars
1331− Nothing
1332−
1333− else
1334− let
1335− ephPubHex =
1336− String.slice 0 64 blob
1337−
1338− nonceHex =
1339− String.slice 64 112 blob
1340−
1341− ciphertextHex =
1342− String.slice 112 (hexLen - 192) blob
1343−
1344− senderEdPubHex =
1345− String.slice (hexLen - 192) (hexLen - 128) blob
1346−
1347− sigHex =
1348− String.slice (hexLen - 128) hexLen blob
1349− in
1350− case ( Ed.publicKeyFromHex senderEdPubHex, Ed.signatureFromHex sigHex ) of
1351− ( Just senderEdPub, Just sig ) ->
1352− -- Verify Ed25519 signature on ciphertext
1353− if not (Ed.verify sig ciphertextHex senderEdPub) then
1354− Nothing
1355−
1356− else
1357− case ( X.publicKeyFromHex ephPubHex, Cipher.nonce24FromHex nonceHex ) of
1358− ( Just ephPub, Just nonce ) ->
1359− let
1360− sharedSecretHex =
1361− X.getSharedSecret xPriv ephPub
1362− in
1363− case Cipher.keyFromHex sharedSecretHex of
1364− Just key ->
1365− case Cipher.xchacha20Decrypt key nonce ciphertextHex of
1366− Just plaintextHex ->
1367− Just ( senderEdPubHex, hexToText plaintextHex )
1368−
1369− Nothing ->
1370− Nothing
1371−
1372− Nothing ->
1373− Nothing
1374−
1375− _ ->
1376− Nothing
1377−
1378− _ ->
1379− Nothing
1380−
1381−
1382−{-| Process poll events: decrypt, dedup, collect ACK IDs.
1383−-}
1384−processEvents :
1385− X.PrivateKey
1386− -> String
1387− -> List PollEvent
1388− -> Set String
1389− -> ( List ( String, ChatMessage ), Set String, List String )
1390−processEvents xPriv myEdHex events seenIds =
1391− List.foldl
1392− (\event ( msgs, seen, acks ) ->
1393− if event.eventType /= "message" then
1394− ( msgs, seen, acks )
1395−
1396− else
1397− case Decode.decodeString envelopeDecoder event.payload of
1398− Err _ ->
1399− ( msgs, seen, event.id :: acks )
1400−
1401− Ok envelope ->
1402− if envelope.fromKey == myEdHex then
1403− -- Skip our own messages
1404− ( msgs, seen, event.id :: acks )
1405−
1406− else
1407− case decryptMessage xPriv envelope.encryptedBlob of
1408− Nothing ->
1409− ( msgs, seen, event.id :: acks )
1410−
1411− Just ( senderPub, json ) ->
1412− case Decode.decodeString messagePayloadDecoder json of
1413− Err _ ->
1414− ( msgs, seen, event.id :: acks )
1415−
1416− Ok payload ->
1417− if Set.member payload.id seen then
1418− ( msgs, seen, event.id :: acks )
1419−
1420− else
1421− ( ( senderPub
1422− , { id = payload.id
1423− , body = payload.body
1424− , timestamp = payload.timestamp
1425− , outgoing = False
1426− }
1427− )
1428− :: msgs
1429− , Set.insert payload.id seen
1430− , event.id :: acks
1431− )
1432− )
1433− ( [], seenIds, [] )
1434− events
1435− |> (\( msgs, seen, acks ) -> ( List.reverse msgs, seen, acks ))
1436−
1437−
1438−
1439−-- ============================================================================
1440−-- JSON DECODERS
1441−-- ============================================================================
1442−
1443−
1444−type alias ChallengeResponse =
1445− { challenge : String }
1446−
1447−
1448−challengeDecoder : Decoder ChallengeResponse
1449−challengeDecoder =
1450− Decode.map ChallengeResponse
1451− (Decode.field "challenge" Decode.string)
1452−
1453−
1454−type alias VerifyResponse =
1455− { token : String, expiresAt : Int }
1456−
1457−
1458−verifyDecoder : Decoder VerifyResponse
1459−verifyDecoder =
1460− Decode.map2 VerifyResponse
1461− (Decode.field "token" Decode.string)
1462− (Decode.field "expiresAt" Decode.int)
1463−
1464−
1465−type alias PollEvent =
1466− { id : String
1467− , eventType : String
1468− , payload : String
1469− }
1470−
1471−
1472−type alias PollResponse =
1473− { events : List PollEvent
1474− , cursor : String
1475− }
1476−
1477−
1478−pollEventDecoder : Decoder PollEvent
1479−pollEventDecoder =
1480− Decode.map3 PollEvent
1481− (Decode.field "id" Decode.string)
1482− (Decode.field "eventType" Decode.string)
1483− (Decode.field "payload" Decode.string)
1484−
1485−
1486−pollDecoder : Decoder PollResponse
1487−pollDecoder =
1488− Decode.map2 PollResponse
1489− (Decode.field "events" (Decode.list pollEventDecoder))
1490− (Decode.field "cursor" Decode.string)
1491−
1492−
1493−type alias Envelope =
1494− { toKey : String
1495− , fromKey : String
1496− , encryptedBlob : String
1497− , timestamp : Int
1498− }
1499−
1500−
1501−envelopeDecoder : Decoder Envelope
1502−envelopeDecoder =
1503− Decode.map4 Envelope
1504− (Decode.field "toKey" Decode.string)
1505− (Decode.field "fromKey" Decode.string)
1506− (Decode.field "encryptedBlob" Decode.string)
1507− (Decode.field "timestamp" Decode.int)
1508−
1509−
1510−type alias SendResponse =
1511− { status : String
1512− , messageId : String
1513− }
1514−
1515−
1516−sendDecoder : Decoder SendResponse
1517−sendDecoder =
1518− Decode.map2 SendResponse
1519− (Decode.field "status" Decode.string)
1520− (Decode.field "messageId" Decode.string)
1521−
1522−
1523−type alias MessagePayload =
1524− { type_ : String
1525− , id : String
1526− , timestamp : Int
1527− , body : String
1528− }
1529−
1530−
1531−messagePayloadDecoder : Decoder MessagePayload
1532−messagePayloadDecoder =
1533− Decode.map4 MessagePayload
1534− (Decode.field "type" Decode.string)
1535− (Decode.field "id" Decode.string)
1536− (Decode.field "timestamp" Decode.int)
1537− (Decode.field "body" Decode.string)
1538−
1539−
1540−type alias LookupResponse =
1541− { pubkey : String
1542− , online : Bool
1543− }
1544−
1545−
1546−lookupDecoder : String -> Decoder LookupResponse
1547−lookupDecoder pubkey =
1548− Decode.map (LookupResponse pubkey)
1549− (Decode.field "online" Decode.bool)
1550−
1551−
1552−
1553−-- ============================================================================
1554−-- HELPERS
1555−-- ============================================================================
1556−
1557−
1558−{-| Parse identity string "ed25519hex.x25519hex" into (edHex, xHex).
1559−-}
1560−parseIdentity : String -> Maybe ( String, String )
1561−parseIdentity str =
1562− case String.split "." str of
1563− [ edHex, xHex ] ->
1564− if String.length edHex == 64 && String.length xHex == 64 then
1565− Just ( edHex, xHex )
1566−
1567− else
1568− Nothing
1569−
1570− _ ->
1571− Nothing
1572−
1573−
1574−truncateKey : String -> String
1575−truncateKey key =
1576− if String.length key > 16 then
1577− String.left 8 key ++ "..." ++ String.right 8 key
1578−
1579− else
1580− key
1581−
1582−
1583−httpErrorToString : Http.Error -> String
1584−httpErrorToString err =
1585− case err of
1586− Http.BadUrl url ->
1587− "Bad URL: " ++ url
1588−
1589− Http.Timeout ->
1590− "Request timed out"
1591−
1592− Http.NetworkError ->
1593− "Network error"
1594−
1595− Http.BadStatus code ->
1596− "Server error: " ++ String.fromInt code
1597−
1598− Http.BadBody msg_ ->
1599− "Bad response: " ++ msg_
1600−
1601−
1602−{-| Convert a UTF-8 string to hex-encoded bytes. Only handles ASCII/Latin-1.
1603−-}
1604−textToHex : String -> String
1605−textToHex str =
1606− str
1607− |> String.toList
1608− |> List.concatMap charToHexBytes
1609− |> String.fromList
1610−
1611−
1612−charToHexBytes : Char -> List Char
1613−charToHexBytes c =
1614− let
1615− code =
1616− Char.toCode c
1617− in
1618− if code < 128 then
1619− [ nibbleToHex (code // 16), nibbleToHex (modBy 16 code) ]
1620−
1621− else if code < 2048 then
1622− -- 2-byte UTF-8
1623− let
1624− b1 =
1625− 0xC0 + (code // 64)
1626−
1627− b2 =
1628− 0x80 + modBy 64 code
1629− in
1630− [ nibbleToHex (b1 // 16)
1631− , nibbleToHex (modBy 16 b1)
1632− , nibbleToHex (b2 // 16)
1633− , nibbleToHex (modBy 16 b2)
1634− ]
1635−
1636− else if code < 65536 then
1637− -- 3-byte UTF-8
1638− let
1639− b1 =
1640− 0xE0 + (code // 4096)
1641−
1642− b2 =
1643− 0x80 + modBy 64 (code // 64)
1644−
1645− b3 =
1646− 0x80 + modBy 64 code
1647− in
1648− [ nibbleToHex (b1 // 16)
1649− , nibbleToHex (modBy 16 b1)
1650− , nibbleToHex (b2 // 16)
1651− , nibbleToHex (modBy 16 b2)
1652− , nibbleToHex (b3 // 16)
1653− , nibbleToHex (modBy 16 b3)
1654− ]
1655−
1656− else
1657− -- 4-byte UTF-8 (U+10000 and above: emoji, supplementary planes)
1658− let
1659− b1 =
1660− 0xF0 + (code // 262144)
1661−
1662− b2 =
1663− 0x80 + modBy 64 (code // 4096)
1664−
1665− b3 =
1666− 0x80 + modBy 64 (code // 64)
1667−
1668− b4 =
1669− 0x80 + modBy 64 code
1670− in
1671− [ nibbleToHex (b1 // 16)
1672− , nibbleToHex (modBy 16 b1)
1673− , nibbleToHex (b2 // 16)
1674− , nibbleToHex (modBy 16 b2)
1675− , nibbleToHex (b3 // 16)
1676− , nibbleToHex (modBy 16 b3)
1677− , nibbleToHex (b4 // 16)
1678− , nibbleToHex (modBy 16 b4)
1679− ]
1680−
1681−
1682−nibbleToHex : Int -> Char
1683−nibbleToHex n =
1684− case n of
1685− 0 ->
1686− '0'
1687−
1688− 1 ->
1689− '1'
1690−
1691− 2 ->
1692− '2'
1693−
1694− 3 ->
1695− '3'
1696−
1697− 4 ->
1698− '4'
1699−
1700− 5 ->
1701− '5'
1702−
1703− 6 ->
1704− '6'
1705−
1706− 7 ->
1707− '7'
1708−
1709− 8 ->
1710− '8'
1711−
1712− 9 ->
1713− '9'
1714−
1715− 10 ->
1716− 'a'
1717−
1718− 11 ->
1719− 'b'
1720−
1721− 12 ->
1722− 'c'
1723−
1724− 13 ->
1725− 'd'
1726−
1727− 14 ->
1728− 'e'
1729−
1730− _ ->
1731− 'f'
1732−
1733−
1734−{-| Decode hex-encoded UTF-8 bytes back to a string.
1735−-}
1736−hexToText : String -> String
1737−hexToText hex =
1738− hexToBytes hex
1739− |> decodeUtf8Bytes
1740− |> Maybe.withDefault ""
1741−
1742−
1743−hexToBytes : String -> List Int
1744−hexToBytes hex =
1745− let
1746− chars =
1747− String.toList hex
1748−
1749− pairs =
1750− pairUp chars
1751− in
1752− List.filterMap
1753− (\( hi, lo ) ->
1754− Maybe.map2 (\h l -> h * 16 + l) (hexCharToInt hi) (hexCharToInt lo)
1755− )
1756− pairs
1757−
1758−
1759−pairUp : List a -> List ( a, a )
1760−pairUp list =
1761− case list of
1762− a :: b :: rest ->
1763− ( a, b ) :: pairUp rest
1764−
1765− _ ->
1766− []
1767−
1768−
1769−hexCharToInt : Char -> Maybe Int
1770−hexCharToInt c =
1771− let
1772− code =
1773− Char.toCode c
1774− in
1775− if code >= 48 && code <= 57 then
1776− Just (code - 48)
1777−
1778− else if code >= 97 && code <= 102 then
1779− Just (code - 87)
1780−
1781− else if code >= 65 && code <= 70 then
1782− Just (code - 55)
1783−
1784− else
1785− Nothing
1786−
1787−
1788−decodeUtf8Bytes : List Int -> Maybe String
1789−decodeUtf8Bytes bytes =
1790− decodeUtf8Help bytes []
1791− |> Maybe.map (List.reverse >> String.fromList)
1792−
1793−
1794−decodeUtf8Help : List Int -> List Char -> Maybe (List Char)
1795−decodeUtf8Help bytes acc =
1796− case bytes of
1797− [] ->
1798− Just acc
1799−
1800− b :: rest ->
1801− if b < 128 then
1802− decodeUtf8Help rest (Char.fromCode b :: acc)
1803−
1804− else if b >= 0xC0 && b < 0xE0 then
1805− case rest of
1806− b2 :: rest2 ->
1807− let
1808− code =
1809− (b - 0xC0) * 64 + (b2 - 0x80)
1810− in
1811− decodeUtf8Help rest2 (Char.fromCode code :: acc)
1812−
1813− _ ->
1814− Nothing
1815−
1816− else if b >= 0xE0 && b < 0xF0 then
1817− case rest of
1818− b2 :: b3 :: rest3 ->
1819− let
1820− code =
1821− (b - 0xE0) * 4096 + (b2 - 0x80) * 64 + (b3 - 0x80)
1822− in
1823− decodeUtf8Help rest3 (Char.fromCode code :: acc)
1824−
1825− _ ->
1826− Nothing
1827−
1828− else if b >= 0xF0 then
1829− case rest of
1830− b2 :: b3 :: b4 :: rest4 ->
1831− let
1832− code =
1833− (b - 0xF0) * 262144 + (b2 - 0x80) * 4096 + (b3 - 0x80) * 64 + (b4 - 0x80)
1834− in
1835− decodeUtf8Help rest4 (Char.fromCode code :: acc)
1836−
1837− _ ->
1838− Nothing
1839−
1840− else
1841− Nothing
1842−
1843−
1844−{-| Generate a random hex string of the given byte count.
1845−-}
1846−randomHexString : Int -> Random.Generator String
1847−randomHexString byteCount =
1848− Random.list (byteCount * 2) randomHexChar
1849− |> Random.map String.fromList
1850−
1851−
1852−randomHexChar : Random.Generator Char
1853−randomHexChar =
1854− Random.map
1855− (\n ->
1856− if n < 10 then
1857− Char.fromCode (n + 48)
1858−
1859− else
1860− Char.fromCode (n - 10 + 97)
1861− )
1862− (Random.int 0 15)
core/Cargo.tomlnew file+60
@@ -0,0 +1,60 @@
1+[package]
2+name = "skrepka_core"
3+version = "0.1.0"
4+edition.workspace = true
5+rust-version.workspace = true
6+license.workspace = true
7+repository.workspace = true
8+
9+[lib]
10+name = "skrepka_core"
11+crate-type = ["cdylib", "lib", "staticlib"]
12+
13+[[bin]]
14+name = "codegen"
15+required-features = ["codegen"]
16+
17+[features]
18+facet_typegen = ["crux_core/facet_typegen"]
19+codegen = [
20+ "dep:anyhow",
21+ "dep:clap",
22+ "dep:log",
23+ "dep:pretty_env_logger",
24+ "facet_typegen",
25+]
26+
27+[dependencies]
28+crux_core.workspace = true
29+crux_http.workspace = true
30+crux_kv.workspace = true
31+crux_time.workspace = true
32+boltffi.workspace = true
33+facet.workspace = true
34+serde.workspace = true
35+serde_json.workspace = true
36+
37+# crypto
38+ed25519-dalek.workspace = true
39+x25519-dalek.workspace = true
40+curve25519-dalek.workspace = true
41+chacha20poly1305.workspace = true
42+hkdf.workspace = true
43+sha2.workspace = true
44+zstd.workspace = true
45+rand_core.workspace = true
46+uuid.workspace = true
47+hex.workspace = true
48+base64.workspace = true
49+zeroize.workspace = true
50+url.workspace = true
51+
52+# optional, only for the codegen bin
53+anyhow = { workspace = true, optional = true }
54+clap = { workspace = true, optional = true }
55+log = { workspace = true, optional = true }
56+pretty_env_logger = { workspace = true, optional = true }
57+
58+[dev-dependencies]
59+rand_chacha.workspace = true
60+crux_core = { workspace = true, features = ["testing"] }
core/boltffi.tomlnew file+24
@@ -0,0 +1,24 @@
1+[package]
2+name = "skrepka_core"
3+crate = "skrepka_core"
4+version = "0.1.0"
5+repository = "https://github.com/ilyakooo0/skrepka"
6+license = "MIT"
7+
8+[cargo]
9+global_args = []
10+
11+[cargo.command_args]
12+build = ["--features", "facet_typegen"]
13+
14+[targets.apple]
15+output = "../apple/generated/Shared"
16+deployment_target = "17.0"
17+include_macos = false
18+
19+[targets.apple.swift]
20+module_name = "SkrepkaShared"
21+
22+[targets.apple.spm]
23+package_name = "SkrepkaShared"
24+layout = "ffi-only"
core/src/app.rsnew file+5027

This diff is too big to show here.

core/src/bin/codegen.rsnew file+41

This diff is too big to show here.

core/src/crypto.rsnew file+894

This diff is too big to show here.

core/src/ffi.rsnew file+96

This diff is too big to show here.

core/src/lib.rsnew file+10

This diff is too big to show here.

core/src/model.rsnew file+255

This diff is too big to show here.

core/src/phonemic.rsnew file+289

This diff is too big to show here.

core/src/protocol.rsnew file+355

This diff is too big to show here.

install.shnew file+303

This diff is too big to show here.

server.knot+1797−258

This diff is too big to show here.

server.onew file

Binary file not shown.

servers.jsonnew file+5

This diff is too big to show here.

skrepka.servicenew file+30

This diff is too big to show here.