diff --git a/.cargo/config.toml b/.cargo/config.toml
new file mode 100644
index 0000000..5592118
--- /dev/null
+++ b/.cargo/config.toml
@@ -0,0 +1,2 @@
+[alias]
+xtask = "run -p xtask --"
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
new file mode 100644
index 0000000..446429b
--- /dev/null
+++ b/.github/workflows/check.yml
@@ -0,0 +1,57 @@
+name: Verify and package
+on: [push, pull_request, workflow_dispatch]
+permissions:
+ contents: read
+jobs:
+ verify:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci --ignore-scripts
+ - name: Install pinned Rust and build tools
+ run: |
+ rustup toolchain install 1.99.1 --profile minimal --component clippy,rustfmt --target wasm32-unknown-unknown
+ curl -fsSL https://github.com/wasm-bindgen/wasm-bindgen/releases/download/0.2.129/wasm-bindgen-0.2.129-x86_64-unknown-linux-musl.tar.gz -o /tmp/bindgen.tar.gz
+ tar -xzf /tmp/bindgen.tar.gz -C /tmp
+ echo /tmp/wasm-bindgen-0.2.129-x86_64-unknown-linux-musl >> "$GITHUB_PATH"
+ curl -fsSL https://github.com/WebAssembly/binaryen/releases/download/version_133/binaryen-version_133-x86_64-linux.tar.gz -o /tmp/binaryen.tar.gz
+ tar -xzf /tmp/binaryen.tar.gz -C /tmp
+ echo /tmp/binaryen-version_133/bin >> "$GITHUB_PATH"
+ - name: Rust validation
+ run: |
+ cargo fmt --all -- --check
+ cargo xtask test
+ cargo clippy --workspace --all-targets --locked -- -D warnings
+ cargo clippy --target wasm32-unknown-unknown -p limitless-runtime -p limitless-ui --locked -- -D warnings
+ - name: Build validated browser packages and source archive
+ run: cargo xtask package
+ - name: Install browser harness
+ run: |
+ python3 -m pip install -r tests/browser/requirements.txt
+ python3 -m playwright install --with-deps chromium firefox
+ - name: Chromium extension integration
+ run: |
+ python3 tests/browser/smoke.py
+ python3 tests/browser/inline.py
+ python3 tests/browser/inline_contracts.py
+ python3 tests/browser/native_fidelity.py
+ python3 tests/browser/lifecycle.py
+ python3 tests/browser/infinite_scroll.py
+ python3 tests/browser/identity_recovery.py
+ python3 tests/browser/review_regressions.py
+ python3 tests/browser/media_player.py
+ - name: Firefox extension integration
+ run: |
+ curl -fsSL https://github.com/mozilla/geckodriver/releases/download/v0.37.1/geckodriver-v0.37.1-linux64.tar.gz -o /tmp/geckodriver.tar.gz
+ tar -xzf /tmp/geckodriver.tar.gz -C /tmp
+ export GECKODRIVER=/tmp/geckodriver
+ python3 tests/browser/firefox_smoke.py
+ python3 tests/browser/firefox_inline.py
+ - uses: actions/upload-artifact@v4
+ with:
+ name: limitlessx-development-packages
+ path: dist/releases/*
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..52cf420
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+/target/
+/dist/
+.superpowers/
+__pycache__/
+/tests/browser/artifacts/
+
+/node_modules/
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..6f5880a
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,2024 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "any_spawner"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1384d3fe1eecb464229fcf6eebb72306591c56bf27b373561489458a7c73027d"
+dependencies = [
+ "futures",
+ "thiserror 2.0.21",
+ "wasm-bindgen-futures",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "arbitrary"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
+dependencies = [
+ "derive_arbitrary",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-once-cell"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a"
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "attribute-derive"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f9ef7a68684657ce9da341c934c73b63df7a3585a61e366d84c84ed5d3d167"
+dependencies = [
+ "attribute-derive-macro",
+ "manyhow",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "attribute-derive-macro"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300d426e5e73549125bb8e4649bdd5508f82c94ddf898fb0517246e71dfb8e98"
+dependencies = [
+ "collection_literals",
+ "interpolator",
+ "manyhow",
+ "proc-macro-utils",
+ "proc-macro2",
+ "quote",
+ "quote-use",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "base16"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "camino"
+version = "1.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "codee"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9dbbdc4b4d349732bc6690de10a9de952bd39ba6a065c586e26600b6b0b91f5"
+dependencies = [
+ "serde",
+ "serde_json",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "collection_literals"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084"
+
+[[package]]
+name = "config"
+version = "0.15.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38e9fbc53afcec54441422f1c596cd054655b32e70e1cbac956651b7b175a404"
+dependencies = [
+ "convert_case 0.6.0",
+ "pathdiff",
+ "serde_core",
+ "toml",
+ "winnow",
+]
+
+[[package]]
+name = "const-str"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f"
+
+[[package]]
+name = "const_format"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3decb4d6965448659923544a634a0571ba44cede4e11ef15a5a317126840454"
+dependencies = [
+ "const_format_proc_macros",
+ "konst",
+]
+
+[[package]]
+name = "const_format_proc_macros"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "941b570269a642506f08cb5b688c04bfa6cfd41a26f8bcdc49db8fd00c0ab659"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-xid",
+]
+
+[[package]]
+name = "const_str_slice_concat"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b"
+
+[[package]]
+name = "convert_case"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "convert_case"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "convert_case_extras"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589c70f0faf8aa9d17787557d5eae854d7755cac50f5c3d12c81d3d57661cebb"
+dependencies = [
+ "convert_case 0.11.0",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "derive-where"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "derive_arbitrary"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "drain_filter_polyfill"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408"
+
+[[package]]
+name = "either"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
+
+[[package]]
+name = "either_of"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5060e0a4cbf26a87550792688ade88e6b8aec9208613631a7a363bda7bc2d4cd"
+dependencies = [
+ "paste",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
+
+[[package]]
+name = "erased"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1731451909bde27714eacba19c2566362a7f35224f52b153d3f42cf60f72472"
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "flate2"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
+dependencies = [
+ "zlib-rs",
+]
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "gloo-net"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-sink",
+ "gloo-utils",
+ "http",
+ "js-sys",
+ "pin-project",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "gloo-utils"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
+dependencies = [
+ "js-sys",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "guardian"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f"
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "html-escape"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5"
+
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "hydration_context"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "285b05858fe7d58c5eff059f7b46c228fd86996d996c59eb35fc0340d174f640"
+dependencies = [
+ "futures",
+ "or_poisoned",
+ "pin-project-lite",
+ "serde",
+ "throw_error",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
+
+[[package]]
+name = "icu_properties"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
+
+[[package]]
+name = "icu_provider"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idb"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6554f394e990a1af530a528a7fdcad6e01b29cb1b990f89df3ffd62cf15f7828"
+dependencies = [
+ "indexmap",
+ "js-sys",
+ "num-traits",
+ "thiserror 2.0.21",
+ "tokio",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "interpolator"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8"
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "konst"
+version = "0.2.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb"
+dependencies = [
+ "konst_macro_rules",
+]
+
+[[package]]
+name = "konst_macro_rules"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
+
+[[package]]
+name = "leptos"
+version = "0.8.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "713ee6895e3d1126ea9781280d01414f5643f3f201a949b71efffac07d735f00"
+dependencies = [
+ "any_spawner",
+ "cfg-if",
+ "either_of",
+ "futures",
+ "getrandom",
+ "hydration_context",
+ "leptos_config",
+ "leptos_dom",
+ "leptos_hot_reload",
+ "leptos_macro",
+ "leptos_server",
+ "oco_ref",
+ "or_poisoned",
+ "paste",
+ "reactive_graph",
+ "rustc-hash",
+ "rustc_version",
+ "send_wrapper",
+ "serde",
+ "serde_json",
+ "serde_qs",
+ "server_fn",
+ "slotmap",
+ "tachys",
+ "thiserror 2.0.21",
+ "throw_error",
+ "typed-builder",
+ "typed-builder-macro",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm_split_helpers",
+ "web-sys",
+]
+
+[[package]]
+name = "leptos_config"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c06f751315bccc0d193fab302ac01d25bcfcd97474d4676440e7e3250dc3fc3"
+dependencies = [
+ "config",
+ "regex",
+ "serde",
+ "thiserror 2.0.21",
+ "typed-builder",
+]
+
+[[package]]
+name = "leptos_dom"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35742e9ed8f8aaf9e549b454c68a7ac0992536e06856365639b111f72ab07884"
+dependencies = [
+ "js-sys",
+ "or_poisoned",
+ "reactive_graph",
+ "send_wrapper",
+ "tachys",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "leptos_hot_reload"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d84989bd3d604e66adbfe13a32bec2ea47bdcb6c9fcd643f784facbb32f6ef10"
+dependencies = [
+ "anyhow",
+ "camino",
+ "indexmap",
+ "or_poisoned",
+ "proc-macro2",
+ "quote",
+ "rstml",
+ "serde",
+ "syn 3.0.9",
+ "walkdir",
+]
+
+[[package]]
+name = "leptos_macro"
+version = "0.8.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0902af1e6947ffc7e906b7003a165f76f178dc711a47b74241025090b0c32a72"
+dependencies = [
+ "attribute-derive",
+ "cfg-if",
+ "convert_case 0.11.0",
+ "convert_case_extras",
+ "html-escape",
+ "itertools",
+ "leptos_hot_reload",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "rstml",
+ "rustc_version",
+ "server_fn_macro",
+ "syn 3.0.9",
+ "uuid",
+]
+
+[[package]]
+name = "leptos_server"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202b2d1261c22d886f307934760a6bb566444f68f39b24f6f10defdc23633049"
+dependencies = [
+ "any_spawner",
+ "base64",
+ "codee",
+ "futures",
+ "hydration_context",
+ "or_poisoned",
+ "reactive_graph",
+ "send_wrapper",
+ "serde",
+ "serde_json",
+ "server_fn",
+ "tachys",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.191"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
+
+[[package]]
+name = "limitless-core"
+version = "0.1.0"
+dependencies = [
+ "serde",
+ "sha2",
+ "thiserror 2.0.21",
+ "url",
+]
+
+[[package]]
+name = "limitless-fxtwitter"
+version = "0.1.0"
+dependencies = [
+ "limitless-core",
+ "serde",
+ "serde_json",
+ "url",
+]
+
+[[package]]
+name = "limitless-runtime"
+version = "0.1.0"
+dependencies = [
+ "futures",
+ "js-sys",
+ "limitless-core",
+ "limitless-fxtwitter",
+ "limitless-storage",
+ "serde",
+ "serde-wasm-bindgen",
+ "serde_json",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "limitless-storage"
+version = "0.1.0"
+dependencies = [
+ "js-sys",
+ "limitless-core",
+ "rexie",
+ "serde",
+ "serde-wasm-bindgen",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "limitless-ui"
+version = "0.1.0"
+dependencies = [
+ "js-sys",
+ "leptos",
+ "limitless-core",
+ "serde",
+ "serde-wasm-bindgen",
+ "serde_json",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "manyhow"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1704b073316272a6785b70599f91f73b49022c2e290b065591ee6b9109ed8c7"
+dependencies = [
+ "manyhow-macros",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "manyhow-macros"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31601db71f1f5fb6f831a18aa3052d13a62528ee4f2cba7e8c9a5e258d953725"
+dependencies = [
+ "proc-macro-utils",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "next_tuple"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "oco_ref"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed0423ff9973dea4d6bd075934fdda86ebb8c05bdf9d6b0507067d4a1226371d"
+dependencies = [
+ "serde",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "or_poisoned"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd"
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "pathdiff"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
+dependencies = [
+ "proc-macro2",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "proc-macro-utils"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "smallvec",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "proc-macro2-diagnostics"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df921e1ef974ebc8dd9effdb0c037a96928ae8cbf871438e02d6fd67204b595e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "version_check",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "quote-use"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48356e493823dc3c1d7e9fc7b3ff204ecec48afbdd718fc4134091b88eb63f3d"
+dependencies = [
+ "quote",
+ "quote-use-macros",
+]
+
+[[package]]
+name = "quote-use-macros"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8cf1c5de6a7cc231c71e03c77aa4c7019097859ea7029156b54d2894a8430704"
+dependencies = [
+ "proc-macro-utils",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "reactive_graph"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3bcec36a2d1b9a53a4129b3270b19e3df6f6b88c2e7c97ed9404dc9307f137d7"
+dependencies = [
+ "any_spawner",
+ "async-lock",
+ "futures",
+ "guardian",
+ "hydration_context",
+ "indexmap",
+ "or_poisoned",
+ "paste",
+ "pin-project-lite",
+ "rustc-hash",
+ "rustc_version",
+ "send_wrapper",
+ "serde",
+ "slotmap",
+ "thiserror 2.0.21",
+ "web-sys",
+]
+
+[[package]]
+name = "reactive_stores"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a29e469b556f25486678e9f5f65fa21a0351d98affde9a7eea9fee286bf8ebd6"
+dependencies = [
+ "guardian",
+ "indexmap",
+ "itertools",
+ "or_poisoned",
+ "paste",
+ "reactive_graph",
+ "reactive_stores_macro",
+ "rustc-hash",
+ "send_wrapper",
+]
+
+[[package]]
+name = "reactive_stores_macro"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d11f1b82859d94577892b8037c35f8a4f066cbb3864583e22e68def0af49a88"
+dependencies = [
+ "convert_case 0.11.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "rexie"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "887466cfa8a12c08ee4b174998135cea8ff0fd84858627cd793e56535a045bc9"
+dependencies = [
+ "idb",
+ "thiserror 1.0.69",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "rstml"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6bd1bfba3c4c0b57aae906204d9e5744f3f65800ce6c75bd4cc162f2c26f3aa"
+dependencies = [
+ "derive-where",
+ "proc-macro2",
+ "proc-macro2-diagnostics",
+ "quote",
+ "syn 3.0.9",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "send_wrapper"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde-wasm-bindgen"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
+dependencies = [
+ "js-sys",
+ "serde",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.154"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_qs"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352"
+dependencies = [
+ "percent-encoding",
+ "serde",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "server_fn"
+version = "0.8.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be8559dd05af1b5b7e363a150616589d5a88af5187273f7f331ba0dae8922812"
+dependencies = [
+ "base64",
+ "bytes",
+ "const-str",
+ "const_format",
+ "futures",
+ "gloo-net",
+ "http",
+ "js-sys",
+ "or_poisoned",
+ "pin-project-lite",
+ "rustc_version",
+ "rustversion",
+ "send_wrapper",
+ "serde",
+ "serde_json",
+ "serde_qs",
+ "server_fn_macro_default",
+ "thiserror 2.0.21",
+ "throw_error",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+ "xxhash-rust",
+]
+
+[[package]]
+name = "server_fn_macro"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce79d7b6ab93f7994e5ff4999eb962f2aea370d7c3b91986eb75da738a8e65f9"
+dependencies = [
+ "const_format",
+ "convert_case 0.11.0",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 3.0.9",
+ "xxhash-rust",
+]
+
+[[package]]
+name = "server_fn_macro_default"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9a4f81a0d855f279c3448243138a55cbfdd9851da9341b9c1e5971b90089e61"
+dependencies = [
+ "server_fn_macro",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.16.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "tachys"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e47e2220bb9d2a3be976a22a0fb7685a0b84499bb11ea5cb3bbc82907675ac68"
+dependencies = [
+ "any_spawner",
+ "async-trait",
+ "const_str_slice_concat",
+ "drain_filter_polyfill",
+ "either_of",
+ "erased",
+ "futures",
+ "html-escape",
+ "indexmap",
+ "itertools",
+ "js-sys",
+ "next_tuple",
+ "oco_ref",
+ "or_poisoned",
+ "paste",
+ "reactive_graph",
+ "reactive_stores",
+ "rustc-hash",
+ "rustc_version",
+ "send_wrapper",
+ "slotmap",
+ "throw_error",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
+dependencies = [
+ "thiserror-impl 2.0.21",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "throw_error"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc0ed6038fcbc0795aca7c92963ddda636573b956679204e044492d2b13c8f64"
+dependencies = [
+ "pin-project-lite",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tokio"
+version = "1.54.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
+dependencies = [
+ "pin-project-lite",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.8+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_parser",
+ "toml_writer",
+ "winnow",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
+
+[[package]]
+name = "typed-builder"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda"
+dependencies = [
+ "typed-builder-macro",
+]
+
+[[package]]
+name = "typed-builder-macro"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0"
+dependencies = [
+ "utf_types",
+]
+
+[[package]]
+name = "utf_types"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6"
+
+[[package]]
+name = "uuid"
+version = "1.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
+dependencies = [
+ "getrandom",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
+dependencies = [
+ "js-sys",
+ "tokio",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-streams"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "wasm_split_helpers"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab578aae2fe2916edaea06843187d50f87b0965622da0ceef648edca27b385ba"
+dependencies = [
+ "async-once-cell",
+ "wasm_split_macros",
+]
+
+[[package]]
+name = "wasm_split_macros"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e653af7ee4a9ef0fce481a9ec6f43cb78de20d0cdb4f4f5862e1dc6e407e6c8"
+dependencies = [
+ "base16",
+ "quote",
+ "sha2",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "xtask"
+version = "0.1.0"
+dependencies = [
+ "serde_json",
+ "zip",
+]
+
+[[package]]
+name = "xxhash-rust"
+version = "0.8.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
+
+[[package]]
+name = "yoke"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "synstructure",
+]
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "zip"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b"
+dependencies = [
+ "arbitrary",
+ "crc32fast",
+ "flate2",
+ "indexmap",
+ "memchr",
+ "zopfli",
+]
+
+[[package]]
+name = "zlib-rs"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+
+[[package]]
+name = "zopfli"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
+dependencies = [
+ "bumpalo",
+ "crc32fast",
+ "log",
+ "simd-adler32",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..0496417
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,30 @@
+[workspace]
+members = ["crates/core", "crates/fxtwitter", "crates/storage", "crates/runtime", "crates/ui", "crates/xtask"]
+resolver = "2"
+
+[workspace.package]
+version = "0.1.0"
+edition = "2024"
+license = "MIT"
+rust-version = "1.90"
+
+[workspace.dependencies]
+limitless-core = { path = "crates/core" }
+limitless-fxtwitter = { path = "crates/fxtwitter" }
+limitless-storage = { path = "crates/storage" }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+thiserror = "2"
+url = "2"
+wasm-bindgen = "0.2"
+wasm-bindgen-futures = "0.4"
+serde-wasm-bindgen = "0.6"
+js-sys = "0.3"
+web-sys = "0.3"
+
+[profile.release]
+opt-level = "s"
+lto = true
+codegen-units = 1
+strip = true
+panic = "abort"
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..37858de
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 LimitlessX contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..7a534e9
--- /dev/null
+++ b/README.md
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+# LimitlessX
+
+### No VPN. No proxy setup. No DPI hacks.
+
+Read region-restricted public X content directly on X.
+
+[](https://github.com/OnurByte/LimitlessX/actions/workflows/check.yml)
+[](LICENSE)
+[](https://github.com/OnurByte/LimitlessX)
+
+[](#install)
+[](#install)
+[](docs/compatibility.md)
+
+
+
+LimitlessX detects supported country-withholding notices and displays available public profiles, posts and replies inside X. It uses FxTwitter as an alternative public source, with no X login or separate LimitlessX account.
+
+**Open source, MIT licensed.** Rust owns fetching, validation, sessions and storage. TypeScript connects those components to the browser. All executable code ships in the extension. There is no LimitlessX backend, analytics or remote executable code.
+
+## What it does
+
+- Automatically replaces a supported regional-withholding notice with available public content.
+- Shows five posts first, then loads more as you scroll. Existing videos keep playing during feed expansion.
+- Displays profile details, media, quoted posts and available conversation replies in X's visual style.
+- Resolves exact public account identities when a username lookup fails, without confusing similarly named accounts.
+- Keeps unique local counts of successfully viewed restricted accounts and posts. Disable or clear them in the popup.
+- Uses one popup for manual URLs, current-page actions and settings. No extension reader page or iframe.
+
+## Install
+
+**Development builds. Not published in browser stores yet.** These badges describe build targets and test evidence, not store availability.
+
+Build the project below, then load the generated package:
+
+| Browser | Local installation | Verification |
+|---|---|---|
+| Chromium | Open the Extensions page, enable Developer mode, choose **Load unpacked**, select `dist/chromium` | Packaged extension integration tests |
+| Helium | Use `chrome://extensions`, enable Developer mode and load `dist/chromium` | Actual X session tested locally |
+| Firefox | Open `about:debugging`, choose **This Firefox**, **Load Temporary Add-on**, select `dist/firefox/manifest.json` | Packaged extension integration tests |
+
+Firefox temporary add-ons disappear when the browser closes. Permanent Firefox distribution requires signing. Other Chromium/Firefox variants are intended targets; see [individual browser evidence](docs/compatibility.md) before assuming compatibility.
+
+On a supported blocked X profile or post, reading starts automatically. The popup also accepts an `@handle` or an X profile/post URL.
+
+## Privacy and limits
+
+The public provider receives requested identifiers and network information, including your IP address. Media loads from X media hosts. Requests omit credentials; the extension does not read X passwords, authentication cookies or private messages. This is not an anonymity tool.
+
+The Record stores unique salted identifier hashes in local IndexedDB. It does not store post bodies or plaintext browsing history. Failed requests and ordinary manual lookups do not increase restricted-content totals.
+
+Availability depends on the provider and public source. Private content is unavailable. Timelines, replies and metadata can be incomplete; this is a focused reader, not a complete authenticated X client. Identity recovery fixes a known username-lookup failure, not every possible provider outage. Read the [privacy details](docs/privacy.md) and [release audit](docs/verification/release-audit.md).
+
+## Build
+
+Requirements: Node.js 22+, npm, rustup, Python for browser tests, `wasm-bindgen` 0.2.129 and Binaryen `version_133`. Rust 1.99.1 and the WASM target are pinned in `rust-toolchain.toml`.
+
+```sh
+npm ci --ignore-scripts
+cargo install wasm-bindgen-cli --version 0.2.129 --locked
+# Install Binaryen version_133 and place wasm-opt on PATH.
+cargo xtask build chromium
+cargo xtask build firefox
+cargo xtask test
+cargo xtask package
+```
+
+If needed, set `WASM_BINDGEN` and `WASM_OPT` to their executable paths. `cargo xtask` typechecks and compiles the handwritten TypeScript adapters; application logic remains in Rust. Packages and a reviewable source archive are written to `dist/releases`.
+
+## Verify and contribute
+
+```sh
+npm run typecheck
+cargo fmt --all -- --check
+cargo clippy --workspace --all-targets --locked -- -D warnings
+cargo clippy --target wasm32-unknown-unknown -p limitless-runtime -p limitless-ui --locked -- -D warnings
+python3 -m pip install -r tests/browser/requirements.txt
+python3 -m playwright install chromium firefox
+```
+
+The [CI workflow](.github/workflows/check.yml) runs native checks, builds both packages and exercises the installed extensions. Browser fixtures cover malformed responses, identity recovery, navigation, pagination, counters and retry behavior. Firefox's inline test also uses a live public provider response; that dependency is disclosed in its output.
+
+Issues and pull requests are welcome. Keep permissions narrow, executable code local, provider text escaped and public/private boundaries explicit. Include a regression test for behavioral fixes. See [architecture](docs/architecture.md), [design](docs/brand.md) and [browser tests](tests/browser).
+
+Built with Rust, WebAssembly, Leptos and TypeScript. Public data via [FxTwitter / FxEmbed](https://github.com/FxEmbed/FxEmbed).
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
new file mode 100644
index 0000000..fd86458
--- /dev/null
+++ b/crates/core/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "limitless-core"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+serde.workspace = true
+thiserror.workspace = true
+url.workspace = true
+sha2 = "0.10"
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
new file mode 100644
index 0000000..e0d78e8
--- /dev/null
+++ b/crates/core/src/lib.rs
@@ -0,0 +1,298 @@
+//! Provider-independent domain and security boundaries. No browser APIs here.
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use url::Url;
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
+pub enum Target {
+ Profile { handle: String },
+ Post { id: String },
+}
+impl Target {
+ pub fn parse(input: &str) -> Result {
+ let input = input.trim();
+ if input.len() > 2048 || input.chars().any(char::is_control) {
+ return Err(Error::InvalidTarget);
+ }
+ if !input.contains("://") && !input.contains('/') {
+ return valid_handle(input.trim_start_matches('@'))
+ .map(|handle| Self::Profile { handle })
+ .ok_or(Error::InvalidTarget);
+ }
+ let url = Url::parse(input).map_err(|_| Error::InvalidTarget)?;
+ if url.scheme() != "https"
+ || !url.username().is_empty()
+ || url.password().is_some()
+ || url.port().is_some()
+ || !matches!(
+ url.host_str(),
+ Some(
+ "x.com"
+ | "www.x.com"
+ | "twitter.com"
+ | "www.twitter.com"
+ | "mobile.twitter.com"
+ )
+ )
+ {
+ return Err(Error::InvalidTarget);
+ }
+ let parts: Vec<_> = url
+ .path()
+ .trim_end_matches('/')
+ .split('/')
+ .skip(1)
+ .collect();
+ match parts.as_slice() {
+ [handle] | [handle, "with_replies" | "reposts" | "media"] => valid_handle(handle)
+ .map(|handle| Self::Profile { handle })
+ .ok_or(Error::InvalidTarget),
+ [handle, "status", id]
+ if (*handle == "i" || valid_handle(handle).is_some()) && valid_id(id) =>
+ {
+ Ok(Self::Post { id: (*id).into() })
+ }
+ _ => Err(Error::InvalidTarget),
+ }
+ }
+ pub fn canonical_url(&self) -> String {
+ match self {
+ Self::Profile { handle } => format!("https://x.com/{handle}"),
+ Self::Post { id } => format!("https://x.com/i/status/{id}"),
+ }
+ }
+ pub fn label(&self) -> String {
+ match self {
+ Self::Profile { handle } => format!("@{handle}"),
+ Self::Post { id } => format!("Post {id}"),
+ }
+ }
+ pub fn is_valid(&self) -> bool {
+ match self {
+ Self::Profile { handle } => valid_handle(handle).as_ref() == Some(handle),
+ Self::Post { id } => valid_id(id),
+ }
+ }
+}
+pub fn valid_handle(handle: &str) -> Option {
+ let h = handle.to_ascii_lowercase();
+ if h.is_empty()
+ || h.len() > 15
+ || !h.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_')
+ || matches!(
+ h.as_str(),
+ "home"
+ | "explore"
+ | "search"
+ | "notifications"
+ | "messages"
+ | "settings"
+ | "i"
+ | "intent"
+ | "compose"
+ | "login"
+ | "logout"
+ | "signup"
+ | "tos"
+ | "privacy"
+ | "share"
+ )
+ {
+ None
+ } else {
+ Some(h)
+ }
+}
+pub fn valid_id(id: &str) -> bool {
+ !id.is_empty()
+ && id.len() <= 20
+ && id.bytes().all(|c| c.is_ascii_digit())
+ && id.parse::().is_ok_and(|n| n > 0)
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Notice {
+ pub text: String,
+ pub policy_url: String,
+}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+pub enum Restriction {
+ Regional,
+}
+impl Restriction {
+ pub fn detect(target: &Target, notice: &Notice) -> Option {
+ if notice.text.len() > 1600 {
+ return None;
+ }
+ let u = Url::parse(¬ice.policy_url).ok()?;
+ if u.scheme() != "https"
+ || !matches!(u.host_str(), Some("help.x.com" | "help.twitter.com"))
+ || !u.username().is_empty()
+ || u.port().is_some()
+ || !u
+ .path()
+ .ends_with("/rules-and-policies/post-withheld-by-country")
+ {
+ return None;
+ }
+ let t = notice
+ .text
+ .split_whitespace()
+ .collect::>()
+ .join(" ")
+ .to_lowercase();
+ let t = t
+ .strip_prefix("account withheld")
+ .unwrap_or(&t)
+ .trim_start();
+ let phrase = match target {
+ Target::Profile { handle } => {
+ let prefix = format!("@{handle}'s account has been withheld in ");
+ let alternate = format!("@{handle} has been withheld in ");
+ t.starts_with(&prefix)
+ || t.starts_with(&alternate)
+ || (t.starts_with(&format!("@{handle}"))
+ && t.contains("hesab")
+ && t.contains("erişime engellen")
+ && (t.contains("ülke") || t.contains("türkiye")))
+ }
+ Target::Post { .. } => {
+ t.starts_with("this post has been withheld in ")
+ || t.starts_with("this tweet has been withheld in ")
+ || ((t.starts_with("bu gönderi") || t.starts_with("bu tweet"))
+ && t.contains("erişime engellen"))
+ }
+ };
+ phrase.then_some(Self::Regional)
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
+#[serde(rename_all = "snake_case")]
+pub enum Error {
+ #[error("Enter an X profile or post URL, or an account handle.")]
+ InvalidTarget,
+ #[error("This account is protected. Private content is unavailable.")]
+ Protected,
+ #[error("The provider reports that this account is suspended.")]
+ Suspended,
+ #[error("The provider reports that this post was deleted.")]
+ Deleted,
+ #[error("The provider could not find this content.")]
+ NotFound,
+ #[error("This content is unavailable from the provider.")]
+ Unavailable,
+ #[error("FxTwitter is receiving too many requests. Try again later.")]
+ RateLimited,
+ #[error("FxTwitter returned an unexpected response.")]
+ InvalidResponse,
+ #[error("The request timed out. Try again.")]
+ Timeout,
+ #[error("Could not reach FxTwitter. Check your connection and try again.")]
+ Network,
+ #[error("This reader session expired. Open the content again.")]
+ Expired,
+ #[error("This action is not allowed from this page.")]
+ Unauthorized,
+ #[error("Local storage is unavailable. Try reopening the extension.")]
+ Storage,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+pub struct Profile {
+ pub id: String,
+ pub handle: String,
+ pub name: String,
+ pub biography: Option,
+ pub avatar: Option,
+ #[serde(default)]
+ pub banner: Option,
+ #[serde(default)]
+ pub location: Option,
+ #[serde(default)]
+ pub website: Option,
+ #[serde(default)]
+ pub joined: Option,
+ #[serde(default)]
+ pub followers: Option,
+ #[serde(default)]
+ pub following: Option,
+ #[serde(default)]
+ pub statuses: Option,
+ #[serde(default)]
+ pub verified: Option,
+ #[serde(default)]
+ pub verification_type: Option,
+}
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Media {
+ pub kind: String,
+ pub url: String,
+ pub preview: Option,
+ pub alt: Option,
+}
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+pub struct Post {
+ pub id: String,
+ pub text: String,
+ pub author: Profile,
+ pub published: Option,
+ pub media: Vec,
+ #[serde(default)]
+ pub metrics: Metrics,
+ #[serde(default)]
+ pub quote: Option>,
+ #[serde(default)]
+ pub replying_to: Option,
+}
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+pub struct Metrics {
+ pub replies: Option,
+ pub reposts: Option,
+ pub likes: Option,
+ pub views: Option,
+ pub bookmarks: Option,
+ pub quotes: Option,
+}
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Conversation {
+ pub post: Post,
+ pub thread: Vec,
+ pub replies: Vec,
+ pub next_cursor: Option,
+ pub unavailable_replies: usize,
+}
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Timeline {
+ pub posts: Vec,
+ pub next_cursor: Option,
+}
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+pub struct Record {
+ pub profiles: u32,
+ pub posts: u32,
+ pub enabled: bool,
+ pub since: f64,
+}
+
+pub fn safe_media_url(raw: &str) -> Option {
+ let u = Url::parse(raw).ok()?;
+ (u.scheme() == "https"
+ && u.username().is_empty()
+ && u.password().is_none()
+ && u.port().is_none()
+ && matches!(u.host_str(), Some("pbs.twimg.com" | "video.twimg.com")))
+ .then(|| u.to_string())
+}
+/// Domain-separated salted identifiers. Salt never leaves the installation.
+pub fn record_key(salt: &[u8], kind: &str, id: &str) -> String {
+ let mut hash = Sha256::new();
+ hash.update(salt);
+ hash.update([0]);
+ hash.update(kind);
+ hash.update([0]);
+ hash.update(id);
+ format!("{:x}", hash.finalize())
+}
diff --git a/crates/core/tests/domain.rs b/crates/core/tests/domain.rs
new file mode 100644
index 0000000..12b734f
--- /dev/null
+++ b/crates/core/tests/domain.rs
@@ -0,0 +1,138 @@
+use limitless_core::{Notice, Restriction, Target, record_key, safe_media_url};
+
+#[test]
+fn parses_supported_urls_without_leaking_queries() {
+ assert_eq!(
+ Target::parse("https://x.com/Some_User/status/1234567890123456789?s=20").unwrap(),
+ Target::Post {
+ id: "1234567890123456789".into()
+ }
+ );
+ assert_eq!(
+ Target::parse("@Some_User").unwrap(),
+ Target::Profile {
+ handle: "some_user".into()
+ }
+ );
+ assert_eq!(
+ Target::parse("https://twitter.com/Some_User/").unwrap(),
+ Target::Profile {
+ handle: "some_user".into()
+ }
+ );
+}
+#[test]
+fn rejects_confused_hosts_routes_credentials_and_unicode() {
+ for s in [
+ "https://x.com.evil.test/foo",
+ "https://evil.test/x.com/foo",
+ "https://u:p@x.com/foo",
+ "http://x.com/foo",
+ "https://x.com:444/foo",
+ "https://x.com/home",
+ "https://x.com/i",
+ "https://x.com/foo/followers",
+ "https://x.com/foo/status/notanid",
+ "a\nb",
+ "@ı",
+ "",
+ "https://x.com/foo%2fbar",
+ ] {
+ assert!(Target::parse(s).is_err(), "accepted {s}");
+ }
+}
+#[test]
+fn requires_explicit_matching_notice_and_policy_link() {
+ let target = Target::parse("@example").unwrap();
+ let notice = Notice {
+ text: "@example's account has been withheld in Türkiye in response to a legal demand."
+ .into(),
+ policy_url: "https://help.x.com/en/rules-and-policies/post-withheld-by-country".into(),
+ };
+ assert_eq!(
+ Restriction::detect(&target, ¬ice),
+ Some(Restriction::Regional)
+ );
+ assert_eq!(
+ Restriction::detect(&Target::parse("@other").unwrap(), ¬ice),
+ None
+ );
+ assert_eq!(
+ Restriction::detect(
+ &target,
+ &Notice {
+ text: "Something went wrong. Try again.".into(),
+ ..notice.clone()
+ }
+ ),
+ None
+ );
+ assert_eq!(
+ Restriction::detect(
+ &target,
+ &Notice {
+ policy_url: "https://evil.test/post-withheld-by-country".into(),
+ ..notice
+ }
+ ),
+ None
+ );
+}
+#[test]
+fn never_classifies_private_suspended_or_missing_as_regional() {
+ let t = Target::parse("@test").unwrap();
+ for text in [
+ "@test Account suspended",
+ "@test These posts are protected",
+ "@test This account doesn’t exist",
+ "@test unavailable in your country",
+ "@test wrote: account has been withheld in Türkiye",
+ ] {
+ assert_eq!(
+ Restriction::detect(
+ &t,
+ &Notice {
+ text: text.into(),
+ policy_url: "https://help.x.com/en/rules-and-policies/post-withheld-by-country"
+ .into()
+ }
+ ),
+ None
+ );
+ }
+}
+#[test]
+fn media_is_https_on_explicit_provider_hosts() {
+ assert!(safe_media_url("https://pbs.twimg.com/media/abc.jpg").is_some());
+ for s in [
+ "javascript:alert(1)",
+ "data:text/html,x",
+ "https://evil.test/a",
+ "https://pbs.twimg.com.evil.test/a",
+ "http://pbs.twimg.com/a",
+ "https://a@pbs.twimg.com/a",
+ ] {
+ assert!(safe_media_url(s).is_none());
+ }
+}
+#[test]
+fn record_is_per_install_per_kind_and_does_not_store_ids() {
+ let a = record_key(&[1; 32], "profile", "422861505");
+ assert_eq!(a.len(), 64);
+ assert_ne!(a, record_key(&[2; 32], "profile", "422861505"));
+ assert_ne!(a, record_key(&[1; 32], "post", "422861505"));
+ assert!(!a.contains("422861505"));
+}
+
+#[test]
+fn detects_the_actual_helium_x_account_withheld_dom_text() {
+ let notice=Notice{text:"Account Withheld@ekrem_imamoglu has been withheld in TR in response to a legal demand. https://Learn more.".into(),policy_url:"https://help.x.com/en/rules-and-policies/post-withheld-by-country".into()};
+ assert_eq!(
+ Restriction::detect(&Target::parse("@ekrem_imamoglu").unwrap(), ¬ice),
+ Some(Restriction::Regional)
+ );
+ assert_eq!(
+ Restriction::detect(&Target::parse("@other").unwrap(), ¬ice),
+ None
+ );
+}
diff --git a/crates/fxtwitter/Cargo.toml b/crates/fxtwitter/Cargo.toml
new file mode 100644
index 0000000..c0e8620
--- /dev/null
+++ b/crates/fxtwitter/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "limitless-fxtwitter"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+limitless-core.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+url.workspace = true
diff --git a/crates/fxtwitter/src/lib.rs b/crates/fxtwitter/src/lib.rs
new file mode 100644
index 0000000..9b640da
--- /dev/null
+++ b/crates/fxtwitter/src/lib.rs
@@ -0,0 +1,412 @@
+//! FxTwitter v2 wire types. Unknown fields are ignored; required identity fields are not.
+use limitless_core::{
+ Error, Media, Post, Profile, Target, Timeline, safe_media_url, valid_handle, valid_id,
+};
+use serde::Deserialize;
+
+pub fn endpoint(target: &Target, timeline: bool, cursor: Option<&str>) -> Result {
+ if !target.is_valid() {
+ return Err(Error::InvalidTarget);
+ }
+ let path = match target {
+ Target::Profile { handle } => format!(
+ "profile/{handle}{}",
+ if timeline { "/statuses" } else { "" }
+ ),
+ Target::Post { id } => format!("status/{id}"),
+ };
+ let mut url = url::Url::parse(&format!("https://api.fxtwitter.com/2/{path}")).unwrap();
+ if let Some(cursor) = cursor {
+ if cursor.len() > 4096 {
+ return Err(Error::InvalidResponse);
+ }
+ url.query_pairs_mut().append_pair("cursor", cursor);
+ }
+ Ok(url.into())
+}
+#[derive(Deserialize)]
+struct Envelope {
+ code: u16,
+ reason: Option,
+}
+fn error_reason(reason: &str) -> Error {
+ match reason {
+ "suspended" => Error::Suspended,
+ "deleted" => Error::Deleted,
+ "private" | "protected" => Error::Protected,
+ "not_found" => Error::NotFound,
+ _ => Error::Unavailable,
+ }
+}
+fn check(http: u16, body: &str) -> Result<(), Error> {
+ if http == 429 {
+ return Err(Error::RateLimited);
+ }
+ if http >= 500 {
+ return Err(Error::Unavailable);
+ }
+ if body.len() > 4 * 1024 * 1024 {
+ return Err(Error::InvalidResponse);
+ }
+ let e: Envelope = serde_json::from_str(body).map_err(|_| Error::InvalidResponse)?;
+ if let Some(reason) = e.reason {
+ return Err(error_reason(&reason));
+ }
+ for code in [http, e.code] {
+ match code {
+ 200 => {}
+ 404 => return Err(Error::NotFound),
+ 429 => return Err(Error::RateLimited),
+ _ => return Err(Error::Unavailable),
+ }
+ }
+ Ok(())
+}
+#[derive(Deserialize)]
+struct RawProfile {
+ #[serde(rename = "type")]
+ kind: String,
+ id: String,
+ screen_name: String,
+ name: String,
+ protected: bool,
+ description: Option,
+ avatar_url: Option,
+ banner_url: Option,
+ location: Option,
+ joined: Option,
+ followers: Option,
+ following: Option,
+ statuses: Option,
+ website: Option,
+ verification: Option,
+}
+#[derive(Deserialize)]
+struct RawWebsite {
+ url: String,
+}
+#[derive(Deserialize)]
+struct RawVerification {
+ verified: bool,
+ #[serde(rename = "type")]
+ kind: Option,
+}
+impl RawProfile {
+ fn normalize(self) -> Result {
+ if self.protected {
+ return Err(Error::Protected);
+ }
+ if self.kind != "profile"
+ || !valid_id(&self.id)
+ || self.name.len() > 4096
+ || self.description.as_ref().is_some_and(|x| x.len() > 32768)
+ {
+ return Err(Error::InvalidResponse);
+ }
+ valid_handle(&self.screen_name).ok_or(Error::InvalidResponse)?;
+ let handle = self.screen_name;
+ Ok(Profile {
+ id: self.id,
+ handle,
+ name: self.name,
+ biography: self.description,
+ avatar: self.avatar_url.and_then(|u| safe_media_url(&u)),
+ banner: self.banner_url.and_then(|u| safe_media_url(&u)),
+ location: self.location.filter(|v| !v.is_empty()),
+ joined: self.joined,
+ followers: self.followers,
+ following: self.following,
+ statuses: self.statuses,
+ website: self
+ .website
+ .and_then(|w| url::Url::parse(&w.url).ok())
+ .filter(|u| {
+ u.scheme() == "https" && u.username().is_empty() && u.password().is_none()
+ })
+ .map(Into::into),
+ verified: self.verification.as_ref().map(|v| v.verified),
+ verification_type: self.verification.and_then(|v| v.kind),
+ })
+ }
+}
+#[derive(Deserialize)]
+struct ProfileResponse {
+ user: RawProfile,
+}
+pub fn parse_profile(http: u16, body: &str, expected_handle: &str) -> Result {
+ check(http, body)?;
+ let r: ProfileResponse = serde_json::from_str(body).map_err(|_| Error::InvalidResponse)?;
+ let p = r.user.normalize()?;
+ if !p.handle.eq_ignore_ascii_case(expected_handle) {
+ return Err(Error::InvalidResponse);
+ }
+ Ok(p)
+}
+#[derive(Deserialize)]
+#[serde(tag = "type")]
+enum RawPost {
+ #[serde(rename = "status")]
+ Status(Box),
+ #[serde(rename = "tombstone")]
+ Tombstone { reason: String },
+}
+#[derive(Deserialize)]
+struct RawStatus {
+ provider: String,
+ id: String,
+ text: String,
+ author: Box,
+ created_at: Option,
+ media: Option,
+ #[serde(flatten)]
+ metrics: limitless_core::Metrics,
+ quote: Option>,
+ replying_to: Option,
+}
+#[derive(Deserialize)]
+struct RawReply {
+ screen_name: Option,
+}
+#[derive(Deserialize, Default)]
+struct RawMedia {
+ photos: Option>,
+ videos: Option>,
+}
+#[derive(Deserialize)]
+struct RawAttachment {
+ #[serde(rename = "type")]
+ kind: String,
+ url: String,
+ thumbnail_url: Option,
+ #[serde(rename = "altText")]
+ alt: Option,
+}
+impl RawPost {
+ fn normalize(self) -> Result {
+ self.normalize_depth(0)
+ }
+ fn normalize_depth(self, depth: u8) -> Result {
+ if depth > 6 {
+ return Err(Error::InvalidResponse);
+ }
+ match self {
+ Self::Tombstone { reason } => Err(error_reason(&reason)),
+ Self::Status(raw) => {
+ let RawStatus {
+ provider,
+ id,
+ text,
+ author,
+ created_at,
+ media,
+ metrics,
+ quote,
+ replying_to,
+ } = *raw;
+ if provider != "twitter"
+ || !valid_id(&id)
+ || text.len() > 256 * 1024
+ || created_at.as_ref().is_some_and(|x| x.len() > 128)
+ {
+ return Err(Error::InvalidResponse);
+ }
+ let raw = media.unwrap_or_default();
+ let media = raw
+ .photos
+ .unwrap_or_default()
+ .into_iter()
+ .chain(raw.videos.unwrap_or_default())
+ .take(16)
+ .filter_map(|m| {
+ let kind = match m.kind.as_str() {
+ "photo" => "photo",
+ "video" | "gif" => "video",
+ _ => return None,
+ };
+ Some(Media {
+ kind: kind.into(),
+ url: safe_media_url(&m.url)?,
+ preview: m.thumbnail_url.and_then(|u| safe_media_url(&u)),
+ alt: m.alt,
+ })
+ })
+ .collect();
+ Ok(Post {
+ id,
+ text,
+ author: author.normalize()?,
+ published: created_at,
+ media,
+ metrics,
+ quote: quote
+ .and_then(|q| q.normalize_depth(depth + 1).ok())
+ .map(Box::new),
+ replying_to: replying_to
+ .and_then(|r| r.screen_name)
+ .filter(|h| valid_handle(h).is_some()),
+ })
+ }
+ }
+ }
+}
+#[derive(Deserialize)]
+struct PostResponse {
+ status: RawPost,
+}
+pub fn parse_post(http: u16, body: &str, expected_id: &str) -> Result {
+ check(http, body)?;
+ let r: PostResponse = serde_json::from_str(body).map_err(|_| Error::InvalidResponse)?;
+ let p = r.status.normalize()?;
+ if p.id != expected_id {
+ return Err(Error::InvalidResponse);
+ }
+ Ok(p)
+}
+#[derive(Deserialize)]
+struct Cursor {
+ bottom: Option,
+}
+#[derive(Deserialize)]
+struct TimelineResponse {
+ results: Vec,
+ cursor: Option,
+}
+pub fn parse_timeline(http: u16, body: &str) -> Result {
+ check(http, body)?;
+ let r: TimelineResponse = serde_json::from_str(body).map_err(|_| Error::InvalidResponse)?;
+ if r.results.len() > 200 {
+ return Err(Error::InvalidResponse);
+ }
+ let posts = r
+ .results
+ .into_iter()
+ .map(RawPost::normalize)
+ .collect::>()?;
+ let next_cursor = r.cursor.and_then(|c| c.bottom).filter(|s| !s.is_empty());
+ if next_cursor.as_ref().is_some_and(|s| s.len() > 4096) {
+ return Err(Error::InvalidResponse);
+ }
+ Ok(Timeline { posts, next_cursor })
+}
+
+#[derive(Deserialize)]
+struct ConversationResponse {
+ status: RawPost,
+ thread: Option>,
+ replies: Vec,
+ cursor: Option,
+}
+pub fn parse_conversation(
+ http: u16,
+ body: &str,
+ expected: &str,
+) -> Result {
+ check(http, body)?;
+ let r: ConversationResponse = serde_json::from_str(body).map_err(|_| Error::InvalidResponse)?;
+ let post = r.status.normalize()?;
+ if post.id != expected {
+ return Err(Error::InvalidResponse);
+ }
+ if r.replies.len() > 200 || r.thread.as_ref().is_some_and(|t| t.len() > 200) {
+ return Err(Error::InvalidResponse);
+ }
+ let mut unavailable = 0;
+ let mut normalize = |values: Vec| -> Result, Error> {
+ values
+ .into_iter()
+ .filter_map(|raw| match raw {
+ RawPost::Tombstone { .. } => {
+ unavailable += 1;
+ None
+ }
+ post => Some(post.normalize()),
+ })
+ .collect()
+ };
+ let thread = normalize(r.thread.unwrap_or_default())?
+ .into_iter()
+ .filter(|p| p.id != expected)
+ .collect();
+ let replies = normalize(r.replies)?;
+ let next_cursor = r.cursor.and_then(|c| c.bottom).filter(|c| !c.is_empty());
+ if next_cursor.as_ref().is_some_and(|c| c.len() > 4096) {
+ return Err(Error::InvalidResponse);
+ }
+ Ok(limitless_core::Conversation {
+ post,
+ thread,
+ replies,
+ next_cursor,
+ unavailable_replies: unavailable,
+ })
+}
+pub fn conversation_endpoint(id: &str, cursor: Option<&str>) -> Result {
+ if !valid_id(id) {
+ return Err(Error::InvalidTarget);
+ }
+ let mut url =
+ url::Url::parse(&format!("https://api.fxtwitter.com/2/conversation/{id}")).unwrap();
+ if let Some(cursor) = cursor {
+ if cursor.len() > 4096 {
+ return Err(Error::InvalidResponse);
+ }
+ url.query_pairs_mut().append_pair("cursor", cursor);
+ }
+ Ok(url.into())
+}
+
+/// Use public typeahead only to resolve identity. Its placeholder zero counters are not profile data.
+pub fn identity_endpoint(handle: &str) -> Result {
+ let handle = valid_handle(handle).ok_or(Error::InvalidTarget)?;
+ let mut url = url::Url::parse("https://api.fxtwitter.com/2/typeahead").unwrap();
+ url.query_pairs_mut()
+ .append_pair("q", &handle)
+ .append_pair("result_type", "users");
+ Ok(url.into())
+}
+pub fn parse_identity(http: u16, body: &str, expected: &str) -> Result {
+ check(http, body)?;
+ #[derive(Deserialize)]
+ struct Candidate {
+ id: String,
+ screen_name: String,
+ protected: bool,
+ }
+ #[derive(Deserialize)]
+ struct Response {
+ users: Vec,
+ }
+ let response: Response = serde_json::from_str(body).map_err(|_| Error::InvalidResponse)?;
+ if response.users.len() > 100 {
+ return Err(Error::InvalidResponse);
+ }
+ let user = response
+ .users
+ .into_iter()
+ .find(|u| u.screen_name.eq_ignore_ascii_case(expected))
+ .ok_or(Error::NotFound)?;
+ if user.protected {
+ return Err(Error::Protected);
+ }
+ if !valid_id(&user.id) {
+ return Err(Error::InvalidResponse);
+ }
+ Ok(user.id)
+}
+pub fn id_endpoint(id: &str, timeline: bool, cursor: Option<&str>) -> Result {
+ if !valid_id(id) || cursor.is_some_and(|c| c.len() > 4096) {
+ return Err(Error::InvalidTarget);
+ }
+ let mut url = url::Url::parse(&format!(
+ "https://api.fxtwitter.com/2/profile/id:{id}{}",
+ if timeline { "/statuses" } else { "" }
+ ))
+ .unwrap();
+ if timeline {
+ url.query_pairs_mut().append_pair("count", "5");
+ }
+ if let Some(cursor) = cursor {
+ url.query_pairs_mut().append_pair("cursor", cursor);
+ }
+ Ok(url.into())
+}
diff --git a/crates/fxtwitter/tests/provider.rs b/crates/fxtwitter/tests/provider.rs
new file mode 100644
index 0000000..d910e54
--- /dev/null
+++ b/crates/fxtwitter/tests/provider.rs
@@ -0,0 +1,155 @@
+use limitless_core::Error;
+use limitless_fxtwitter::{endpoint, parse_post, parse_profile, parse_timeline};
+const USER: &str = r#"{"code":200,"user":{"type":"profile","id":"12","screen_name":"jack","name":"jack","description":"bio","avatar_url":"https://pbs.twimg.com/a.jpg","protected":false}}"#;
+const POST: &str = r#"{"code":200,"status":{"type":"status","provider":"twitter","id":"20","text":"just setting up my twttr","author":{"type":"profile","id":"12","screen_name":"jack","name":"jack","protected":false},"created_at":"Tue Mar 21 20:50:14 +0000 2006","media":{"photos":[{"type":"photo","url":"https://pbs.twimg.com/a.jpg","altText":"a","width":1,"height":1}]}}}"#;
+#[test]
+fn normalizes_only_real_fields_and_keeps_text_as_text() {
+ let p = parse_profile(200, USER, "jack").unwrap();
+ assert_eq!(p.id, "12");
+ assert_eq!(p.biography.as_deref(), Some("bio"));
+ let p = parse_post(200, POST, "20").unwrap();
+ assert_eq!(p.text, "just setting up my twttr");
+ assert_eq!(p.author.avatar, None);
+ assert_eq!(p.media[0].alt.as_deref(), Some("a"));
+}
+#[test]
+fn cannot_substitute_wrong_content_or_provider() {
+ assert!(parse_profile(200, USER, "other").is_err());
+ assert!(parse_post(200, POST, "21").is_err());
+ assert!(parse_post(200, &POST.replace("twitter", "mastodon"), "20").is_err());
+ assert!(
+ parse_post(
+ 200,
+ &POST.replace("https://pbs.twimg.com/a.jpg", "javascript:alert(1)"),
+ "20"
+ )
+ .unwrap()
+ .media
+ .is_empty()
+ );
+}
+#[test]
+fn separates_unavailability_states_and_never_infers_withholding() {
+ for (body, error) in [
+ (r#"{"code":404,"reason":"suspended"}"#, Error::Suspended),
+ (
+ r#"{"code":200,"status":{"type":"tombstone","provider":"twitter","reason":"deleted","message":"gone"}}"#,
+ Error::Deleted,
+ ),
+ (
+ r#"{"code":200,"status":{"type":"tombstone","provider":"twitter","reason":"private","message":"private"}}"#,
+ Error::Protected,
+ ),
+ (
+ r#"{"code":404,"message":"User not found"}"#,
+ Error::NotFound,
+ ),
+ (r#"{"code":429}"#, Error::RateLimited),
+ ] {
+ assert_eq!(parse_post(200, body, "20").unwrap_err(), error);
+ }
+ assert_eq!(parse_post(503, POST, "20").unwrap_err(), Error::Unavailable);
+ assert_eq!(
+ parse_post(200, "not json", "20").unwrap_err(),
+ Error::InvalidResponse
+ );
+ assert_eq!(
+ parse_profile(200, &USER.replace("false", "true"), "jack").unwrap_err(),
+ Error::Protected
+ );
+}
+#[test]
+fn preserves_pagination_and_does_not_invent_empty_arrays() {
+ let status: serde_json::Value = serde_json::from_str(POST).unwrap();
+ let timeline =
+ serde_json::json!({"code":200,"results":[status["status"]],"cursor":{"bottom":"next+/="}})
+ .to_string();
+ let t = parse_timeline(200, &timeline).unwrap();
+ assert_eq!(t.posts.len(), 1);
+ assert_eq!(t.next_cursor.as_deref(), Some("next+/="));
+ assert!(parse_timeline(200, r#"{"code":200}"#).is_err());
+ assert!(
+ parse_timeline(200, r#"{"code":200,"results":[],"cursor":{"bottom":null}}"#)
+ .unwrap()
+ .posts
+ .is_empty()
+ );
+ assert!(
+ endpoint(
+ &limitless_core::Target::parse("@jack").unwrap(),
+ true,
+ Some("next+/=")
+ )
+ .unwrap()
+ .ends_with("?cursor=next%2B%2F%3D")
+ );
+}
+
+#[test]
+fn normalizes_real_profile_metadata_and_post_metrics_without_inventing_nulls() {
+ let mut user: serde_json::Value = serde_json::from_str(USER).unwrap();
+ user["user"]["banner_url"] = serde_json::json!("https://pbs.twimg.com/profile_banners/12/1");
+ user["user"]["followers"] = serde_json::json!(9876543);
+ user["user"]["following"] = serde_json::json!(820);
+ user["user"]["location"] = serde_json::json!("İstanbul");
+ user["user"]["joined"] = serde_json::json!("Sun Nov 27 19:45:16 +0000 2011");
+ user["user"]["verification"] = serde_json::json!({"verified":true,"type":"individual"});
+ let p = parse_profile(200, &user.to_string(), "jack").unwrap();
+ assert_eq!(p.followers, Some(9876543));
+ assert!(p.banner.is_some());
+ assert_eq!(p.verified, Some(true));
+ let mut status: serde_json::Value = serde_json::from_str(POST).unwrap();
+ status["status"]["likes"] = serde_json::json!(0);
+ status["status"]["views"] = serde_json::Value::Null;
+ status["status"]["replies"] = serde_json::json!(34);
+ let p = parse_post(200, &status.to_string(), "20").unwrap();
+ assert_eq!(p.metrics.likes, Some(0));
+ assert_eq!(p.metrics.views, None);
+ assert_eq!(p.metrics.replies, Some(34));
+}
+#[test]
+fn conversation_checks_focal_identity_and_retains_reply_cursor() {
+ let status: serde_json::Value = serde_json::from_str(POST).unwrap();
+ let mut reply = status["status"].clone();
+ reply["id"] = serde_json::json!("21");
+ reply["text"] = serde_json::json!("a real reply");
+ let body=serde_json::json!({"code":200,"status":status["status"],"thread":null,"replies":[reply,{"type":"tombstone","reason":"deleted","provider":"twitter","message":"gone"}],"cursor":{"bottom":"reply+/="}}).to_string();
+ let c = limitless_fxtwitter::parse_conversation(200, &body, "20").unwrap();
+ assert_eq!(c.post.id, "20");
+ assert_eq!(c.replies[0].id, "21");
+ assert_eq!(c.next_cursor.as_deref(), Some("reply+/="));
+ assert_eq!(c.unavailable_replies, 1);
+ assert!(limitless_fxtwitter::parse_conversation(200, &body, "22").is_err());
+ assert_eq!(
+ limitless_fxtwitter::conversation_endpoint("20", Some("reply+/=")).unwrap(),
+ "https://api.fxtwitter.com/2/conversation/20?cursor=reply%2B%2F%3D"
+ );
+}
+
+#[test]
+fn accepts_the_actual_live_v2_conversation_shape() {
+ let c = limitless_fxtwitter::parse_conversation(
+ 200,
+ include_str!("../../../tests/fixtures/public-conversation.json"),
+ "2115761952937128381",
+ )
+ .unwrap();
+ assert_eq!(c.replies.len(), 2);
+ assert!(c.next_cursor.is_some());
+ assert!(c.post.metrics.likes.is_some());
+}
+
+#[test]
+fn identity_resolution_is_exact_and_never_uses_suggestion_counters() {
+ let body = r#"{"code":200,"users":[{"id":"99","screen_name":"jackfan","protected":false},{"id":"12","screen_name":"Jack","protected":false,"followers":0}]}"#;
+ assert_eq!(
+ limitless_fxtwitter::parse_identity(200, body, "jack").unwrap(),
+ "12"
+ );
+ assert!(limitless_fxtwitter::parse_identity(200, body, "other").is_err());
+ assert!(limitless_fxtwitter::id_endpoint("12/evil", true, None).is_err());
+ assert_eq!(
+ limitless_fxtwitter::id_endpoint("12", true, Some("+/=")).unwrap(),
+ "https://api.fxtwitter.com/2/profile/id:12/statuses?count=5&cursor=%2B%2F%3D"
+ );
+}
diff --git a/crates/runtime/Cargo.toml b/crates/runtime/Cargo.toml
new file mode 100644
index 0000000..5688420
--- /dev/null
+++ b/crates/runtime/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "limitless-runtime"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+limitless-core.workspace = true
+limitless-fxtwitter.workspace = true
+limitless-storage.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+url.workspace = true
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+futures = "0.3"
+wasm-bindgen.workspace = true
+wasm-bindgen-futures.workspace = true
+serde-wasm-bindgen.workspace = true
+js-sys.workspace = true
+web-sys = { workspace = true, features = ["Request", "RequestInit", "RequestCredentials", "RequestRedirect", "RequestCache", "Response", "Headers", "AbortController", "AbortSignal", "ReadableStream", "ReadableStreamDefaultReader"] }
diff --git a/crates/runtime/src/icons.rs b/crates/runtime/src/icons.rs
new file mode 100644
index 0000000..8b21c28
--- /dev/null
+++ b/crates/runtime/src/icons.rs
@@ -0,0 +1,14 @@
+//! Native X action paths captured through the user-authorized Helium inspection.
+pub const REPLY: &str = "M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z";
+pub const REPOST: &str = "M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z";
+pub const LIKE: &str = "M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z";
+pub const BOOKMARK: &str = "M4 4.5C4 3.12 5.119 2 6.5 2h11C18.881 2 20 3.12 20 4.5v18.44l-8-5.71-8 5.71V4.5zM6.5 4c-.276 0-.5.22-.5.5v14.56l6-4.29 6 4.29V4.5c0-.28-.224-.5-.5-.5h-11z";
+pub const SHARE: &str = "M12 2.59l5.7 5.7-1.41 1.42L13 6.41V16h-2V6.41l-3.3 3.3-1.41-1.42L12 2.59zM21 15l-.02 3.51c0 1.38-1.12 2.49-2.5 2.49H5.5C4.11 21 3 19.88 3 18.5V15h2v3.5c0 .28.22.5.5.5h12.98c.28 0 .5-.22.5-.5L19 15h2z";
+pub const VIEWS: &str =
+ "M8.75 21V3h2v18h-2zM18 21V8.5h2V21h-2zM4 21l.004-10h2L6 21H4zm9.248 0v-7h2v7h-2z";
+
+pub const VERIFIED: &str = "M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z";
+
+pub const LOCATION: &str = "M12 7c-1.93 0-3.5 1.57-3.5 3.5S10.07 14 12 14s3.5-1.57 3.5-3.5S13.93 7 12 7zm0 5c-.827 0-1.5-.673-1.5-1.5S11.173 9 12 9s1.5.673 1.5 1.5S12.827 12 12 12zm0-10c-4.687 0-8.5 3.813-8.5 8.5 0 5.967 7.621 11.116 7.945 11.332l.555.37.555-.37c.324-.216 7.945-5.365 7.945-11.332C20.5 5.813 16.687 2 12 2zm0 17.77c-1.665-1.241-6.5-5.196-6.5-9.27C5.5 6.916 8.416 4 12 4s6.5 2.916 6.5 6.5c0 4.073-4.835 8.028-6.5 9.27z";
+pub const WEBSITE: &str = "M18.36 5.64c-1.95-1.96-5.11-1.96-7.07 0L9.88 7.05 8.46 5.64l1.42-1.42c2.73-2.73 7.16-2.73 9.9 0 2.73 2.74 2.73 7.17 0 9.9l-1.42 1.42-1.41-1.42 1.41-1.41c1.96-1.96 1.96-5.12 0-7.07zm-2.12 3.53l-7.07 7.07-1.41-1.41 7.07-7.07 1.41 1.41zm-12.02.71l1.42-1.42 1.41 1.42-1.41 1.41c-1.96 1.96-1.96 5.12 0 7.07 1.95 1.96 5.11 1.96 7.07 0l1.41-1.41 1.42 1.41-1.42 1.42c-2.73 2.73-7.16 2.73-9.9 0-2.73-2.74-2.73-7.17 0-9.9z";
+pub const JOINED: &str = "M7 4V3h2v1h6V3h2v1h1.5C19.89 4 21 5.12 21 6.5v12c0 1.38-1.11 2.5-2.5 2.5h-13C4.12 21 3 19.88 3 18.5v-12C3 5.12 4.12 4 5.5 4H7zm0 2H5.5c-.27 0-.5.22-.5.5v12c0 .28.23.5.5.5h13c.28 0 .5-.22.5-.5v-12c0-.28-.22-.5-.5-.5H17v1h-2V6H9v1H7V6zm0 6h2v-2H7v2zm0 4h2v-2H7v2zm4-4h2v-2h-2v2zm0 4h2v-2h-2v2zm4-4h2v-2h-2v2z";
diff --git a/crates/runtime/src/lib.rs b/crates/runtime/src/lib.rs
new file mode 100644
index 0000000..1cac8b4
--- /dev/null
+++ b/crates/runtime/src/lib.rs
@@ -0,0 +1,97 @@
+mod icons;
+pub mod view;
+use limitless_core::Target;
+use serde::{Deserialize, Serialize};
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Sender {
+ pub id: String,
+ pub url: String,
+ #[serde(default)]
+ pub page_url: Option,
+ pub tab_id: Option,
+ pub frame_id: u32,
+ pub extension_origin: String,
+}
+impl Sender {
+ pub fn current_url(&self) -> &str {
+ self.page_url.as_deref().unwrap_or(&self.url)
+ }
+ fn same_document_origin(&self) -> bool {
+ let (Ok(document), Ok(current)) = (
+ url::Url::parse(&self.url),
+ url::Url::parse(self.current_url()),
+ ) else {
+ return false;
+ };
+ document.origin() == current.origin()
+ }
+}
+pub fn authorized(sender: &Sender, op: &str) -> bool {
+ if sender.id.is_empty() || sender.frame_id != 0 {
+ return false;
+ }
+ if let Some(path) = sender.url.strip_prefix(&sender.extension_origin) {
+ return matches!(path.split('?').next(), Some("popup.html"))
+ && matches!(
+ op,
+ "ping"
+ | "record"
+ | "settings"
+ | "clear"
+ | "open_manual"
+ | "current"
+ | "open_current"
+ );
+ }
+ let Ok(u) = url::Url::parse(&sender.url) else {
+ return false;
+ };
+ sender.same_document_origin()
+ && u.scheme() == "https"
+ && matches!(
+ u.host_str(),
+ Some("x.com" | "www.x.com" | "twitter.com" | "www.twitter.com")
+ )
+ && sender.tab_id.is_some()
+ && matches!(
+ op,
+ "inspect"
+ | "open_detected"
+ | "auto_detected"
+ | "pending"
+ | "read"
+ | "timeline"
+ | "open_post"
+ | "replies"
+ )
+}
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Session {
+ pub target: Target,
+ pub source: Target,
+ pub restricted: bool,
+ #[serde(default)]
+ pub deliberate: bool,
+ #[serde(default)]
+ pub pending: bool,
+ #[serde(default)]
+ pub created: f64,
+ pub expires: f64,
+ pub tab_id: i32,
+ #[serde(default)]
+ pub allowed_posts: Vec,
+ #[serde(default)]
+ pub content: Option,
+}
+impl Session {
+ pub fn bound_to(&self, sender: &Sender) -> bool {
+ sender.tab_id == Some(self.tab_id)
+ && Target::parse(sender.current_url()).is_ok_and(|target| target == self.source)
+ }
+ pub fn valid(&self, target: &Target, now: f64) -> bool {
+ self.target == *target && target.is_valid() && now.is_finite() && now < self.expires
+ }
+}
+
+#[cfg(target_arch = "wasm32")]
+mod wasm;
diff --git a/crates/runtime/src/view.rs b/crates/runtime/src/view.rs
new file mode 100644
index 0000000..5da615d
--- /dev/null
+++ b/crates/runtime/src/view.rs
@@ -0,0 +1,886 @@
+//! A typed DOM description rendered by the narrow content-script DOM adapter.
+//! All product copy, view decisions and actions are constructed in Rust.
+use limitless_core::{Error, Post, Profile, Target, Timeline};
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(untagged)]
+pub enum Node {
+ Text(String),
+ Element {
+ tag: String,
+ attrs: Vec<(String, String)>,
+ children: Vec,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ action: Option,
+ },
+}
+fn text(s: impl Into) -> Node {
+ Node::Text(s.into())
+}
+fn el(tag: &str, class: &str, children: Vec) -> Node {
+ Node::Element {
+ tag: tag.into(),
+ attrs: if class.is_empty() {
+ vec![]
+ } else {
+ vec![("class".into(), class.into())]
+ },
+ children,
+ action: None,
+ }
+}
+fn attr(mut node: Node, key: &str, value: impl Into) -> Node {
+ if let Node::Element { attrs, .. } = &mut node {
+ attrs.push((key.into(), value.into()));
+ }
+ node
+}
+fn button(label: &str, action: Value) -> Node {
+ let mut node = el("button", "secondary", vec![text(label)]);
+ if let Node::Element { action: a, .. } = &mut node {
+ *a = Some(action);
+ }
+ attr(node, "type", "button")
+}
+fn link(label: &str, url: String) -> Node {
+ attr(
+ attr(
+ attr(el("a", "source-link", vec![text(label)]), "href", url),
+ "target",
+ "_blank",
+ ),
+ "rel",
+ "noopener noreferrer",
+ )
+}
+fn frame(children: Vec) -> Node {
+ el("main", "reader x-reader", children)
+}
+pub fn loading(_target: &Target) -> Node {
+ frame(vec![attr(
+ el("p", "x-loading", vec![text("Loading public content…")]),
+ "role",
+ "status",
+ )])
+}
+pub fn error(error: &Error, retry: Option) -> Node {
+ let mut children = vec![el("p", "", vec![text(error.to_string())])];
+ if let Some(retry) = retry {
+ children.push(button("Try again", retry));
+ }
+ frame(vec![attr(
+ el("section", "x-error", children),
+ "role",
+ "alert",
+ )])
+}
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum ProfileFilter {
+ #[default]
+ Posts,
+ Replies,
+ Reposts,
+ Media,
+}
+impl ProfileFilter {
+ pub fn from_url(url: &str) -> Self {
+ match url::Url::parse(url)
+ .ok()
+ .as_ref()
+ .map(|u| {
+ u.path()
+ .trim_end_matches('/')
+ .rsplit('/')
+ .next()
+ .unwrap_or("")
+ .to_string()
+ })
+ .as_deref()
+ {
+ Some("with_replies") => Self::Replies,
+ Some("reposts") => Self::Reposts,
+ Some("media") => Self::Media,
+ _ => Self::Posts,
+ }
+ }
+ fn accepts(self, post: &Post, owner: Option<&Profile>) -> bool {
+ match self {
+ Self::Posts => true,
+ Self::Replies => post.replying_to.is_some(),
+ Self::Reposts => {
+ owner.is_some_and(|p| !p.handle.eq_ignore_ascii_case(&post.author.handle))
+ }
+ Self::Media => !post.media.is_empty(),
+ }
+ }
+}
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
+pub struct Content {
+ #[serde(default)]
+ pub visible_posts: usize,
+ #[serde(default)]
+ pub profile_filter: ProfileFilter,
+ pub profile: Option,
+ pub post: Option,
+ pub timeline: Option,
+ pub profile_error: Option,
+ pub timeline_error: Option,
+ pub record_error: Option,
+ #[serde(default)]
+ pub conversation: Option,
+ #[serde(default)]
+ pub replies_error: Option,
+}
+impl Content {
+ pub fn visible_limit(&self) -> usize {
+ self.visible_posts.max(5)
+ }
+ pub fn reveal_buffered(&mut self) -> bool {
+ let len = self.timeline.as_ref().map_or(0, |t| {
+ t.posts
+ .iter()
+ .filter(|p| self.profile_filter.accepts(p, self.profile.as_ref()))
+ .count()
+ });
+ if self.visible_limit() >= len {
+ return false;
+ }
+ self.visible_posts = (self.visible_limit() + 5).min(len);
+ true
+ }
+}
+fn svg(path: &str, class: &str) -> Node {
+ attr(
+ attr(
+ attr(
+ el("svg", class, vec![attr(el("path", "", vec![]), "d", path)]),
+ "viewBox",
+ "0 0 24 24",
+ ),
+ "fill",
+ "currentColor",
+ ),
+ "aria-hidden",
+ "true",
+ )
+}
+fn compact(n: u64) -> String {
+ let (value, suffix) = if n >= 1_000_000_000 {
+ (n as f64 / 1e9, "B")
+ } else if n >= 1_000_000 {
+ (n as f64 / 1e6, "M")
+ } else if n >= 1000 {
+ (n as f64 / 1e3, "K")
+ } else {
+ return n.to_string();
+ };
+ format!(
+ "{}{suffix}",
+ format!("{value:.1}")
+ .trim_end_matches('0')
+ .trim_end_matches('.')
+ )
+}
+fn linked_text(value: &str) -> Vec {
+ value
+ .split_inclusive(char::is_whitespace)
+ .flat_map(|chunk| {
+ let token = chunk.trim_end_matches(char::is_whitespace);
+ let rest = &chunk[token.len()..];
+ let url = if token.starts_with("https://") {
+ url::Url::parse(token)
+ .ok()
+ .filter(|u| u.username().is_empty() && u.password().is_none())
+ .map(Into::into)
+ } else if token.starts_with('@') && limitless_core::valid_handle(&token[1..]).is_some()
+ {
+ Some(format!("https://x.com/{}", &token[1..]))
+ } else if token.starts_with('#') && token.len() > 1 {
+ let mut u = url::Url::parse("https://x.com/hashtag/").unwrap();
+ u.path_segments_mut().unwrap().push(&token[1..]);
+ Some(u.into())
+ } else {
+ None
+ };
+ let first = if let Some(url) = url {
+ attr(link(token, url), "class", "x-entity")
+ } else {
+ text(token)
+ };
+ vec![first, text(rest)]
+ })
+ .collect()
+}
+fn badge(p: &Profile) -> Option {
+ p.verified.filter(|v| *v).map(|_| {
+ let path = crate::icons::VERIFIED;
+ attr(
+ attr(
+ svg(
+ path,
+ if p.verification_type.as_deref() == Some("organization") {
+ "x-verified organization"
+ } else {
+ "x-verified"
+ },
+ ),
+ "viewBox",
+ "0 0 22 22",
+ ),
+ "aria-label",
+ "Verified",
+ )
+ })
+}
+fn author(p: &Profile, detailed: bool) -> Node {
+ let mut name = vec![attr(
+ link(&p.name, format!("https://x.com/{}", p.handle)),
+ "class",
+ "x-author-name",
+ )];
+ if let Some(badge) = badge(p) {
+ name.push(badge);
+ }
+ el(
+ "div",
+ if detailed {
+ "x-author detailed-author"
+ } else {
+ "x-author"
+ },
+ vec![
+ el("span", "x-name-line", name),
+ attr(
+ link(
+ &format!("@{}", p.handle),
+ format!("https://x.com/{}", p.handle),
+ ),
+ "class",
+ "x-handle",
+ ),
+ ],
+ )
+}
+fn avatar(p: &Profile, class: &str) -> Node {
+ if let Some(src) = &p.avatar {
+ let src = if class == "x-profile-avatar" {
+ src.replace("_normal.", "_200x200.")
+ } else {
+ src.clone()
+ };
+ attr(
+ attr(
+ attr(attr(el("img", class, vec![]), "src", &src), "alt", ""),
+ "crossorigin",
+ "anonymous",
+ ),
+ "referrerpolicy",
+ "no-referrer",
+ )
+ } else {
+ attr(
+ el("div", &format!("{class} avatar-placeholder"), vec![]),
+ "aria-label",
+ "Profile image unavailable",
+ )
+ }
+}
+fn profile(p: &Profile) -> Node {
+ let mut hero = vec![];
+ if let Some(banner) = &p.banner {
+ hero.push(attr(
+ attr(
+ attr(el("img", "x-banner", vec![]), "src", banner),
+ "alt",
+ "",
+ ),
+ "referrerpolicy",
+ "no-referrer",
+ ));
+ }
+ hero.push(avatar(p, "x-profile-avatar"));
+ let mut name = vec![text(&p.name)];
+ if let Some(b) = badge(p) {
+ name.push(b);
+ }
+ let mut info = vec![
+ el(
+ "div",
+ "x-profile-actions",
+ vec![attr(
+ link(
+ "Follow",
+ format!("https://x.com/intent/follow?screen_name={}", p.handle),
+ ),
+ "class",
+ "x-follow",
+ )],
+ ),
+ el(
+ "div",
+ "x-profile-names",
+ vec![
+ el("h2", "", name),
+ el("p", "x-handle", vec![text(format!("@{}", p.handle))]),
+ ],
+ ),
+ ];
+ if let Some(bio) = &p.biography {
+ info.push(el("div", "biography", linked_text(bio)));
+ }
+ let mut details = vec![];
+ if let Some(location) = &p.location {
+ details.push(el(
+ "span",
+ "x-profile-location",
+ vec![
+ svg(crate::icons::LOCATION, "x-profile-icon"),
+ text(location),
+ ],
+ ));
+ }
+ if let Some(website) = &p.website {
+ let label = website
+ .trim_start_matches("https://")
+ .trim_end_matches('/')
+ .to_string();
+ details.push(attr(
+ link(&label, website.clone()).with_children(vec![
+ svg(crate::icons::WEBSITE, "x-profile-icon"),
+ text(label),
+ ]),
+ "class",
+ "x-profile-website",
+ ));
+ }
+ if let Some(joined) = &p.joined {
+ let fields: Vec<_> = joined.split_whitespace().collect();
+ let label = if fields.len() >= 6 {
+ let month = match fields[1] {
+ "Jan" => "January",
+ "Feb" => "February",
+ "Mar" => "March",
+ "Apr" => "April",
+ "May" => "May",
+ "Jun" => "June",
+ "Jul" => "July",
+ "Aug" => "August",
+ "Sep" => "September",
+ "Oct" => "October",
+ "Nov" => "November",
+ "Dec" => "December",
+ other => other,
+ };
+ format!("Joined {month} {}", fields[5])
+ } else {
+ format!("Joined {joined}")
+ };
+ details.push(el(
+ "span",
+ "x-profile-joined",
+ vec![svg(crate::icons::JOINED, "x-profile-icon"), text(label)],
+ ));
+ }
+ if !details.is_empty() {
+ info.push(el("div", "x-profile-details", details));
+ }
+ let mut counts = vec![];
+ for (number, label, path) in [
+ (p.following, "Following", "following"),
+ (p.followers, "Followers", "verified_followers"),
+ ] {
+ if let Some(n) = number {
+ counts.push(
+ attr(
+ link("", format!("https://x.com/{}/{path}", p.handle)),
+ "class",
+ "x-profile-count",
+ )
+ .with_children(vec![
+ el("strong", "", vec![text(compact(n))]),
+ text(format!(" {label}")),
+ ]),
+ );
+ }
+ }
+ if !counts.is_empty() {
+ info.push(el("div", "x-profile-counts", counts));
+ }
+ el(
+ "section",
+ "x-profile",
+ vec![
+ el("div", "x-profile-hero", hero),
+ el("div", "x-profile-info", info),
+ ],
+ )
+}
+impl Node {
+ fn with_attr(self, key: &str, value: &str) -> Self {
+ attr(self, key, value)
+ }
+ fn with_children(mut self, next: Vec) -> Self {
+ if let Node::Element { children, .. } = &mut self {
+ *children = next;
+ }
+ self
+ }
+}
+fn short_date(value: &str) -> String {
+ let fields: Vec<_> = value.split_whitespace().collect();
+ if fields.len() == 6 {
+ format!("{} {}, {}", fields[1], fields[2], fields[5])
+ } else {
+ value.into()
+ }
+}
+fn date_label(value: &str, detailed: bool) -> String {
+ #[cfg(target_arch = "wasm32")]
+ {
+ let date = js_sys::Date::new(&wasm_bindgen::JsValue::from_str(value));
+ if date.get_time().is_finite() {
+ let months = [
+ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
+ ];
+ let month = months[date.get_month() as usize];
+ let day = date.get_date();
+ let year = date.get_full_year();
+ if detailed {
+ let h = date.get_hours();
+ return format!(
+ "{}:{:02} {} · {month} {day}, {year}",
+ if h.is_multiple_of(12) { 12 } else { h % 12 },
+ date.get_minutes(),
+ if h >= 12 { "PM" } else { "AM" }
+ );
+ }
+ let elapsed = (js_sys::Date::now() - date.get_time()) / 1000.;
+ if (0.0..60.).contains(&elapsed) {
+ return format!("{}s", elapsed.max(1.) as u64);
+ }
+ if (60.0..3600.).contains(&elapsed) {
+ return format!("{}m", (elapsed / 60.) as u64);
+ }
+ if (3600.0..86400.).contains(&elapsed) {
+ return format!("{}h", (elapsed / 3600.) as u64);
+ }
+ let current_year = js_sys::Date::new_0().get_full_year();
+ return if year == current_year {
+ format!("{month} {day}")
+ } else {
+ format!("{month} {day}, {year}")
+ };
+ }
+ }
+ let _ = detailed;
+ short_date(value)
+}
+fn media_control(label: &str, command: &str, path: &str) -> Node {
+ attr(
+ attr(
+ attr(
+ el(
+ "button",
+ "lx-media-button",
+ vec![svg(path, "lx-media-icon")],
+ ),
+ "type",
+ "button",
+ ),
+ "aria-label",
+ label,
+ ),
+ "data-media-command",
+ command,
+ )
+}
+fn media(p: &Post) -> Node {
+ el(
+ "div",
+ &format!("media-grid media-count-{}", p.media.len().min(4)),
+ p.media
+ .iter()
+ .map(|m| {
+ if m.kind == "photo" {
+ attr(
+ attr(
+ attr(
+ attr(
+ attr(el("img", "post-media", vec![]), "src", &m.url),
+ "alt",
+ m.alt.as_deref().unwrap_or("Image from this post"),
+ ),
+ "loading",
+ "lazy",
+ ),
+ "crossorigin",
+ "anonymous",
+ ),
+ "referrerpolicy",
+ "no-referrer",
+ )
+ } else {
+ let mut n = attr(
+ attr(
+ attr(
+ attr(el("video", "post-media", vec![]), "src", &m.url),
+ "controls",
+ "",
+ ),
+ "preload",
+ "none",
+ ),
+ "crossorigin",
+ "anonymous",
+ );
+ n = attr(n, "playsinline", "");
+ if let Some(preview) = &m.preview {
+ n = attr(n, "poster", preview);
+ }
+ let controls=el("div","lx-media-controls",vec![
+ media_control("Play video","play","M8 5v14l11-7z"),
+ attr(attr(attr(attr(attr(el("input","lx-seek",vec![]),"type","range"),"min","0"),"max","1000"),"value","0"),"aria-label","Seek video").with_attr("data-media-role","seek"),
+ attr(el("span","lx-media-time",vec![text("0:00 / 0:00")]),"data-media-role","time"),
+ media_control("Mute video","mute","M3 9v6h4l5 4V5L7 9H3zm12-2v10c2-1 3-3 3-5s-1-4-3-5z"),
+ attr(attr(attr(attr(attr(attr(el("input","lx-volume",vec![]),"type","range"),"min","0"),"max","1"),"step","0.05"),"value","1"),"aria-label","Volume").with_attr("data-media-role","volume"),
+ attr(attr(el("select","lx-speed",["0.5","1","1.5","2"].into_iter().map(|rate|attr(el("option","",vec![text(format!("{rate}×"))]),"value",rate)).collect()),"aria-label","Playback speed"),"data-media-role","speed"),
+ media_control("Full screen","fullscreen","M3 3h7v2H5v5H3V3zm11 0h7v7h-2V5h-5V3zM3 14h2v5h5v2H3v-7zm16 0h2v7h-7v-2h5v-5z"),
+ ]);
+ el("div","lx-player",vec![n,controls,
+ attr(attr(el("p","lx-media-status",vec![]),"data-media-role","status"),"role","status"),
+ attr(attr(el("button","lx-media-retry",vec![text("Retry video")]),"type","button"),"data-media-command","retry"),
+ attr(link("Open video on X",format!("https://x.com/{}/status/{}",p.author.handle,p.id)),"class","lx-media-source"),
+ ])
+ }
+ })
+ .collect(),
+ )
+}
+fn metrics(p: &Post, detailed: bool) -> Node {
+ let mut items = vec![];
+ let data = if detailed {
+ vec![
+ ("Reply", crate::icons::REPLY, p.metrics.replies),
+ ("Repost", crate::icons::REPOST, p.metrics.reposts),
+ ("Like", crate::icons::LIKE, p.metrics.likes),
+ ("Bookmark", crate::icons::BOOKMARK, p.metrics.bookmarks),
+ ("Share", crate::icons::SHARE, None),
+ ]
+ } else {
+ vec![
+ ("Reply", crate::icons::REPLY, p.metrics.replies),
+ ("Repost", crate::icons::REPOST, p.metrics.reposts),
+ ("Like", crate::icons::LIKE, p.metrics.likes),
+ ("Views", crate::icons::VIEWS, p.metrics.views),
+ ("Bookmark", crate::icons::BOOKMARK, None),
+ ("Share", crate::icons::SHARE, None),
+ ]
+ };
+ for (label, path, count) in data {
+ let mut children = vec![svg(path, "x-metric-icon")];
+ if let Some(number) = count.filter(|n| *n > 0) {
+ children.push(el("span", "x-metric-value", vec![text(compact(number))]));
+ }
+ let href = match label {
+ "Reply" => format!("https://x.com/intent/post?in_reply_to={}", p.id),
+ "Repost" => format!("https://x.com/intent/retweet?tweet_id={}", p.id),
+ "Like" => format!("https://x.com/intent/like?tweet_id={}", p.id),
+ _ => format!("https://x.com/{}/status/{}", p.author.handle, p.id),
+ };
+ items.push(
+ attr(
+ attr(
+ link("", href),
+ "class",
+ format!("x-metric metric-{}", label.to_ascii_lowercase()),
+ ),
+ "aria-label",
+ format!(
+ "{label}{} · Open on X",
+ count.map(|n| format!(" {n}")).unwrap_or_default()
+ ),
+ )
+ .with_children(children),
+ );
+ }
+ attr(
+ el(
+ "div",
+ if detailed {
+ "x-metrics detailed-metrics"
+ } else {
+ "x-metrics"
+ },
+ items,
+ ),
+ "role",
+ "group",
+ )
+}
+fn quote(p: &Post, depth: u8) -> Node {
+ let mut children = vec![
+ el(
+ "div",
+ "x-quote-author",
+ vec![
+ avatar(&p.author, "x-quote-avatar"),
+ author(&p.author, false),
+ ],
+ ),
+ el("div", "x-quote-text", linked_text(&p.text)),
+ media(p),
+ ];
+ if depth < 3
+ && let Some(q) = &p.quote
+ {
+ children.push(quote(q, depth + 1));
+ }
+ attr(
+ el("section", "x-quote", children),
+ "aria-label",
+ "Quoted post",
+ )
+}
+fn post(p: &Post, token: &str, detailed: bool) -> Node {
+ let mut headline = vec![author(&p.author, detailed)];
+ if !detailed && let Some(date) = &p.published {
+ headline.push(el(
+ "time",
+ "x-timeline-time",
+ vec![text(format!("· {}", date_label(date, false)))],
+ ));
+ }
+ headline.push(attr(
+ link(
+ "…",
+ format!("https://x.com/{}/status/{}", p.author.handle, p.id),
+ ),
+ "class",
+ "x-post-more",
+ ));
+ let header = el("div", "post-heading", headline);
+ let mut body = vec![];
+ let mut body_text = el("p", "post-text", linked_text(&p.text));
+ if !detailed {
+ if let Node::Element { action, .. } = &mut body_text {
+ *action = Some(json!({"op":"open_post","session":token,"id":p.id}));
+ }
+ body_text = attr(attr(body_text, "role", "link"), "tabindex", "0");
+ }
+ body.push(body_text);
+ body.push(media(p));
+ if let Some(q) = &p.quote {
+ body.push(quote(q, 0));
+ }
+ if detailed {
+ let mut meta = vec![];
+ if let Some(date) = &p.published {
+ meta.push(el("time", "", vec![text(date_label(date, true))]));
+ }
+ if let Some(views) = p.metrics.views {
+ meta.push(text(" · "));
+ meta.push(el("strong", "", vec![text(compact(views))]));
+ meta.push(text(" Views"));
+ }
+ if !meta.is_empty() {
+ body.push(el("div", "x-post-meta", meta));
+ }
+ body.push(metrics(p, true));
+ let mut children = vec![el(
+ "div",
+ "x-detail-author",
+ vec![avatar(&p.author, "post-avatar"), header],
+ )];
+ children.extend(body);
+ attr(
+ el("article", "post-card detailed-post", children),
+ "data-post-id",
+ &p.id,
+ )
+ } else {
+ body.insert(0, header);
+ body.push(metrics(p, false));
+ attr(
+ el(
+ "article",
+ "post-card",
+ vec![
+ avatar(&p.author, "post-avatar"),
+ el("div", "post-content", body),
+ ],
+ ),
+ "data-post-id",
+ &p.id,
+ )
+ }
+}
+pub fn reader(content: &Content, target: &Target, token: &str, _restricted: bool) -> Node {
+ let mut children = vec![];
+ if let Some(p) = &content.profile {
+ children.push(profile(p));
+ children.push(attr(
+ el(
+ "div",
+ "x-profile-tabs",
+ [
+ ("Posts", "", ProfileFilter::Posts),
+ ("Replies", "/with_replies", ProfileFilter::Replies),
+ ("Reposts", "/reposts", ProfileFilter::Reposts),
+ ("Media", "/media", ProfileFilter::Media),
+ ]
+ .into_iter()
+ .map(|(label, path, filter)| {
+ attr(
+ attr(
+ link(label, format!("{}{path}", target.canonical_url())),
+ "target",
+ "_self",
+ ),
+ "class",
+ if content.profile_filter == filter {
+ "x-tab active"
+ } else {
+ "x-tab"
+ },
+ )
+ })
+ .collect(),
+ ),
+ "role",
+ "navigation",
+ ));
+ }
+ for (label, error) in [
+ ("Profile lookup", &content.profile_error),
+ ("Posts", &content.timeline_error),
+ ("The Record could not update", &content.record_error),
+ ] {
+ if let Some(error) = error {
+ children.push(el("p", "notice", vec![text(format!("{label}: {error}"))]));
+ }
+ }
+ if let Some(p) = &content.post {
+ children.push(post(p, token, true));
+ }
+ if content.timeline.is_none() && content.timeline_error.is_some() {
+ children.push(button(
+ "Try again",
+ json!({"op":"read","session":token,"target":target}),
+ ));
+ }
+ if let Some(t) = &content.timeline {
+ if !t
+ .posts
+ .iter()
+ .any(|p| content.profile_filter.accepts(p, content.profile.as_ref()))
+ {
+ children.push(el(
+ "p",
+ "empty-state",
+ vec![text(
+ "No matching public posts were returned by the provider.",
+ )],
+ ));
+ }
+ let filtered: Vec<_> = t
+ .posts
+ .iter()
+ .filter(|p| content.profile_filter.accepts(p, content.profile.as_ref()))
+ .collect();
+ for p in filtered.iter().take(content.visible_limit()) {
+ children.push(post(p, token, false));
+ }
+ if filtered.len() > content.visible_limit() || t.next_cursor.is_some() {
+ let cursor = if filtered.len() > content.visible_limit() {
+ None
+ } else {
+ t.next_cursor.as_deref()
+ };
+ let mut sentinel = attr(
+ button(
+ if content.timeline_error.is_some() {
+ "Try again"
+ } else {
+ "Load more posts"
+ },
+ json!({"op":"timeline","session":token,"target":target,"cursor":cursor}),
+ ),
+ "class",
+ "x-scroll-sentinel",
+ );
+ if content.timeline_error.is_none() {
+ sentinel = attr(sentinel, "data-autoload", "true");
+ }
+ children.push(sentinel);
+ }
+ }
+ if let Some(c) = &content.conversation {
+ for p in &c.thread {
+ children.push(post(p, token, false));
+ }
+ children.push(el("div", "x-replies-heading", vec![text("Most relevant")]));
+ for p in &c.replies {
+ children.push(post(p, token, false));
+ }
+ if c.unavailable_replies > 0 {
+ children.push(el(
+ "p",
+ "notice",
+ vec![text("Some replies are unavailable from the source.")],
+ ));
+ }
+ if let Some(cursor) = &c.next_cursor {
+ children.push(attr(
+ button(
+ if content.replies_error.is_some() {
+ "Try again"
+ } else {
+ "Show more replies"
+ },
+ json!({"op":"replies","session":token,"target":target,"cursor":cursor}),
+ ),
+ "class",
+ "x-more-replies",
+ ));
+ }
+ }
+ if let Some(error) = &content.replies_error {
+ children.push(el(
+ "p",
+ "notice",
+ vec![text(format!("Replies unavailable: {error}"))],
+ ));
+ }
+ children.push(el(
+ "p",
+ "attribution",
+ vec![
+ text("LimitlessX · via "),
+ link("FxTwitter", "https://github.com/FxEmbed/FxEmbed".into()),
+ ],
+ ));
+ let page = if let Some(p) = &content.profile {
+ Some((
+ format!("{} (@{}) / X", p.name, p.handle),
+ p.biography.clone().unwrap_or_default(),
+ ))
+ } else {
+ content
+ .post
+ .as_ref()
+ .map(|p| (format!("{} on X", p.author.name), p.text.clone()))
+ };
+ let node = frame(children);
+ if let Some((title, description)) = page {
+ attr(
+ attr(node, "data-page-title", title),
+ "data-page-description",
+ description,
+ )
+ } else {
+ node
+ }
+}
diff --git a/crates/runtime/src/wasm.rs b/crates/runtime/src/wasm.rs
new file mode 100644
index 0000000..96c8121
--- /dev/null
+++ b/crates/runtime/src/wasm.rs
@@ -0,0 +1,758 @@
+use crate::{Sender, Session, authorized};
+use futures::FutureExt;
+use limitless_core::{Error, Notice, Post, Profile, Restriction, Target};
+use limitless_fxtwitter as provider;
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+use std::cell::{Cell, RefCell};
+use wasm_bindgen::{JsCast, prelude::*};
+use wasm_bindgen_futures::JsFuture;
+
+#[wasm_bindgen]
+extern "C" {
+ #[wasm_bindgen(js_namespace=limitlessBridge)]
+ fn random_bytes(size: u32) -> js_sys::Uint8Array;
+ #[wasm_bindgen(js_namespace=limitlessBridge,catch)]
+ async fn session_get(key: &str) -> Result;
+ #[wasm_bindgen(js_namespace=limitlessBridge,catch)]
+ async fn session_set(key: &str, value: JsValue) -> Result<(), JsValue>;
+ #[wasm_bindgen(js_namespace=limitlessBridge,catch)]
+ async fn session_remove(keys: JsValue) -> Result<(), JsValue>;
+ #[wasm_bindgen(js_namespace=limitlessBridge,catch)]
+ async fn session_all() -> Result;
+ #[wasm_bindgen(js_namespace=limitlessBridge,catch)]
+ async fn new_tab() -> Result;
+ #[wasm_bindgen(js_namespace=limitlessBridge,catch)]
+ async fn navigate_tab(tab_id: i32, url: &str) -> Result<(), JsValue>;
+ #[wasm_bindgen(js_namespace=limitlessBridge,catch)]
+ async fn show_in_tab(tab_id: i32, context: JsValue) -> Result;
+ #[wasm_bindgen(js_namespace=limitlessBridge,catch)]
+ async fn active_context() -> Result;
+ #[wasm_bindgen(js_namespace=limitlessBridge)]
+ fn schedule_abort(controller: &web_sys::AbortController, ms: u32) -> i32;
+ #[wasm_bindgen(js_namespace=limitlessBridge)]
+ fn cancel_timer(id: i32);
+ #[wasm_bindgen(js_namespace=globalThis,js_name=fetch)]
+ fn global_fetch(request: &web_sys::Request) -> js_sys::Promise;
+}
+fn now() -> f64 {
+ js_sys::Date::now()
+}
+fn js(value: &T) -> JsValue {
+ value
+ .serialize(&serde_wasm_bindgen::Serializer::json_compatible())
+ .unwrap_or(JsValue::NULL)
+}
+fn decode(value: JsValue) -> Result {
+ serde_wasm_bindgen::from_value(value).map_err(|_| Error::InvalidResponse)
+}
+#[derive(Deserialize)]
+#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)]
+enum Request {
+ Ping,
+ Record,
+ Clear,
+ Settings {
+ enabled: bool,
+ },
+ Inspect {
+ notices: Vec,
+ },
+ OpenDetected {
+ notices: Vec,
+ },
+ AutoDetected {
+ notices: Vec,
+ },
+ Pending,
+ Current,
+ OpenCurrent,
+ OpenManual {
+ input: String,
+ },
+ Read {
+ session: String,
+ target: Target,
+ },
+ Timeline {
+ session: String,
+ target: Target,
+ cursor: Option,
+ },
+ OpenPost {
+ session: String,
+ id: String,
+ },
+ Replies {
+ session: String,
+ target: Target,
+ cursor: String,
+ },
+}
+#[derive(Deserialize)]
+struct ActiveContext {
+ url: String,
+ notices: Vec,
+ tab_id: i32,
+}
+fn restricted(target: &Target, notices: &[Notice]) -> bool {
+ notices.len() <= 8
+ && notices
+ .iter()
+ .any(|n| Restriction::detect(target, n).is_some())
+}
+fn session_key(token: &str) -> Result {
+ if token.len() == 48 && token.bytes().all(|b| b.is_ascii_hexdigit()) {
+ Ok(format!("reader:{token}"))
+ } else {
+ Err(Error::Expired)
+ }
+}
+async fn load_session(
+ token: &str,
+ target: Option<&Target>,
+ sender: &Sender,
+) -> Result {
+ let value = session_get(&session_key(token)?)
+ .await
+ .map_err(|_| Error::Storage)?;
+ let s: Session = decode(value).map_err(|_| Error::Expired)?;
+ if !s.valid(target.unwrap_or(&s.target), now()) || !s.bound_to(sender) {
+ return Err(Error::Expired);
+ }
+ Ok(s)
+}
+async fn create_reader(
+ tab_id: i32,
+ target: Target,
+ source: Target,
+ is_restricted: bool,
+ expires: Option,
+ pending: bool,
+ deliberate: bool,
+) -> Result {
+ // Only live reader contexts are retained in browser memory. No disk-backed session ledger.
+ let all: std::collections::HashMap =
+ decode(session_all().await.map_err(|_| Error::Storage)?).unwrap_or_default();
+ let expired: Vec<_> = all
+ .iter()
+ .filter(|(_, s)| s.expires <= now())
+ .map(|(k, _)| k.clone())
+ .collect();
+ if !expired.is_empty() {
+ session_remove(js(&expired))
+ .await
+ .map_err(|_| Error::Storage)?;
+ }
+ if all.len().saturating_sub(expired.len()) >= 100 {
+ return Err(Error::Unavailable);
+ }
+ let token: String = random_bytes(24)
+ .to_vec()
+ .iter()
+ .map(|b| format!("{b:02x}"))
+ .collect();
+ let s = Session {
+ target: target.clone(),
+ source,
+ pending,
+ deliberate,
+ restricted: is_restricted,
+ created: next_created(),
+ expires: expires.unwrap_or(now() + 10. * 60. * 1000.),
+ tab_id,
+ allowed_posts: vec![],
+ content: None,
+ };
+ session_set(&session_key(&token)?, js(&s))
+ .await
+ .map_err(|_| Error::Storage)?;
+ Ok(
+ json!({"session":token,"target":target,"view":crate::view::loading(&target),"load":{"op":"read","session":token,"target":target}}),
+ )
+}
+async fn deliver(tab_id: i32, context: Value) -> Result {
+ let result: Value = decode(
+ show_in_tab(tab_id, js(&context))
+ .await
+ .map_err(|_| Error::Unavailable)?,
+ )?;
+ if result["ok"] != true {
+ return Err(Error::Unavailable);
+ }
+ Ok(json!({"opened":true}))
+}
+
+thread_local! {static LAST_CREATED:Cell=const{Cell::new(0.)};}
+fn next_created() -> f64 {
+ LAST_CREATED.with(|last| {
+ let value = now().max(last.get() + 0.001);
+ last.set(value);
+ value
+ })
+}
+thread_local! {static COOLDOWN:Cell=const{Cell::new(0.)};}
+struct Timer(i32);
+impl Drop for Timer {
+ fn drop(&mut self) {
+ cancel_timer(self.0)
+ }
+}
+async fn fetch(
+ target: &Target,
+ timeline: bool,
+ cursor: Option<&str>,
+) -> Result<(u16, String), Error> {
+ fetch_url(provider::endpoint(target, timeline, cursor)?).await
+}
+async fn fetch_url(endpoint: String) -> Result<(u16, String), Error> {
+ if COOLDOWN.with(|v| v.get() > now()) {
+ return Err(Error::RateLimited);
+ }
+ let controller = web_sys::AbortController::new().map_err(|_| Error::Network)?;
+ let init = web_sys::RequestInit::new();
+ init.set_method("GET");
+ init.set_credentials(web_sys::RequestCredentials::Omit);
+ init.set_redirect(web_sys::RequestRedirect::Error);
+ init.set_cache(web_sys::RequestCache::NoStore);
+ init.set_referrer("");
+ init.set_signal(Some(&controller.signal()));
+ let request =
+ web_sys::Request::new_with_str_and_init(&endpoint, &init).map_err(|_| Error::Network)?;
+ request
+ .headers()
+ .set("Accept", "application/json")
+ .map_err(|_| Error::Network)?;
+ let _timer = Timer(schedule_abort(&controller, 15000));
+ let map_net = |_: JsValue| {
+ if controller.signal().aborted() {
+ Error::Timeout
+ } else {
+ Error::Network
+ }
+ };
+ let response: web_sys::Response = JsFuture::from(global_fetch(&request))
+ .await
+ .map_err(map_net)?
+ .dyn_into()
+ .map_err(|_| Error::InvalidResponse)?;
+ let status = response.status();
+ if status == 429 {
+ let retry = response.headers().get("Retry-After").ok().flatten();
+ let seconds = retry
+ .and_then(|s| {
+ s.parse::().ok().or_else(|| {
+ let date = js_sys::Date::parse(&s);
+ date.is_finite().then_some((date - now()) / 1000.)
+ })
+ })
+ .unwrap_or(60.)
+ .clamp(1., 3600.);
+ COOLDOWN.with(|v| v.set(now() + seconds * 1000.));
+ return Err(Error::RateLimited);
+ }
+ if status >= 500 {
+ return Err(Error::Unavailable);
+ }
+ if response
+ .headers()
+ .get("Content-Length")
+ .ok()
+ .flatten()
+ .and_then(|s| s.parse::().ok())
+ .is_some_and(|n| n > 4 * 1024 * 1024)
+ {
+ controller.abort();
+ return Err(Error::InvalidResponse);
+ }
+ let reader = response
+ .body()
+ .ok_or(Error::InvalidResponse)?
+ .get_reader()
+ .unchecked_into::();
+ let mut bytes = Vec::new();
+ loop {
+ let chunk = JsFuture::from(reader.read()).await.map_err(map_net)?;
+ if js_sys::Reflect::get(&chunk, &"done".into())
+ .ok()
+ .and_then(|v| v.as_bool())
+ == Some(true)
+ {
+ break;
+ }
+ let chunk =
+ js_sys::Reflect::get(&chunk, &"value".into()).map_err(|_| Error::InvalidResponse)?;
+ let chunk = js_sys::Uint8Array::new(&chunk);
+ if bytes.len() + chunk.length() as usize > 4 * 1024 * 1024 {
+ controller.abort();
+ return Err(Error::InvalidResponse);
+ }
+ bytes.extend(chunk.to_vec());
+ }
+ Ok((
+ status,
+ String::from_utf8(bytes).map_err(|_| Error::InvalidResponse)?,
+ ))
+}
+async fn get_post(target: &Target) -> Result {
+ let Target::Post { id } = target else {
+ return Err(Error::InvalidTarget);
+ };
+ let (h, b) = fetch(target, false, None).await?;
+ provider::parse_post(h, &b, id)
+}
+async fn get_conversation(
+ target: &Target,
+ cursor: Option<&str>,
+) -> Result {
+ let Target::Post { id } = target else {
+ return Err(Error::InvalidTarget);
+ };
+ let (h, b) = fetch_url(provider::conversation_endpoint(id, cursor)?).await?;
+ provider::parse_conversation(h, &b, id)
+}
+type IdentityFuture =
+ futures::future::Shared>>;
+thread_local! {static IDENTITIES:RefCell>=RefCell::new(std::collections::HashMap::new());}
+async fn resolve_identity(handle: &str) -> Result {
+ let future = IDENTITIES.with(|cache| {
+ let mut cache = cache.borrow_mut();
+ cache.retain(|_, (expires, _)| *expires > now());
+ if let Some((_, future)) = cache.get(handle) {
+ return future.clone();
+ }
+ if cache.len() >= 64 {
+ cache.clear();
+ }
+ let handle = handle.to_string();
+ let query = handle.clone();
+ let future = async move {
+ let (http, body) = fetch_url(provider::identity_endpoint(&query)?).await?;
+ provider::parse_identity(http, &body, &query)
+ }
+ .boxed_local()
+ .shared();
+ cache.insert(handle, (now() + 300_000., future.clone()));
+ future
+ });
+ let result = future.clone().await;
+ if result.is_err() {
+ IDENTITIES.with(|cache| {
+ let mut cache = cache.borrow_mut();
+ if cache
+ .get(handle)
+ .is_some_and(|(_, current)| current.ptr_eq(&future))
+ {
+ cache.remove(handle);
+ }
+ });
+ }
+ result
+}
+async fn get_profile(target: &Target) -> Result {
+ let Target::Profile { handle } = target else {
+ return Err(Error::InvalidTarget);
+ };
+ let (h, b) = fetch(target, false, None).await?;
+ match provider::parse_profile(h, &b, handle) {
+ Err(Error::NotFound) => {
+ let id = resolve_identity(handle).await?;
+ let (h, b) = fetch_url(provider::id_endpoint(&id, false, None)?).await?;
+ let profile = provider::parse_profile(h, &b, handle)?;
+ if profile.id != id {
+ return Err(Error::InvalidResponse);
+ }
+ Ok(profile)
+ }
+ result => result,
+ }
+}
+async fn timeline(
+ token: &str,
+ session: &mut Session,
+ cursor: Option<&str>,
+) -> Result {
+ if !matches!(session.target, Target::Profile { .. }) {
+ return Err(Error::InvalidTarget);
+ }
+ let (h, b) = fetch(&session.target, true, cursor).await?;
+ let t = match provider::parse_timeline(h, &b) {
+ Err(Error::NotFound) => {
+ let Target::Profile { handle } = &session.target else {
+ return Err(Error::InvalidTarget);
+ };
+ let id = resolve_identity(handle).await?;
+ let (h, b) = fetch_url(provider::id_endpoint(&id, true, cursor)?).await?;
+ provider::parse_timeline(h, &b)?
+ }
+ result => result?,
+ };
+ for post in &t.posts {
+ if !session.allowed_posts.contains(&post.id) && session.allowed_posts.len() < 2000 {
+ session.allowed_posts.push(post.id.clone());
+ }
+ }
+ session_set(&session_key(token)?, js(session))
+ .await
+ .map_err(|_| Error::Storage)?;
+ Ok(t)
+}
+async fn handle(request: Request, sender: Sender) -> Result {
+ let automatic = matches!(&request, Request::AutoDetected { .. });
+ match request {
+ Request::Ping => Ok(json!({"runtime":"rust-wasm"})),
+ Request::Record => Ok(json!(limitless_storage::record(None, None, false).await?)),
+ Request::Settings { enabled } => Ok(json!(
+ limitless_storage::record(None, Some(enabled), false).await?
+ )),
+ Request::Clear => Ok(json!(limitless_storage::record(None, None, true).await?)),
+ Request::Inspect { notices } => {
+ let target = Target::parse(sender.current_url())?;
+ Ok(json!({"eligible":restricted(&target,¬ices),"target":target}))
+ }
+ Request::OpenDetected { notices } | Request::AutoDetected { notices } => {
+ let target = Target::parse(sender.current_url())?;
+ if !restricted(&target, ¬ices) {
+ return Err(Error::Unauthorized);
+ }
+ create_reader(
+ sender.tab_id.ok_or(Error::Unauthorized)?,
+ target.clone(),
+ target,
+ true,
+ None,
+ false,
+ !automatic,
+ )
+ .await
+ }
+ Request::OpenManual { input } => {
+ let target = Target::parse(&input)?;
+ let tab_id = new_tab().await.map_err(|_| Error::Unavailable)?;
+ create_reader(
+ tab_id,
+ target.clone(),
+ target.clone(),
+ false,
+ None,
+ true,
+ true,
+ )
+ .await?;
+ navigate_tab(tab_id, &target.canonical_url())
+ .await
+ .map_err(|_| Error::Unavailable)?;
+ Ok(json!({"opened":true}))
+ }
+ Request::Pending => {
+ let all: std::collections::HashMap =
+ decode(session_all().await.map_err(|_| Error::Storage)?)?;
+ let mut sessions: Vec<_> = all.into_iter().collect();
+ sessions.sort_by(|a, b| b.1.created.total_cmp(&a.1.created));
+ for (key, mut session) in sessions {
+ if session.valid(&session.target, now())
+ && session.bound_to(&sender)
+ && Target::parse(sender.current_url())
+ .is_ok_and(|target| target == session.target)
+ {
+ session.pending = false;
+ if let Some(content) = session.content.as_mut() {
+ let filter = crate::view::ProfileFilter::from_url(sender.current_url());
+ if content.profile_filter != filter {
+ content.profile_filter = filter;
+ content.visible_posts = 5;
+ }
+ }
+ session_set(&key, js(&session))
+ .await
+ .map_err(|_| Error::Storage)?;
+ let token = key.strip_prefix("reader:").ok_or(Error::Expired)?;
+ if let Some(content) = &session.content {
+ return Ok(
+ json!({"view":crate::view::reader(content,&session.target,token,session.restricted)}),
+ );
+ }
+ return Ok(
+ json!({"session":token,"target":session.target,"view":crate::view::loading(&session.target),"load":{"op":"read","session":token,"target":session.target}}),
+ );
+ }
+ }
+ Ok(Value::Null)
+ }
+ Request::Current | Request::OpenCurrent => {
+ let open = matches!(request, Request::OpenCurrent);
+ let c: ActiveContext = decode(active_context().await.map_err(|_| Error::Unavailable)?)?;
+ match Target::parse(&c.url) {
+ Ok(target) => {
+ let is_restricted = restricted(&target, &c.notices);
+ if open {
+ let context = create_reader(
+ c.tab_id,
+ target.clone(),
+ target,
+ is_restricted,
+ None,
+ false,
+ true,
+ )
+ .await?;
+ deliver(c.tab_id, context).await
+ } else {
+ Ok(json!({"target":target,"restricted":is_restricted}))
+ }
+ }
+ Err(_) => {
+ if open {
+ Err(Error::InvalidTarget)
+ } else {
+ Ok(Value::Null)
+ }
+ }
+ }
+ }
+ Request::Read { session, target } => {
+ let mut s = load_session(&session, Some(&target), &sender).await?;
+ match &target {
+ Target::Post { .. } => {
+ let (post, conversation, replies_error) = match get_conversation(&target, None)
+ .await
+ {
+ Ok(conversation) => (conversation.post.clone(), Some(conversation), None),
+ Err(
+ error @ (Error::Protected
+ | Error::Deleted
+ | Error::Suspended
+ | Error::RateLimited
+ | Error::Network
+ | Error::Timeout),
+ ) => return Err(error),
+ Err(error) => (get_post(&target).await?, None, Some(error.to_string())),
+ };
+ if let Some(conversation) = &conversation {
+ for post in conversation
+ .thread
+ .iter()
+ .chain(conversation.replies.iter())
+ {
+ if !s.allowed_posts.contains(&post.id) {
+ s.allowed_posts.push(post.id.clone());
+ }
+ }
+ }
+ let record_error = if s.restricted {
+ limitless_storage::record(Some(("post", &post.id)), None, false)
+ .await
+ .err()
+ .map(|e| e.to_string())
+ } else {
+ None
+ };
+ s.content = Some(crate::view::Content {
+ post: Some(post),
+ conversation,
+ replies_error,
+ record_error,
+ ..Default::default()
+ });
+ session_set(&session_key(&session)?, js(&s))
+ .await
+ .map_err(|_| Error::Storage)?;
+ Ok(
+ json!({"view":crate::view::reader(s.content.as_ref().unwrap(),&target,&session,s.restricted)}),
+ )
+ }
+ Target::Profile { handle } => {
+ let (profile, feed) =
+ futures::join!(get_profile(&target), timeline(&session, &mut s, None));
+ if let Err(error @ (Error::Protected | Error::Suspended | Error::Deleted)) =
+ &profile
+ {
+ return Err(error.clone());
+ }
+ if let Err(error) = &profile
+ && feed.is_err()
+ {
+ return Err(error.clone());
+ }
+ let mut profile_error = profile.as_ref().err().map(ToString::to_string);
+ let profile = profile.ok().or_else(|| {
+ feed.as_ref()
+ .ok()?
+ .posts
+ .iter()
+ .find(|p| p.author.handle.eq_ignore_ascii_case(handle))
+ .map(|p| p.author.clone())
+ });
+ if profile.is_some() {
+ profile_error = None;
+ }
+ let record_error = if s.restricted {
+ if let Some(p) = profile.as_ref() {
+ limitless_storage::record(Some(("profile", &p.id)), None, false)
+ .await
+ .err()
+ .map(|e| e.to_string())
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+ let feed_error = feed.as_ref().err().map(ToString::to_string);
+ s.content = Some(crate::view::Content {
+ profile_filter: crate::view::ProfileFilter::from_url(sender.current_url()),
+ profile,
+ timeline: feed.ok(),
+ profile_error,
+ timeline_error: feed_error,
+ record_error,
+ ..Default::default()
+ });
+ session_set(&session_key(&session)?, js(&s))
+ .await
+ .map_err(|_| Error::Storage)?;
+ Ok(
+ json!({"view":crate::view::reader(s.content.as_ref().unwrap(),&target,&session,s.restricted)}),
+ )
+ }
+ }
+ }
+ Request::Timeline {
+ session,
+ target,
+ cursor,
+ } => {
+ let mut s = load_session(&session, Some(&target), &sender).await?;
+ let content = s.content.as_mut().ok_or(Error::Expired)?;
+ if !content.reveal_buffered() {
+ let expected = content
+ .timeline
+ .as_ref()
+ .ok_or(Error::InvalidTarget)?
+ .next_cursor
+ .as_ref();
+ if expected.is_none() || expected != cursor.as_ref() {
+ return Err(Error::InvalidTarget);
+ }
+ let visible = content.visible_limit();
+ let next = match timeline(&session, &mut s, cursor.as_deref()).await {
+ Ok(next) => next,
+ Err(error) => {
+ let content = s.content.as_mut().ok_or(Error::Expired)?;
+ content.timeline_error = Some(error.to_string());
+ session_set(&session_key(&session)?, js(&s))
+ .await
+ .map_err(|_| Error::Storage)?;
+ return Ok(
+ json!({"view":crate::view::reader(s.content.as_ref().unwrap(),&target,&session,s.restricted)}),
+ );
+ }
+ };
+ let content = s.content.as_mut().ok_or(Error::Expired)?;
+ let feed = content.timeline.as_mut().ok_or(Error::InvalidTarget)?;
+ for post in next.posts {
+ if !feed.posts.iter().any(|p| p.id == post.id) {
+ feed.posts.push(post);
+ }
+ }
+ feed.next_cursor = next
+ .next_cursor
+ .filter(|next| Some(next) != cursor.as_ref());
+ content.visible_posts = (visible + 5).min(feed.posts.len());
+ content.timeline_error = None;
+ }
+ session_set(&session_key(&session)?, js(&s))
+ .await
+ .map_err(|_| Error::Storage)?;
+ Ok(
+ json!({"view":crate::view::reader(s.content.as_ref().unwrap(),&target,&session,s.restricted)}),
+ )
+ }
+ Request::Replies {
+ session,
+ target,
+ cursor,
+ } => {
+ let mut s = load_session(&session, Some(&target), &sender).await?;
+ let next = match get_conversation(&target, Some(&cursor)).await {
+ Ok(next) => next,
+ Err(error) => {
+ let content = s.content.as_mut().ok_or(Error::Expired)?;
+ content.replies_error = Some(error.to_string());
+ session_set(&session_key(&session)?, js(&s))
+ .await
+ .map_err(|_| Error::Storage)?;
+ return Ok(
+ json!({"view":crate::view::reader(s.content.as_ref().unwrap(),&target,&session,s.restricted)}),
+ );
+ }
+ };
+ if let Some(content) = s.content.as_mut() {
+ content.replies_error = None;
+ }
+ for post in &next.replies {
+ if !s.allowed_posts.contains(&post.id) && s.allowed_posts.len() < 2000 {
+ s.allowed_posts.push(post.id.clone());
+ }
+ }
+ let conversation = s
+ .content
+ .as_mut()
+ .and_then(|c| c.conversation.as_mut())
+ .ok_or(Error::Expired)?;
+ for post in next.replies {
+ if !conversation.replies.iter().any(|p| p.id == post.id) {
+ conversation.replies.push(post);
+ }
+ }
+ conversation.unavailable_replies += next.unavailable_replies;
+ conversation.next_cursor = next.next_cursor.filter(|c| c != &cursor);
+ session_set(&session_key(&session)?, js(&s))
+ .await
+ .map_err(|_| Error::Storage)?;
+ Ok(
+ json!({"view":crate::view::reader(s.content.as_ref().unwrap(),&target,&session,s.restricted)}),
+ )
+ }
+ Request::OpenPost { session, id } => {
+ let s = load_session(&session, None, &sender).await?;
+ if !limitless_core::valid_id(&id) || !s.allowed_posts.contains(&id) {
+ return Err(Error::Unauthorized);
+ }
+ create_reader(
+ s.tab_id,
+ Target::Post { id },
+ s.source,
+ s.restricted,
+ Some(s.expires),
+ false,
+ true,
+ )
+ .await
+ }
+ }
+}
+#[wasm_bindgen]
+pub async fn dispatch(message: JsValue, sender: JsValue) -> JsValue {
+ let raw_message: Value = decode(message.clone()).unwrap_or(Value::Null);
+ let result = async {
+ let sender: Sender = decode(sender).map_err(|_| Error::Unauthorized)?;
+ let raw: Value = decode(message.clone())?;
+ let op = raw
+ .get("op")
+ .and_then(Value::as_str)
+ .ok_or(Error::Unauthorized)?;
+ if !authorized(&sender, op) {
+ return Err(Error::Unauthorized);
+ }
+ let request: Request = decode(message)?;
+ handle(request, sender).await
+ }
+ .await;
+ js(&match result {
+ Ok(data) => json!({"ok":true,"data":data}),
+ Err(error) => {
+ json!({"ok":false,"message":error.to_string(),"view":crate::view::error(&error, matches!(raw_message["op"].as_str(),Some("read"|"timeline")).then_some(raw_message)),"error":error})
+ }
+ })
+}
diff --git a/crates/runtime/tests/security.rs b/crates/runtime/tests/security.rs
new file mode 100644
index 0000000..32666c3
--- /dev/null
+++ b/crates/runtime/tests/security.rs
@@ -0,0 +1,108 @@
+use limitless_core::Target;
+use limitless_runtime::{Sender, Session, authorized};
+fn sender(url: &str) -> Sender {
+ Sender {
+ id: "abc".into(),
+ url: url.into(),
+ page_url: None,
+ tab_id: Some(4),
+ frame_id: 0,
+ extension_origin: "chrome-extension://abc/".into(),
+ }
+}
+#[test]
+fn page_cannot_call_privileged_operations() {
+ assert!(authorized(
+ &sender("chrome-extension://abc/popup.html"),
+ "record"
+ ));
+ assert!(!authorized(&sender("https://x.com/test"), "record"));
+ assert!(authorized(&sender("https://x.com/test"), "inspect"));
+ assert!(!authorized(&sender("https://evil.test"), "inspect"));
+ assert!(!authorized(
+ &Sender {
+ frame_id: 2,
+ ..sender("https://x.com/test")
+ },
+ "inspect"
+ ));
+ assert!(!authorized(
+ &sender("chrome-extension://abc/reader.html.fake"),
+ "record"
+ ));
+}
+#[test]
+fn sessions_expire_and_bind_to_exact_target() {
+ let target = Target::parse("@test").unwrap();
+ let s = Session {
+ target: target.clone(),
+ source: target.clone(),
+ pending: false,
+ restricted: true,
+ deliberate: true,
+ created: 0.,
+ expires: 10000.,
+ tab_id: 4,
+ allowed_posts: vec![],
+ content: None,
+ };
+ assert!(s.valid(&target, 9999.));
+ assert!(!s.valid(&target, 10000.));
+ assert!(!s.valid(&Target::parse("@other").unwrap(), 9999.));
+}
+
+#[test]
+fn inline_session_cannot_be_moved_to_another_x_tab_or_route() {
+ let target = Target::parse("@test").unwrap();
+ let s = Session {
+ target: target.clone(),
+ source: target,
+ restricted: true,
+ deliberate: true,
+ pending: false,
+ created: 0.,
+ expires: 10000.,
+ tab_id: 4,
+ allowed_posts: vec![],
+ content: None,
+ };
+ assert!(s.bound_to(&sender("https://x.com/test")));
+ assert!(!s.bound_to(&Sender {
+ tab_id: Some(5),
+ ..sender("https://x.com/test")
+ }));
+ assert!(!s.bound_to(&sender("https://x.com/other")));
+ assert!(!authorized(
+ &sender("chrome-extension://abc/reader.html"),
+ "read"
+ ));
+ assert!(!authorized(
+ &sender("chrome-extension://abc/options.html"),
+ "record"
+ ));
+}
+
+#[test]
+fn spa_route_is_current_but_cannot_change_its_document_origin() {
+ let mut origin = sender("https://x.com/jack/status/20");
+ origin.page_url = Some("https://x.com/jack/status/21".into());
+ assert!(authorized(&origin, "auto_detected"));
+ assert_eq!(origin.current_url(), "https://x.com/jack/status/21");
+ let session = Session {
+ target: Target::parse("https://x.com/jack/status/20").unwrap(),
+ source: Target::parse("https://x.com/jack/status/20").unwrap(),
+ restricted: true,
+ deliberate: false,
+ pending: false,
+ created: 0.,
+ expires: 10000.,
+ tab_id: 4,
+ allowed_posts: vec![],
+ content: None,
+ };
+ assert!(!session.bound_to(&origin));
+ origin.page_url = Some("https://evil.test/jack/status/21".into());
+ assert!(!authorized(&origin, "auto_detected"));
+ origin.page_url = Some("chrome-extension://abc/popup.html".into());
+ assert!(!authorized(&origin, "record"));
+}
diff --git a/crates/runtime/tests/view.rs b/crates/runtime/tests/view.rs
new file mode 100644
index 0000000..e6af8bd
--- /dev/null
+++ b/crates/runtime/tests/view.rs
@@ -0,0 +1,76 @@
+use limitless_core::{Profile, Target, Timeline};
+use limitless_runtime::view::{Content, Node, reader};
+fn nodes<'a>(node: &'a Node, out: &mut Vec<&'a Node>) {
+ out.push(node);
+ if let Node::Element { children, .. } = node {
+ for child in children {
+ nodes(child, out)
+ }
+ }
+}
+#[test]
+fn provider_text_stays_a_text_node_and_sessions_never_become_dom_attributes() {
+ let payload = "";
+ let content = Content {
+ profile: Some(Profile {
+ id: "12".into(),
+ handle: "jack".into(),
+ name: payload.into(),
+ biography: Some(payload.into()),
+ avatar: None,
+ ..Default::default()
+ }),
+ timeline: Some(Timeline {
+ posts: vec![],
+ next_cursor: Some("next+/=".into()),
+ }),
+ ..Default::default()
+ };
+ let view = reader(
+ &content,
+ &Target::parse("@jack").unwrap(),
+ "secret-session",
+ true,
+ );
+ let mut all = vec![];
+ nodes(&view, &mut all);
+ assert!(all.iter().any(|n| matches!(n,Node::Text(s) if s==payload)));
+ assert!(
+ all.iter()
+ .all(|n| !matches!(n,Node::Element{tag,..} if tag=="script"||tag=="iframe"))
+ );
+ assert!(all.iter().all(|n|!matches!(n,Node::Element{attrs,..} if attrs.iter().any(|(_,v)|v.contains("secret-session")))));
+ assert!(all.iter().any(|n|matches!(n,Node::Element{action:Some(a),..} if a["op"]=="timeline"&&a["session"]=="secret-session"&&a["cursor"]=="next+/=")));
+}
+
+#[test]
+fn timeline_starts_with_five_and_reveals_buffered_batches() {
+ let mut content = Content {
+ timeline: Some(Timeline {
+ posts: (1..=12)
+ .map(|id| limitless_core::Post {
+ id: id.to_string(),
+ text: format!("post {id}"),
+ ..Default::default()
+ })
+ .collect(),
+ next_cursor: Some("next".into()),
+ }),
+ ..Default::default()
+ };
+ let target = Target::parse("@jack").unwrap();
+ let count = |c: &Content| {
+ let view = reader(c, &target, "token", false);
+ let mut all = vec![];
+ nodes(&view, &mut all);
+ all.iter()
+ .filter(|n| matches!(n,Node::Element{tag,..} if tag=="article"))
+ .count()
+ };
+ assert_eq!(count(&content), 5);
+ assert!(content.reveal_buffered());
+ assert_eq!(count(&content), 10);
+ assert!(content.reveal_buffered());
+ assert_eq!(count(&content), 12);
+ assert!(!content.reveal_buffered());
+}
diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml
new file mode 100644
index 0000000..40b424c
--- /dev/null
+++ b/crates/storage/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "limitless-storage"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+limitless-core.workspace = true
+serde.workspace = true
+
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+rexie = "0.6"
+wasm-bindgen.workspace = true
+serde-wasm-bindgen.workspace = true
+js-sys.workspace = true
diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs
new file mode 100644
index 0000000..307db66
--- /dev/null
+++ b/crates/storage/src/lib.rs
@@ -0,0 +1,5 @@
+//! IndexedDB is the source of truth. Every settings/ledger change uses one transaction.
+#[cfg(target_arch = "wasm32")]
+mod wasm;
+#[cfg(target_arch = "wasm32")]
+pub use wasm::*;
diff --git a/crates/storage/src/wasm.rs b/crates/storage/src/wasm.rs
new file mode 100644
index 0000000..6b52aa7
--- /dev/null
+++ b/crates/storage/src/wasm.rs
@@ -0,0 +1,94 @@
+use limitless_core::{Error, Record, record_key};
+use rexie::{ObjectStore, Rexie, TransactionMode};
+use serde::{Deserialize, Serialize};
+use std::{cell::RefCell, rc::Rc};
+use wasm_bindgen::prelude::*;
+
+#[wasm_bindgen]
+extern "C" {
+ #[wasm_bindgen(js_namespace=limitlessBridge)]
+ fn record_changed();
+ #[wasm_bindgen(js_namespace=limitlessBridge)]
+ fn random_bytes(size: u32) -> js_sys::Uint8Array;
+}
+thread_local! {static DB:RefCell