diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index 5e494adc08..209ecf82b6 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -62,7 +62,8 @@ jobs: run: | cargo check --locked --target "$TARGET" --features "$FEATURES" --features "$IN_PROCESS_SSH" \ -p tablepro-core -p tablepro-net -p tablepro-storage -p tablepro-ssh -p tablepro-session \ - -p tablepro-foreign-import -p tablepro-ai -p tablepro-mcp -p tablepro-test-support -p tablepro-test-fixtures \ + -p tablepro-foreign-import -p tablepro-ai -p tablepro-mcp -p tablepro-account \ + -p tablepro-test-support -p tablepro-test-fixtures \ -p tablepro-driver-cassandra -p tablepro-driver-clickhouse -p tablepro-driver-duckdb \ -p tablepro-driver-mongodb -p tablepro-driver-mssql -p tablepro-driver-mysql \ -p tablepro-driver-postgres -p tablepro-driver-redis -p tablepro-driver-sqlite @@ -77,6 +78,9 @@ jobs: # The rest of net's tests read how Linux handles a full listen backlog. cargo test --locked --target "$TARGET" -p tablepro-net --lib -- loopback private_ cargo test --locked --target "$TARGET" --features tablepro-ssh/in-process -p tablepro-ssh --lib in_process + # The sign-in's loopback listener and the token's file on each platform. + - name: Account unit tests + run: cargo test --locked --target "$TARGET" -p tablepro-account --lib - name: Start Windows' OpenSSH Server if: runner.os == 'Windows' shell: pwsh diff --git a/.github/workflows/linux-packages.yml b/.github/workflows/linux-packages.yml index 2ec13fcb9c..cb642842e4 100644 --- a/.github/workflows/linux-packages.yml +++ b/.github/workflows/linux-packages.yml @@ -21,6 +21,8 @@ name: Linux packages # A run by hand can add arm64, which the nightly and pull requests leave out. # Given a release tag, it packages that release's tarballs instead, with # the packaging from this checkout, for a PPA upload with a new revision. +# Only release-linux.yml passes the PostHog key, so every other run builds +# packages that send no usage data. on: pull_request: @@ -80,6 +82,11 @@ on: description: The Flatpak branch, stable or beta. type: string default: stable + # Only a release's call passes it, so a run of this workflow on its own builds packages without one. + posthog-key: + description: The PostHog project key the vendor tarball carries for the .deb and .rpm. Public by design. + type: string + default: "" # A pull request keeps only its newest run; every other run, a release's call included, has its own group. concurrency: @@ -132,7 +139,9 @@ jobs: persist-credentials: false - name: Make the source and vendor tarballs if: inputs.release-tag == '' - run: linux/build-aux/packaging/make-sources.sh --install-deps _packages/sources + env: + POSTHOG_KEY: ${{ inputs.posthog-key }} + run: linux/build-aux/packaging/make-sources.sh --install-deps --posthog-key "$POSTHOG_KEY" _packages/sources - name: Download the released source and vendor tarballs if: inputs.release-tag != '' env: diff --git a/.github/workflows/release-linux.yml b/.github/workflows/release-linux.yml index 7a36f0662d..aa0aea7adf 100644 --- a/.github/workflows/release-linux.yml +++ b/.github/workflows/release-linux.yml @@ -40,6 +40,10 @@ name: Release Linux # Variables LINUX_PPA and LINUX_PPA_BETA: the stable and the beta PPA, as # owner/name. # Variable LINUX_FLATPAK_R2_ENDPOINT: https://.r2.cloudflarestorage.com. +# Variable POSTHOG_PROJECT_KEY: the PostHog project key the macOS release +# (release.yml) builds with too, public by design, which make-sources.sh puts +# in the vendor tarball. The packages send usage data to it unless the user +# turns it off. A packaging fix reuses the release's tarballs, and so its key. # Variable LINUX_BRANCH: the branch a release tag must be on, main when unset. on: @@ -83,6 +87,14 @@ jobs: version: ${{ steps.check.outputs.version }} flatpak-branch: ${{ steps.check.outputs.flatpak-branch }} steps: + - name: The PostHog key is set + env: + POSTHOG_KEY: ${{ vars.POSTHOG_PROJECT_KEY }} + run: | + if [ -z "$POSTHOG_KEY" ]; then + echo "::error::Set the POSTHOG_PROJECT_KEY repository variable, the PostHog project key the packages send usage data to" + exit 1 + fi # meson introspect detects the project's compilers, C and Rust, before it reads the version. - name: Install git, meson and the compilers run: | @@ -132,6 +144,7 @@ jobs: with: deb-runners: '["ubuntu-24.04", "ubuntu-24.04-arm"]' rpm-runners: '["ubuntu-24.04", "ubuntu-24.04-arm"]' + posthog-key: ${{ vars.POSTHOG_PROJECT_KEY }} flatpak: true flatpak-branch: ${{ needs.verify.outputs.flatpak-branch }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10b29f0249..2f497ee602 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -111,7 +111,7 @@ jobs: - name: Write Secrets.xcconfig env: - POSTHOG_PROJECT_KEY: ${{ secrets.POSTHOG_PROJECT_KEY }} + POSTHOG_PROJECT_KEY: ${{ vars.POSTHOG_PROJECT_KEY }} run: printf 'POSTHOG_PROJECT_KEY = %s\n' "$POSTHOG_PROJECT_KEY" > Configs/Secrets.xcconfig - uses: ./.github/actions/prepare-build diff --git a/Cargo.lock b/Cargo.lock index d01cac06d7..08174e799f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -379,7 +379,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror", + "thiserror 2.0.20", "time", ] @@ -446,6 +446,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "async-compression" +version = "0.4.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ad47a740fb906fffdd891e8b6d5fde2511600f7bfaebd68939fa08a14999459" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-lock" version = "3.4.2" @@ -811,6 +823,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "blocking" version = "1.7.0" @@ -884,7 +905,7 @@ dependencies = [ "serde_derive", "serde_json", "serde_urlencoded", - "thiserror", + "thiserror 2.0.20", "time", "tokio", "tokio-stream", @@ -952,7 +973,7 @@ dependencies = [ "serde_bytes", "serde_json", "simdutf8", - "thiserror", + "thiserror 2.0.20", "time", "uuid", ] @@ -1045,7 +1066,7 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -1246,7 +1267,7 @@ dependencies = [ "hyper-util", "serde", "serde_json", - "thiserror", + "thiserror 2.0.20", "tokio", "tracing", "url", @@ -1271,7 +1292,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3966eea13bfc826e24d471e49cb2095727261009be59d0ac5a6c0be0286974d1" dependencies = [ "bytes", - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -1314,6 +1335,23 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "compression-codecs" +version = "0.4.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84f840cff84d10085260e460023934d0af699e71212b09b2a41452e8bd9cda43" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1654,14 +1692,38 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", ] [[package]] @@ -1677,13 +1739,24 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling_macro" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core", + "darling_core 0.24.1", "quote", "syn 3.0.5", ] @@ -1769,7 +1842,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror", + "thiserror 2.0.20", ] [[package]] @@ -1862,6 +1935,37 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1917,6 +2021,16 @@ dependencies = [ "ctutils", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -2001,7 +2115,7 @@ dependencies = [ "digest 0.11.3", "elliptic-curve", "rfc6979", - "signature", + "signature 3.0.0", "spki 0.8.0", "zeroize", ] @@ -2013,7 +2127,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a" dependencies = [ "pkcs8", - "signature", + "signature 3.0.0", ] [[package]] @@ -2027,7 +2141,7 @@ dependencies = [ "rand_core 0.10.1", "serde", "sha2 0.11.0", - "signature", + "signature 3.0.0", "subtle", "zeroize", ] @@ -2598,8 +2712,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -2793,7 +2909,7 @@ dependencies = [ "memmap2", "nix 0.30.1", "static_assertions", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-stream", "tracing", @@ -2815,7 +2931,7 @@ dependencies = [ "paste", "rmp-serde", "serde", - "thiserror", + "thiserror 2.0.20", "zerocopy", "zvariant", ] @@ -2842,7 +2958,7 @@ dependencies = [ "paste", "rayon", "serde", - "thiserror", + "thiserror 2.0.20", "tokio", "zbus", "zerocopy", @@ -2993,7 +3109,7 @@ checksum = "9f23bc0d0e791259ce4fe703c4d988c64317e33477bc4d1d7526ab8eb9fcaab6" dependencies = [ "paste", "serde", - "thiserror", + "thiserror 2.0.20", "zvariant", ] @@ -3004,7 +3120,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad61fccc591119491879e7bee63ebbfbd27811b35367b4728a857119fde78843" dependencies = [ "gufo-common", - "thiserror", + "thiserror 2.0.20", "tracing", ] @@ -3135,7 +3251,7 @@ dependencies = [ "ipnet", "jni", "rand 0.10.2", - "thiserror", + "thiserror 2.0.20", "tinyvec", "tokio", "tracing", @@ -3156,7 +3272,7 @@ dependencies = [ "prefix-trie", "rand 0.10.2", "ring", - "thiserror", + "thiserror 2.0.20", "tinyvec", "tracing", "url", @@ -3183,7 +3299,7 @@ dependencies = [ "resolv-conf", "smallvec", "system-configuration", - "thiserror", + "thiserror 2.0.20", "tokio", "tracing", ] @@ -3360,13 +3476,16 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -3753,7 +3872,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -3811,6 +3930,22 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "11.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1" +dependencies = [ + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", + "serde", + "serde_json", + "signature 2.2.0", + "zeroize", +] + [[package]] name = "keccak" version = "0.2.2" @@ -4089,6 +4224,12 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "lz4_flex" version = "0.14.0" @@ -4366,7 +4507,7 @@ dependencies = [ "stringprep", "strsim", "take_mut", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tokio-util", @@ -4571,6 +4712,25 @@ dependencies = [ "libm", ] +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.8", + "serde", + "serde_json", + "serde_path_to_error", + "sha2 0.10.9", + "thiserror 1.0.69", + "url", +] + [[package]] name = "objc" version = "0.2.7" @@ -4591,6 +4751,36 @@ dependencies = [ "objc_id", ] +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -4598,6 +4788,95 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.13.2", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2", + "libc", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", ] [[package]] @@ -4609,6 +4888,37 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + [[package]] name = "objc_id" version = "0.1.1" @@ -4668,6 +4978,17 @@ dependencies = [ "zvariant", ] +[[package]] +name = "openapiv3" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8d427828b22ae1fff2833a03d8486c2c881367f1c336349f307f321e7f4d05" +dependencies = [ + "indexmap 2.14.2", + "serde", + "serde_json", +] + [[package]] name = "openssl-probe" version = "0.2.1" @@ -4684,6 +5005,22 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_info" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" +dependencies = [ + "android_system_properties", + "log", + "nix 0.31.3", + "objc2", + "objc2-foundation", + "objc2-ui-kit", + "serde", + "windows-sys 0.61.2", +] + [[package]] name = "p256" version = "0.14.0" @@ -4739,7 +5076,7 @@ dependencies = [ "log", "rand 0.10.2", "sha2 0.11.0", - "thiserror", + "thiserror 2.0.20", "tokio", "windows", "windows-strings", @@ -5085,6 +5422,26 @@ dependencies = [ "postgres-protocol", ] +[[package]] +name = "posthog-rs" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48feacce1343c625054b88191f5c6ea28042643e44a27dd5bfd95d17914d06ff" +dependencies = [ + "chrono", + "derive_builder", + "flate2", + "os_info", + "regex", + "reqwest", + "semver", + "serde", + "serde_json", + "sha1", + "tracing", + "uuid", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -5184,35 +5541,81 @@ dependencies = [ ] [[package]] -name = "proptest" -version = "1.11.0" +name = "progenitor" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +checksum = "15bd395aa613c6491be9233fae4a870a35cd611655c0ef1cbaae7aa9e1c4c0e6" dependencies = [ - "bit-set", - "bit-vec 0.8.0", - "bitflags 2.13.2", - "num-traits", - "rand 0.9.5", - "rand_chacha 0.9.0", - "rand_xorshift", - "regex-syntax", - "rusty-fork", - "tempfile", - "unarray", + "progenitor-impl", ] [[package]] -name = "prost" -version = "0.14.4" +name = "progenitor-client" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +checksum = "6aa13eb6d9499a851512ec8fa3280f586be059682da4f05d953924be45d736f0" dependencies = [ "bytes", - "prost-derive", -] - -[[package]] + "futures-core", + "percent-encoding", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", +] + +[[package]] +name = "progenitor-impl" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d9fbe5a1aa114c6c7c507a30ebeb0b12de383edab951ea27300210a3841917c" +dependencies = [ + "heck", + "http", + "indexmap 2.14.2", + "openapiv3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "syn 3.0.5", + "thiserror 2.0.20", + "typify", + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec 0.8.0", + "bitflags 2.13.2", + "num-traits", + "rand 0.9.5", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] name = "prost-derive" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5260,6 +5663,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.20", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg 0.10.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.20", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -5300,6 +5760,17 @@ dependencies = [ "rand_hc", ] +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.5" @@ -5331,6 +5802,16 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -5350,6 +5831,15 @@ dependencies = [ "getrandom 0.1.16", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "rand_core" version = "0.9.5" @@ -5383,6 +5873,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -5507,6 +6006,57 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "regress" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32eef8b209c3c1c15dbad02c1f30f9539f00dc7253e0cbcdaae442a50a09d7c1" +dependencies = [ + "memchr", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + [[package]] name = "resolv-conf" version = "0.7.6" @@ -5560,7 +6110,7 @@ dependencies = [ "serde", "serde_json", "sse-stream", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-stream", "tokio-util", @@ -5608,7 +6158,7 @@ dependencies = [ "pkcs8", "rand_core 0.10.1", "sha2 0.11.0", - "signature", + "signature 3.0.0", "spki 0.8.0", "zeroize", ] @@ -5671,12 +6221,12 @@ dependencies = [ "sha1", "sha2 0.11.0", "sha3", - "signature", + "signature 3.0.0", "spki 0.8.0", "ssh-encoding", "ssh-key", "subtle", - "thiserror", + "thiserror 2.0.20", "tokio", "typenum", "universal-hash", @@ -5798,6 +6348,7 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ + "web-time", "zeroize", ] @@ -5901,6 +6452,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive 0.8.22", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "0.9.0" @@ -5922,11 +6485,23 @@ dependencies = [ "chrono", "dyn-clone", "ref-cast", - "schemars_derive", + "schemars_derive 1.2.2", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals 0.29.1", + "syn 2.0.119", +] + [[package]] name = "schemars_derive" version = "1.2.2" @@ -5992,7 +6567,7 @@ dependencies = [ "hashbrown 0.17.1", "itertools 0.15.0", "rand 0.9.5", - "rand_pcg", + "rand_pcg 0.9.0", "rustls", "scylla-cql", "scylla-cql-core", @@ -6000,7 +6575,7 @@ dependencies = [ "serde_json", "smallvec", "socket2", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tracing", @@ -6021,7 +6596,7 @@ dependencies = [ "scylla-cql-core", "snap", "stable_deref_trait", - "thiserror", + "thiserror 2.0.20", "tokio", "uuid", "yoke", @@ -6038,7 +6613,7 @@ dependencies = [ "chrono", "itertools 0.15.0", "scylla-macros", - "thiserror", + "thiserror 2.0.20", "uuid", ] @@ -6048,7 +6623,7 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c82c9c67cb4912cefc8cb2a68ebdcbe0273b66510c2bb06581ae6b68d98498aa" dependencies = [ - "darling", + "darling 0.24.1", "proc-macro2", "quote", "syn 3.0.5", @@ -6186,6 +6761,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -6206,6 +6792,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_tokenstream" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db95b8acf6eca5bc1bc771a7d7fb1a0d1485bdef0c46eec2f573bbaf4b4118f8" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.5", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -6245,7 +6843,7 @@ version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d607aa01a3cb0ad757d6fd216136910db3c97b102fe686585689615a02dbcdc" dependencies = [ - "darling", + "darling 0.24.1", "proc-macro2", "quote", "syn 3.0.5", @@ -6347,6 +6945,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "signature" version = "3.0.0" @@ -6553,7 +7160,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-stream", "tracing", @@ -6596,7 +7203,7 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn 2.0.119", - "thiserror", + "thiserror 2.0.20", "tokio", "url", ] @@ -6626,7 +7233,7 @@ dependencies = [ "sha1", "sha2 0.11.0", "sqlx-core", - "thiserror", + "thiserror 2.0.20", "tracing", "uuid", ] @@ -6662,7 +7269,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.20", "tracing", "uuid", "whoami", @@ -6688,7 +7295,7 @@ dependencies = [ "percent-encoding", "serde", "sqlx-core", - "thiserror", + "thiserror 2.0.20", "tracing", "url", "uuid", @@ -6760,7 +7367,7 @@ dependencies = [ "sec1", "sha1", "sha2 0.11.0", - "signature", + "signature 3.0.0", "ssh-cipher", "ssh-encoding", "zeroize", @@ -6872,6 +7479,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -6952,6 +7562,7 @@ dependencies = [ "libadwaita", "log", "num-bigint 0.4.8", + "posthog-rs", "proptest", "pulldown-cmark", "rustix", @@ -6961,6 +7572,7 @@ dependencies = [ "serde_json", "sourceview5", "sqlformat", + "tablepro-account", "tablepro-ai", "tablepro-core", "tablepro-driver-cassandra", @@ -6982,7 +7594,7 @@ dependencies = [ "tablepro-test-fixtures", "tablepro-test-support", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -6993,6 +7605,39 @@ dependencies = [ "x11rb", ] +[[package]] +name = "tablepro-account" +version = "0.0.0" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64 0.22.1", + "bytes", + "chrono", + "getrandom 0.3.4", + "http", + "http-body-util", + "hyper", + "hyper-util", + "jsonwebtoken", + "oauth2", + "openapiv3", + "progenitor", + "progenitor-client", + "regress", + "reqwest", + "secrecy", + "serde", + "serde_json", + "tablepro-core", + "tablepro-storage", + "tempfile", + "thiserror 2.0.20", + "tokio", + "tracing", + "url", +] + [[package]] name = "tablepro-ai" version = "0.0.0" @@ -7011,7 +7656,7 @@ dependencies = [ "sse-stream", "tablepro-core", "tablepro-net", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -7043,7 +7688,7 @@ dependencies = [ "serde", "serde_json", "sqlparser", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-util", "unicode-width", @@ -7180,7 +7825,7 @@ dependencies = [ "tablepro-test-fixtures", "tablepro-test-support", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -7277,7 +7922,7 @@ dependencies = [ "tablepro-ssh", "tablepro-storage", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -7310,7 +7955,7 @@ dependencies = [ "tablepro-core", "tablepro-storage", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -7330,7 +7975,7 @@ dependencies = [ "tablepro-foreign-import", "tablepro-storage", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "uuid", ] @@ -7358,7 +8003,7 @@ dependencies = [ "tablepro-ssh", "tablepro-storage", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -7383,7 +8028,7 @@ dependencies = [ "socket2", "tablepro-core", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-rustls", "tower-service", @@ -7408,7 +8053,7 @@ dependencies = [ "tablepro-test-fixtures", "tablepro-test-support", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -7431,7 +8076,7 @@ dependencies = [ "tablepro-test-support", "tempfile", "testcontainers", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-util", "tracing", @@ -7453,7 +8098,7 @@ dependencies = [ "sqlx", "tablepro-core", "tempfile", - "thiserror", + "thiserror 2.0.20", "tokio", "unicode-normalization", "uuid", @@ -7469,7 +8114,7 @@ dependencies = [ "tempfile", "testcontainers", "testcontainers-modules", - "thiserror", + "thiserror 2.0.20", "tiberius", "tokio", "tokio-rustls", @@ -7570,7 +8215,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror", + "thiserror 2.0.20", "tokio", "tokio-stream", "tokio-util", @@ -7596,13 +8241,33 @@ dependencies = [ "unicode-width", ] +[[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.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.20", +] + +[[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]] @@ -7651,7 +8316,7 @@ dependencies = [ "rustls-pki-types", "rustls-webpki", "secrecy", - "thiserror", + "thiserror 2.0.20", "tiberius-macros", "tokio-rustls", "tokio-util", @@ -7988,6 +8653,29 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags 2.13.2", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -8119,6 +8807,53 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "typify" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71187d2e983363e9a6ca884f7b061620bc6da4316902058e74bbc9d61c347696" +dependencies = [ + "typify-impl", + "typify-macro", +] + +[[package]] +name = "typify-impl" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cbc3132686085f84a068ad86fc281cb3d566dd940c8911fd3562013851e9ae8" +dependencies = [ + "heck", + "log", + "proc-macro2", + "quote", + "regress", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "syn 3.0.5", + "thiserror 2.0.20", + "unicode-ident", +] + +[[package]] +name = "typify-macro" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3a7f8d08ec85254fb8b383a6c66fe43b81f7f6b288fa71eb365f0867fc8f3a6" +dependencies = [ + "proc-macro2", + "quote", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "serde_tokenstream", + "syn 3.0.5", + "typify-impl", +] + [[package]] name = "uds_windows" version = "1.2.1" @@ -8546,6 +9281,19 @@ 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 = "web-sys" version = "0.3.105" @@ -8954,7 +9702,7 @@ dependencies = [ "nom", "oid-registry", "rusticata-macros", - "thiserror", + "thiserror 2.0.20", "time", ] diff --git a/Cargo.toml b/Cargo.toml index 3dd77b195d..c5921a4efc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "3" members = [ + "core/crates/account", "core/crates/ai", "core/crates/bindgen", "core/crates/core", @@ -33,6 +34,7 @@ license = "AGPL-3.0-or-later" publish = false [workspace.dependencies] +tablepro-account = { path = "core/crates/account" } tablepro-ai = { path = "core/crates/ai" } tablepro-core = { path = "core/crates/core" } tablepro-driver-cassandra = { path = "core/crates/drivers/cassandra" } @@ -138,6 +140,9 @@ rustix = { version = "1.1", default-features = false, features = ["std"] } tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "logging", "tls12"] } hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } hyper-rustls = { version = "0.27", default-features = false, features = ["aws-lc-rs", "http1", "tls12"] } +# rustls on aws-lc-rs with rustls-platform-verifier, the provider and verifier +# tablepro-net's Verify Full builds. +reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "json", "query", "form"] } tower-service = "0.3" http = "1" rcgen = { version = "0.14", default-features = false, features = ["crypto", "pem", "aws_lc_rs"] } diff --git a/core/AGENTS.md b/core/AGENTS.md index 8fc4895f77..fe48bb58d8 100644 --- a/core/AGENTS.md +++ b/core/AGENTS.md @@ -15,7 +15,7 @@ This is the guide for work under `core/`. The root `AGENTS.md` holds the rules e ## Map -- `crates/core`, `crates/net`, `crates/ssh`, `crates/storage`, `crates/session`, `crates/ai`, `crates/mcp`: the libraries. `crates/drivers/*`: one crate per engine. `crates/ffi`: the library the Windows and Apple apps load (`ARCHITECTURE.md`, Bindings). `crates/bindgen`: the Swift binding generator. `crates/test-fixtures` and `crates/test-support`: test-only. +- `crates/core`, `crates/net`, `crates/ssh`, `crates/storage`, `crates/session`, `crates/ai`, `crates/mcp`, `crates/account`: the libraries. `crates/drivers/*`: one crate per engine. `crates/ffi`: the library the Windows and Apple apps load (`ARCHITECTURE.md`, Bindings). `crates/bindgen`: the Swift binding generator. `crates/test-fixtures` and `crates/test-support`: test-only. - `ARCHITECTURE.md`: the crates, the graph, the platform seams and the driver layer. `docs/decisions/`: the cross-platform decisions. - The Cargo workspace and its tool settings are at the top of the repository: `Cargo.toml`, `Cargo.lock`, `rust-toolchain.toml`, `rustfmt.toml`, `clippy.toml`, `deny.toml`, `.config/nextest.toml`. - Until another app builds the core, the Linux docs hold its contracts: `linux/docs/adding-drivers.md`, `linux/docs/drivers/`, `linux/docs/error-handling.md`, `linux/docs/testing.md` and `linux/docs/storage.md`. diff --git a/core/ARCHITECTURE.md b/core/ARCHITECTURE.md index a463952736..a618df1fec 100644 --- a/core/ARCHITECTURE.md +++ b/core/ARCHITECTURE.md @@ -15,6 +15,7 @@ core/crates/ ├── storage/ saved connections, history, drafts, credentials, file persistence ├── foreign-import/ connections and saved queries read from other apps' files, and their review ├── ai/ the AI assistant's providers, prompts and replies +├── account/ the TablePro account: sign-in, device, entitlement and the feature gate ├── mcp/ the MCP server: transport, token and tools ├── ffi/ the library C# and Swift load: one UniFFI API and the grid's C ABI ├── bindgen/ uniffi-bindgen-swift, which writes the Swift bindings from a built ffi @@ -45,6 +46,7 @@ core/crates/ | `session` | `core`, `net`, `ssh`, `storage` | | `mcp` | `core`, `session`, `storage` | | `ai` | `core`, `net` | +| `account` | `core`, `storage` | | `ffi` | `core`, `session`, `ssh`, `storage`, and every driver behind its default `engines` feature | | An app, such as `linux/crates/app` | every crate above, and its platform crate | @@ -57,6 +59,7 @@ Rules, enforced by review: - `session` depends on `core`, `net`, `ssh` and `storage`, and on no driver outside its tests: it reaches drivers through `DriverRegistry`, and SSH through the `SshTunnels` the app passes in. Its Docker tests take `tablepro-driver-postgres`, `tablepro-driver-redis` and `tablepro-driver-mongodb` as dev-dependencies. - `mcp` depends on `core`, `session` and `storage`, and on no driver outside its tests. It reaches the windows and the user through the `McpHost` trait the app implements. - `ai` depends on `core` and `net`. It speaks the providers' protocols and builds the prompts from what the app hands it; the app decides what a prompt may carry. +- `account` depends on `core` and `storage`. It reaches the browser, the keyring and the machine ID through what the app passes in ([0016](docs/decisions/0016-tablepro-accounts.md)). - `ffi` stands in for an app outside Rust: it names every driver and passes the host's services in. No other core crate depends on it. - No core crate depends on a GUI toolkit or on a platform crate. An app, or `ffi` for one outside Rust, is the only crate that names every driver. - `test-support` and `test-fixtures` are dev-dependencies, with one exception: `session` takes `test-support` as an optional dependency behind its `fakes` feature, which only an app's dev-dependencies turn on. @@ -78,6 +81,7 @@ A value or a trait the app passes in: | `SecretVault` and `SecretItems`, labelled through `SecretLabeler` and `SecretItemLabeler` | `tablepro_linux_platform::SecretStore`, the Secret Service through oo7 | `Core::with_system_secrets`, the platform's store the core reaches itself (below); `HostSecrets`, a callback interface, for a host with a store of its own | | `SshTunnels` | `tablepro_ssh::SshServices`, the system OpenSSH client | `tablepro_ssh::InProcessSsh`, behind `tablepro-session`'s `in-process-ssh` feature ([ADR 0009](docs/decisions/0009-in-process-ssh-on-windows.md)) | | `ProcessHold` | `gio::Application::hold` | none | +| `RefreshTokenVault`, `BrowserLauncher`, `MachineId` and `AppClient` (`tablepro-account`) | the keyring through `SecretStore`, `gtk::UriLauncher`, `MachineId::derive` of what `tablepro_linux_platform::read_machine_id` reads, `AppClient::LINUX` | none yet | | `ForeignAppDirs` and `ForeignHost` (`tablepro-foreign-import`) | `tablepro_linux_platform::foreign_app_dirs`, the host's XDG directories inside a Flatpak, and `PlainHost`: no other app's keychain | none yet | A `cfg` split inside a crate: diff --git a/core/crates/account/Cargo.toml b/core/crates/account/Cargo.toml new file mode 100644 index 0000000000..4df5e2405d --- /dev/null +++ b/core/crates/account/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "tablepro-account" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +publish.workspace = true + +[features] +# Entitlements signed with RFC 8037's test key, for the app's tests. +fakes = [] + +[dependencies] +tablepro-core.workspace = true +tablepro-storage.workspace = true +async-trait.workspace = true +# HMAC and SHA-256 from the one crypto provider (Linux ADR 0009). +aws-lc-rs.workspace = true +base64.workspace = true +bytes.workspace = true +chrono.workspace = true +http.workspace = true +http-body-util = "0.1" +hyper = { version = "1", features = ["server", "http1"] } +hyper-util.workspace = true +jsonwebtoken = { version = "11.1.0", default-features = false, features = ["aws_lc_rs"] } +# Its own reqwest 0.12 stays off: requests go through the workspace's +# reqwest, with redirects off. +oauth2 = { version = "5.0.0", default-features = false } +progenitor-client = "0.15.1" +# The generated client checks the document's string patterns with it. +regress = "0.12.0" +reqwest.workspace = true +secrecy.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["fs", "rt"] } +tracing.workspace = true +url.workspace = true + +[build-dependencies] +openapiv3 = "2.2" +progenitor = { version = "0.15.1", default-features = false } +serde_json.workspace = true + +[dev-dependencies] +getrandom = "0.3" +tempfile.workspace = true +tokio = { workspace = true, features = ["rt", "test-util"] } + +[lints] +workspace = true + +# Only the v2 client build.rs generates into OUT_DIR uses it, and shear does not read that. +[package.metadata.cargo-shear] +ignored = ["regress"] diff --git a/core/crates/account/build.rs b/core/crates/account/build.rs new file mode 100644 index 0000000000..acf27fb15e --- /dev/null +++ b/core/crates/account/build.rs @@ -0,0 +1,18 @@ +use std::path::PathBuf; + +use openapiv3::OpenAPI; + +const SPEC: &str = "openapi/v2.openapi-3.0.json"; + +fn main() -> Result<(), Box> { + println!("cargo::rerun-if-changed={SPEC}"); + let spec: OpenAPI = serde_json::from_slice(&std::fs::read(SPEC)?)?; + let mut settings = progenitor::GenerationSettings::new(); + settings + .with_inner_type("crate::cloud_api::Bearer".parse()?) + .with_hooks(progenitor::HooksMode::Expected); + let tokens = progenitor::Generator::new(&settings).generate_tokens(&spec)?; + let out = PathBuf::from(std::env::var("OUT_DIR")?).join("v2.rs"); + std::fs::write(out, tokens.to_string())?; + Ok(()) +} diff --git a/core/crates/account/openapi/v2.openapi-3.0.json b/core/crates/account/openapi/v2.openapi-3.0.json new file mode 100644 index 0000000000..e400b16478 --- /dev/null +++ b/core/crates/account/openapi/v2.openapi-3.0.json @@ -0,0 +1,5634 @@ +{ + "components": { + "headers": { + "Retry-After": { + "description": "Seconds to wait before trying again", + "schema": { + "type": "integer" + } + }, + "X-RateLimit-Limit": { + "description": "Requests the rate limit allows in its window", + "schema": { + "type": "integer" + } + }, + "X-RateLimit-Remaining": { + "description": "Requests left in the current window", + "schema": { + "type": "integer" + } + }, + "X-RateLimit-Reset": { + "description": "When the current window ends, in Unix time", + "schema": { + "type": "integer" + } + } + }, + "schemas": { + "Account": { + "properties": { + "email": { + "example": "ada@example.com", + "format": "email", + "type": "string" + }, + "id": { + "description": "Account TypeID", + "example": "acct_01jbvh7pn9e3sv4bzq4rqk4kzh", + "type": "string" + }, + "name": { + "example": "Ada Lovelace", + "type": "string" + } + }, + "required": [ + "id", + "email" + ], + "type": "object" + }, + "AssignableRole": { + "enum": [ + "admin", + "member" + ], + "type": "string" + }, + "Checkout": { + "properties": { + "url": { + "description": "Polar's checkout: open it in Polar's embedded overlay, or go there when the overlay cannot load", + "format": "uri", + "type": "string" + } + }, + "required": [ + "url" + ], + "type": "object" + }, + "CheckoutRequest": { + "additionalProperties": false, + "properties": { + "attribution": { + "description": "First-touch attribution, kept with the order: an object whose string members source, medium, campaign, term, content (each also with a utm_ prefix) and referrer are read. Anything else is ignored and never fails the request" + }, + "cycle": { + "$ref": "#/components/schemas/Cycle" + }, + "discount_code": { + "description": "A code /discounts/preview accepted. Without one, the regional discount applies; a code that no longer applies is dropped", + "maxLength": 64, + "type": "string" + }, + "locale": { + "description": "Language of Polar's checkout and emails, one of the public site's locales; en when absent or unknown", + "maxLength": 35, + "type": "string" + }, + "plan": { + "$ref": "#/components/schemas/Plan" + }, + "seats": { + "description": "Per-seat plans: the seats to buy, at least the plan's min_seats", + "format": "int64", + "maximum": 10000, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "plan", + "cycle" + ], + "type": "object" + }, + "Cycle": { + "enum": [ + "monthly", + "yearly", + "lifetime" + ], + "type": "string" + }, + "Device": { + "properties": { + "app_version": { + "example": "2.0.0", + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "id": { + "description": "Device TypeID", + "example": "dev_01jbvh7pn9e3sv4bzq4rqk4kzh", + "type": "string" + }, + "last_seen_at": { + "description": "Last registration or entitlement", + "format": "date-time", + "type": "string" + }, + "licensed_at": { + "description": "When the device took one of the account's 3 license slots; absent while it holds none", + "format": "date-time", + "type": "string" + }, + "machine_id": { + "description": "The machine ID the app reported, 64 lowercase hex characters", + "example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + "type": "string" + }, + "name": { + "example": "Ada's MacBook Pro", + "type": "string" + }, + "offline_until": { + "description": "When the last valid offline file issued for the device expires; the device is kept until then", + "format": "date-time", + "type": "string" + }, + "os_version": { + "example": "26.0", + "type": "string" + }, + "platform": { + "description": "Extensible enum: macos, linux", + "example": "macos", + "type": "string", + "x-extensible-enum": [ + "macos", + "linux" + ] + } + }, + "required": [ + "id", + "machine_id", + "platform", + "name", + "app_version", + "os_version", + "created_at", + "last_seen_at" + ], + "type": "object" + }, + "DeviceList": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/Device" + }, + "type": "array" + }, + "next_cursor": { + "description": "Pass as cursor for the next page; absent on the last page", + "type": "string" + } + }, + "required": [ + "data" + ], + "type": "object" + }, + "DeviceRegistration": { + "additionalProperties": false, + "properties": { + "app_version": { + "description": "1 to 100 characters, cleaned like name", + "example": "2.0.0", + "minLength": 1, + "type": "string" + }, + "machine_id": { + "description": "The app's machine ID", + "example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + "pattern": "^[0-9a-f]{64}$", + "patternDescription": "64 lowercase hex characters", + "type": "string" + }, + "name": { + "description": "1 to 100 characters once trimmed and stripped of control, format and line or paragraph separator characters", + "example": "Ada's MacBook Pro", + "minLength": 1, + "type": "string" + }, + "os_version": { + "description": "1 to 100 characters, cleaned like name", + "example": "26.0", + "minLength": 1, + "type": "string" + }, + "platform": { + "$ref": "#/components/schemas/Platform" + } + }, + "required": [ + "machine_id", + "platform", + "name", + "app_version", + "os_version" + ], + "type": "object" + }, + "DeviceUpdate": { + "additionalProperties": false, + "properties": { + "name": { + "description": "1 to 100 characters once trimmed and stripped of control, format and line or paragraph separator characters", + "example": "Studio", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "DiscountPreview": { + "properties": { + "amount": { + "description": "Percent off, or the fixed amount in minor units of currency", + "format": "double", + "type": "number" + }, + "amount_type": { + "description": "Extensible enum: percent, fixed", + "example": "percent", + "type": "string", + "x-extensible-enum": [ + "percent", + "fixed" + ] + }, + "currency": { + "description": "A fixed amount's currency", + "example": "USD", + "type": "string" + }, + "valid": { + "description": "Whether checkout would apply the code; the other members are absent when it would not", + "type": "boolean" + } + }, + "required": [ + "valid" + ], + "type": "object" + }, + "DiscountPreviewRequest": { + "additionalProperties": false, + "properties": { + "code": { + "maxLength": 64, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "code" + ], + "type": "object" + }, + "Entitlement": { + "properties": { + "expires_at": { + "description": "The token's exp: 14 days after issue at most, never past the end of a license it carries", + "format": "date-time", + "type": "string" + }, + "refresh_after": { + "description": "When to ask for the next one: 24 hours after this one was issued", + "format": "date-time", + "type": "string" + }, + "token": { + "description": "JWS compact serialization, typ tablepro-entitlement+jwt, signed with Ed25519; see the entitlement token in the API design", + "type": "string" + } + }, + "required": [ + "token", + "expires_at", + "refresh_after" + ], + "type": "object" + }, + "EntitlementRequest": { + "additionalProperties": false, + "properties": { + "app_version": { + "description": "1 to 100 characters once trimmed and stripped of control, format and line or paragraph separator characters", + "example": "2.0.0", + "minLength": 1, + "type": "string" + }, + "os_version": { + "description": "1 to 100 characters, cleaned like app_version", + "example": "26.0", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "app_version", + "os_version" + ], + "type": "object" + }, + "ErrorDetail": { + "properties": { + "location": { + "description": "Where the error occurred, e.g. 'body.items[3].tags' or 'path.thing-id'", + "type": "string" + }, + "message": { + "description": "Error message text", + "type": "string" + }, + "value": { + "description": "The value at the given location" + } + }, + "type": "object" + }, + "Invitation": { + "properties": { + "created_at": { + "format": "date-time", + "type": "string" + }, + "email": { + "example": "grace@example.com", + "format": "email", + "type": "string" + }, + "expires_at": { + "description": "7 days after it was sent or last resent", + "format": "date-time", + "type": "string" + }, + "id": { + "description": "Invitation TypeID", + "example": "inv_01jbvh7pn9e3sv4bzq4rqk4kzh", + "type": "string" + }, + "invited_by": { + "description": "Account TypeID of the inviter; absent when a team API token invited or the inviter's account was deleted", + "type": "string" + }, + "role": { + "description": "Extensible enum: admin, member", + "example": "admin", + "type": "string", + "x-extensible-enum": [ + "admin", + "member" + ] + }, + "with_seat": { + "description": "Whether the invitation reserves a seat, which accepting gives. One whose reservation was taken back reserves none, and accepting it needs a free seat", + "type": "boolean" + } + }, + "required": [ + "id", + "email", + "role", + "with_seat", + "expires_at", + "created_at" + ], + "type": "object" + }, + "InvitationList": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/Invitation" + }, + "type": "array" + }, + "next_cursor": { + "description": "Pass as cursor for the next page; absent on the last page", + "type": "string" + } + }, + "required": [ + "data" + ], + "type": "object" + }, + "InvitationRequest": { + "additionalProperties": false, + "properties": { + "email": { + "example": "grace@example.com", + "format": "email", + "maxLength": 254, + "type": "string" + }, + "role": { + "$ref": "#/components/schemas/AssignableRole" + }, + "with_seat": { + "description": "Whether accepting gives a seat; a pending invitation with a seat reserves it", + "type": "boolean" + } + }, + "required": [ + "email", + "role", + "with_seat" + ], + "type": "object" + }, + "LibraryChange": { + "properties": { + "client_id": { + "description": "The item's ID, chosen by the app that created it", + "example": "4C6B1E2A-0D8F-4F43-9A51-7C1B2E3D4F50", + "type": "string" + }, + "data": { + "additionalProperties": {}, + "description": "The item, absent when deleted. A connection: the app's connection settings, never a secret. A folder: name, parent_client_id, sort_order. A query: name, query, keyword, folder_client_id, connection_client_id, sort_order", + "type": "object" + }, + "deleted": { + "type": "boolean" + }, + "kind": { + "description": "Extensible enum: connection, folder, query", + "example": "connection", + "type": "string", + "x-extensible-enum": [ + "connection", + "folder", + "query" + ] + }, + "updated_at": { + "format": "date-time", + "type": "string" + }, + "updated_by": { + "description": "Account TypeID of the last writer; absent once that account is deleted", + "type": "string" + }, + "updated_by_name": { + "description": "The last writer's name, as the portal's library shows it. Without one, its address for an owner or admin, who may read the member list, and absent for a member. Absent once that account is deleted", + "example": "Grace Hopper", + "type": "string" + }, + "version": { + "description": "Goes up by one with every write of the item", + "format": "int64", + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "kind", + "client_id", + "version", + "deleted", + "updated_at" + ], + "type": "object" + }, + "LibraryChanges": { + "properties": { + "changes": { + "description": "Without a cursor, every live item; with one, what changed after it, deletions included", + "items": { + "$ref": "#/components/schemas/LibraryChange" + }, + "type": "array" + }, + "cursor": { + "description": "Pass as cursor to get what changes next", + "type": "string" + }, + "has_more": { + "description": "More changes wait; ask again with cursor right away", + "type": "boolean" + } + }, + "required": [ + "changes", + "cursor", + "has_more" + ], + "type": "object" + }, + "LibraryEdit": { + "additionalProperties": false, + "properties": { + "base_version": { + "description": "The version the edit starts from; null or absent for a new item", + "format": "int64", + "minimum": 1, + "nullable": true, + "type": "integer" + }, + "client_id": { + "example": "4C6B1E2A-0D8F-4F43-9A51-7C1B2E3D4F50", + "maxLength": 64, + "minLength": 1, + "type": "string" + }, + "data": { + "additionalProperties": {}, + "description": "The item, for upsert, in the shape LibraryChange.data describes; secret-bearing keys are refused", + "type": "object" + }, + "kind": { + "$ref": "#/components/schemas/LibraryItemKind" + }, + "op": { + "$ref": "#/components/schemas/LibraryEditOp" + } + }, + "required": [ + "kind", + "client_id", + "op" + ], + "type": "object" + }, + "LibraryEditOp": { + "enum": [ + "upsert", + "delete" + ], + "type": "string" + }, + "LibraryItemKind": { + "enum": [ + "connection", + "folder", + "query" + ], + "type": "string" + }, + "LibraryPush": { + "additionalProperties": false, + "properties": { + "changes": { + "description": "Applied in one transaction, judged item by item", + "items": { + "$ref": "#/components/schemas/LibraryEdit" + }, + "maxItems": 500, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "changes" + ], + "type": "object" + }, + "LibraryPushResult": { + "properties": { + "cursor": { + "description": "Covers every change committed up to this push, other writers' too. Keep pulling from the cursor of the last pull: pulling from this one would skip their changes", + "type": "string" + }, + "results": { + "description": "One per change, in the order sent", + "items": { + "$ref": "#/components/schemas/LibraryResult" + }, + "type": "array" + } + }, + "required": [ + "results", + "cursor" + ], + "type": "object" + }, + "LibraryResult": { + "properties": { + "client_id": { + "type": "string" + }, + "current": { + "$ref": "#/components/schemas/LibraryChange", + "description": "On a conflict, the item as stored" + }, + "kind": { + "description": "Extensible enum: connection, folder, query", + "example": "connection", + "type": "string", + "x-extensible-enum": [ + "connection", + "folder", + "query" + ] + }, + "status": { + "description": "Extensible enum: applied, conflict. conflict: base_version was not the stored version, so nothing changed", + "example": "applied", + "type": "string", + "x-extensible-enum": [ + "applied", + "conflict" + ] + }, + "version": { + "description": "The item's version after the push: the new one when applied, the stored one on a conflict", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "kind", + "client_id", + "status", + "version" + ], + "type": "object" + }, + "License": { + "properties": { + "cycle": { + "description": "Extensible enum: monthly, yearly, lifetime", + "example": "monthly", + "type": "string", + "x-extensible-enum": [ + "monthly", + "yearly", + "lifetime" + ] + }, + "ends_at": { + "description": "Absent for a license without an end, such as a renewing subscription or a lifetime purchase", + "format": "date-time", + "type": "string" + }, + "id": { + "description": "License TypeID", + "example": "lic_01jbvh7pn9e3sv4bzq4rqk4kzh", + "type": "string" + }, + "plan": { + "description": "Extensible enum: starter, team", + "example": "starter", + "type": "string", + "x-extensible-enum": [ + "starter", + "team" + ] + }, + "renews_at": { + "description": "When an active subscription renews: the end of its current period. Absent for any other license, a past due or canceling subscription included", + "format": "date-time", + "type": "string" + }, + "source": { + "description": "Extensible enum: subscription, order, manual, legacy", + "example": "subscription", + "type": "string", + "x-extensible-enum": [ + "subscription", + "order", + "manual", + "legacy" + ] + }, + "status": { + "description": "Extensible enum: active, past_due, canceling. past_due: a renewal failed and Polar retries it; canceling: the license ends at ends_at", + "example": "active", + "type": "string", + "x-extensible-enum": [ + "active", + "past_due", + "canceling" + ] + }, + "team": { + "$ref": "#/components/schemas/TeamRef", + "description": "The team that owns the license; absent for a personal one" + } + }, + "required": [ + "id", + "plan", + "cycle", + "source", + "status" + ], + "type": "object" + }, + "LicensedDevice": { + "properties": { + "app_version": { + "example": "2.0.0", + "type": "string" + }, + "created_at": { + "format": "date-time", + "type": "string" + }, + "id": { + "description": "Device TypeID", + "example": "dev_01jbvh7pn9e3sv4bzq4rqk4kzh", + "type": "string" + }, + "last_seen_at": { + "format": "date-time", + "type": "string" + }, + "licensed_at": { + "description": "When the device took its license slot", + "format": "date-time", + "type": "string" + }, + "machine_id": { + "example": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + "type": "string" + }, + "name": { + "example": "Ada's MacBook Pro", + "type": "string" + }, + "offline_until": { + "description": "When the last valid offline file issued for the device expires, as the device list gives it; until then the device cannot be removed (offline_file_active)", + "format": "date-time", + "type": "string" + }, + "os_version": { + "example": "26.0", + "type": "string" + }, + "platform": { + "description": "Extensible enum: macos, linux", + "example": "macos", + "type": "string", + "x-extensible-enum": [ + "macos", + "linux" + ] + } + }, + "required": [ + "id", + "machine_id", + "platform", + "name", + "app_version", + "os_version", + "licensed_at", + "created_at", + "last_seen_at" + ], + "type": "object" + }, + "Me": { + "properties": { + "account": { + "$ref": "#/components/schemas/Account" + }, + "licenses": { + "description": "The licenses whose features the account can use: its own, and those of teams where it holds a seat", + "items": { + "$ref": "#/components/schemas/License" + }, + "type": "array" + }, + "teams": { + "description": "Every team the account belongs to, in the order it joined them", + "items": { + "$ref": "#/components/schemas/Membership" + }, + "type": "array" + } + }, + "required": [ + "account", + "teams", + "licenses" + ], + "type": "object" + }, + "Member": { + "properties": { + "account_id": { + "description": "Account TypeID", + "example": "acct_01jbvh7pn9e3sv4bzq4rqk4kzh", + "type": "string" + }, + "email": { + "example": "grace@example.com", + "format": "email", + "type": "string" + }, + "has_seat": { + "type": "boolean" + }, + "id": { + "description": "Member TypeID", + "example": "mem_01jbvh7pn9e3sv4bzq4rqk4kzh", + "type": "string" + }, + "joined_at": { + "format": "date-time", + "type": "string" + }, + "name": { + "example": "Grace Hopper", + "type": "string" + }, + "role": { + "description": "Extensible enum: owner, admin, member", + "example": "owner", + "type": "string", + "x-extensible-enum": [ + "owner", + "admin", + "member" + ] + } + }, + "required": [ + "id", + "account_id", + "email", + "role", + "has_seat", + "joined_at" + ], + "type": "object" + }, + "MemberList": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/Member" + }, + "type": "array" + }, + "next_cursor": { + "description": "Pass as cursor for the next page; absent on the last page", + "type": "string" + } + }, + "required": [ + "data" + ], + "type": "object" + }, + "MemberUpdate": { + "additionalProperties": false, + "properties": { + "has_seat": { + "description": "Absent keeps the seat; true needs a free seat", + "type": "boolean" + }, + "role": { + "$ref": "#/components/schemas/AssignableRole", + "description": "Absent keeps the role" + } + }, + "type": "object" + }, + "Membership": { + "properties": { + "has_seat": { + "description": "Whether the account holds one of the team's seats, which gives it the team's features", + "type": "boolean" + }, + "role": { + "description": "Extensible enum: owner, admin, member", + "example": "owner", + "type": "string", + "x-extensible-enum": [ + "owner", + "admin", + "member" + ] + }, + "team": { + "$ref": "#/components/schemas/TeamRef" + } + }, + "required": [ + "team", + "role", + "has_seat" + ], + "type": "object" + }, + "NewsletterStats": { + "properties": { + "subscribers": { + "description": "Subscribers no suppression holds back, counted at most once an hour", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "subscribers" + ], + "type": "object" + }, + "NewsletterSubscriptionRequest": { + "additionalProperties": false, + "properties": { + "consent_version": { + "description": "Names the consent text the form showed", + "maxLength": 32, + "type": "string" + }, + "email": { + "format": "email", + "maxLength": 254, + "type": "string" + }, + "locale": { + "description": "Language of the confirmation email, one of the public site's locales; en when absent or unknown", + "maxLength": 16, + "type": "string" + }, + "source": { + "description": "URL of the page whose form was sent; kept in the consent log when it is on the public site, else the public site's origin is", + "maxLength": 2048, + "type": "string" + }, + "turnstile_token": { + "description": "Token from the Turnstile widget, action newsletter", + "maxLength": 2048, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "email", + "turnstile_token" + ], + "type": "object" + }, + "Plan": { + "enum": [ + "starter", + "team" + ], + "type": "string" + }, + "PlanPrice": { + "properties": { + "amount": { + "description": "List price in minor units of currency, per seat when per_seat", + "format": "int64", + "type": "integer" + }, + "currency": { + "example": "USD", + "type": "string" + }, + "cycle": { + "description": "Extensible enum: monthly, yearly, lifetime", + "example": "monthly", + "type": "string", + "x-extensible-enum": [ + "monthly", + "yearly", + "lifetime" + ] + }, + "discount_percent": { + "description": "The regional discount checkout applies without a discount code, 0 for none", + "format": "int64", + "maximum": 90, + "minimum": 0, + "type": "integer" + }, + "discounted_amount": { + "description": "amount less the regional discount, rounded half up as Polar rounds it. Per seat when per_seat, rounded per seat: Polar takes the percentage off all the seats at once, so seats times this can be a few cents off; discounted_total is exact", + "format": "int64", + "type": "integer" + }, + "discounted_total": { + "description": "What checkout charges for min_seats before tax: amount times min_seats, less discount_percent of that rounded half up once. Any other seat count is priced the same way", + "format": "int64", + "type": "integer" + }, + "features": { + "description": "Feature codes the plan unlocks", + "items": { + "type": "string" + }, + "type": "array" + }, + "max_seats": { + "description": "The most seats a checkout sells, from the plan's Polar seat tiers; 1 unless per_seat, null when the tiers set no maximum", + "format": "int64", + "minimum": 1, + "nullable": true, + "type": "integer" + }, + "min_seats": { + "description": "The fewest seats a checkout sells; 1 unless per_seat", + "format": "int64", + "minimum": 1, + "type": "integer" + }, + "per_seat": { + "type": "boolean" + }, + "plan": { + "description": "Extensible enum: starter, team", + "example": "starter", + "type": "string", + "x-extensible-enum": [ + "starter", + "team" + ] + } + }, + "required": [ + "plan", + "cycle", + "currency", + "amount", + "per_seat", + "min_seats", + "max_seats", + "features", + "discount_percent", + "discounted_amount", + "discounted_total" + ], + "type": "object" + }, + "Platform": { + "enum": [ + "macos", + "linux" + ], + "type": "string" + }, + "Pricing": { + "properties": { + "country": { + "description": "From CF-IPCountry: XX when unknown, T1 for Tor, neither of which gets a regional discount", + "example": "VN", + "type": "string" + }, + "plans": { + "items": { + "$ref": "#/components/schemas/PlanPrice" + }, + "type": "array" + } + }, + "required": [ + "country", + "plans" + ], + "type": "object" + }, + "Problem": { + "additionalProperties": true, + "properties": { + "code": { + "description": "Stable machine-readable problem code", + "type": "string" + }, + "detail": { + "description": "Explanation specific to this occurrence", + "type": "string" + }, + "devices": { + "description": "With device_limit_reached: the devices that hold the account's license slots, in the order they took them", + "items": { + "$ref": "#/components/schemas/LicensedDevice" + }, + "type": "array" + }, + "errors": { + "description": "Individual validation errors", + "items": { + "$ref": "#/components/schemas/ErrorDetail" + }, + "nullable": true, + "type": "array" + }, + "status": { + "description": "HTTP status code", + "format": "int64", + "type": "integer" + }, + "title": { + "description": "Short summary of the problem type", + "type": "string" + }, + "type": { + "description": "Identifies the problem type, one per code", + "format": "uri", + "type": "string" + } + }, + "required": [ + "type", + "title", + "status", + "code" + ], + "type": "object" + }, + "Seats": { + "properties": { + "available": { + "description": "total minus used and reserved; below zero when the pool shrank under what is in use", + "format": "int64", + "type": "integer" + }, + "reserved": { + "description": "Pending invitations that include a seat, expired ones included", + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "total": { + "description": "Seats on the team's active licenses", + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "used": { + "description": "Members holding a seat", + "format": "int64", + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "total", + "used", + "reserved", + "available" + ], + "type": "object" + }, + "Team": { + "properties": { + "has_seat": { + "description": "Whether the caller holds one of the team's seats", + "type": "boolean" + }, + "id": { + "description": "Team TypeID", + "example": "team_01jbvh7pn9e3sv4bzq4rqk4kzh", + "type": "string" + }, + "name": { + "example": "Acme", + "type": "string" + }, + "role": { + "description": "Extensible enum: owner, admin, member. The caller's role", + "example": "owner", + "type": "string", + "x-extensible-enum": [ + "owner", + "admin", + "member" + ] + } + }, + "required": [ + "id", + "name", + "role", + "has_seat" + ], + "type": "object" + }, + "TeamDetail": { + "properties": { + "has_seat": { + "description": "Whether the caller holds a seat; absent for a team API token", + "type": "boolean" + }, + "id": { + "description": "Team TypeID", + "example": "team_01jbvh7pn9e3sv4bzq4rqk4kzh", + "type": "string" + }, + "name": { + "example": "Acme", + "type": "string" + }, + "role": { + "description": "Extensible enum: owner, admin, member. The caller's role; absent for a team API token", + "example": "owner", + "type": "string", + "x-extensible-enum": [ + "owner", + "admin", + "member" + ] + }, + "seats": { + "$ref": "#/components/schemas/Seats" + } + }, + "required": [ + "id", + "name", + "seats" + ], + "type": "object" + }, + "TeamList": { + "properties": { + "data": { + "items": { + "$ref": "#/components/schemas/Team" + }, + "type": "array" + }, + "next_cursor": { + "description": "Pass as cursor for the next page; absent on the last page", + "type": "string" + } + }, + "required": [ + "data" + ], + "type": "object" + }, + "TeamRef": { + "properties": { + "id": { + "description": "Team TypeID", + "example": "team_01jbvh7pn9e3sv4bzq4rqk4kzh", + "type": "string" + }, + "name": { + "example": "Acme", + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + } + }, + "securitySchemes": { + "oauth": { + "description": "An access token from the TablePro app's sign-in.", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://account.tablepro.app/oauth/authorize", + "refreshUrl": "https://account.tablepro.app/oauth/token", + "scopes": { + "app": "Use the API as the TablePro app" + }, + "tokenUrl": "https://account.tablepro.app/oauth/token" + } + }, + "type": "oauth2" + }, + "team_token": { + "bearerFormat": "tpt_...", + "description": "A team API token, created by the team's owner or admins in the portal.", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "title": "TablePro API", + "version": "2" + }, + "openapi": "3.0.3", + "paths": { + "/checkouts": { + "post": { + "description": "Creates Polar's checkout for a guest. The price is the catalog's, less the discount code's discount or, without a code, the regional discount GET /pricing shows; tax and currency follow the caller's IP address.", + "operationId": "create-checkout", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CheckoutRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Checkout" + } + } + }, + "description": "Created", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Start a checkout", + "tags": [ + "Checkout" + ] + } + }, + "/devices": { + "get": { + "description": "Every device that was not removed, oldest first.", + "operationId": "list-devices", + "parameters": [ + { + "description": "Devices per page", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "description": "Devices per page", + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "next_cursor of the previous page; absent for the first page", + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "description": "next_cursor of the previous page; absent for the first page", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceList" + } + } + }, + "description": "OK", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "content": { + "application/json": { + "examples": { + "unauthorized": { + "summary": "Unauthorized", + "value": { + "code": "unauthorized", + "detail": "The token is invalid or has expired.", + "status": 401, + "title": "Unauthorized", + "type": "https://api.tablepro.app/problems/unauthorized" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "examples": { + "forbidden": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The token lacks the app scope.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "422": { + "content": { + "application/json": { + "examples": { + "validation_failed": { + "summary": "Validation failed", + "value": { + "code": "validation_failed", + "detail": "validation failed", + "errors": [ + { + "location": "query.cursor", + "message": "expected the next_cursor of a previous page", + "value": "abc" + } + ], + "status": 422, + "title": "Validation failed", + "type": "https://api.tablepro.app/problems/validation-failed" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "examples": { + "rate_limited": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "Too many requests. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "oauth": [ + "app" + ] + } + ], + "summary": "The account's devices", + "tags": [ + "Devices" + ] + }, + "post": { + "description": "Registers the machine the app runs on, or updates the account's device for it, and binds the refresh token family of the caller's sign-in to the device: access tokens refreshed afterwards carry dev, which the entitlement needs. Answers 201 for a new device and 200 when the machine was registered already. A sign-in already bound to another device is refused. 10 an hour per account.", + "operationId": "register-device", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceRegistration" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Device" + } + } + }, + "description": "The machine was registered already; its name, platform and versions are updated", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Device" + } + } + }, + "description": "Created", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "content": { + "application/json": { + "examples": { + "unauthorized": { + "summary": "Unauthorized", + "value": { + "code": "unauthorized", + "detail": "The token is invalid or has expired.", + "status": 401, + "title": "Unauthorized", + "type": "https://api.tablepro.app/problems/unauthorized" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "examples": { + "forbidden": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The token lacks the app scope.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "forbidden_2": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "This sign-in belongs to another device. Sign in again on this one.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "422": { + "content": { + "application/json": { + "examples": { + "validation_failed": { + "summary": "Validation failed", + "value": { + "code": "validation_failed", + "detail": "validation failed", + "errors": [ + { + "location": "body.machine_id", + "message": "expected string to match pattern ^[0-9a-f]{64}$", + "value": "MAC-1" + } + ], + "status": 422, + "title": "Validation failed", + "type": "https://api.tablepro.app/problems/validation-failed" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "examples": { + "rate_limited": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "Too many requests. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "oauth": [ + "app" + ] + } + ], + "summary": "Register this device", + "tags": [ + "Devices" + ] + } + }, + "/devices/{device_id}": { + "delete": { + "description": "Frees its license slot and revokes its refresh tokens, so the app on it signs out at its next refresh. Its entitlement stays valid until it expires. A device with an offline file that has not expired (offline_until) keeps its slot until its last valid file expires and is refused with offline_file_active.", + "operationId": "delete-device", + "parameters": [ + { + "description": "Device TypeID", + "in": "path", + "name": "device_id", + "required": true, + "schema": { + "description": "Device TypeID", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "content": { + "application/json": { + "examples": { + "unauthorized": { + "summary": "Unauthorized", + "value": { + "code": "unauthorized", + "detail": "The token is invalid or has expired.", + "status": 401, + "title": "Unauthorized", + "type": "https://api.tablepro.app/problems/unauthorized" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "examples": { + "forbidden": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The token lacks the app scope.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "examples": { + "not_found": { + "summary": "Not found", + "value": { + "code": "not_found", + "detail": "This account has no such device.", + "status": 404, + "title": "Not found", + "type": "https://api.tablepro.app/problems/not-found" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "examples": { + "offline_file_active": { + "summary": "Offline file active", + "value": { + "code": "offline_file_active", + "detail": "This device's offline file has not expired, so it keeps its license slot until offline_until.", + "status": 409, + "title": "Offline file active", + "type": "https://api.tablepro.app/problems/offline-file-active" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "examples": { + "rate_limited": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "Too many requests. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "oauth": [ + "app" + ] + } + ], + "summary": "Remove a device", + "tags": [ + "Devices" + ] + }, + "patch": { + "operationId": "update-device", + "parameters": [ + { + "description": "Device TypeID", + "in": "path", + "name": "device_id", + "required": true, + "schema": { + "description": "Device TypeID", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Device" + } + } + }, + "description": "OK", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "content": { + "application/json": { + "examples": { + "unauthorized": { + "summary": "Unauthorized", + "value": { + "code": "unauthorized", + "detail": "The token is invalid or has expired.", + "status": 401, + "title": "Unauthorized", + "type": "https://api.tablepro.app/problems/unauthorized" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "examples": { + "forbidden": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The token lacks the app scope.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "examples": { + "not_found": { + "summary": "Not found", + "value": { + "code": "not_found", + "detail": "This account has no such device.", + "status": 404, + "title": "Not found", + "type": "https://api.tablepro.app/problems/not-found" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "examples": { + "validation_failed": { + "summary": "Validation failed", + "value": { + "code": "validation_failed", + "detail": "validation failed", + "errors": [ + { + "location": "body.name", + "message": "expected a name of 1 to 100 characters", + "value": "\t" + } + ], + "status": 422, + "title": "Validation failed", + "type": "https://api.tablepro.app/problems/validation-failed" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "examples": { + "rate_limited": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "Too many requests. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "oauth": [ + "app" + ] + } + ], + "summary": "Rename a device", + "tags": [ + "Devices" + ] + } + }, + "/devices/{device_id}/entitlement": { + "post": { + "description": "Answers only for the device the caller's token is bound to: register the device, refresh the token, then ask. When the account has a license and the device holds none of its 3 slots, the device takes one, or the answer is device_limit_reached with the devices that hold them. An account without licenses gets a token with no features.", + "operationId": "issue-entitlement", + "parameters": [ + { + "description": "Device TypeID; must be the device the caller's token is bound to (its dev claim)", + "in": "path", + "name": "device_id", + "required": true, + "schema": { + "description": "Device TypeID; must be the device the caller's token is bound to (its dev claim)", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EntitlementRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Entitlement" + } + } + }, + "description": "OK", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "content": { + "application/json": { + "examples": { + "unauthorized": { + "summary": "Unauthorized", + "value": { + "code": "unauthorized", + "detail": "The token is invalid or has expired.", + "status": 401, + "title": "Unauthorized", + "type": "https://api.tablepro.app/problems/unauthorized" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "examples": { + "account_locked": { + "summary": "Account locked", + "value": { + "code": "account_locked", + "detail": "This account is locked.", + "status": 403, + "title": "Account locked", + "type": "https://api.tablepro.app/problems/account-locked" + } + }, + "device_revoked": { + "summary": "Device revoked", + "value": { + "code": "device_revoked", + "detail": "This device was removed from the account. Sign in again to use it.", + "status": 403, + "title": "Device revoked", + "type": "https://api.tablepro.app/problems/device-revoked" + } + }, + "forbidden": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The token lacks the app scope.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "forbidden_2": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "This token is not bound to that device. Register this device, refresh the token, and ask again.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "409": { + "content": { + "application/json": { + "examples": { + "device_limit_reached": { + "summary": "Device limit reached", + "value": { + "code": "device_limit_reached", + "detail": "The license is already used on 3 devices.", + "devices": [ + { + "app_version": "2.0.0", + "created_at": "2026-10-01T09:00:00Z", + "id": "dev_01k8djrfaefw08m00000000001", + "last_seen_at": "2026-10-21T09:00:00Z", + "licensed_at": "2026-10-01T09:00:00Z", + "machine_id": "0000000000000000000000000000000000000000000000000000000000000001", + "name": "Ada's MacBook Pro", + "os_version": "26.0", + "platform": "macos" + }, + { + "app_version": "2.0.0", + "created_at": "2026-10-02T09:00:00Z", + "id": "dev_01k8djrfaefw08m00000000002", + "last_seen_at": "2026-10-22T09:00:00Z", + "licensed_at": "2026-10-02T09:00:00Z", + "machine_id": "0000000000000000000000000000000000000000000000000000000000000002", + "name": "Studio", + "offline_until": "2027-10-02T09:00:00Z", + "os_version": "26.0", + "platform": "macos" + }, + { + "app_version": "2.0.0", + "created_at": "2026-10-03T09:00:00Z", + "id": "dev_01k8djrfaefw08m00000000003", + "last_seen_at": "2026-10-23T09:00:00Z", + "licensed_at": "2026-10-03T09:00:00Z", + "machine_id": "0000000000000000000000000000000000000000000000000000000000000003", + "name": "Workstation", + "os_version": "Fedora 43", + "platform": "linux" + } + ], + "status": 409, + "title": "Device limit reached", + "type": "https://api.tablepro.app/problems/device-limit-reached" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "examples": { + "rate_limited": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "Too many requests. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "oauth": [ + "app" + ] + } + ], + "summary": "An entitlement for this device", + "tags": [ + "Devices" + ] + } + }, + "/discounts/preview": { + "post": { + "description": "Answers whether checkout would apply the code, and how much it takes off. An unknown code is not an error.", + "operationId": "preview-discount", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiscountPreviewRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DiscountPreview" + } + } + }, + "description": "OK", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Check a discount code", + "tags": [ + "Checkout" + ] + } + }, + "/me": { + "get": { + "description": "The account, the teams it belongs to with its role and seat in each, and the licenses whose features it can use.", + "operationId": "get-me", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Me" + } + } + }, + "description": "OK", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "content": { + "application/json": { + "examples": { + "unauthorized": { + "summary": "Unauthorized", + "value": { + "code": "unauthorized", + "detail": "The token is invalid or has expired.", + "status": 401, + "title": "Unauthorized", + "type": "https://api.tablepro.app/problems/unauthorized" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "examples": { + "forbidden": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The token lacks the app scope.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "429": { + "content": { + "application/json": { + "examples": { + "rate_limited": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "Too many requests. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "oauth": [ + "app" + ] + } + ], + "summary": "The signed-in account", + "tags": [ + "Account" + ] + } + }, + "/newsletter/stats": { + "get": { + "operationId": "get-newsletter-stats", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewsletterStats" + } + } + }, + "description": "OK" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Service Unavailable" + } + }, + "summary": "Count the newsletter's subscribers", + "tags": [ + "Newsletter" + ] + } + }, + "/newsletter/subscriptions": { + "post": { + "description": "Emails a confirmation link. Answers 202 whether or not the address is subscribed and whether or not the Turnstile check passed, so it reveals nothing.", + "operationId": "create-newsletter-subscription", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewsletterSubscriptionRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Accepted", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Sign up for the newsletter", + "tags": [ + "Newsletter" + ] + } + }, + "/pricing": { + "get": { + "description": "What checkout charges the caller without a discount code, regional discount included. Answered for the caller's country, so it is never cached.", + "operationId": "get-pricing", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pricing" + } + } + }, + "description": "OK", + "headers": { + "Cache-Control": { + "schema": { + "type": "string" + } + } + } + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "summary": "Prices in the caller's country", + "tags": [ + "Checkout" + ] + } + }, + "/teams": { + "get": { + "description": "With the caller's role and seat in each, in the order the account joined them. App sign-ins only.", + "operationId": "list-teams", + "parameters": [ + { + "description": "Items per page", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "description": "Items per page", + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "next_cursor of the previous page; absent for the first page", + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "description": "next_cursor of the previous page; absent for the first page", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamList" + } + } + }, + "description": "OK", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "content": { + "application/json": { + "examples": { + "unauthorized": { + "summary": "Unauthorized", + "value": { + "code": "unauthorized", + "detail": "The token is invalid or has expired.", + "status": 401, + "title": "Unauthorized", + "type": "https://api.tablepro.app/problems/unauthorized" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "examples": { + "forbidden": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The token lacks the app scope.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "interactive_sign_in_required": { + "summary": "Interactive sign in required", + "value": { + "code": "interactive_sign_in_required", + "detail": "This app signed in with a license key. Sign in with your account to manage teams.", + "status": 403, + "title": "Interactive sign in required", + "type": "https://api.tablepro.app/problems/interactive-sign-in-required" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "422": { + "content": { + "application/json": { + "examples": { + "validation_failed": { + "summary": "Validation failed", + "value": { + "code": "validation_failed", + "detail": "validation failed", + "errors": [ + { + "location": "query.cursor", + "message": "expected the next_cursor of a previous page", + "value": "abc" + } + ], + "status": 422, + "title": "Validation failed", + "type": "https://api.tablepro.app/problems/validation-failed" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "examples": { + "rate_limited": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "Too many requests. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "oauth": [ + "app" + ] + } + ], + "summary": "The teams the account belongs to", + "tags": [ + "Teams" + ] + } + }, + "/teams/{team_id}": { + "get": { + "description": "Its name, the caller's role and its seats. Any member, or a team API token of the team with any scope.", + "operationId": "get-team", + "parameters": [ + { + "description": "Team TypeID", + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "description": "Team TypeID", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TeamDetail" + } + } + }, + "description": "OK", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "content": { + "application/json": { + "examples": { + "unauthorized": { + "summary": "Unauthorized", + "value": { + "code": "unauthorized", + "detail": "The token is invalid or has expired.", + "status": 401, + "title": "Unauthorized", + "type": "https://api.tablepro.app/problems/unauthorized" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "examples": { + "forbidden": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The token lacks the app scope.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "interactive_sign_in_required": { + "summary": "Interactive sign in required", + "value": { + "code": "interactive_sign_in_required", + "detail": "This app signed in with a license key. Sign in with your account to manage teams.", + "status": 403, + "title": "Interactive sign in required", + "type": "https://api.tablepro.app/problems/interactive-sign-in-required" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "examples": { + "not_found": { + "summary": "Not found", + "value": { + "code": "not_found", + "detail": "No such team, or the caller is not in it.", + "status": 404, + "title": "Not found", + "type": "https://api.tablepro.app/problems/not-found" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "examples": { + "rate_limited": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "Too many requests. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "oauth": [ + "app" + ] + }, + { + "team_token": [] + } + ], + "summary": "A team", + "tags": [ + "Teams" + ] + } + }, + "/teams/{team_id}/invitations": { + "get": { + "description": "Owners and admins, or a team API token of the team with the invitations:read scope. Neither accepted nor revoked, expired ones included, in the order they were created.", + "operationId": "list-team-invitations", + "parameters": [ + { + "description": "Team TypeID", + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "description": "Team TypeID", + "type": "string" + } + }, + { + "description": "Items per page", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "description": "Items per page", + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "next_cursor of the previous page; absent for the first page", + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "description": "next_cursor of the previous page; absent for the first page", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationList" + } + } + }, + "description": "OK", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "content": { + "application/json": { + "examples": { + "unauthorized": { + "summary": "Unauthorized", + "value": { + "code": "unauthorized", + "detail": "The token is invalid or has expired.", + "status": 401, + "title": "Unauthorized", + "type": "https://api.tablepro.app/problems/unauthorized" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "examples": { + "forbidden": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The token lacks the app scope.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "forbidden_2": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "Only the team's owner and admins can do this.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "insufficient_scope": { + "summary": "Insufficient scope", + "value": { + "code": "insufficient_scope", + "detail": "The team API token lacks the invitations:read scope.", + "scope": "invitations:read", + "status": 403, + "title": "Insufficient scope", + "type": "https://api.tablepro.app/problems/insufficient-scope" + } + }, + "interactive_sign_in_required": { + "summary": "Interactive sign in required", + "value": { + "code": "interactive_sign_in_required", + "detail": "This app signed in with a license key. Sign in with your account to manage teams.", + "status": 403, + "title": "Interactive sign in required", + "type": "https://api.tablepro.app/problems/interactive-sign-in-required" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "examples": { + "not_found": { + "summary": "Not found", + "value": { + "code": "not_found", + "detail": "No such team, or the caller is not in it.", + "status": 404, + "title": "Not found", + "type": "https://api.tablepro.app/problems/not-found" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "examples": { + "validation_failed": { + "summary": "Validation failed", + "value": { + "code": "validation_failed", + "detail": "validation failed", + "errors": [ + { + "location": "query.cursor", + "message": "expected the next_cursor of a previous page", + "value": "abc" + } + ], + "status": 422, + "title": "Validation failed", + "type": "https://api.tablepro.app/problems/validation-failed" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "examples": { + "rate_limited": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "Too many requests. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "oauth": [ + "app" + ] + }, + { + "team_token": [] + } + ], + "summary": "A team's pending invitations", + "tags": [ + "Teams" + ] + }, + "post": { + "description": "Owners and admins, or a team API token of the team with the invitations:write scope. Emails a link valid for 7 days. With a seat, it needs a free one and reserves it while pending. Creating and resending together: 50 an hour per caller and team, and 100 an hour per team, whoever sends them; one that fails is not counted against the team.", + "operationId": "create-team-invitation", + "parameters": [ + { + "description": "Team TypeID", + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "description": "Team TypeID", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvitationRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + } + }, + "description": "Created", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "content": { + "application/json": { + "examples": { + "unauthorized": { + "summary": "Unauthorized", + "value": { + "code": "unauthorized", + "detail": "The token is invalid or has expired.", + "status": 401, + "title": "Unauthorized", + "type": "https://api.tablepro.app/problems/unauthorized" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "examples": { + "forbidden": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The token lacks the app scope.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "forbidden_2": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "Only the team's owner and admins can do this.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "insufficient_scope": { + "summary": "Insufficient scope", + "value": { + "code": "insufficient_scope", + "detail": "The team API token lacks the invitations:write scope.", + "scope": "invitations:write", + "status": 403, + "title": "Insufficient scope", + "type": "https://api.tablepro.app/problems/insufficient-scope" + } + }, + "interactive_sign_in_required": { + "summary": "Interactive sign in required", + "value": { + "code": "interactive_sign_in_required", + "detail": "This app signed in with a license key. Sign in with your account to manage teams.", + "status": 403, + "title": "Interactive sign in required", + "type": "https://api.tablepro.app/problems/interactive-sign-in-required" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "examples": { + "not_found": { + "summary": "Not found", + "value": { + "code": "not_found", + "detail": "No such team, or the caller is not in it.", + "status": 404, + "title": "Not found", + "type": "https://api.tablepro.app/problems/not-found" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "examples": { + "already_member": { + "summary": "Already member", + "value": { + "code": "already_member", + "detail": "That address belongs to a member of the team already.", + "status": 409, + "title": "Already member", + "type": "https://api.tablepro.app/problems/already-member" + } + }, + "invitation_pending": { + "summary": "Invitation pending", + "value": { + "code": "invitation_pending", + "detail": "That address has a pending invitation already. Resend it instead.", + "status": 409, + "title": "Invitation pending", + "type": "https://api.tablepro.app/problems/invitation-pending" + } + }, + "seat_limit_reached": { + "summary": "Seat limit reached", + "value": { + "code": "seat_limit_reached", + "detail": "Every seat of the team is in use. Buy more seats in the portal.", + "status": 409, + "title": "Seat limit reached", + "type": "https://api.tablepro.app/problems/seat-limit-reached" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "examples": { + "validation_failed": { + "summary": "Validation failed", + "value": { + "code": "validation_failed", + "detail": "validation failed", + "errors": [ + { + "location": "body.email", + "message": "expected an email address", + "value": "grace@localhost" + } + ], + "status": 422, + "title": "Validation failed", + "type": "https://api.tablepro.app/problems/validation-failed" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "examples": { + "rate_limited": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "Too many requests. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "oauth": [ + "app" + ] + }, + { + "team_token": [] + } + ], + "summary": "Invite someone to a team", + "tags": [ + "Teams" + ] + } + }, + "/teams/{team_id}/invitations/{invitation_id}": { + "delete": { + "description": "Owners and admins, or a team API token of the team with the invitations:write scope. Its link stops working and the seat it reserved is free again.", + "operationId": "delete-team-invitation", + "parameters": [ + { + "description": "Team TypeID", + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "description": "Team TypeID", + "type": "string" + } + }, + { + "description": "Invitation TypeID", + "in": "path", + "name": "invitation_id", + "required": true, + "schema": { + "description": "Invitation TypeID", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "content": { + "application/json": { + "examples": { + "unauthorized": { + "summary": "Unauthorized", + "value": { + "code": "unauthorized", + "detail": "The token is invalid or has expired.", + "status": 401, + "title": "Unauthorized", + "type": "https://api.tablepro.app/problems/unauthorized" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "examples": { + "forbidden": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The token lacks the app scope.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "forbidden_2": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "Only the team's owner and admins can do this.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "insufficient_scope": { + "summary": "Insufficient scope", + "value": { + "code": "insufficient_scope", + "detail": "The team API token lacks the invitations:write scope.", + "scope": "invitations:write", + "status": 403, + "title": "Insufficient scope", + "type": "https://api.tablepro.app/problems/insufficient-scope" + } + }, + "interactive_sign_in_required": { + "summary": "Interactive sign in required", + "value": { + "code": "interactive_sign_in_required", + "detail": "This app signed in with a license key. Sign in with your account to manage teams.", + "status": 403, + "title": "Interactive sign in required", + "type": "https://api.tablepro.app/problems/interactive-sign-in-required" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "examples": { + "not_found": { + "summary": "Not found", + "value": { + "code": "not_found", + "detail": "The team has no such pending invitation.", + "status": 404, + "title": "Not found", + "type": "https://api.tablepro.app/problems/not-found" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "examples": { + "rate_limited": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "Too many requests. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "oauth": [ + "app" + ] + }, + { + "team_token": [] + } + ], + "summary": "Revoke an invitation", + "tags": [ + "Teams" + ] + } + }, + "/teams/{team_id}/invitations/{invitation_id}/resend": { + "post": { + "description": "Owners and admins, or a team API token of the team with the invitations:write scope. The new link replaces the old one and lasts 7 days. An invitation is sent at most once every 10 minutes. It keeps its seat as it is: an expired one still holds its reservation, and one whose reservation was taken back stays without a seat, so accepting it needs a free seat at that moment. Creating and resending together: 50 an hour per caller and team, and 100 an hour per team, whoever sends them; one that fails is not counted against the team.", + "operationId": "resend-team-invitation", + "parameters": [ + { + "description": "Team TypeID", + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "description": "Team TypeID", + "type": "string" + } + }, + { + "description": "Invitation TypeID", + "in": "path", + "name": "invitation_id", + "required": true, + "schema": { + "description": "Invitation TypeID", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Invitation" + } + } + }, + "description": "OK", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "content": { + "application/json": { + "examples": { + "unauthorized": { + "summary": "Unauthorized", + "value": { + "code": "unauthorized", + "detail": "The token is invalid or has expired.", + "status": 401, + "title": "Unauthorized", + "type": "https://api.tablepro.app/problems/unauthorized" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "examples": { + "forbidden": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The token lacks the app scope.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "forbidden_2": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "Only the team's owner and admins can do this.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "insufficient_scope": { + "summary": "Insufficient scope", + "value": { + "code": "insufficient_scope", + "detail": "The team API token lacks the invitations:write scope.", + "scope": "invitations:write", + "status": 403, + "title": "Insufficient scope", + "type": "https://api.tablepro.app/problems/insufficient-scope" + } + }, + "interactive_sign_in_required": { + "summary": "Interactive sign in required", + "value": { + "code": "interactive_sign_in_required", + "detail": "This app signed in with a license key. Sign in with your account to manage teams.", + "status": 403, + "title": "Interactive sign in required", + "type": "https://api.tablepro.app/problems/interactive-sign-in-required" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "examples": { + "not_found": { + "summary": "Not found", + "value": { + "code": "not_found", + "detail": "The team has no such pending invitation.", + "status": 404, + "title": "Not found", + "type": "https://api.tablepro.app/problems/not-found" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "examples": { + "seat_limit_reached": { + "summary": "Seat limit reached", + "value": { + "code": "seat_limit_reached", + "detail": "Every seat of the team is in use. Buy more seats in the portal.", + "status": 409, + "title": "Seat limit reached", + "type": "https://api.tablepro.app/problems/seat-limit-reached" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "examples": { + "rate_limited": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "Too many requests. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + }, + "rate_limited_2": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "The invitation was sent less than 10 minutes ago. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "oauth": [ + "app" + ] + }, + { + "team_token": [] + } + ], + "summary": "Send an invitation again", + "tags": [ + "Teams" + ] + } + }, + "/teams/{team_id}/library/changes": { + "get": { + "description": "Needs a seat in the team and the team_library feature.", + "operationId": "list-library-changes", + "parameters": [ + { + "description": "Team TypeID", + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "description": "Team TypeID", + "type": "string" + } + }, + { + "description": "cursor of the previous pull; absent for a snapshot of every live item", + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "description": "cursor of the previous pull; absent for a snapshot of every live item", + "maxLength": 64, + "type": "string" + } + }, + { + "description": "Changes per response", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 500, + "description": "Changes per response", + "format": "int64", + "maximum": 500, + "minimum": 1, + "type": "integer" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryChanges" + } + } + }, + "description": "OK", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "content": { + "application/json": { + "examples": { + "unauthorized": { + "summary": "Unauthorized", + "value": { + "code": "unauthorized", + "detail": "The token is invalid or has expired.", + "status": 401, + "title": "Unauthorized", + "type": "https://api.tablepro.app/problems/unauthorized" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "examples": { + "feature_required": { + "summary": "Feature required", + "value": { + "code": "feature_required", + "detail": "The team's plan does not include team_library.", + "status": 403, + "title": "Feature required", + "type": "https://api.tablepro.app/problems/feature-required" + } + }, + "forbidden": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The token lacks the app scope.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "forbidden_2": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The library needs a seat in the team.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "examples": { + "not_found": { + "summary": "Not found", + "value": { + "code": "not_found", + "detail": "No such team, or the caller is not in it.", + "status": 404, + "title": "Not found", + "type": "https://api.tablepro.app/problems/not-found" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "examples": { + "validation_failed": { + "summary": "Validation failed", + "value": { + "code": "validation_failed", + "detail": "validation failed", + "errors": [ + { + "location": "query.cursor", + "message": "expected the cursor of a previous pull", + "value": "abc" + } + ], + "status": 422, + "title": "Validation failed", + "type": "https://api.tablepro.app/problems/validation-failed" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "examples": { + "rate_limited": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "Too many requests. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "oauth": [ + "app" + ] + } + ], + "summary": "Pull a team's library", + "tags": [ + "Team library" + ] + }, + "post": { + "description": "Owners and admins with a seat and the team_library feature. Up to 500 changes, applied in one transaction and judged item by item. An item's data is at most 256 KiB of JSON and the body at most 4 MiB. A change the server refuses, such as data with a secret-bearing key, refuses the whole push.", + "operationId": "push-library-changes", + "parameters": [ + { + "description": "Team TypeID", + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "description": "Team TypeID", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryPush" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LibraryPushResult" + } + } + }, + "description": "OK", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "content": { + "application/json": { + "examples": { + "unauthorized": { + "summary": "Unauthorized", + "value": { + "code": "unauthorized", + "detail": "The token is invalid or has expired.", + "status": 401, + "title": "Unauthorized", + "type": "https://api.tablepro.app/problems/unauthorized" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "examples": { + "feature_required": { + "summary": "Feature required", + "value": { + "code": "feature_required", + "detail": "The team's plan does not include team_library.", + "status": 403, + "title": "Feature required", + "type": "https://api.tablepro.app/problems/feature-required" + } + }, + "forbidden": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The token lacks the app scope.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "forbidden_2": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The library needs a seat in the team.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "library_write_forbidden": { + "summary": "Library write forbidden", + "value": { + "code": "library_write_forbidden", + "detail": "Only the team's owner and admins publish to its library.", + "status": 403, + "title": "Library write forbidden", + "type": "https://api.tablepro.app/problems/library-write-forbidden" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "examples": { + "not_found": { + "summary": "Not found", + "value": { + "code": "not_found", + "detail": "No such team, or the caller is not in it.", + "status": 404, + "title": "Not found", + "type": "https://api.tablepro.app/problems/not-found" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "413": { + "content": { + "application/json": { + "examples": { + "request_entity_too_large": { + "summary": "Request entity too large", + "value": { + "code": "request_entity_too_large", + "detail": "request body is too large limit=4194305 bytes", + "status": 413, + "title": "Request entity too large", + "type": "https://api.tablepro.app/problems/request-entity-too-large" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Request Entity Too Large" + }, + "422": { + "content": { + "application/json": { + "examples": { + "validation_failed": { + "summary": "Validation failed", + "value": { + "code": "validation_failed", + "detail": "validation failed", + "errors": [ + { + "location": "body.changes[0].data.password", + "message": "secret-bearing keys are refused; the library never stores a secret", + "value": "password" + } + ], + "status": 422, + "title": "Validation failed", + "type": "https://api.tablepro.app/problems/validation-failed" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "examples": { + "rate_limited": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "Too many requests. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "oauth": [ + "app" + ] + } + ], + "summary": "Push changes to a team's library", + "tags": [ + "Team library" + ] + } + }, + "/teams/{team_id}/members": { + "get": { + "description": "Owners and admins, or a team API token of the team with the members:read scope. In the order they joined.", + "operationId": "list-team-members", + "parameters": [ + { + "description": "Team TypeID", + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "description": "Team TypeID", + "type": "string" + } + }, + { + "description": "Items per page", + "explode": false, + "in": "query", + "name": "limit", + "schema": { + "default": 50, + "description": "Items per page", + "format": "int64", + "maximum": 200, + "minimum": 1, + "type": "integer" + } + }, + { + "description": "next_cursor of the previous page; absent for the first page", + "explode": false, + "in": "query", + "name": "cursor", + "schema": { + "description": "next_cursor of the previous page; absent for the first page", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberList" + } + } + }, + "description": "OK", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "content": { + "application/json": { + "examples": { + "unauthorized": { + "summary": "Unauthorized", + "value": { + "code": "unauthorized", + "detail": "The token is invalid or has expired.", + "status": 401, + "title": "Unauthorized", + "type": "https://api.tablepro.app/problems/unauthorized" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "examples": { + "forbidden": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The token lacks the app scope.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "forbidden_2": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "Only the team's owner and admins can do this.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "insufficient_scope": { + "summary": "Insufficient scope", + "value": { + "code": "insufficient_scope", + "detail": "The team API token lacks the members:read scope.", + "scope": "members:read", + "status": 403, + "title": "Insufficient scope", + "type": "https://api.tablepro.app/problems/insufficient-scope" + } + }, + "interactive_sign_in_required": { + "summary": "Interactive sign in required", + "value": { + "code": "interactive_sign_in_required", + "detail": "This app signed in with a license key. Sign in with your account to manage teams.", + "status": 403, + "title": "Interactive sign in required", + "type": "https://api.tablepro.app/problems/interactive-sign-in-required" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "examples": { + "not_found": { + "summary": "Not found", + "value": { + "code": "not_found", + "detail": "No such team, or the caller is not in it.", + "status": 404, + "title": "Not found", + "type": "https://api.tablepro.app/problems/not-found" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "examples": { + "validation_failed": { + "summary": "Validation failed", + "value": { + "code": "validation_failed", + "detail": "validation failed", + "errors": [ + { + "location": "query.cursor", + "message": "expected the next_cursor of a previous page", + "value": "abc" + } + ], + "status": 422, + "title": "Validation failed", + "type": "https://api.tablepro.app/problems/validation-failed" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "examples": { + "rate_limited": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "Too many requests. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "oauth": [ + "app" + ] + }, + { + "team_token": [] + } + ], + "summary": "A team's members", + "tags": [ + "Teams" + ] + } + }, + "/teams/{team_id}/members/{member_id}": { + "delete": { + "description": "Owners and admins, or a team API token of the team with the members:write scope. The owner cannot be removed.", + "operationId": "delete-team-member", + "parameters": [ + { + "description": "Team TypeID", + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "description": "Team TypeID", + "type": "string" + } + }, + { + "description": "Member TypeID", + "in": "path", + "name": "member_id", + "required": true, + "schema": { + "description": "Member TypeID", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "No Content", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "content": { + "application/json": { + "examples": { + "unauthorized": { + "summary": "Unauthorized", + "value": { + "code": "unauthorized", + "detail": "The token is invalid or has expired.", + "status": 401, + "title": "Unauthorized", + "type": "https://api.tablepro.app/problems/unauthorized" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "examples": { + "forbidden": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The token lacks the app scope.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "forbidden_2": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "Only the team's owner and admins can do this.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "forbidden_3": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The owner stays until ownership is transferred in the portal.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "insufficient_scope": { + "summary": "Insufficient scope", + "value": { + "code": "insufficient_scope", + "detail": "The team API token lacks the members:write scope.", + "scope": "members:write", + "status": 403, + "title": "Insufficient scope", + "type": "https://api.tablepro.app/problems/insufficient-scope" + } + }, + "interactive_sign_in_required": { + "summary": "Interactive sign in required", + "value": { + "code": "interactive_sign_in_required", + "detail": "This app signed in with a license key. Sign in with your account to manage teams.", + "status": 403, + "title": "Interactive sign in required", + "type": "https://api.tablepro.app/problems/interactive-sign-in-required" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "examples": { + "not_found": { + "summary": "Not found", + "value": { + "code": "not_found", + "detail": "The team has no such member.", + "status": 404, + "title": "Not found", + "type": "https://api.tablepro.app/problems/not-found" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "examples": { + "rate_limited": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "Too many requests. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "oauth": [ + "app" + ] + }, + { + "team_token": [] + } + ], + "summary": "Remove a member", + "tags": [ + "Teams" + ] + }, + "patch": { + "description": "Owners and admins, or a team API token of the team with the members:write scope. The owner's role changes only by a transfer in the portal, and only the owner changes its own seat.", + "operationId": "update-team-member", + "parameters": [ + { + "description": "Team TypeID", + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "description": "Team TypeID", + "type": "string" + } + }, + { + "description": "Member TypeID", + "in": "path", + "name": "member_id", + "required": true, + "schema": { + "description": "Member TypeID", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemberUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Member" + } + } + }, + "description": "OK", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "content": { + "application/json": { + "examples": { + "unauthorized": { + "summary": "Unauthorized", + "value": { + "code": "unauthorized", + "detail": "The token is invalid or has expired.", + "status": 401, + "title": "Unauthorized", + "type": "https://api.tablepro.app/problems/unauthorized" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "examples": { + "forbidden": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The token lacks the app scope.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "forbidden_2": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "Only the team's owner and admins can do this.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "forbidden_3": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The owner stays until ownership is transferred in the portal.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "forbidden_4": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "Only the team's owner can do this.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "insufficient_scope": { + "summary": "Insufficient scope", + "value": { + "code": "insufficient_scope", + "detail": "The team API token lacks the members:write scope.", + "scope": "members:write", + "status": 403, + "title": "Insufficient scope", + "type": "https://api.tablepro.app/problems/insufficient-scope" + } + }, + "interactive_sign_in_required": { + "summary": "Interactive sign in required", + "value": { + "code": "interactive_sign_in_required", + "detail": "This app signed in with a license key. Sign in with your account to manage teams.", + "status": 403, + "title": "Interactive sign in required", + "type": "https://api.tablepro.app/problems/interactive-sign-in-required" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "examples": { + "not_found": { + "summary": "Not found", + "value": { + "code": "not_found", + "detail": "The team has no such member.", + "status": 404, + "title": "Not found", + "type": "https://api.tablepro.app/problems/not-found" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "409": { + "content": { + "application/json": { + "examples": { + "seat_limit_reached": { + "summary": "Seat limit reached", + "value": { + "code": "seat_limit_reached", + "detail": "Every seat of the team is in use. Buy more seats in the portal.", + "status": 409, + "title": "Seat limit reached", + "type": "https://api.tablepro.app/problems/seat-limit-reached" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Conflict" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "examples": { + "rate_limited": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "Too many requests. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "oauth": [ + "app" + ] + }, + { + "team_token": [] + } + ], + "summary": "Change a member's role or seat", + "tags": [ + "Teams" + ] + } + }, + "/teams/{team_id}/seats": { + "get": { + "description": "Owners and admins, or a team API token of the team with the seats:read scope.", + "operationId": "get-team-seats", + "parameters": [ + { + "description": "Team TypeID", + "in": "path", + "name": "team_id", + "required": true, + "schema": { + "description": "Team TypeID", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Seats" + } + } + }, + "description": "OK", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "content": { + "application/json": { + "examples": { + "unauthorized": { + "summary": "Unauthorized", + "value": { + "code": "unauthorized", + "detail": "The token is invalid or has expired.", + "status": 401, + "title": "Unauthorized", + "type": "https://api.tablepro.app/problems/unauthorized" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "examples": { + "forbidden": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "The token lacks the app scope.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "forbidden_2": { + "summary": "Forbidden", + "value": { + "code": "forbidden", + "detail": "Only the team's owner and admins can do this.", + "status": 403, + "title": "Forbidden", + "type": "https://api.tablepro.app/problems/forbidden" + } + }, + "insufficient_scope": { + "summary": "Insufficient scope", + "value": { + "code": "insufficient_scope", + "detail": "The team API token lacks the seats:read scope.", + "scope": "seats:read", + "status": 403, + "title": "Insufficient scope", + "type": "https://api.tablepro.app/problems/insufficient-scope" + } + }, + "interactive_sign_in_required": { + "summary": "Interactive sign in required", + "value": { + "code": "interactive_sign_in_required", + "detail": "This app signed in with a license key. Sign in with your account to manage teams.", + "status": 403, + "title": "Interactive sign in required", + "type": "https://api.tablepro.app/problems/interactive-sign-in-required" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Forbidden" + }, + "404": { + "content": { + "application/json": { + "examples": { + "not_found": { + "summary": "Not found", + "value": { + "code": "not_found", + "detail": "No such team, or the caller is not in it.", + "status": 404, + "title": "Not found", + "type": "https://api.tablepro.app/problems/not-found" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Not Found" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Unprocessable Entity" + }, + "429": { + "content": { + "application/json": { + "examples": { + "rate_limited": { + "summary": "Rate limited", + "value": { + "code": "rate_limited", + "detail": "Too many requests. Try again later.", + "status": 429, + "title": "Rate limited", + "type": "https://api.tablepro.app/problems/rate-limited" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Too Many Requests", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + }, + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Problem" + } + } + }, + "description": "Internal Server Error" + } + }, + "security": [ + { + "oauth": [ + "app" + ] + }, + { + "team_token": [] + } + ], + "summary": "A team's seats", + "tags": [ + "Teams" + ] + } + } + }, + "servers": [ + { + "url": "https://api.tablepro.app/v2" + } + ] +} diff --git a/core/crates/account/src/account.rs b/core/crates/account/src/account.rs new file mode 100644 index 0000000000..9734fcda27 --- /dev/null +++ b/core/crates/account/src/account.rs @@ -0,0 +1,1740 @@ +use std::collections::HashMap; +use std::num::NonZeroU64; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use chrono::{DateTime, TimeDelta, Utc}; +use secrecy::{ExposeSecret as _, SecretString}; +use tokio::sync::{Mutex, watch}; +use tokio::time::Instant; + +use crate::account_error::AccountError; +use crate::account_file::{self, AccountFile}; +use crate::account_settings::AccountSettings; +use crate::account_status::{AccountStatus, EntitlementProblem, SignedIn}; +use crate::browser::BrowserLauncher; +use crate::cloud_api::{Bearer, answer_error, v2}; +use crate::entitlement::{Entitlement, EntitlementError}; +use crate::loopback::{Callback, Loopback}; +use crate::machine_id::MachineId; +use crate::oauth::{OAuth, Tokens}; +use crate::profile::{Device, Profile}; +use crate::token_vault::RefreshTokenVault; + +// The server forgets an unanswered sign-in after 10 minutes. +const SIGN_IN_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const ACCESS_TOKEN_MARGIN: TimeDelta = TimeDelta::seconds(60); +pub const REFRESH_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60); +pub const RETRY_INTERVAL: Duration = Duration::from_secs(60 * 60); +// How soon after a wake a refresh that came due during the sleep runs. +const WALL_CLOCK_CHECK: Duration = Duration::from_secs(5 * 60); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(15); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const FIELD_CHARACTERS: usize = 100; +const DEVICE_PAGE: NonZeroU64 = NonZeroU64::MIN.saturating_add(199); +// A server that keeps answering with a cursor cannot hold the session forever. +const DEVICE_PAGES: usize = 10; + +// Every operation holds the session, so a token refresh is single-flight. +#[derive(Clone)] +pub struct Account { + inner: Arc, +} + +struct Inner { + settings: AccountSettings, + http: reqwest::Client, + oauth: OAuth, + vault: Arc, + browser: Arc, + session: Mutex, + status: watch::Sender, + // When run() refreshes next. Every refresh moves it, whatever asked for it. + due: watch::Sender, + // Bumped by each sign-in and sign-out, so an earlier sign-in still out cannot commit over them. + attempts: AtomicU64, +} + +#[derive(Default)] +struct Session { + refresh: Option, + // On the wall clock: Instant stops while the machine sleeps, so a token would outlive its expiry. + access: Option<(SecretString, DateTime)>, + device_id: Option, + // The account the sign-in is of: its last entitlement's, or what /v2/me answered. + account_id: Option, + remembered: bool, + // Until when each call a 429 answered waits, by its Retry-After, on the wall clock as the access token's expiry is. + held: HashMap>, +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Call { + Token, + Register, + Entitlement, + Profile, + Devices, + RemoveDevice, +} + +impl Session { + // Until a 429's Retry-After passes, the call answers the 429 again without a request. + fn waited(&self, call: Call) -> Result<(), AccountError> { + let now = Utc::now(); + match self.held.get(&call) { + Some(until) if *until > now => Err(AccountError::RateLimited { + retry_after: Some((*until - now).to_std().unwrap_or_default()), + }), + _ => Ok(()), + } + } + + fn answered(&mut self, call: Call, answer: Result) -> Result { + match &answer { + Err(AccountError::RateLimited { + retry_after: Some(retry_after), + }) => { + let until = TimeDelta::from_std(*retry_after) + .ok() + .and_then(|wait| Utc::now().checked_add_signed(wait)) + .unwrap_or(DateTime::::MAX_UTC); + self.held.insert(call, until); + } + // The API no longer takes the access token, whatever its expiry says, so the next call refreshes it. + Err(AccountError::Refused { status: 401, .. }) => self.access = None, + _ => {} + } + answer + } +} + +impl Account { + // Reads the system's certificates, so it belongs off the GTK thread. + pub fn new( + settings: AccountSettings, + vault: Arc, + browser: Arc, + status: watch::Sender, + ) -> Result { + // Redirects stay off so a token never follows one to another host. + let http = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .user_agent(format!( + "TablePro/{} ({})", + settings.device.app_version, settings.client.system + )) + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + .build() + .map_err(|error| AccountError::Protocol(error.to_string()))?; + let oauth = OAuth::new(settings.client, &settings.endpoints, http.clone())?; + Ok(Self { + inner: Arc::new(Inner { + settings, + http, + oauth, + vault, + browser, + session: Mutex::new(Session::default()), + status, + due: watch::Sender::new(Instant::now()), + attempts: AtomicU64::new(0), + }), + }) + } + + pub fn status(&self) -> watch::Receiver { + self.inner.status.subscribe() + } + + pub fn machine_id(&self) -> &MachineId { + &self.inner.settings.machine_id + } + + pub async fn run(&self) { + self.restore().await; + let mut due = self.inner.due.subscribe(); + loop { + let refreshed = self.refresh_entitlement().await; + if let Err(error) = &refreshed + && next_refresh(&refreshed) == RETRY_INTERVAL + { + tracing::warn!(%error, "the entitlement refresh failed"); + } + wait_until_due(&mut due, Utc::now).await; + } + } + + // Without account.json no one is signed in, so the keyring stays shut: opening it can prompt. + pub async fn restore(&self) { + let mut session = self.inner.session.lock().await; + let path = &self.inner.settings.account_file; + let file = match account_file::read(path).await { + Ok(Some(file)) => file, + Ok(None) => { + self.signed_out(&mut session); + return; + } + Err(error) => { + tracing::warn!(%error, "the account file was not read"); + AccountFile::default() + } + }; + if file.offline_only { + self.restore_offline(&mut session, file).await; + return; + } + let refresh = match self.inner.vault.load_refresh_token().await { + Ok(Some(refresh)) => refresh, + Ok(None) => { + if let Err(error) = account_file::remove(path).await { + tracing::warn!(%error, "the account file was not removed"); + } + self.signed_out(&mut session); + return; + } + Err(error) => { + tracing::warn!(%error, "the keyring gave no account sign-in, so this run starts signed out"); + self.signed_out(&mut session); + return; + } + }; + let kept = file.entitlement.as_deref().map(|token| (token, self.verify(token))); + let (entitlement, problem) = match kept { + None => (None, None), + Some((_, Ok(entitlement))) => (Some(entitlement), None), + Some((token, Err(EntitlementError::Expired(at)))) => { + (self.lapsed(token, at), Some(EntitlementProblem::Expired)) + } + Some((_, Err(error))) => { + tracing::warn!(%error, "the kept entitlement does not verify"); + (None, None) + } + }; + *session = Session { + refresh: Some(refresh), + access: None, + device_id: file.device_id, + account_id: entitlement + .as_ref() + .map(|it| it.claims().sub.clone()) + .or(file.account_id), + remembered: true, + held: HashMap::new(), + }; + self.publish(&session, entitlement, problem); + } + + // No sign-in goes with it, so the keyring stays shut. + async fn restore_offline(&self, session: &mut Session, file: AccountFile) { + match file.entitlement.as_deref().map(|token| self.verify(token)) { + Some(Ok(entitlement)) => { + *session = Session::default(); + self.inner + .status + .send_replace(AccountStatus::Offline(Box::new(entitlement))); + } + kept => { + if let Some(Err(error)) = kept { + tracing::info!(%error, "the imported offline file no longer verifies"); + } + if let Err(error) = account_file::remove(&self.inner.settings.account_file).await { + tracing::warn!(%error, "the account file was not removed"); + } + self.signed_out(session); + } + } + } + + // An error after the code exchange leaves the account signed in, with the problem in its status. `cancelled` + // ends only the wait for the browser: once the code is back, the tokens it brings are stored whatever happens. + pub async fn sign_in(&self, cancelled: impl Future + Send) -> Result<(), AccountError> { + let attempt = self.inner.attempts.fetch_add(1, Ordering::SeqCst) + 1; + let loopback = Loopback::bind() + .await + .map_err(|error| AccountError::Protocol(format!("no loopback port: {error}")))?; + let authorization = self + .inner + .oauth + .authorization(loopback.redirect_uri(), &self.inner.settings.device)?; + self.inner + .browser + .open(&authorization.url) + .await + .map_err(AccountError::Browser)?; + let waiting = loopback.wait(authorization.state.secret(), &self.inner.settings.callback_page); + let callback = tokio::select! { + waited = tokio::time::timeout(SIGN_IN_TIMEOUT, waiting) => waited, + () = cancelled => return Err(AccountError::SignInCanceled), + }; + let callback = callback + .map_err(|_| AccountError::SignInTimedOut)? + .map_err(|error| AccountError::Protocol(format!("the loopback listener failed: {error}")))?; + let code = match callback { + Callback::Code(code) => code, + Callback::Refused { error, description } => { + return Err(AccountError::SignInRefused { error, description }); + } + }; + let tokens = self.inner.oauth.exchange_code(code, authorization).await?; + self.commit_sign_in(attempt, tokens).await + } + + // Only the latest sign-in or sign-out counts: tokens that come back for an attempt a later one superseded are + // revoked, never adopted. Nothing replaces a working sign-in until the new one has a refresh token. + async fn commit_sign_in(&self, attempt: u64, tokens: Tokens) -> Result<(), AccountError> { + let Some(refresh) = tokens.refresh.clone().filter(|token| !token.expose_secret().is_empty()) else { + return Err(AccountError::Protocol("the sign-in gave no refresh token".to_owned())); + }; + let mut session = self.inner.session.lock().await; + if self.inner.attempts.load(Ordering::SeqCst) != attempt { + if let Err(error) = self.inner.oauth.revoke(&refresh).await { + tracing::warn!(%error, "a superseded sign-in was not revoked"); + } + return Err(AccountError::SignInCanceled); + } + // A sign-in over another, such as to switch accounts, ends the first one's token family. + if let Some(previous) = session.refresh.take() + && let Err(error) = self.inner.oauth.revoke(&previous).await + { + tracing::warn!(%error, "the previous sign-in was not revoked"); + } + *session = Session::default(); + self.adopt(&mut session, tokens).await; + self.write_file(&session, None).await; + self.publish(&session, None, None); + tracing::info!(remembered = session.remembered, "signed in to the TablePro account"); + let fetched = self.fetch_entitlement(&mut session).await; + self.settle(&mut session, fetched).await + } + + // Signs out locally even when the revocation fails, which the error then says. + pub async fn sign_out(&self) -> Result<(), AccountError> { + let mut session = self.inner.session.lock().await; + self.inner.attempts.fetch_add(1, Ordering::SeqCst); + let revoked = match session.refresh.as_ref() { + Some(refresh) => self.inner.oauth.revoke(refresh).await, + None => Ok(()), + }; + self.forget(&mut session).await; + tracing::info!("signed out of the TablePro account"); + revoked + } + + pub async fn profile(&self) -> Result { + let mut session = self.inner.session.lock().await; + let fetched = self.fetch_profile(&mut session).await; + self.ended_by(&mut session, fetched).await + } + + pub async fn devices(&self) -> Result, AccountError> { + let mut session = self.inner.session.lock().await; + let fetched = self.fetch_devices(&mut session).await; + self.ended_by(&mut session, fetched).await + } + + // Over the device limit, the refresh gives the freed slot to this device, and its error is the caller's. + pub async fn remove_device(&self, device_id: &str) -> Result<(), AccountError> { + { + let mut session = self.inner.session.lock().await; + let removed = self.delete_device(&mut session, device_id).await; + self.ended_by(&mut session, removed).await?; + } + tracing::info!("removed a device from the account"); + let past_limit = self.inner.status.borrow().past_device_limit(); + let refreshed = self.refresh_entitlement().await; + match (past_limit, refreshed) { + (true, refreshed) => refreshed, + (false, Err(error)) => { + tracing::warn!(%error, "the entitlement refresh after a device removal failed"); + Ok(()) + } + (false, Ok(())) => Ok(()), + } + } + + // The file holds the token itself and passes the checks an online one does. + pub async fn import_offline_file(&self, token: &str) -> Result<(), AccountError> { + let token = token.trim(); + let entitlement = self.verify(token)?; + let mut session = self.inner.session.lock().await; + let signed_in = session.refresh.is_some(); + // Another account's file would show its plan, and identify its user, under this sign-in. + if signed_in && self.account_id(&mut session).await? != entitlement.claims().sub { + return Err(AccountError::OtherAccount); + } + let file = AccountFile { + device_id: session.device_id.clone(), + account_id: Some(entitlement.claims().sub.clone()), + entitlement: Some(token.to_owned()), + offline_only: !signed_in, + }; + account_file::write(&self.inner.settings.account_file, &file).await?; + tracing::info!(expires_at = %entitlement.expires_at(), signed_in, "imported an offline file"); + match signed_in { + true => self.publish(&session, Some(entitlement), None), + false => { + self.inner + .status + .send_replace(AccountStatus::Offline(Box::new(entitlement))); + } + } + Ok(()) + } + + // Call it after any change to the account too. + pub async fn refresh_entitlement(&self) -> Result<(), AccountError> { + let mut session = self.inner.session.lock().await; + if session.refresh.is_none() { + let signed_out = Err(AccountError::SignedOut); + self.reschedule(&signed_out); + return signed_out; + } + let fetched = self.fetch_entitlement(&mut session).await; + self.settle(&mut session, fetched).await + } + + // Back from the portal or another device, where a slot may have been freed. + pub async fn refresh_past_device_limit(&self) -> Option> { + let past_limit = self.inner.status.borrow().past_device_limit(); + match past_limit { + true => Some(self.refresh_entitlement().await), + false => None, + } + } + + async fn fetch_entitlement(&self, session: &mut Session) -> Result<(), AccountError> { + session.waited(Call::Entitlement)?; + let device_id = match session.device_id.clone() { + Some(device_id) => device_id, + None => self.register(session).await?, + }; + let bearer = self.bearer(session, false).await?; + let device = &self.inner.settings.device; + let request = v2::types::EntitlementRequest { + app_version: field(&device.app_version)?, + os_version: field(&device.os_version)?, + }; + let answer = match self.api(bearer).issue_entitlement(&device_id, &request).await { + Ok(answer) => Ok(answer.into_inner()), + Err(error) => Err(answer_error(error).await), + }; + let answer = session.answered(Call::Entitlement, answer)?; + let entitlement = self.verify(&answer.token)?; + session.account_id = Some(entitlement.claims().sub.clone()); + self.write_file(session, Some(entitlement.token().to_owned())).await; + tracing::info!(features = entitlement.claims().features.len(), expires_at = %entitlement.expires_at(), "entitlement refreshed"); + self.publish(session, Some(entitlement), None); + Ok(()) + } + + // With no entitlement since the sign-in, such as over the device limit, /v2/me says which. + async fn account_id(&self, session: &mut Session) -> Result { + if let Some(account_id) = &session.account_id { + return Ok(account_id.clone()); + } + let fetched = self.fetch_profile(session).await; + let account_id = self.ended_by(session, fetched).await?.id; + session.account_id = Some(account_id.clone()); + Ok(account_id) + } + + async fn fetch_profile(&self, session: &mut Session) -> Result { + session.waited(Call::Profile)?; + let bearer = self.bearer(session, false).await?; + let answer = match self.api(bearer).get_me().await { + Ok(answer) => Ok(answer.into_inner().into()), + Err(error) => Err(answer_error(error).await), + }; + session.answered(Call::Profile, answer) + } + + async fn fetch_devices(&self, session: &mut Session) -> Result, AccountError> { + session.waited(Call::Devices)?; + let api = self.api(self.bearer(session, false).await?); + let mut devices = Vec::new(); + let mut cursor = None; + for _ in 0..DEVICE_PAGES { + let page = match api.list_devices(cursor.as_deref(), Some(DEVICE_PAGE)).await { + Ok(answer) => answer.into_inner(), + Err(error) => return session.answered(Call::Devices, Err(answer_error(error).await)), + }; + devices.extend(page.data.into_iter().map(Device::from)); + cursor = page.next_cursor; + if cursor.is_none() { + break; + } + } + Ok(devices) + } + + async fn delete_device(&self, session: &mut Session, device_id: &str) -> Result<(), AccountError> { + session.waited(Call::RemoveDevice)?; + let bearer = self.bearer(session, false).await?; + let answer = match self.api(bearer).delete_device(device_id).await { + Ok(_) => Ok(()), + Err(error) => Err(answer_error(error).await), + }; + session.answered(Call::RemoveDevice, answer) + } + + // A refused sign-in or a removed device ends the session, whichever call learns it. + async fn ended_by(&self, session: &mut Session, result: Result) -> Result { + if let Err(AccountError::SignedOut | AccountError::DeviceRevoked) = &result + && session.refresh.is_some() + { + self.forget(session).await; + } + result + } + + async fn register(&self, session: &mut Session) -> Result { + session.waited(Call::Register)?; + let bearer = self.bearer(session, false).await?; + let settings = &self.inner.settings; + let registration = v2::types::DeviceRegistration { + app_version: field(&settings.device.app_version)?, + machine_id: field(settings.machine_id.as_str())?, + name: field(&settings.device.name)?, + os_version: field(&settings.device.os_version)?, + platform: settings.client.platform, + }; + let device = match self.api(bearer).register_device(®istration).await { + Ok(answer) => Ok(answer.into_inner()), + Err(error) => Err(answer_error(error).await), + }; + let device = session.answered(Call::Register, device)?; + session.device_id = Some(device.id.clone()); + self.write_file(session, None).await; + // Only a token refreshed after the registration names the device; a retry must not reuse the one before. + session.access = None; + self.bearer(session, true).await?; + Ok(device.id) + } + + async fn bearer(&self, session: &mut Session, renew: bool) -> Result { + if !renew + && let Some((access, until)) = &session.access + && *until > Utc::now() + ACCESS_TOKEN_MARGIN + { + return Ok(Bearer(access.clone())); + } + let refresh = session.refresh.clone().ok_or(AccountError::SignedOut)?; + session.waited(Call::Token)?; + let tokens = self.inner.oauth.refresh(&refresh).await; + let tokens = session.answered(Call::Token, tokens)?; + let access = tokens.access.clone(); + self.adopt(session, tokens).await; + Ok(Bearer(access)) + } + + async fn adopt(&self, session: &mut Session, tokens: Tokens) { + let lifetime = TimeDelta::from_std(tokens.lifetime).unwrap_or_default(); + session.access = Some((tokens.access, Utc::now() + lifetime)); + let Some(refresh) = tokens.refresh else { + return; + }; + session.remembered = match self.inner.vault.store_refresh_token(&refresh).await { + Ok(()) => true, + Err(error) => { + tracing::warn!(%error, "no keyring took the account's sign-in; it lasts until the app quits"); + // A rotated token presented again revokes the whole family. + let _ = self.inner.vault.delete_refresh_token().await; + false + } + }; + session.refresh = Some(refresh); + } + + async fn settle(&self, session: &mut Session, result: Result<(), AccountError>) -> Result<(), AccountError> { + self.reschedule(&result); + let Err(error) = result else { + return Ok(()); + }; + let kept = self.inner.status.borrow().entitlement().cloned(); + match &error { + AccountError::SignedOut | AccountError::DeviceRevoked => self.forget(session).await, + AccountError::DeviceLimitReached { devices } => { + let problem = EntitlementProblem::DeviceLimitReached(devices.clone()); + self.publish(session, kept, Some(problem)); + } + other => { + let problem = EntitlementProblem::RefreshFailed(other.clone()); + self.publish(session, kept, Some(problem)); + } + } + Err(error) + } + + async fn forget(&self, session: &mut Session) { + if let Err(error) = self.inner.vault.delete_refresh_token().await { + tracing::warn!(%error, "the account's sign-in was not removed from the keyring"); + } + if let Err(error) = account_file::remove(&self.inner.settings.account_file).await { + tracing::warn!(%error, "the account file was not removed"); + } + self.signed_out(session); + } + + fn reschedule(&self, refreshed: &Result<(), AccountError>) { + self.inner.due.send_replace(Instant::now() + next_refresh(refreshed)); + } + + fn signed_out(&self, session: &mut Session) { + *session = Session::default(); + self.inner.status.send_replace(AccountStatus::SignedOut); + } + + fn publish(&self, session: &Session, entitlement: Option, problem: Option) { + self.inner + .status + .send_replace(AccountStatus::SignedIn(Box::new(SignedIn { + entitlement, + problem, + remembered: session.remembered, + }))); + } + + async fn write_file(&self, session: &Session, entitlement: Option) { + let file = AccountFile { + device_id: session.device_id.clone(), + account_id: session.account_id.clone(), + entitlement, + offline_only: false, + }; + if let Err(error) = account_file::write(&self.inner.settings.account_file, &file).await { + tracing::warn!(%error, "the account file was not written"); + } + } + + // Still checked, at the moment before it expired: what it names says who has paid, as a lapse while the app + // runs keeps it too. Only its features stop. + fn lapsed(&self, token: &str, expired_at: DateTime) -> Option { + let settings = &self.inner.settings; + let before = expired_at - TimeDelta::seconds(1); + Entitlement::verify(token, &settings.keys, settings.machine_id.as_str(), before).ok() + } + + fn verify(&self, token: &str) -> Result { + let settings = &self.inner.settings; + Entitlement::verify(token, &settings.keys, settings.machine_id.as_str(), Utc::now()) + } + + fn api(&self, bearer: Bearer) -> v2::Client { + v2::Client::new_with_client(&self.inner.settings.endpoints.api, self.inner.http.clone(), bearer) + } +} + +// clients.md, Shared rules: a day after a refresh that worked, an hour after one that failed or hit the device limit, +// and never before a 429's Retry-After. +fn next_refresh(refreshed: &Result<(), AccountError>) -> Duration { + match refreshed { + Ok(()) | Err(AccountError::SignedOut | AccountError::DeviceRevoked) => REFRESH_INTERVAL, + Err(AccountError::RateLimited { + retry_after: Some(retry_after), + }) => RETRY_INTERVAL.max(*retry_after), + Err(_) => RETRY_INTERVAL, + } +} + +// The time can move while it waits, earlier or later. Instant stops while the machine sleeps and the wall clock does +// not, so the wait also looks at the wall clock now and then. +async fn wait_until_due(due: &mut watch::Receiver, wall_clock: impl Fn() -> DateTime) { + let deadline = |at: Instant| { + TimeDelta::from_std(at.saturating_duration_since(Instant::now())) + .ok() + .and_then(|left| wall_clock().checked_add_signed(left)) + .unwrap_or(DateTime::::MAX_UTC) + }; + let mut at = *due.borrow_and_update(); + let mut wall = deadline(at); + loop { + if wall_clock() >= wall { + return; + } + tokio::select! { + () = tokio::time::sleep_until(at) => return, + () = tokio::time::sleep(WALL_CLOCK_CHECK) => {} + Ok(()) = due.changed() => { + at = *due.borrow_and_update(); + wall = deadline(at); + } + } + } +} + +fn field>(text: &str) -> Result { + let cleaned: String = text.chars().filter(|character| !character.is_control()).collect(); + let cleaned: String = cleaned.trim().chars().take(FIELD_CHARACTERS).collect(); + T::try_from(cleaned).map_err(|_| AccountError::Protocol(format!("{text:?} is not a device field the server takes"))) +} + +#[cfg(test)] +mod tests { + use std::path::Path; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + use async_trait::async_trait; + use chrono::TimeDelta; + use tablepro_core::credentials::SecretError; + use url::Url; + + use super::*; + use crate::account_settings::{AppClient, CallbackPage, DeviceDescription, Endpoints}; + use crate::account_status::FeatureAccess; + use crate::entitlement::test_tokens::{claims, keys, sign}; + + const FEATURE: &str = "env_var_references"; + const ACCOUNT: &str = "acct_01k8d6wk00f8xsr79e7x052rkk"; + const OTHER_ACCOUNT: &str = "acct_01k8d6x3q0f8xsr79e7x052zzz"; + + // Counts every time the account opens it. + struct Keyring { + token: Mutex>, + opened: AtomicUsize, + } + + impl Keyring { + fn holding(token: Option<&str>) -> Arc { + Arc::new(Self { + token: Mutex::new(token.map(SecretString::from)), + opened: AtomicUsize::new(0), + }) + } + + fn opened(&self) -> usize { + self.opened.load(Ordering::SeqCst) + } + } + + #[async_trait] + impl RefreshTokenVault for Keyring { + async fn load_refresh_token(&self) -> Result, SecretError> { + self.opened.fetch_add(1, Ordering::SeqCst); + Ok(self.token.lock().await.clone()) + } + + async fn store_refresh_token(&self, token: &SecretString) -> Result<(), SecretError> { + self.opened.fetch_add(1, Ordering::SeqCst); + *self.token.lock().await = Some(token.clone()); + Ok(()) + } + + async fn delete_refresh_token(&self) -> Result<(), SecretError> { + self.opened.fetch_add(1, Ordering::SeqCst); + *self.token.lock().await = None; + Ok(()) + } + } + + struct NoBrowser; + + #[async_trait] + impl BrowserLauncher for NoBrowser { + async fn open(&self, _: &Url) -> Result<(), String> { + Err("a unit test opens no browser".to_owned()) + } + } + + fn machine() -> MachineId { + MachineId::derive("4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f20\n").expect("a machine ID") + } + + // Nothing listens on the port once the listener is dropped, so every request is refused. + fn unreachable() -> Endpoints { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a free port"); + let base = format!("http://{}", listener.local_addr().expect("its address")); + Endpoints::new(&base, &format!("{base}/v2")).expect("the endpoints") + } + + // Stands in for tablepro-cloud: `answer` gives the status and body for a method and path. + fn cloud(answer: impl Fn(&str, &str) -> (u16, String) + Send + 'static) -> Endpoints { + cloud_with_headers(move |method, path| { + let (status, body) = answer(method, path); + (status, "", body) + }) + } + + // As cloud, with header lines such as "retry-after: 30\r\n" in the answer. + fn cloud_with_headers(answer: impl Fn(&str, &str) -> (u16, &'static str, String) + Send + 'static) -> Endpoints { + cloud_with_bodies(move |method, path, _| answer(method, path)) + } + + // The status, header lines and body the stub answers a method, path and request body with. + type Answer = dyn Fn(&str, &str, &str) -> (u16, &'static str, String) + Send; + + // As cloud_with_headers, also given the request's body. + fn cloud_with_bodies( + answer: impl Fn(&str, &str, &str) -> (u16, &'static str, String) + Send + 'static, + ) -> Endpoints { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a free port"); + let base = format!("http://{}", listener.local_addr().expect("its address")); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + serve(stream, &answer); + } + }); + Endpoints::new(&base, &format!("{base}/v2")).expect("the endpoints") + } + + fn serve(stream: std::net::TcpStream, answer: &Answer) { + use std::io::{BufRead as _, Read as _, Write as _}; + let mut reader = std::io::BufReader::new(stream.try_clone().expect("a second handle")); + let mut line = String::new(); + reader.read_line(&mut line).expect("the request line"); + let mut words = line.split_whitespace(); + let (method, path) = (words.next().unwrap_or_default(), words.next().unwrap_or_default()); + let mut length = 0; + loop { + let mut header = String::new(); + reader.read_line(&mut header).expect("a header"); + if header.trim().is_empty() { + break; + } + if let Some(value) = header.to_ascii_lowercase().strip_prefix("content-length:") { + length = value.trim().parse().expect("a length"); + } + } + let mut request = vec![0; length]; + reader.read_exact(&mut request).expect("the body"); + let (status, headers, body) = answer(method, path, &String::from_utf8_lossy(&request)); + let mut stream = stream; + write!( + stream, + "HTTP/1.1 {status} Stub\r\ncontent-type: application/json\r\n{headers}content-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ) + .expect("the answer"); + } + + const TOKENS: &str = + r#"{"access_token":"stub-access","token_type":"Bearer","expires_in":900,"refresh_token":"stub-refresh"}"#; + + fn account(dir: &Path, keyring: Arc) -> Account { + account_at(dir, keyring, unreachable()) + } + + fn account_at(dir: &Path, keyring: Arc, endpoints: Endpoints) -> Account { + let settings = AccountSettings { + client: AppClient::LINUX, + endpoints, + device: DeviceDescription { + name: "Ada's laptop".to_owned(), + app_version: "0.1.0".to_owned(), + os_version: "Fedora Linux 43 (Workstation Edition)".to_owned(), + }, + machine_id: machine(), + keys: keys(), + account_file: dir.join("account.json"), + callback_page: CallbackPage { + finished: String::new(), + unknown: String::new(), + }, + }; + let status = watch::Sender::new(AccountStatus::SignedOut); + Account::new(settings, keyring, Arc::new(NoBrowser), status).expect("an account") + } + + // As a refresh writes it: the account goes with the entitlement. + async fn keep(dir: &Path, entitlement: Option) { + let file = AccountFile { + device_id: Some("dev_01k8d6wpggfd68tbhz818p4ww4".to_owned()), + account_id: entitlement.as_ref().map(|_| ACCOUNT.to_owned()), + entitlement, + offline_only: false, + }; + account_file::write(&dir.join("account.json"), &file) + .await + .expect("the account file"); + } + + fn entitlement(issued: chrono::DateTime) -> String { + let mut claims = claims(issued, TimeDelta::days(14), &[FEATURE]); + claims.mid = machine().as_str().to_owned(); + sign(&claims) + } + + fn offline_file(issued: chrono::DateTime) -> String { + let mut claims = claims(issued, TimeDelta::days(30), &[FEATURE]); + claims.mid = machine().as_str().to_owned(); + claims.offline = true; + sign(&claims) + } + + fn another_accounts_offline_file() -> String { + let mut claims = claims(Utc::now(), TimeDelta::days(30), &["team_library"]); + claims.mid = machine().as_str().to_owned(); + claims.offline = true; + claims.sub = OTHER_ACCOUNT.to_owned(); + sign(&claims) + } + + fn signed_in(account: &Account) -> SignedIn { + match &*account.status().borrow() { + AccountStatus::SignedIn(signed_in) => (**signed_in).clone(), + other => panic!("the account is not signed in: {other:?}"), + } + } + + async fn stored(dir: &Path) -> AccountFile { + account_file::read(&dir.join("account.json")) + .await + .expect("read") + .expect("an account file") + } + + #[tokio::test] + async fn an_offline_file_unlocks_a_signed_out_machine_without_the_keyring() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let keyring = Keyring::holding(None); + let account = account(dir.path(), keyring.clone()); + account.restore().await; + + account + .import_offline_file(&format!("{}\n", offline_file(Utc::now()))) + .await + .expect("imported"); + + assert!(matches!(*account.status().borrow(), AccountStatus::Offline(_))); + assert_eq!( + account.status().borrow().feature_access(FEATURE, Utc::now()), + FeatureAccess::Available + ); + assert!(stored(dir.path()).await.offline_only); + let restarted = self::account(dir.path(), keyring.clone()); + restarted.restore().await; + assert!(matches!(*restarted.status().borrow(), AccountStatus::Offline(_))); + assert_eq!(keyring.opened(), 0); + } + + #[tokio::test] + async fn a_file_for_another_machine_is_refused_and_nothing_is_kept() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let account = account(dir.path(), Keyring::holding(None)); + let mut claims = claims(Utc::now(), TimeDelta::days(30), &[FEATURE]); + claims.offline = true; + + let imported = account.import_offline_file(&sign(&claims)).await; + + assert!( + matches!(imported, Err(AccountError::Entitlement(EntitlementError::Machine))), + "{imported:?}" + ); + assert!(matches!(*account.status().borrow(), AccountStatus::SignedOut)); + assert!(!dir.path().join("account.json").exists()); + } + + #[tokio::test] + async fn an_offline_file_that_expired_is_dropped_at_restore() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let path = dir.path().join("account.json"); + let file = AccountFile { + device_id: None, + account_id: None, + entitlement: Some(offline_file(Utc::now() - TimeDelta::days(40))), + offline_only: true, + }; + account_file::write(&path, &file).await.expect("the account file"); + let keyring = Keyring::holding(None); + let account = account(dir.path(), keyring.clone()); + + account.restore().await; + + assert!(matches!(*account.status().borrow(), AccountStatus::SignedOut)); + assert!(!path.exists()); + assert_eq!(keyring.opened(), 0); + } + + #[tokio::test] + async fn signed_in_an_offline_file_replaces_the_entitlement_and_keeps_the_sign_in() { + let dir = tempfile::tempdir().expect("a temporary directory"); + keep(dir.path(), Some(entitlement(Utc::now()))).await; + let account = account(dir.path(), Keyring::holding(Some("refresh"))); + account.restore().await; + let token = offline_file(Utc::now()); + + account.import_offline_file(&token).await.expect("imported"); + + let held = signed_in(&account); + assert_eq!(held.entitlement.map(|it| it.claims().offline), Some(true)); + let file = stored(dir.path()).await; + assert!(!file.offline_only); + assert_eq!(file.entitlement.as_deref(), Some(token.as_str())); + assert_eq!(file.device_id.as_deref(), Some("dev_01k8d6wpggfd68tbhz818p4ww4")); + assert_eq!(file.account_id.as_deref(), Some(ACCOUNT)); + } + + #[tokio::test] + async fn signed_in_a_file_of_another_account_is_refused_and_one_of_this_account_is_taken() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let online = entitlement(Utc::now()); + keep(dir.path(), Some(online.clone())).await; + let account = account(dir.path(), Keyring::holding(Some("refresh"))); + account.restore().await; + + let refused = account.import_offline_file(&another_accounts_offline_file()).await; + + assert!(matches!(refused, Err(AccountError::OtherAccount)), "{refused:?}"); + let held = signed_in(&account); + assert_eq!( + held.entitlement.map(|it| it.claims().sub.clone()).as_deref(), + Some(ACCOUNT) + ); + assert_eq!(stored(dir.path()).await.entitlement.as_deref(), Some(online.as_str())); + account + .import_offline_file(&offline_file(Utc::now())) + .await + .expect("this account's file"); + assert_eq!( + signed_in(&account).entitlement.map(|it| it.claims().offline), + Some(true) + ); + } + + #[tokio::test] + async fn signed_in_after_the_entitlement_expired_a_file_of_another_account_is_still_refused() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let file = AccountFile { + device_id: Some("dev_01k8d6wpggfd68tbhz818p4ww4".to_owned()), + account_id: Some(ACCOUNT.to_owned()), + entitlement: Some(entitlement(Utc::now() - TimeDelta::days(20))), + offline_only: false, + }; + account_file::write(&dir.path().join("account.json"), &file) + .await + .expect("the account file"); + let account = account(dir.path(), Keyring::holding(Some("refresh"))); + account.restore().await; + assert!(matches!(signed_in(&account).problem, Some(EntitlementProblem::Expired))); + + let refused = account.import_offline_file(&another_accounts_offline_file()).await; + + assert!(matches!(refused, Err(AccountError::OtherAccount)), "{refused:?}"); + let kept = signed_in(&account).entitlement.expect("the lapsed entitlement"); + assert_eq!(kept.claims().sub, ACCOUNT); + assert!(!kept.is_current(Utc::now())); + let token = offline_file(Utc::now()); + account.import_offline_file(&token).await.expect("this account's file"); + let kept = stored(dir.path()).await; + assert_eq!( + (kept.account_id.as_deref(), kept.entitlement.as_deref()), + (Some(ACCOUNT), Some(token.as_str())) + ); + } + + #[tokio::test] + async fn over_the_device_limit_at_sign_in_the_account_service_names_the_account() { + let dir = tempfile::tempdir().expect("a temporary directory"); + keep(dir.path(), None).await; + let problem = r#"{"type":"about:blank","title":"Device limit reached","status":409,"code":"device_limit_reached","devices":[]}"#; + let me = format!(r#"{{"account":{{"id":"{ACCOUNT}","email":"ada@m17.example"}},"licenses":[],"teams":[]}}"#); + let endpoints = cloud(move |method, path| match (method, path) { + ("POST", "/oauth/token") => (200, TOKENS.to_owned()), + ("POST", "/v2/devices/dev_01k8d6wpggfd68tbhz818p4ww4/entitlement") => (409, problem.to_owned()), + ("GET", "/v2/me") => (200, me.clone()), + _ => (404, String::new()), + }); + let account = account_at(dir.path(), Keyring::holding(Some("refresh")), endpoints); + account.restore().await; + + let limited = account.refresh_entitlement().await; + let refused = account.import_offline_file(&another_accounts_offline_file()).await; + + assert!( + matches!(limited, Err(AccountError::DeviceLimitReached { .. })), + "{limited:?}" + ); + assert!(matches!(refused, Err(AccountError::OtherAccount)), "{refused:?}"); + assert!(signed_in(&account).entitlement.is_none()); + account + .import_offline_file(&offline_file(Utc::now())) + .await + .expect("this account's file"); + assert_eq!( + signed_in(&account).entitlement.map(|it| it.claims().offline), + Some(true) + ); + } + + #[tokio::test] + async fn a_refreshed_entitlement_keeps_its_account_in_the_file() { + let dir = tempfile::tempdir().expect("a temporary directory"); + keep(dir.path(), None).await; + let answer = format!( + r#"{{"token":"{}","expires_at":"2026-11-12T00:00:00Z","refresh_after":"2026-10-30T00:00:00Z"}}"#, + entitlement(Utc::now()) + ); + let endpoints = cloud(move |method, path| match (method, path) { + ("POST", "/oauth/token") => (200, TOKENS.to_owned()), + ("POST", "/v2/devices/dev_01k8d6wpggfd68tbhz818p4ww4/entitlement") => (200, answer.clone()), + _ => (404, String::new()), + }); + let account = account_at(dir.path(), Keyring::holding(Some("refresh")), endpoints); + account.restore().await; + + account.refresh_entitlement().await.expect("refreshed"); + + assert_eq!(stored(dir.path()).await.account_id.as_deref(), Some(ACCOUNT)); + } + + #[tokio::test] + async fn signed_out_the_account_service_is_not_asked() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let account = account(dir.path(), Keyring::holding(None)); + account + .import_offline_file(&offline_file(Utc::now())) + .await + .expect("imported"); + + let profile = account.profile().await; + let devices = account.devices().await; + let removed = account.remove_device("dev_01k8d6wpggfd68tbhz818p4ww4").await; + + assert!(matches!(profile, Err(AccountError::SignedOut)), "{profile:?}"); + assert!(matches!(devices, Err(AccountError::SignedOut)), "{devices:?}"); + assert!(matches!(removed, Err(AccountError::SignedOut)), "{removed:?}"); + assert!(matches!(*account.status().borrow(), AccountStatus::Offline(_))); + assert!(dir.path().join("account.json").exists()); + } + + #[tokio::test(start_paused = true)] + async fn without_an_account_file_the_keyring_is_never_opened() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let keyring = Keyring::holding(Some("left by an earlier install")); + let account = account(dir.path(), keyring.clone()); + + let running = tokio::spawn({ + let account = account.clone(); + async move { account.run().await } + }); + tokio::time::sleep(REFRESH_INTERVAL * 2 + RETRY_INTERVAL).await; + running.abort(); + + assert!(matches!(*account.status().borrow(), AccountStatus::SignedOut)); + assert_eq!(keyring.opened(), 0); + } + + #[tokio::test] + async fn an_account_file_without_a_keyring_token_is_removed() { + let dir = tempfile::tempdir().expect("a temporary directory"); + keep(dir.path(), None).await; + let keyring = Keyring::holding(None); + let account = account(dir.path(), keyring.clone()); + + account.restore().await; + + assert!(matches!(*account.status().borrow(), AccountStatus::SignedOut)); + assert!(!dir.path().join("account.json").exists()); + assert_eq!(keyring.opened(), 1); + } + + #[tokio::test] + async fn a_kept_entitlement_unlocks_its_features_at_restore() { + let dir = tempfile::tempdir().expect("a temporary directory"); + keep(dir.path(), Some(entitlement(Utc::now()))).await; + let account = account(dir.path(), Keyring::holding(Some("refresh"))); + + account.restore().await; + + let status = account.status().borrow().clone(); + assert!(signed_in(&account).problem.is_none()); + assert_eq!(status.feature_access(FEATURE, Utc::now()), FeatureAccess::Available); + } + + #[tokio::test] + async fn a_kept_entitlement_that_expired_restores_as_expired() { + let dir = tempfile::tempdir().expect("a temporary directory"); + keep(dir.path(), Some(entitlement(Utc::now() - TimeDelta::days(20)))).await; + let account = account(dir.path(), Keyring::holding(Some("refresh"))); + + account.restore().await; + + let held = signed_in(&account); + assert!(held.entitlement.is_some_and(|lapsed| !lapsed.is_current(Utc::now()))); + assert!( + matches!(held.problem, Some(EntitlementProblem::Expired)), + "{:?}", + held.problem + ); + assert_eq!( + account.status().borrow().feature_access(FEATURE, Utc::now()), + FeatureAccess::Expired + ); + } + + #[tokio::test(start_paused = true)] + async fn a_failed_refresh_is_tried_again_an_hour_later() { + let dir = tempfile::tempdir().expect("a temporary directory"); + keep(dir.path(), None).await; + let account = account(dir.path(), Keyring::holding(Some("refresh"))); + let mut status = account.status(); + + let started = Instant::now(); + let running = tokio::spawn({ + let account = account.clone(); + async move { account.run().await } + }); + let mut failures = Vec::new(); + while failures.len() < 3 { + status.changed().await.expect("the account runs"); + if let AccountStatus::SignedIn(held) = &*status.borrow_and_update() + && let Some(EntitlementProblem::RefreshFailed(error)) = &held.problem + { + assert!(matches!(error, AccountError::Unreachable(_)), "{error:?}"); + failures.push(started.elapsed()); + } + } + running.abort(); + + // A request may run into its own timeouts first under the paused clock. + let slack = REQUEST_TIMEOUT + CONNECT_TIMEOUT; + assert!(failures[0] <= slack, "{failures:?}"); + for pair in failures.windows(2) { + let gap = pair[1] - pair[0]; + assert!(gap >= RETRY_INTERVAL && gap <= RETRY_INTERVAL + slack, "{failures:?}"); + } + } + + #[test] + fn a_day_follows_a_refresh_that_worked_or_signed_out_and_an_hour_any_other() { + let daily = [Ok(()), Err(AccountError::SignedOut), Err(AccountError::DeviceRevoked)]; + let hourly = [ + AccountError::Unreachable("connection refused".to_owned()), + AccountError::Refused { + status: 503, + code: "unavailable".to_owned(), + detail: String::new(), + }, + AccountError::DeviceLimitReached { devices: Vec::new() }, + AccountError::Entitlement(EntitlementError::Signature), + ]; + + for refreshed in &daily { + assert_eq!(next_refresh(refreshed), REFRESH_INTERVAL, "{refreshed:?}"); + } + for error in hourly { + assert_eq!(next_refresh(&Err(error)), RETRY_INTERVAL); + } + } + + type Asked = Arc>>; + + // Answers the entitlement with the device limit until `freed`, and keeps what it was asked. + fn limit_cloud(freed: Arc, asked: Asked) -> Endpoints { + let limited = r#"{"type":"about:blank","title":"Device limit reached","status":409,"code":"device_limit_reached","devices":[]}"#; + let answer = format!( + r#"{{"token":"{}","expires_at":"2026-11-12T00:00:00Z","refresh_after":"2026-10-30T00:00:00Z"}}"#, + entitlement(Utc::now()) + ); + cloud(move |method, path| { + asked.lock().expect("the requests").push(format!("{method} {path}")); + match (method, path) { + ("POST", "/oauth/token") => (200, TOKENS.to_owned()), + ("POST", "/v2/devices/dev_01k8d6wpggfd68tbhz818p4ww4/entitlement") if freed.load(Ordering::SeqCst) => { + (200, answer.clone()) + } + ("POST", "/v2/devices/dev_01k8d6wpggfd68tbhz818p4ww4/entitlement") => (409, limited.to_owned()), + ("DELETE", "/v2/devices/dev_other") => (204, String::new()), + _ => (404, String::new()), + } + }) + } + + fn asked(asked: &Asked) -> Vec { + asked.lock().expect("the requests").clone() + } + + fn due_in(account: &Account) -> Duration { + account.inner.due.borrow().saturating_duration_since(Instant::now()) + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn the_next_refresh_follows_the_last_one_whoever_asked_for_it() { + let dir = tempfile::tempdir().expect("a temporary directory"); + keep(dir.path(), None).await; + let freed = Arc::new(AtomicBool::new(false)); + let endpoints = limit_cloud(freed.clone(), Asked::default()); + let account = account_at(dir.path(), Keyring::holding(Some("refresh")), endpoints); + account.restore().await; + let minute = Duration::from_secs(60); + + let limited = account.refresh_entitlement().await; + let after_limit = due_in(&account); + freed.store(true, Ordering::SeqCst); + account.refresh_entitlement().await.expect("refreshed"); + let after_success = due_in(&account); + + assert!( + matches!(limited, Err(AccountError::DeviceLimitReached { .. })), + "{limited:?}" + ); + assert!( + after_limit > RETRY_INTERVAL - minute && after_limit <= RETRY_INTERVAL, + "{after_limit:?}" + ); + assert!( + after_success > REFRESH_INTERVAL - minute && after_success <= REFRESH_INTERVAL, + "{after_success:?}" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_removed_device_refreshes_the_entitlement_within_the_limit_too() { + let dir = tempfile::tempdir().expect("a temporary directory"); + keep(dir.path(), Some(entitlement(Utc::now()))).await; + let requests = Asked::default(); + let endpoints = limit_cloud(Arc::new(AtomicBool::new(true)), requests.clone()); + let account = account_at(dir.path(), Keyring::holding(Some("refresh")), endpoints); + account.restore().await; + + account.remove_device("dev_other").await.expect("removed"); + + let asked = asked(&requests); + let removed = asked + .iter() + .position(|request| request == "DELETE /v2/devices/dev_other"); + let refreshed = asked.iter().rposition(|request| request.ends_with("/entitlement")); + assert!( + matches!((removed, refreshed), (Some(removed), Some(refreshed)) if refreshed > removed), + "{asked:?}" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn becoming_active_refreshes_only_past_the_device_limit() { + let dir = tempfile::tempdir().expect("a temporary directory"); + keep(dir.path(), None).await; + let requests = Asked::default(); + let freed = Arc::new(AtomicBool::new(false)); + let endpoints = limit_cloud(freed.clone(), requests.clone()); + let account = account_at(dir.path(), Keyring::holding(Some("refresh")), endpoints); + account.restore().await; + + let before = account.refresh_past_device_limit().await; + let limited = account.refresh_entitlement().await; + freed.store(true, Ordering::SeqCst); + let past = account.refresh_past_device_limit().await; + let within = account.refresh_past_device_limit().await; + + assert!(before.is_none(), "{before:?}"); + assert!( + matches!(limited, Err(AccountError::DeviceLimitReached { .. })), + "{limited:?}" + ); + assert!(matches!(past, Some(Ok(()))), "{past:?}"); + assert!(within.is_none(), "{within:?}"); + let refreshes = asked(&requests) + .iter() + .filter(|request| request.ends_with("/entitlement")) + .count(); + assert_eq!(refreshes, 2); + } + + #[test] + fn a_retry_after_longer_than_the_hour_moves_the_next_refresh_past_it() { + let limited = |seconds: Option| { + next_refresh(&Err(AccountError::RateLimited { + retry_after: seconds.map(Duration::from_secs), + })) + }; + + assert_eq!(limited(Some(2 * 60 * 60)), Duration::from_secs(2 * 60 * 60)); + assert_eq!(limited(Some(30)), RETRY_INTERVAL); + assert_eq!(limited(None), RETRY_INTERVAL); + } + + const LIMITED: &str = r#"{"type":"about:blank","title":"Too many requests","status":429,"code":"rate_limited"}"#; + + fn sent(asked: &Asked, ending: &str) -> usize { + self::asked(asked) + .iter() + .filter(|request| request.ends_with(ending)) + .count() + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_429_holds_the_call_back_until_its_retry_after_passes() { + let dir = tempfile::tempdir().expect("a temporary directory"); + keep(dir.path(), None).await; + let requests = Asked::default(); + let answer = format!( + r#"{{"token":"{}","expires_at":"2026-11-12T00:00:00Z","refresh_after":"2026-10-30T00:00:00Z"}}"#, + entitlement(Utc::now()) + ); + let first = Arc::new(AtomicBool::new(true)); + let asked = requests.clone(); + let endpoints = cloud_with_headers(move |method, path| { + asked.lock().expect("the requests").push(format!("{method} {path}")); + match (method, path) { + ("POST", "/oauth/token") => (200, "", TOKENS.to_owned()), + ("POST", "/v2/devices/dev_01k8d6wpggfd68tbhz818p4ww4/entitlement") + if first.swap(false, Ordering::SeqCst) => + { + (429, "retry-after: 1\r\n", LIMITED.to_owned()) + } + ("POST", "/v2/devices/dev_01k8d6wpggfd68tbhz818p4ww4/entitlement") => (200, "", answer.clone()), + _ => (404, "", String::new()), + } + }); + let account = account_at(dir.path(), Keyring::holding(Some("refresh")), endpoints); + account.restore().await; + + let limited = account.refresh_entitlement().await; + let held = account.refresh_entitlement().await; + let sent_while_held = sent(&requests, "/entitlement"); + tokio::time::sleep(Duration::from_millis(1100)).await; + let after = account.refresh_entitlement().await; + + assert!( + matches!(limited, Err(AccountError::RateLimited { retry_after: Some(wait) }) if wait == Duration::from_secs(1)), + "{limited:?}" + ); + assert!( + matches!(held, Err(AccountError::RateLimited { retry_after: Some(wait) }) if wait <= Duration::from_secs(1)), + "{held:?}" + ); + assert_eq!(sent_while_held, 1); + assert!(after.is_ok(), "{after:?}"); + assert_eq!(sent(&requests, "/entitlement"), 2); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_429_from_the_token_endpoint_holds_the_token_refresh_back() { + let dir = tempfile::tempdir().expect("a temporary directory"); + keep(dir.path(), None).await; + let requests = Asked::default(); + let asked = requests.clone(); + let endpoints = cloud_with_headers(move |method, path| { + asked.lock().expect("the requests").push(format!("{method} {path}")); + match (method, path) { + ("POST", "/oauth/token") => ( + 429, + "retry-after: 30\r\n", + r#"{"error":"temporarily_unavailable"}"#.to_owned(), + ), + _ => (404, "", String::new()), + } + }); + let account = account_at(dir.path(), Keyring::holding(Some("refresh")), endpoints); + account.restore().await; + + let limited = account.refresh_entitlement().await; + let held = account.profile().await; + + assert!( + matches!(limited, Err(AccountError::RateLimited { retry_after: Some(wait) }) if wait == Duration::from_secs(30)), + "{limited:?}" + ); + assert!( + matches!(held, Err(AccountError::RateLimited { retry_after: Some(wait) }) if wait <= Duration::from_secs(30)), + "{held:?}" + ); + assert_eq!(sent(&requests, "/oauth/token"), 1); + assert!(matches!(*account.status().borrow(), AccountStatus::SignedIn(_))); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn an_access_token_the_api_refused_is_refreshed_before_the_next_call() { + let dir = tempfile::tempdir().expect("a temporary directory"); + keep(dir.path(), None).await; + let requests = Asked::default(); + let answer = format!( + r#"{{"token":"{}","expires_at":"2026-11-12T00:00:00Z","refresh_after":"2026-10-30T00:00:00Z"}}"#, + entitlement(Utc::now()) + ); + let first = Arc::new(AtomicBool::new(true)); + let log = requests.clone(); + let endpoints = cloud(move |method, path| { + log.lock().expect("the requests").push(format!("{method} {path}")); + match (method, path) { + ("POST", "/oauth/token") => (200, TOKENS.to_owned()), + ("POST", "/v2/devices/dev_01k8d6wpggfd68tbhz818p4ww4/entitlement") + if first.swap(false, Ordering::SeqCst) => + { + ( + 401, + r#"{"type":"about:blank","title":"Unauthorized","status":401,"code":"unauthorized"}"# + .to_owned(), + ) + } + ("POST", "/v2/devices/dev_01k8d6wpggfd68tbhz818p4ww4/entitlement") => (200, answer.clone()), + _ => (404, String::new()), + } + }); + let account = account_at(dir.path(), Keyring::holding(Some("refresh")), endpoints); + account.restore().await; + + let refused = account.refresh_entitlement().await; + let tried_again = account.refresh_entitlement().await; + + assert!( + matches!(refused, Err(AccountError::Refused { status: 401, .. })), + "{refused:?}" + ); + assert!(tried_again.is_ok(), "{tried_again:?}"); + assert_eq!( + asked(&requests), + [ + "POST /oauth/token", + "POST /v2/devices/dev_01k8d6wpggfd68tbhz818p4ww4/entitlement", + "POST /oauth/token", + "POST /v2/devices/dev_01k8d6wpggfd68tbhz818p4ww4/entitlement", + ] + ); + } + + #[tokio::test(start_paused = true)] + async fn a_refresh_that_came_due_during_a_sleep_runs_soon_after_the_wake() { + let due = watch::Sender::new(Instant::now() + REFRESH_INTERVAL); + let mut receiver = due.subscribe(); + let asleep = Arc::new(std::sync::Mutex::new(TimeDelta::zero())); + let wall_clock = { + let asleep = asleep.clone(); + move || Utc::now() + *asleep.lock().expect("the sleep") + }; + let started = Instant::now(); + let waiting = tokio::spawn(async move { + wait_until_due(&mut receiver, wall_clock).await; + Instant::now() + }); + tokio::time::sleep(Duration::from_secs(60)).await; + + // Instant stood still for the night; only the wall clock moved. + *asleep.lock().expect("the sleep") = TimeDelta::days(1); + let woke = waiting.await.expect("woke"); + + assert!(woke - started <= Duration::from_secs(60) + WALL_CLOCK_CHECK); + drop(due); + } + + #[tokio::test(start_paused = true)] + async fn the_schedule_wakes_at_the_time_it_was_last_moved_to() { + let due = watch::Sender::new(Instant::now() + RETRY_INTERVAL); + let mut receiver = due.subscribe(); + let started = Instant::now(); + let waiting = tokio::spawn(async move { + wait_until_due(&mut receiver, Utc::now).await; + Instant::now() + }); + tokio::time::sleep(RETRY_INTERVAL / 2).await; + + due.send_replace(Instant::now() + REFRESH_INTERVAL); + tokio::time::sleep(RETRY_INTERVAL).await; + assert!(!waiting.is_finished()); + due.send_replace(Instant::now() + RETRY_INTERVAL); + let woke = waiting.await.expect("woke"); + + assert_eq!( + (woke - started).as_secs(), + (RETRY_INTERVAL / 2 + RETRY_INTERVAL * 2).as_secs() + ); + } + + #[test] + fn a_device_field_is_cleaned_and_cut_to_100_characters() { + let long: v2::types::DeviceRegistrationName = + field(&format!(" Ada's\u{7}laptop {}", "é".repeat(200))).expect("a name"); + let empty = field::(" \n\t"); + + assert!(long.starts_with("Ada'slaptop ")); + assert_eq!(long.chars().count(), 100); + assert!(empty.is_err()); + } + + fn device_answer() -> String { + format!( + r#"{{"id":"dev_01k8d6wpggfd68tbhz818p4ww4","machine_id":"{}","platform":"linux","name":"Ada's laptop","app_version":"0.1.0","os_version":"Fedora Linux 43 (Workstation Edition)","created_at":"2026-10-29T00:00:00Z","last_seen_at":"2026-10-29T00:00:00Z"}}"#, + machine().as_str() + ) + } + + fn form(body: &str) -> HashMap { + url::form_urlencoded::parse(body.as_bytes()).into_owned().collect() + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_refresh_that_failed_after_the_registration_is_not_answered_with_the_unbound_token() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let file = AccountFile { + device_id: None, + account_id: Some(ACCOUNT.to_owned()), + entitlement: None, + offline_only: false, + }; + account_file::write(&dir.path().join("account.json"), &file) + .await + .expect("the account file"); + let requests = Asked::default(); + let tokens_asked = Arc::new(AtomicUsize::new(0)); + let answer = format!( + r#"{{"token":"{}","expires_at":"2026-11-12T00:00:00Z","refresh_after":"2026-10-30T00:00:00Z"}}"#, + entitlement(Utc::now()) + ); + let device = device_answer(); + let endpoints = cloud({ + let requests = requests.clone(); + let tokens_asked = tokens_asked.clone(); + move |method, path| { + requests.lock().expect("the requests").push(format!("{method} {path}")); + match (method, path) { + ("POST", "/oauth/token") if tokens_asked.fetch_add(1, Ordering::SeqCst) == 1 => { + (503, "upstream unavailable".to_owned()) + } + ("POST", "/oauth/token") => (200, TOKENS.to_owned()), + ("POST", "/v2/devices") => (201, device.clone()), + ("POST", "/v2/devices/dev_01k8d6wpggfd68tbhz818p4ww4/entitlement") => (200, answer.clone()), + _ => (404, String::new()), + } + } + }); + let account = account_at(dir.path(), Keyring::holding(Some("refresh")), endpoints); + account.restore().await; + + let failed = account.refresh_entitlement().await; + account.refresh_entitlement().await.expect("refreshed"); + + assert!(failed.is_err()); + let asked = asked(&requests); + let tokens = asked.iter().filter(|request| *request == "POST /oauth/token").count(); + let last_token = asked.iter().rposition(|request| request == "POST /oauth/token"); + let entitlement = asked.iter().position(|request| request.ends_with("/entitlement")); + assert_eq!(tokens, 3, "{asked:?}"); + assert!( + matches!((last_token, entitlement), (Some(token), Some(asked_for)) if token < asked_for), + "{asked:?}" + ); + } + + // Keeps the authorization URL for the test to answer when it chooses. + #[derive(Default)] + struct HeldBrowser { + opened: std::sync::Mutex>, + } + + #[async_trait] + impl BrowserLauncher for HeldBrowser { + async fn open(&self, url: &Url) -> Result<(), String> { + *self.opened.lock().expect("the URL") = Some(url.clone()); + Ok(()) + } + } + + fn held_account(dir: &Path, keyring: Arc, endpoints: Endpoints, browser: Arc) -> Account { + let settings = AccountSettings { + client: AppClient::LINUX, + endpoints, + device: DeviceDescription { + name: "Ada's laptop".to_owned(), + app_version: "0.1.0".to_owned(), + os_version: "Fedora Linux 43 (Workstation Edition)".to_owned(), + }, + machine_id: machine(), + keys: keys(), + account_file: dir.join("account.json"), + callback_page: CallbackPage { + finished: String::new(), + unknown: String::new(), + }, + }; + Account::new(settings, keyring, browser, watch::Sender::new(AccountStatus::SignedOut)).expect("an account") + } + + // Answers the redirect the sign-in waits for, as the browser would once the user approves. + async fn approve(browser: &HeldBrowser) { + let url = loop { + if let Some(url) = browser.opened.lock().expect("the URL").clone() { + break url; + } + tokio::time::sleep(Duration::from_millis(10)).await; + }; + let asked = |name: &str| { + url.query_pairs() + .find(|(key, _)| key == name) + .map(|(_, value)| value.into_owned()) + }; + let mut redirect = Url::parse(&asked("redirect_uri").expect("a redirect")).expect("a URL"); + redirect + .query_pairs_mut() + .append_pair("code", "late-code") + .append_pair("state", &asked("state").expect("a state")); + reqwest::get(redirect).await.expect("the redirect delivered"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_browser_sign_in_that_comes_back_after_a_sign_out_is_revoked_not_adopted() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let revoked = Arc::new(std::sync::Mutex::new(Vec::new())); + let endpoints = cloud_with_bodies({ + let revoked = revoked.clone(); + move |method, path, body| match (method, path) { + ("POST", "/oauth/token") => (200, "", TOKENS.to_owned()), + ("POST", "/oauth/revoke") => { + revoked + .lock() + .expect("the revocations") + .push(form(body).get("token").cloned().unwrap_or_default()); + (200, "", String::new()) + } + _ => (404, "", String::new()), + } + }); + let keyring = Keyring::holding(None); + let browser = Arc::new(HeldBrowser::default()); + let account = held_account(dir.path(), keyring.clone(), endpoints, browser.clone()); + let signing_in = tokio::spawn({ + let account = account.clone(); + async move { account.sign_in(std::future::pending()).await } + }); + while browser.opened.lock().expect("the URL").is_none() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + + account.sign_out().await.expect("signed out"); + approve(&browser).await; + let late = signing_in.await.expect("the sign-in task"); + + assert!(matches!(late, Err(AccountError::SignInCanceled)), "{late:?}"); + assert_eq!(*revoked.lock().expect("the revocations"), ["stub-refresh"]); + assert!(keyring.token.lock().await.is_none()); + assert!(matches!(*account.status().borrow(), AccountStatus::SignedOut)); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_sign_in_that_brings_no_refresh_token_leaves_the_earlier_one_as_it_was() { + let dir = tempfile::tempdir().expect("a temporary directory"); + keep(dir.path(), Some(entitlement(Utc::now()))).await; + let endpoints = cloud(|method, path| match (method, path) { + ("POST", "/oauth/token") => ( + 200, + r#"{"access_token":"stub-access","token_type":"Bearer","expires_in":900}"#.to_owned(), + ), + _ => (404, String::new()), + }); + let keyring = Keyring::holding(Some("earlier-refresh")); + let browser = Arc::new(HeldBrowser::default()); + let account = held_account(dir.path(), keyring.clone(), endpoints, browser.clone()); + account.restore().await; + let signing_in = tokio::spawn({ + let account = account.clone(); + async move { account.sign_in(std::future::pending()).await } + }); + + approve(&browser).await; + let refused = signing_in.await.expect("the sign-in task"); + + assert!(matches!(refused, Err(AccountError::Protocol(_))), "{refused:?}"); + assert_eq!( + keyring + .token + .lock() + .await + .as_ref() + .map(|token| token.expose_secret().to_owned()) + .as_deref(), + Some("earlier-refresh") + ); + assert!(signed_in(&account).entitlement.is_some()); + } +} diff --git a/core/crates/account/src/account_error.rs b/core/crates/account/src/account_error.rs new file mode 100644 index 0000000000..32f79dbc56 --- /dev/null +++ b/core/crates/account/src/account_error.rs @@ -0,0 +1,70 @@ +use std::time::Duration; + +use crate::entitlement::EntitlementError; +use crate::profile::Device; + +#[derive(Debug, Clone, thiserror::Error)] +pub enum AccountError { + #[error("TablePro's account service could not be reached: {0}")] + Unreachable(String), + #[error("the browser did not open the sign-in page: {0}")] + Browser(String), + #[error("the sign-in was not finished in the browser in time")] + SignInTimedOut, + #[error("the sign-in was canceled before the browser came back")] + SignInCanceled, + #[error("the sign-in was refused: {error}")] + SignInRefused { error: String, description: Option }, + #[error("not signed in")] + SignedOut, + #[error("this device was removed from the account")] + DeviceRevoked, + #[error("the license is in use on {} other devices", devices.len())] + DeviceLimitReached { devices: Vec }, + #[error("the offline file is for another account than the one signed in")] + OtherAccount, + #[error("the server refused the request ({status} {code}): {detail}")] + Refused { status: u16, code: String, detail: String }, + #[error("the account service asked for fewer requests (retry after {retry_after:?})")] + RateLimited { retry_after: Option }, + #[error(transparent)] + Entitlement(#[from] EntitlementError), + #[error("cannot keep the account's state in {path}: {detail}")] + Storage { path: String, detail: String }, + #[error("unexpected answer from the account service: {0}")] + Protocol(String), +} + +impl AccountError { + // A stable code an app outside Rust words the error from. + pub fn code(&self) -> &'static str { + match self { + Self::Unreachable(_) => "account.unreachable", + Self::Browser(_) => "account.browser", + Self::SignInTimedOut => "account.sign_in_timed_out", + Self::SignInCanceled => "account.sign_in_canceled", + Self::SignInRefused { .. } => "account.sign_in_refused", + Self::SignedOut => "account.signed_out", + Self::DeviceRevoked => "account.device_revoked", + Self::DeviceLimitReached { .. } => "account.device_limit_reached", + Self::OtherAccount => "account.other_account", + Self::Refused { .. } => "account.refused", + Self::RateLimited { .. } => "account.rate_limited", + Self::Entitlement(_) => "account.entitlement", + Self::Storage { .. } => "account.storage", + Self::Protocol(_) => "account.protocol", + } + } + + // reqwest keeps the cause, such as a refused connection, a level down. + pub(crate) fn unreachable(error: &dyn std::error::Error) -> Self { + let mut detail = error.to_string(); + let mut source = error.source(); + while let Some(cause) = source { + detail.push_str(": "); + detail.push_str(&cause.to_string()); + source = cause.source(); + } + Self::Unreachable(detail) + } +} diff --git a/core/crates/account/src/account_file.rs b/core/crates/account/src/account_file.rs new file mode 100644 index 0000000000..74b12ba9ea --- /dev/null +++ b/core/crates/account/src/account_file.rs @@ -0,0 +1,155 @@ +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::account_error::AccountError; + +const VERSION: u32 = 1; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct AccountFile { + pub device_id: Option, + // The last entitlement's sub, kept once it expires or a refresh is refused, so an offline file + // of another account is refused then too. + #[serde(default)] + pub account_id: Option, + pub entitlement: Option, + // An offline file imported with no sign-in: the keyring holds nothing to open. + #[serde(default)] + pub offline_only: bool, +} + +#[derive(Serialize, Deserialize)] +struct Stored { + version: u32, + #[serde(flatten)] + file: AccountFile, +} + +// None when missing. Read as empty when of another version: the device registers again. +pub(crate) async fn read(path: &Path) -> Result, AccountError> { + let bytes = match tokio::fs::read(path).await { + Ok(bytes) => bytes, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(storage_error(path, &error)), + }; + let file = match serde_json::from_slice::(&bytes) { + Ok(stored) if stored.version == VERSION => stored.file, + Ok(stored) => { + tracing::warn!(version = stored.version, "the account file is of another version"); + AccountFile::default() + } + Err(error) => { + tracing::warn!(%error, "the account file does not read"); + AccountFile::default() + } + }; + Ok(Some(file)) +} + +pub(crate) async fn write(path: &Path, file: &AccountFile) -> Result<(), AccountError> { + let stored = Stored { + version: VERSION, + file: file.clone(), + }; + let bytes = serde_json::to_vec_pretty(&stored).map_err(|error| storage_error(path, &error))?; + let target = path.to_owned(); + tokio::task::spawn_blocking(move || tablepro_storage::fs::write_private_blocking(&target, &bytes)) + .await + .map_err(|error| storage_error(path, &error))? + .map_err(|error| storage_error(path, &error)) +} + +pub(crate) async fn remove(path: &Path) -> Result<(), AccountError> { + match tokio::fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(storage_error(path, &error)), + } +} + +fn storage_error(path: &Path, error: &dyn std::fmt::Display) -> AccountError { + AccountError::Storage { + path: PathBuf::from(path).display().to_string(), + detail: error.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn a_written_file_reads_back_and_is_private() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let path = dir.path().join("state").join("account.json"); + let file = AccountFile { + device_id: Some("dev_01k8d6wpggfd68tbhz818p4ww4".to_owned()), + account_id: Some("acct_01k8d6wk00f8xsr79e7x052rkk".to_owned()), + entitlement: Some("header.payload.signature".to_owned()), + offline_only: false, + }; + + write(&path, &file).await.expect("written"); + let read_back = read(&path).await.expect("read"); + + assert_eq!(read_back, Some(file)); + #[cfg(unix)] + { + let metadata = std::fs::metadata(&path).expect("metadata"); + let mode = std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()); + assert_eq!(mode & 0o777, 0o600); + } + } + + #[tokio::test] + async fn a_file_from_before_offline_files_reads_as_signed_in() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let path = dir.path().join("account.json"); + std::fs::write(&path, r#"{"version":1,"device_id":"dev_x","entitlement":null}"#).expect("written"); + + let file = read(&path).await.expect("read").expect("a file"); + + assert_eq!(file.device_id.as_deref(), Some("dev_x")); + assert_eq!(file.account_id, None); + assert!(!file.offline_only); + } + + #[tokio::test] + async fn a_missing_file_reads_as_none() { + let dir = tempfile::tempdir().expect("a temporary directory"); + + let missing = read(&dir.path().join("account.json")).await.expect("read"); + + assert_eq!(missing, None); + } + + #[tokio::test] + async fn an_unreadable_or_newer_file_reads_as_empty() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let path = dir.path().join("account.json"); + + std::fs::write(&path, "not json").expect("written"); + let garbage = read(&path).await.expect("read"); + std::fs::write(&path, r#"{"version":2,"device_id":"dev_x"}"#).expect("written"); + let newer = read(&path).await.expect("read"); + + for file in [garbage, newer] { + assert_eq!(file, Some(AccountFile::default())); + } + } + + #[tokio::test] + async fn remove_takes_the_file_and_tolerates_none() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let path = dir.path().join("account.json"); + write(&path, &AccountFile::default()).await.expect("written"); + + remove(&path).await.expect("removed"); + let again = remove(&path).await; + + assert!(!path.exists()); + assert!(again.is_ok()); + } +} diff --git a/core/crates/account/src/account_settings.rs b/core/crates/account/src/account_settings.rs new file mode 100644 index 0000000000..daba15b66d --- /dev/null +++ b/core/crates/account/src/account_settings.rs @@ -0,0 +1,105 @@ +use std::path::PathBuf; + +use url::Url; + +use crate::cloud_api::v2::types::Platform; +use crate::entitlement_keys::EntitlementKeys; +use crate::machine_id::MachineId; + +// tablepro-cloud registers one public OAuth client per app, each with its own redirect: the loopback one +// this crate listens on is the Linux client's (api.md, OAuth). Another app adds its own once the server does. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AppClient { + pub client_id: &'static str, + pub platform: Platform, + // For the User-Agent. + pub system: &'static str, +} + +impl AppClient { + pub const LINUX: Self = Self { + client_id: "tablepro-linux", + platform: Platform::Linux, + system: "Linux", + }; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Endpoints { + pub(crate) authorize: String, + pub(crate) token: String, + pub(crate) revoke: String, + pub(crate) api: String, +} + +impl Endpoints { + pub fn production() -> Self { + Self::under("https://account.tablepro.app", "https://api.tablepro.app/v2") + } + + pub fn new(account: &str, api: &str) -> Result { + let (account, api) = (Url::parse(account)?, Url::parse(api)?); + Ok(Self::under( + account.as_str().trim_end_matches('/'), + api.as_str().trim_end_matches('/'), + )) + } + + fn under(account: &str, api: &str) -> Self { + Self { + authorize: format!("{account}/oauth/authorize"), + token: format!("{account}/oauth/token"), + revoke: format!("{account}/oauth/revoke"), + api: api.to_owned(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeviceDescription { + pub name: String, + pub app_version: String, + pub os_version: String, +} + +// The browser's page after the redirect, translated by the app. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CallbackPage { + pub finished: String, + pub unknown: String, +} + +#[derive(Debug, Clone)] +pub struct AccountSettings { + pub client: AppClient, + pub endpoints: Endpoints, + pub device: DeviceDescription, + pub machine_id: MachineId, + pub keys: EntitlementKeys, + pub account_file: PathBuf, + pub callback_page: CallbackPage, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_oauth_endpoints_hang_off_the_issuer() { + let endpoints = + Endpoints::new("http://account.localhost:5284", "http://api.localhost:5284/v2/").expect("the endpoints"); + + assert_eq!(endpoints.authorize, "http://account.localhost:5284/oauth/authorize"); + assert_eq!(endpoints.token, "http://account.localhost:5284/oauth/token"); + assert_eq!(endpoints.revoke, "http://account.localhost:5284/oauth/revoke"); + assert_eq!(endpoints.api, "http://api.localhost:5284/v2"); + } + + #[test] + fn production_is_tablepro_app_over_https() { + let endpoints = Endpoints::production(); + + assert_eq!(endpoints.token, "https://account.tablepro.app/oauth/token"); + assert_eq!(endpoints.api, "https://api.tablepro.app/v2"); + } +} diff --git a/core/crates/account/src/account_status.rs b/core/crates/account/src/account_status.rs new file mode 100644 index 0000000000..c455c3d66b --- /dev/null +++ b/core/crates/account/src/account_status.rs @@ -0,0 +1,207 @@ +use chrono::{DateTime, Utc}; + +use crate::account_error::AccountError; +use crate::entitlement::Entitlement; +use crate::profile::Device; + +#[derive(Debug, Clone, Default)] +pub enum AccountStatus { + #[default] + SignedOut, + SignedIn(Box), + // An imported offline file on a machine that is not signed in. + Offline(Box), +} + +#[derive(Debug, Clone)] +pub struct SignedIn { + // Kept while a refresh fails: an offline machine uses it until it expires. + pub entitlement: Option, + pub problem: Option, + // False when no keyring took the refresh token: the next launch is signed out. + pub remembered: bool, +} + +#[derive(Debug, Clone)] +pub enum EntitlementProblem { + Expired, + RefreshFailed(AccountError), + DeviceLimitReached(Vec), +} + +// From the entitlement's feature codes only: the app knows no plan or tier. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FeatureAccess { + Available, + SignedOut, + NotIncluded, + Expired, + RefreshFailed, + DeviceLimitReached, +} + +impl AccountStatus { + pub fn feature_access(&self, feature: &str, now: DateTime) -> FeatureAccess { + let signed_in = match self { + Self::SignedOut => return FeatureAccess::SignedOut, + Self::Offline(entitlement) if !entitlement.is_current(now) => return FeatureAccess::Expired, + Self::Offline(entitlement) => { + return match entitlement.includes(feature) { + true => FeatureAccess::Available, + false => FeatureAccess::NotIncluded, + }; + } + Self::SignedIn(signed_in) => signed_in, + }; + if let Some(entitlement) = signed_in.entitlement.as_ref().filter(|it| it.is_current(now)) { + return match entitlement.includes(feature) { + true => FeatureAccess::Available, + false => FeatureAccess::NotIncluded, + }; + } + match (&signed_in.problem, &signed_in.entitlement) { + (Some(EntitlementProblem::DeviceLimitReached(_)), _) => FeatureAccess::DeviceLimitReached, + (Some(EntitlementProblem::RefreshFailed(_)), _) => FeatureAccess::RefreshFailed, + (Some(EntitlementProblem::Expired), _) | (None, Some(_)) => FeatureAccess::Expired, + (None, None) => FeatureAccess::NotIncluded, + } + } + + pub fn entitlement(&self) -> Option<&Entitlement> { + match self { + Self::SignedIn(signed_in) => signed_in.entitlement.as_ref(), + Self::Offline(entitlement) => Some(entitlement), + Self::SignedOut => None, + } + } + + // The devices that hold the license, while the last refresh met the device limit. + pub fn device_limit(&self) -> Option<&[Device]> { + match self { + Self::SignedIn(signed_in) => match &signed_in.problem { + Some(EntitlementProblem::DeviceLimitReached(devices)) => Some(devices), + _ => None, + }, + Self::SignedOut | Self::Offline(_) => None, + } + } + + pub fn past_device_limit(&self) -> bool { + self.device_limit().is_some() + } +} + +#[cfg(test)] +mod tests { + use chrono::TimeDelta; + + use super::*; + use crate::entitlement::test_tokens::{MACHINE, claims, keys, sign}; + + const FEATURE: &str = "env_var_references"; + + fn entitlement(issued: DateTime, features: &[&str]) -> Entitlement { + let token = sign(&claims(issued, TimeDelta::days(14), features)); + Entitlement::verify(&token, &keys(), MACHINE, issued).expect("a valid entitlement") + } + + fn offline() -> AccountError { + AccountError::Unreachable("connection refused".to_owned()) + } + + fn signed_in(entitlement: Option, problem: Option) -> AccountStatus { + AccountStatus::SignedIn(Box::new(SignedIn { + entitlement, + problem, + remembered: true, + })) + } + + #[test] + fn signed_out_unlocks_nothing() { + assert_eq!( + AccountStatus::SignedOut.feature_access(FEATURE, Utc::now()), + FeatureAccess::SignedOut + ); + } + + #[test] + fn a_current_entitlement_unlocks_exactly_its_codes() { + let now = Utc::now(); + let status = signed_in(Some(entitlement(now, &[FEATURE])), None); + + assert_eq!(status.feature_access(FEATURE, now), FeatureAccess::Available); + assert_eq!(status.feature_access("team_library", now), FeatureAccess::NotIncluded); + } + + #[test] + fn a_free_account_gets_an_entitlement_without_features() { + let now = Utc::now(); + let status = signed_in(Some(entitlement(now, &[])), None); + + assert_eq!(status.feature_access(FEATURE, now), FeatureAccess::NotIncluded); + } + + #[test] + fn a_failed_refresh_keeps_a_current_entitlement_working() { + let now = Utc::now(); + let failed = Some(EntitlementProblem::RefreshFailed(offline())); + let limited = Some(EntitlementProblem::DeviceLimitReached(Vec::new())); + + let offline = signed_in(Some(entitlement(now, &[FEATURE])), failed); + let over_limit = signed_in(Some(entitlement(now, &[FEATURE])), limited); + + assert_eq!(offline.feature_access(FEATURE, now), FeatureAccess::Available); + assert_eq!(over_limit.feature_access(FEATURE, now), FeatureAccess::Available); + } + + #[test] + fn an_entitlement_stops_at_its_exp_while_the_app_runs() { + let issued = Utc::now() - TimeDelta::days(20); + let held = entitlement(issued, &[FEATURE]); + let later = held.expires_at(); + let failed = Some(EntitlementProblem::RefreshFailed(offline())); + + let expired = signed_in(Some(held.clone()), None); + let unrefreshed = signed_in(Some(held), failed); + + assert_eq!(expired.feature_access(FEATURE, later), FeatureAccess::Expired); + assert_eq!(unrefreshed.feature_access(FEATURE, later), FeatureAccess::RefreshFailed); + } + + #[test] + fn an_offline_file_unlocks_its_codes_until_it_expires() { + let issued = Utc::now(); + let held = entitlement(issued, &[FEATURE]); + let later = held.expires_at(); + let status = AccountStatus::Offline(Box::new(held)); + + assert_eq!(status.feature_access(FEATURE, issued), FeatureAccess::Available); + assert_eq!( + status.feature_access("team_library", issued), + FeatureAccess::NotIncluded + ); + assert_eq!(status.feature_access(FEATURE, later), FeatureAccess::Expired); + } + + #[test] + fn without_an_entitlement_the_problem_says_why() { + let now = Utc::now(); + let cases = [ + (None, FeatureAccess::NotIncluded), + (Some(EntitlementProblem::Expired), FeatureAccess::Expired), + ( + Some(EntitlementProblem::RefreshFailed(offline())), + FeatureAccess::RefreshFailed, + ), + ( + Some(EntitlementProblem::DeviceLimitReached(Vec::new())), + FeatureAccess::DeviceLimitReached, + ), + ]; + + for (problem, access) in cases { + assert_eq!(signed_in(None, problem).feature_access(FEATURE, now), access); + } + } +} diff --git a/core/crates/account/src/browser.rs b/core/crates/account/src/browser.rs new file mode 100644 index 0000000000..0d34f5fdf8 --- /dev/null +++ b/core/crates/account/src/browser.rs @@ -0,0 +1,8 @@ +use async_trait::async_trait; +use url::Url; + +// The crate has no GUI: the app opens the page, with GTK's UriLauncher. +#[async_trait] +pub trait BrowserLauncher: Send + Sync { + async fn open(&self, url: &Url) -> Result<(), String>; +} diff --git a/core/crates/account/src/cloud_api.rs b/core/crates/account/src/cloud_api.rs new file mode 100644 index 0000000000..b39c8de332 --- /dev/null +++ b/core/crates/account/src/cloud_api.rs @@ -0,0 +1,190 @@ +use std::time::Duration; + +use progenitor_client::{ClientHooks, Error, OperationInfo}; +use reqwest::StatusCode; +use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue, RETRY_AFTER}; +use secrecy::{ExposeSecret, SecretString}; + +use crate::account_error::AccountError; + +#[expect( + clippy::unwrap_used, + reason = "progenitor's client unwraps its own reqwest builder and the document's fixed patterns" +)] +pub mod v2 { + include!(concat!(env!("OUT_DIR"), "/v2.rs")); +} + +#[derive(Clone, Debug)] +pub struct Bearer(pub(crate) SecretString); + +impl ClientHooks for v2::Client { + async fn pre(&self, request: &mut reqwest::Request, _: &OperationInfo) -> Result<(), Error> { + use progenitor_client::ClientInfo as _; + let mut value = HeaderValue::from_str(&format!("Bearer {}", self.inner().0.expose_secret())) + .map_err(|_| Error::InvalidRequest("the access token is not a header value".to_owned()))?; + value.set_sensitive(true); + request.headers_mut().insert(AUTHORIZATION, value); + Ok(()) + } +} + +pub(crate) async fn answer_error(error: Error) -> AccountError { + match error { + Error::ErrorResponse(answer) if answer.status() == StatusCode::TOO_MANY_REQUESTS => { + rate_limited(answer.headers()) + } + Error::UnexpectedResponse(response) if response.status() == StatusCode::TOO_MANY_REQUESTS => { + rate_limited(response.headers()) + } + Error::ErrorResponse(answer) => problem_error(answer.status().as_u16(), answer.into_inner()), + // A status the document does not list, such as a proxy's. + Error::UnexpectedResponse(response) => { + let status = response.status().as_u16(); + let body = match response.bytes().await { + Ok(body) => body, + Err(error) => return AccountError::unreachable(&error), + }; + match serde_json::from_slice::(&body) { + Ok(problem) => problem_error(status, problem), + Err(_) => AccountError::Protocol(format!("HTTP {status} without a problem")), + } + } + Error::CommunicationError(error) => AccountError::unreachable(&error), + // Its text carries the whole answer, an entitlement or the account's details among them. + Error::InvalidResponsePayload(_, error) => AccountError::Protocol(schema_mismatch(&error)), + other => AccountError::Protocol(other.to_string()), + } +} + +// Where the answer stopped matching, without any of its values. +pub(crate) fn schema_mismatch(error: &serde_json::Error) -> String { + format!( + "the answer does not match the /v2 document ({:?} at line {}, column {})", + error.classify(), + error.line(), + error.column() + ) +} + +// api.md, Conventions: every 429 carries Retry-After in seconds. +pub(crate) fn rate_limited(headers: &HeaderMap) -> AccountError { + let retry_after = headers + .get(RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()) + .map(Duration::from_secs); + AccountError::RateLimited { retry_after } +} + +fn problem_error(status: u16, problem: v2::types::Problem) -> AccountError { + match problem.code.as_str() { + "device_revoked" => AccountError::DeviceRevoked, + "device_limit_reached" => AccountError::DeviceLimitReached { + devices: problem.devices.into_iter().map(Into::into).collect(), + }, + _ => AccountError::Refused { + status, + code: problem.code, + detail: problem.detail.unwrap_or(problem.title), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn problem(json: &str) -> v2::types::Problem { + serde_json::from_str(json).expect("a problem") + } + + #[test] + fn the_device_limit_names_the_devices_that_hold_the_license() { + let refused = problem_error( + 409, + problem( + r#"{"type":"https://tablepro.app/problems/device_limit_reached","title":"Device limit reached", + "status":409,"code":"device_limit_reached","devices":[{"id":"dev_01","machine_id":"0000000000000000000000000000000000000000000000000000000000000000", + "platform":"windows","name":"Lab","app_version":"2.0.0","os_version":"11","created_at":"2026-10-01T08:00:00Z", + "last_seen_at":"2026-10-28T08:00:00Z","licensed_at":"2026-10-01T08:00:00Z"}, + {"id":"dev_02","machine_id":"1111111111111111111111111111111111111111111111111111111111111111", + "platform":"linux","name":"Kiosk","app_version":"0.1.0","os_version":"Ubuntu 26.04","created_at":"2026-10-02T08:00:00Z", + "last_seen_at":"2026-10-02T08:00:00Z","licensed_at":"2026-10-02T08:00:00Z","offline_until":"2027-10-02T09:00:00Z"}]}"#, + ), + ); + + let AccountError::DeviceLimitReached { devices } = refused else { + panic!("not the device limit: {refused:?}"); + }; + assert_eq!(devices.len(), 2); + assert_eq!( + (devices[0].name.as_str(), devices[0].platform.as_str()), + ("Lab", "windows") + ); + assert!(devices[0].licensed_at.is_some()); + assert_eq!(devices[0].offline_until, None); + assert_eq!( + devices[1].offline_until.map(|until| until.to_rfc3339()).as_deref(), + Some("2027-10-02T09:00:00+00:00") + ); + } + + #[test] + fn a_removed_device_and_any_other_code_read_from_the_problem() { + let revoked = problem_error( + 403, + problem(r#"{"type":"about:blank","title":"Device revoked","status":403,"code":"device_revoked"}"#), + ); + let other = problem_error( + 403, + problem( + r#"{"type":"about:blank","title":"Account locked","status":403,"code":"account_locked","extra":1}"#, + ), + ); + + assert!(matches!(revoked, AccountError::DeviceRevoked), "{revoked:?}"); + assert!( + matches!(&other, AccountError::Refused { status: 403, code, detail } if code == "account_locked" && detail == "Account locked"), + "{other:?}" + ); + } + + #[tokio::test] + async fn an_answer_that_does_not_match_says_where_and_none_of_its_values() { + let body = br#"{"token":"secret-entitlement-token","account":{"email":"ada@example.com"},"expires_at":5}"#; + let error = serde_json::from_slice::(body).expect_err("a mismatch"); + + let mismatch = answer_error(Error::InvalidResponsePayload(bytes::Bytes::from_static(body), error)).await; + + let text = mismatch.to_string(); + assert!(matches!(mismatch, AccountError::Protocol(_)), "{mismatch:?}"); + assert!( + !text.contains("secret-entitlement-token") && !text.contains("ada@example.com"), + "{text}" + ); + } + + #[test] + fn a_429_keeps_its_retry_after_in_seconds() { + let answer = |value: &str| { + let mut headers = HeaderMap::new(); + headers.insert(RETRY_AFTER, HeaderValue::from_str(value).expect("a header value")); + rate_limited(&headers) + }; + + assert!( + matches!(answer("42"), AccountError::RateLimited { retry_after: Some(wait) } if wait == Duration::from_secs(42)), + "{:?}", + answer("42") + ); + assert!(matches!( + answer("Wed, 21 Oct 2026 07:28:00 GMT"), + AccountError::RateLimited { retry_after: None } + )); + assert!(matches!( + rate_limited(&HeaderMap::new()), + AccountError::RateLimited { retry_after: None } + )); + } +} diff --git a/core/crates/account/src/entitlement.rs b/core/crates/account/src/entitlement.rs new file mode 100644 index 0000000000..d5ec75958f --- /dev/null +++ b/core/crates/account/src/entitlement.rs @@ -0,0 +1,351 @@ +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use chrono::{DateTime, TimeDelta, Utc}; +use jsonwebtoken::errors::ErrorKind; +use jsonwebtoken::{Algorithm, Validation}; +use serde::{Deserialize, Serialize}; + +use crate::entitlement_keys::EntitlementKeys; + +pub const TOKEN_TYPE: &str = "tablepro-entitlement+jwt"; +const AUDIENCE: &str = "tablepro-app"; +// For a local clock that runs behind the server's. +pub const NOT_BEFORE_LEEWAY: TimeDelta = TimeDelta::seconds(300); + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum EntitlementError { + #[error("the entitlement is not a signed token: {0}")] + Malformed(String), + #[error("the entitlement is signed with {0}, not EdDSA")] + Algorithm(String), + #[error("the token is not an entitlement (typ {0:?})")] + Type(Option), + #[error("the entitlement is signed with a key this build does not have ({0:?})")] + UnknownKey(Option), + #[error("the entitlement's signature does not verify")] + Signature, + #[error("the entitlement is for another audience")] + Audience, + #[error("the entitlement was issued to another machine")] + Machine, + #[error("the entitlement is not valid before {0}")] + NotYetValid(DateTime), + #[error("the entitlement expired at {0}")] + Expired(DateTime), +} + +// Licenses and the account are for display; only `features` unlocks anything. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntitlementClaims { + pub iss: String, + pub aud: String, + pub sub: String, + pub jti: String, + pub iat: i64, + pub nbf: i64, + pub exp: i64, + pub dev: String, + pub mid: String, + pub features: Vec, + pub licenses: Vec, + pub account: AccountClaim, + #[serde(default)] + pub offline: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LicenseClaim { + pub id: String, + pub plan: String, + pub cycle: String, + pub source: String, + pub status: String, + pub renews_at: Option>, + pub ends_at: Option>, + pub team: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TeamClaim { + pub id: String, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AccountClaim { + pub email: String, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Entitlement { + token: String, + claims: EntitlementClaims, + expires_at: DateTime, +} + +#[derive(Deserialize)] +struct Header { + alg: String, + typ: Option, + kid: Option, +} + +impl Entitlement { + // jsonwebtoken reads the system clock and would give `exp` the leeway too, so the times are checked here. + pub fn verify( + token: &str, + keys: &EntitlementKeys, + machine_id: &str, + now: DateTime, + ) -> Result { + let header = header(token)?; + if header.alg != "EdDSA" { + return Err(EntitlementError::Algorithm(header.alg)); + } + if header.typ.as_deref() != Some(TOKEN_TYPE) { + return Err(EntitlementError::Type(header.typ)); + } + let key = header + .kid + .as_deref() + .and_then(|kid| keys.get(kid)) + .ok_or_else(|| EntitlementError::UnknownKey(header.kid.clone()))?; + let claims = jsonwebtoken::decode::(token, key, &validation()) + .map_err(|error| match error.kind() { + ErrorKind::InvalidSignature => EntitlementError::Signature, + ErrorKind::InvalidAudience => EntitlementError::Audience, + _ => EntitlementError::Malformed(error.to_string()), + })? + .claims; + if claims.mid != machine_id { + return Err(EntitlementError::Machine); + } + let not_before = timestamp(claims.nbf)?; + if not_before > now + NOT_BEFORE_LEEWAY { + return Err(EntitlementError::NotYetValid(not_before)); + } + let expires_at = timestamp(claims.exp)?; + if now >= expires_at { + return Err(EntitlementError::Expired(expires_at)); + } + Ok(Self { + token: token.to_owned(), + claims, + expires_at, + }) + } + + pub fn token(&self) -> &str { + &self.token + } + + pub fn claims(&self) -> &EntitlementClaims { + &self.claims + } + + pub fn expires_at(&self) -> DateTime { + self.expires_at + } + + pub fn is_current(&self, now: DateTime) -> bool { + now < self.expires_at + } + + pub fn includes(&self, feature: &str) -> bool { + self.claims.features.iter().any(|code| code == feature) + } +} + +fn header(token: &str) -> Result { + let malformed = |detail: &str| EntitlementError::Malformed(detail.to_owned()); + let mut parts = token.split('.'); + let (Some(header), Some(_), Some(_), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else { + return Err(malformed("not three dot-separated parts")); + }; + let json = URL_SAFE_NO_PAD + .decode(header) + .map_err(|_| malformed("the header is not base64url"))?; + serde_json::from_slice(&json).map_err(|error| EntitlementError::Malformed(error.to_string())) +} + +fn validation() -> Validation { + let mut validation = Validation::new(Algorithm::EdDSA); + validation.validate_exp = false; + validation.validate_nbf = false; + validation.required_spec_claims = ["aud", "exp", "nbf"].into_iter().map(str::to_owned).collect(); + validation.set_audience(&[AUDIENCE]); + validation +} + +fn timestamp(seconds: i64) -> Result, EntitlementError> { + DateTime::from_timestamp(seconds, 0).ok_or_else(|| EntitlementError::Malformed(format!("time {seconds}"))) +} + +#[cfg(test)] +pub(crate) mod test_tokens { + use chrono::{DateTime, TimeDelta, Utc}; + + use super::EntitlementClaims; + use crate::entitlement_keys::EntitlementKeys; + pub(crate) use crate::fakes::MACHINE; + + pub(crate) fn keys() -> EntitlementKeys { + crate::fakes::keys().expect("the RFC 8037 key") + } + + pub(crate) fn claims(issued: DateTime, lasts: TimeDelta, features: &[&str]) -> EntitlementClaims { + crate::fakes::claims(issued, lasts, features) + } + + pub(crate) fn sign(claims: &EntitlementClaims) -> String { + crate::fakes::sign(claims).expect("a signed token") + } +} + +#[cfg(test)] +mod tests { + use chrono::TimeDelta; + use serde_json::Value; + + use super::test_tokens::{MACHINE, claims, keys, sign}; + use super::*; + + const VECTORS: &str = include_str!("../tests/data/entitlement_vectors.json"); + + #[derive(Deserialize)] + struct Vectors { + key: VectorKey, + machine_id: String, + now: i64, + valid: Vec, + invalid: Vec, + } + + #[derive(Deserialize)] + struct VectorKey { + kid: String, + x: String, + } + + #[derive(Deserialize)] + struct Valid { + name: String, + token: String, + claims: Value, + } + + #[derive(Deserialize)] + struct Invalid { + name: String, + check: String, + token: String, + } + + fn vectors() -> (Vectors, EntitlementKeys, DateTime) { + let vectors: Vectors = serde_json::from_str(VECTORS).expect("the vectors"); + let keys = EntitlementKeys::from_public_keys([vectors.key.x.as_str()]).expect("the vectors' key"); + assert_eq!(keys.kids().collect::>(), [vectors.key.kid.as_str()]); + let now = DateTime::from_timestamp(vectors.now, 0).expect("the vectors' time"); + (vectors, keys, now) + } + + fn check(error: &EntitlementError) -> &'static str { + match error { + EntitlementError::Malformed(_) => "malformed", + EntitlementError::Algorithm(_) => "alg", + EntitlementError::Type(_) => "typ", + EntitlementError::UnknownKey(_) => "kid", + EntitlementError::Signature => "signature", + EntitlementError::Audience => "aud", + EntitlementError::Machine => "mid", + EntitlementError::NotYetValid(_) => "nbf", + EntitlementError::Expired(_) => "exp", + } + } + + #[test] + fn every_valid_vector_passes_and_decodes_to_its_claims() { + let (vectors, keys, now) = vectors(); + + for vector in &vectors.valid { + let entitlement = Entitlement::verify(&vector.token, &keys, &vectors.machine_id, now) + .unwrap_or_else(|error| panic!("{}: {error}", vector.name)); + + let decoded = serde_json::to_value(entitlement.claims()).expect("the claims as JSON"); + assert_eq!(decoded, vector.claims, "{}", vector.name); + assert_eq!(entitlement.token(), vector.token, "{}", vector.name); + } + } + + #[test] + fn every_invalid_vector_fails_the_check_it_names() { + let (vectors, keys, now) = vectors(); + + for vector in &vectors.invalid { + let error = Entitlement::verify(&vector.token, &keys, &vectors.machine_id, now).expect_err(&vector.name); + + assert_eq!(check(&error), vector.check, "{}: {error}", vector.name); + } + } + + #[test] + fn the_vectors_cover_alg_typ_kid_signature_exp_and_nbf() { + let (vectors, ..) = vectors(); + let checks: Vec<&str> = vectors.invalid.iter().map(|vector| vector.check.as_str()).collect(); + + for named in ["alg", "typ", "kid", "signature", "aud", "mid", "nbf", "exp"] { + assert!(checks.contains(&named), "{named}"); + } + } + + #[test] + fn nbf_may_be_up_to_five_minutes_ahead_and_no_further() { + let now = Utc::now(); + let ahead = |seconds| sign(&claims(now + TimeDelta::seconds(seconds), TimeDelta::days(14), &[])); + + let inside = Entitlement::verify(&ahead(300), &keys(), MACHINE, now); + let beyond = Entitlement::verify(&ahead(301), &keys(), MACHINE, now); + + assert!(inside.is_ok(), "{inside:?}"); + assert!(matches!(beyond, Err(EntitlementError::NotYetValid(_))), "{beyond:?}"); + } + + #[test] + fn exp_has_no_leeway() { + let issued = DateTime::from_timestamp(Utc::now().timestamp(), 0).expect("now") - TimeDelta::days(1); + let token = sign(&claims(issued, TimeDelta::days(1), &[])); + let expires_at = issued + TimeDelta::days(1); + + let before = Entitlement::verify(&token, &keys(), MACHINE, expires_at - TimeDelta::seconds(1)); + let at = Entitlement::verify(&token, &keys(), MACHINE, expires_at); + + assert!(before.is_ok(), "{before:?}"); + assert_eq!(at, Err(EntitlementError::Expired(expires_at))); + } + + #[test] + fn a_token_that_is_not_a_jws_is_malformed() { + for token in ["", "a.b", "a.b.c.d", "!!!.e30.sig", "e30.e30.sig"] { + let result = Entitlement::verify(token, &keys(), MACHINE, Utc::now()); + + assert!( + matches!(result, Err(EntitlementError::Malformed(_))), + "{token:?}: {result:?}" + ); + } + } + + #[test] + fn includes_reads_the_feature_codes() { + let now = Utc::now(); + let token = sign(&claims(now, TimeDelta::days(14), &["env_var_references"])); + + let entitlement = Entitlement::verify(&token, &keys(), MACHINE, now).expect("a valid token"); + + assert!(entitlement.includes("env_var_references")); + assert!(!entitlement.includes("team_library")); + assert!(entitlement.is_current(now)); + assert!(!entitlement.is_current(entitlement.expires_at())); + } +} diff --git a/core/crates/account/src/entitlement_keys.rs b/core/crates/account/src/entitlement_keys.rs new file mode 100644 index 0000000000..10b7dbb5b7 --- /dev/null +++ b/core/crates/account/src/entitlement_keys.rs @@ -0,0 +1,108 @@ +use std::collections::HashMap; + +use aws_lc_rs::digest; +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use jsonwebtoken::DecodingKey; + +// (kid, RFC 8037 `x`) as tablepro-cloud's operations.md, Production entitlement key, publishes them. +// Rotation order is in api.md, Entitlement token. +const RELEASE_KEYS: &[(&str, &str)] = &[( + "wLT5qcqj3sxp-0NB_21spMxboiyTAW1BwmsXhhgA-XA", + "0ozC4TYUqinr-4JfyglENxCUuxFf-90uni0M0sCMR9g", +)]; + +#[derive(Clone)] +pub struct EntitlementKeys { + keys: HashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{x:?} is not an Ed25519 public key in base64url")] +pub struct KeyError { + x: String, +} + +impl EntitlementKeys { + pub fn release() -> Self { + let keys = RELEASE_KEYS.iter().filter_map(|(_, x)| keyed(x).ok()).collect(); + Self { keys } + } + + pub fn from_public_keys<'a>(xs: impl IntoIterator) -> Result { + let keys = xs.into_iter().map(keyed).collect::>()?; + Ok(Self { keys }) + } + + pub(crate) fn get(&self, kid: &str) -> Option<&DecodingKey> { + self.keys.get(kid) + } + + pub fn kids(&self) -> impl Iterator { + self.keys.keys().map(String::as_str) + } +} + +impl std::fmt::Debug for EntitlementKeys { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_list().entries(self.keys.keys()).finish() + } +} + +fn keyed(x: &str) -> Result<(String, DecodingKey), KeyError> { + let error = || KeyError { x: x.to_owned() }; + let raw = URL_SAFE_NO_PAD.decode(x).map_err(|_| error())?; + if raw.len() != 32 { + return Err(error()); + } + let key = DecodingKey::from_ed_components(x).map_err(|_| error())?; + Ok((thumbprint(x), key)) +} + +// RFC 7638. A base64url `x` needs no JSON escaping. +fn thumbprint(x: &str) -> String { + let members = format!(r#"{{"crv":"Ed25519","kty":"OKP","x":"{x}"}}"#); + URL_SAFE_NO_PAD.encode(digest::digest(&digest::SHA256, members.as_bytes())) +} + +#[cfg(test)] +mod tests { + use super::*; + + const RFC_8037_X: &str = "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"; + + #[test] + fn the_kid_is_the_rfc_7638_thumbprint() { + let keys = EntitlementKeys::from_public_keys([RFC_8037_X]).expect("the RFC 8037 key"); + + assert_eq!( + keys.kids().collect::>(), + ["kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k"] + ); + } + + #[test] + fn what_is_not_a_32_byte_key_is_refused() { + for x in ["", "not base64!", "AAAA", &format!("{RFC_8037_X}AA")] { + assert!(EntitlementKeys::from_public_keys([x]).is_err(), "{x:?}"); + } + } + + #[test] + fn each_release_kid_is_the_rfc_7638_thumbprint_of_its_key() { + let keys = EntitlementKeys::release(); + let mut kids: Vec<&str> = keys.kids().collect(); + let mut published: Vec<&str> = RELEASE_KEYS.iter().map(|(kid, _)| *kid).collect(); + + assert!( + !published.is_empty(), + "a release build would verify no production entitlement" + ); + for (kid, x) in RELEASE_KEYS { + assert_eq!(thumbprint(x), *kid, "{x}"); + } + kids.sort_unstable(); + published.sort_unstable(); + assert_eq!(kids, published); + } +} diff --git a/core/crates/account/src/fakes.rs b/core/crates/account/src/fakes.rs new file mode 100644 index 0000000000..6c0de1bc80 --- /dev/null +++ b/core/crates/account/src/fakes.rs @@ -0,0 +1,53 @@ +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use chrono::{DateTime, TimeDelta, Utc}; +use jsonwebtoken::{Algorithm, EncodingKey, Header}; + +use crate::entitlement::{AccountClaim, Entitlement, EntitlementClaims, TOKEN_TYPE}; +use crate::entitlement_keys::{EntitlementKeys, KeyError}; + +// RFC 8037 appendix A.1, the key tablepro-cloud's vectors use. +const PRIVATE_PKCS8: &str = "MC4CAQAwBQYDK2VwBCIEIJ1hsZ3v/VpguoRK9JLsLMREScVpezJpGXA7rAMcrn9g"; +pub const PUBLIC_X: &str = "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"; +pub const KID: &str = "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k"; +pub const MACHINE: &str = "e4338b8603de2523305fdfcb87319be2033974658ad97f0c975834d209715658"; + +pub fn keys() -> Result { + EntitlementKeys::from_public_keys([PUBLIC_X]) +} + +pub fn claims(issued: DateTime, lasts: TimeDelta, features: &[&str]) -> EntitlementClaims { + EntitlementClaims { + iss: "https://api.tablepro.app".to_owned(), + aud: "tablepro-app".to_owned(), + sub: "acct_01k8d6wk00f8xsr79e7x052rkk".to_owned(), + jti: "019a1a6e-9e50-7f80-8162-000000000009".to_owned(), + iat: issued.timestamp(), + nbf: issued.timestamp(), + exp: (issued + lasts).timestamp(), + dev: "dev_01k8d6wpggfd68tbhz818p4ww4".to_owned(), + mid: MACHINE.to_owned(), + features: features.iter().map(|code| (*code).to_owned()).collect(), + licenses: Vec::new(), + account: AccountClaim { + email: "dat@example.com".to_owned(), + name: "Dat".to_owned(), + }, + offline: false, + } +} + +pub fn sign(claims: &EntitlementClaims) -> Result { + let mut header = Header::new(Algorithm::EdDSA); + header.typ = Some(TOKEN_TYPE.to_owned()); + header.kid = Some(KID.to_owned()); + let der = STANDARD.decode(PRIVATE_PKCS8).map_err(|error| error.to_string())?; + jsonwebtoken::encode(&header, claims, &EncodingKey::from_ed_der(&der)).map_err(|error| error.to_string()) +} + +// Verified at `iat`, so a token that has expired since still makes one. +pub fn entitlement(claims: &EntitlementClaims) -> Result { + let keys = keys().map_err(|error| error.to_string())?; + let issued = DateTime::from_timestamp(claims.iat, 0).ok_or("iat is out of range")?; + Entitlement::verify(&sign(claims)?, &keys, &claims.mid, issued).map_err(|error| error.to_string()) +} diff --git a/core/crates/account/src/lib.rs b/core/crates/account/src/lib.rs new file mode 100644 index 0000000000..3dc8dd24c1 --- /dev/null +++ b/core/crates/account/src/lib.rs @@ -0,0 +1,36 @@ +//! The TablePro account: sign-in, device, entitlement and the feature gate. Free of GTK. + +mod account; +mod account_error; +mod account_file; +mod account_settings; +mod account_status; +mod browser; +mod cloud_api; +mod entitlement; +mod entitlement_keys; +#[cfg(any(test, feature = "fakes"))] +pub mod fakes; +mod loopback; +mod machine_id; +mod oauth; +mod offline_file; +mod profile; +mod token_vault; + +pub use account::{Account, REFRESH_INTERVAL, RETRY_INTERVAL}; +pub use account_error::AccountError; +pub use account_settings::{AccountSettings, AppClient, CallbackPage, DeviceDescription, Endpoints}; +pub use account_status::{AccountStatus, EntitlementProblem, FeatureAccess, SignedIn}; +pub use browser::BrowserLauncher; +pub use cloud_api::v2; +pub use entitlement::{ + AccountClaim, Entitlement, EntitlementClaims, EntitlementError, LicenseClaim, NOT_BEFORE_LEEWAY, TOKEN_TYPE, + TeamClaim, +}; +pub use entitlement_keys::{EntitlementKeys, KeyError}; +pub use machine_id::MachineId; +pub use offline_file::{OfflineFileError, read_offline_file_blocking}; +pub use profile::{Device, Membership, Profile}; +pub use token_vault::RefreshTokenVault; +pub use url::Url; diff --git a/core/crates/account/src/loopback.rs b/core/crates/account/src/loopback.rs new file mode 100644 index 0000000000..e7efe81bdb --- /dev/null +++ b/core/crates/account/src/loopback.rs @@ -0,0 +1,221 @@ +use std::convert::Infallible; +use std::net::{Ipv4Addr, SocketAddr}; +use std::time::Duration; + +use bytes::Bytes; +use http::header::{CACHE_CONTROL, CONTENT_TYPE}; +use http::{Request, Response, StatusCode}; +use http_body_util::Full; +use hyper::body::Incoming; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper_util::rt::TokioIo; +use tokio::net::TcpListener; +use tokio::sync::mpsc; +use tokio::task::JoinSet; + +use crate::account_settings::CallbackPage; + +const CALLBACK_PATH: &str = "/oauth/callback"; +// The browser may still be reading the page when the answer is in. +const PAGE_GRACE: Duration = Duration::from_secs(2); + +// RFC 8252 section 7.3: only a request carrying this sign-in's `state` ends the wait. +pub(crate) struct Loopback { + listener: TcpListener, + port: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Callback { + Code(String), + Refused { error: String, description: Option }, +} + +impl Loopback { + pub(crate) async fn bind() -> std::io::Result { + let listener = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))).await?; + let port = listener.local_addr()?.port(); + Ok(Self { listener, port }) + } + + pub(crate) fn redirect_uri(&self) -> String { + format!("http://127.0.0.1:{}{CALLBACK_PATH}", self.port) + } + + pub(crate) async fn wait(self, state: &str, page: &CallbackPage) -> std::io::Result { + let (answers, mut answered) = mpsc::channel(1); + let mut connections = JoinSet::new(); + let callback = loop { + tokio::select! { + accepted = self.listener.accept() => { + let (stream, _) = accepted?; + let (state, page, answers) = (state.to_owned(), page.clone(), answers.clone()); + let service = service_fn(move |request: Request| { + let reply = reply(&request, &state, &page); + let answers = answers.clone(); + async move { + if let Some(callback) = reply.callback { + let _ = answers.send(callback).await; + } + Ok::<_, Infallible>(reply.response) + } + }); + connections.spawn( + http1::Builder::new() + .keep_alive(false) + .serve_connection(TokioIo::new(stream), service), + ); + } + Some(callback) = answered.recv() => break callback, + } + }; + let _ = tokio::time::timeout(PAGE_GRACE, async { while connections.join_next().await.is_some() {} }).await; + Ok(callback) + } +} + +struct Reply { + callback: Option, + response: Response>, +} + +fn reply(request: &Request, state: &str, page: &CallbackPage) -> Reply { + if request.uri().path() != CALLBACK_PATH { + return Reply { + callback: None, + response: html(StatusCode::NOT_FOUND, &page.unknown), + }; + } + match callback(request.uri().query().unwrap_or_default(), state) { + Some(callback) => Reply { + callback: Some(callback), + response: html(StatusCode::OK, &page.finished), + }, + None => Reply { + callback: None, + response: html(StatusCode::BAD_REQUEST, &page.unknown), + }, + } +} + +pub(crate) fn callback(query: &str, state: &str) -> Option { + let (mut code, mut error, mut description, mut returned) = (None, None, None, None); + for (name, value) in url::form_urlencoded::parse(query.as_bytes()) { + match &*name { + "code" => code = Some(value.into_owned()), + "error" => error = Some(value.into_owned()), + "error_description" => description = Some(value.into_owned()), + "state" => returned = Some(value.into_owned()), + _ => {} + } + } + if returned.as_deref() != Some(state) { + return None; + } + Some(match (code, error) { + (_, Some(error)) => Callback::Refused { error, description }, + (Some(code), None) => Callback::Code(code), + (None, None) => Callback::Refused { + error: "invalid_request".to_owned(), + description, + }, + }) +} + +fn html(status: StatusCode, text: &str) -> Response> { + let body = format!( + "TablePro

{}

\n", + escape(text) + ); + let mut response = Response::new(Full::new(Bytes::from(body))); + *response.status_mut() = status; + let headers = response.headers_mut(); + headers.insert(CONTENT_TYPE, http::HeaderValue::from_static("text/html; charset=utf-8")); + headers.insert(CACHE_CONTROL, http::HeaderValue::from_static("no-store")); + response +} + +fn escape(text: &str) -> String { + text.chars() + .fold(String::with_capacity(text.len()), |mut escaped, character| { + match character { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + other => escaped.push(other), + } + escaped + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn page() -> CallbackPage { + CallbackPage { + finished: "Back to ".to_owned(), + unknown: "Not this sign-in".to_owned(), + } + } + + #[test] + fn only_the_matching_state_answers() { + assert_eq!( + callback("code=abc&state=s1", "s1"), + Some(Callback::Code("abc".to_owned())) + ); + assert_eq!(callback("code=abc&state=s2", "s1"), None); + assert_eq!(callback("code=abc", "s1"), None); + assert_eq!(callback("", "s1"), None); + } + + #[test] + fn an_error_with_the_matching_state_ends_the_sign_in() { + let refused = callback("error=access_denied&error_description=No+thanks&state=s1", "s1"); + + assert_eq!( + refused, + Some(Callback::Refused { + error: "access_denied".to_owned(), + description: Some("No thanks".to_owned()), + }) + ); + } + + #[test] + fn the_page_text_is_escaped() { + assert_eq!(escape(r#""x" & y"#), "<b>"x" & y</b>"); + } + + #[tokio::test] + async fn the_listener_keeps_waiting_past_other_requests_for_its_state() { + let loopback = Loopback::bind().await.expect("a loopback port"); + let redirect = loopback.redirect_uri(); + let base = redirect.trim_end_matches(CALLBACK_PATH).to_owned(); + let waiting = tokio::spawn(async move { loopback.wait("s1", &page()).await }); + let http = reqwest::Client::new(); + + let favicon = http.get(format!("{base}/favicon.ico")).send().await.expect("an answer"); + let forged = http + .get(format!("{redirect}?code=evil&state=s2")) + .send() + .await + .expect("an answer"); + let real = http + .get(format!("{redirect}?code=abc&state=s1")) + .send() + .await + .expect("an answer"); + let real_page = real.text().await.expect("the page"); + let callback = waiting.await.expect("the wait ends").expect("a callback"); + + assert!(redirect.starts_with("http://127.0.0.1:")); + assert_eq!(favicon.status(), StatusCode::NOT_FOUND); + assert_eq!(forged.status(), StatusCode::BAD_REQUEST); + assert!(real_page.contains("Back to <TablePro>"), "{real_page}"); + assert_eq!(callback, Callback::Code("abc".to_owned())); + } +} diff --git a/core/crates/account/src/machine_id.rs b/core/crates/account/src/machine_id.rs new file mode 100644 index 0000000000..2ac9ae0f7e --- /dev/null +++ b/core/crates/account/src/machine_id.rs @@ -0,0 +1,109 @@ +use std::fmt::Write as _; + +use aws_lc_rs::hmac; + +// Changing it makes every Linux machine a new device. Both build profiles share it. +const KEY: &str = "app.tablepro.TablePro"; + +// Linux's: an HMAC of the machine ID the app reads from /etc/machine-id, which machine-id(5) says never to send +// as it is. The machine ID itself never leaves the machine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MachineId(String); + +impl MachineId { + // machine-id(5): 32 hex characters and a newline, never all zero. + pub fn derive(machine_id: &str) -> Option { + let raw = decode_machine_id(machine_id.trim_end_matches('\n'))?; + let tag = hmac::sign(&hmac::Key::new(hmac::HMAC_SHA256, KEY.as_bytes()), &raw); + Some(Self(hex(tag.as_ref()))) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +fn decode_machine_id(text: &str) -> Option<[u8; 16]> { + if text.len() != 32 { + return None; + } + let mut raw = [0u8; 16]; + let (pairs, _) = text.as_bytes().as_chunks::<2>(); + for (byte, pair) in raw.iter_mut().zip(pairs) { + let pair = std::str::from_utf8(pair).ok()?; + *byte = u8::from_str_radix(pair, 16).ok()?; + } + (raw != [0u8; 16]).then_some(raw) +} + +fn hex(bytes: &[u8]) -> String { + bytes + .iter() + .fold(String::with_capacity(bytes.len() * 2), |mut text, byte| { + let _ = write!(text, "{byte:02x}"); + text + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ETC_MACHINE_ID: &str = "4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f20\n"; + // python3 -c 'import hmac,hashlib;print(hmac.new(b"app.tablepro.TablePro", + // bytes.fromhex("4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f20"),hashlib.sha256).hexdigest())' + const EXPECTED: &str = "95af81c0a8cfbaf248d6e4f595ba544effb155bbf8d0aae9ca4e86c69cfc170e"; + + #[test] + fn the_id_is_the_hmac_of_the_raw_machine_id_keyed_with_the_application_id() { + let id = MachineId::derive(ETC_MACHINE_ID).expect("a machine ID"); + + assert_eq!(id.as_str(), EXPECTED); + } + + #[test] + fn the_id_is_64_lowercase_hex_characters_and_never_the_machine_id() { + let id = MachineId::derive(ETC_MACHINE_ID).expect("a machine ID"); + + assert_eq!(id.as_str().len(), 64); + assert!( + id.as_str() + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + ); + assert!(!id.as_str().contains(ETC_MACHINE_ID.trim_end())); + } + + #[test] + fn upper_case_hex_names_the_same_machine() { + let lower = MachineId::derive(ETC_MACHINE_ID); + let upper = MachineId::derive(&ETC_MACHINE_ID.to_uppercase()); + + assert_eq!(lower, upper); + } + + #[test] + fn another_machine_gets_another_id() { + let first = MachineId::derive(ETC_MACHINE_ID); + let second = MachineId::derive("4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f21\n"); + + assert_ne!(first, second); + } + + #[test] + fn what_is_not_a_machine_id_is_refused() { + for text in [ + "", + "\n", + "uninitialized\n", + "4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f2\n", + "4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f200\n", + "4c4f5e2a-0b6d-4f3c-9e8a-7b6c5d4e3f20\n", + "zc4f5e2a0b6d4f3c9e8a7b6c5d4e3f20\n", + " 4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f20\n", + "00000000000000000000000000000000\n", + ] { + assert_eq!(MachineId::derive(text), None, "{text:?}"); + } + } +} diff --git a/core/crates/account/src/oauth.rs b/core/crates/account/src/oauth.rs new file mode 100644 index 0000000000..a31c11846f --- /dev/null +++ b/core/crates/account/src/oauth.rs @@ -0,0 +1,192 @@ +use std::borrow::Cow; +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use oauth2::basic::{BasicClient, BasicErrorResponseType, BasicTokenResponse}; +use oauth2::{ + AuthUrl, AuthorizationCode, ClientId, CsrfToken, EndpointNotSet, EndpointSet, HttpClientError, HttpRequest, + HttpResponse, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, RefreshToken, RequestTokenError, Scope, + TokenResponse, TokenUrl, +}; +use secrecy::{ExposeSecret, SecretString}; +use url::Url; + +use crate::account_error::AccountError; +use crate::account_settings::{AppClient, DeviceDescription, Endpoints}; +use crate::cloud_api::rate_limited; + +const SCOPES: [&str; 4] = ["openid", "email", "offline_access", "app"]; +// The server's access token lifetime, for an answer without expires_in. +const ACCESS_TOKEN_LIFETIME: Duration = Duration::from_secs(15 * 60); + +type Client = BasicClient; + +pub(crate) struct OAuth { + client: Client, + app: AppClient, + http: reqwest::Client, + revoke: String, +} + +pub(crate) struct Authorization { + pub url: Url, + pub state: CsrfToken, + pub verifier: PkceCodeVerifier, + pub redirect_uri: RedirectUrl, +} + +pub(crate) struct Tokens { + pub access: SecretString, + pub lifetime: Duration, + pub refresh: Option, +} + +impl OAuth { + pub(crate) fn new(app: AppClient, endpoints: &Endpoints, http: reqwest::Client) -> Result { + let client = BasicClient::new(ClientId::new(app.client_id.to_owned())) + .set_auth_uri(AuthUrl::new(endpoints.authorize.clone()).map_err(protocol)?) + .set_token_uri(TokenUrl::new(endpoints.token.clone()).map_err(protocol)?); + Ok(Self { + client, + app, + http, + revoke: endpoints.revoke.clone(), + }) + } + + // The machine's fields name it on the confirmation screen. + pub(crate) fn authorization( + &self, + redirect_uri: String, + device: &DeviceDescription, + ) -> Result { + let redirect_uri = RedirectUrl::new(redirect_uri).map_err(protocol)?; + let (challenge, verifier) = PkceCodeChallenge::new_random_sha256(); + let (url, state) = self + .client + .authorize_url(CsrfToken::new_random) + .set_redirect_uri(Cow::Borrowed(&redirect_uri)) + .add_scopes(SCOPES.map(|scope| Scope::new(scope.to_owned()))) + .set_pkce_challenge(challenge) + .add_extra_param("machine_name", device.name.clone()) + .add_extra_param("app_version", device.app_version.clone()) + .add_extra_param("os_version", device.os_version.clone()) + .url(); + Ok(Authorization { + url, + state, + verifier, + redirect_uri, + }) + } + + pub(crate) async fn exchange_code( + &self, + code: String, + authorization: Authorization, + ) -> Result { + let limited = Arc::new(OnceLock::new()); + let http = |request| execute(self.http.clone(), request, limited.clone()); + let answer = self + .client + .exchange_code(AuthorizationCode::new(code)) + .set_pkce_verifier(authorization.verifier) + .set_redirect_uri(Cow::Owned(authorization.redirect_uri)) + .request_async(&http) + .await; + answer.map(tokens).map_err(|error| grant_error(error, limited.get())) + } + + pub(crate) async fn refresh(&self, refresh: &SecretString) -> Result { + let limited = Arc::new(OnceLock::new()); + let http = |request| execute(self.http.clone(), request, limited.clone()); + let token = RefreshToken::new(refresh.expose_secret().to_owned()); + let answer = self.client.exchange_refresh_token(&token).request_async(&http).await; + answer.map(tokens).map_err(|error| grant_error(error, limited.get())) + } + + // RFC 7009 by hand: oauth2 refuses a revocation URL that is not https. + pub(crate) async fn revoke(&self, refresh: &SecretString) -> Result<(), AccountError> { + let form = [ + ("token", refresh.expose_secret()), + ("token_type_hint", "refresh_token"), + ("client_id", self.app.client_id), + ]; + let response = self + .http + .post(&self.revoke) + .form(&form) + .send() + .await + .map_err(|error| AccountError::unreachable(&error))?; + match response.status().is_success() { + true => Ok(()), + false => Err(AccountError::Refused { + status: response.status().as_u16(), + code: "revoke".to_owned(), + detail: response.text().await.unwrap_or_default(), + }), + } + } +} + +// oauth2 keeps neither the status nor the headers of an error answer, so a 429 is noted here. +async fn execute( + client: reqwest::Client, + request: HttpRequest, + limited: Arc>, +) -> Result> { + let (parts, body) = request.into_parts(); + let response = client + .request(parts.method, parts.uri.to_string()) + .headers(parts.headers) + .body(body) + .send() + .await + .map_err(Box::new)?; + if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS { + drop(limited.set(rate_limited(response.headers()))); + } + let mut answer = http::Response::builder().status(response.status()); + if let Some(headers) = answer.headers_mut() { + headers.extend(response.headers().clone()); + } + let body = response.bytes().await.map_err(Box::new)?; + Ok(answer.body(body.to_vec())?) +} + +fn tokens(response: BasicTokenResponse) -> Tokens { + Tokens { + access: SecretString::from(response.access_token().secret().to_owned()), + lifetime: response.expires_in().unwrap_or(ACCESS_TOKEN_LIFETIME), + refresh: response + .refresh_token() + .map(|token| SecretString::from(token.secret().to_owned())), + } +} + +// invalid_grant: the token's family was revoked, its device removed, or a token replayed. +fn grant_error( + error: RequestTokenError, oauth2::basic::BasicErrorResponse>, + limited: Option<&AccountError>, +) -> AccountError { + if let Some(limited) = limited { + return limited.clone(); + } + match error { + RequestTokenError::ServerResponse(answer) if *answer.error() == BasicErrorResponseType::InvalidGrant => { + AccountError::SignedOut + } + RequestTokenError::ServerResponse(answer) => AccountError::Refused { + status: 400, + code: answer.error().to_string(), + detail: answer.error_description().cloned().unwrap_or_default(), + }, + RequestTokenError::Request(HttpClientError::Reqwest(error)) => AccountError::unreachable(&*error), + other => AccountError::Protocol(other.to_string()), + } +} + +fn protocol(error: url::ParseError) -> AccountError { + AccountError::Protocol(error.to_string()) +} diff --git a/core/crates/account/src/offline_file.rs b/core/crates/account/src/offline_file.rs new file mode 100644 index 0000000000..cee8d5d04d --- /dev/null +++ b/core/crates/account/src/offline_file.rs @@ -0,0 +1,54 @@ +use std::io::Read as _; +use std::path::{Path, PathBuf}; + +// An offline file is one token, a few kilobytes. +const LIMIT: u64 = 64 * 1024; + +#[derive(Debug, thiserror::Error)] +pub enum OfflineFileError { + #[error("cannot read {}: {source}", path.display())] + Read { path: PathBuf, source: std::io::Error }, + #[error("{} is too large to be an offline file", path.display())] + TooLarge { path: PathBuf }, +} + +// The token for Account::import_offline_file. Reads no further than the limit. +pub fn read_offline_file_blocking(path: &Path) -> Result { + let read = |source| OfflineFileError::Read { + path: path.to_owned(), + source, + }; + let file = std::fs::File::open(path).map_err(read)?; + let mut token = String::new(); + file.take(LIMIT + 1).read_to_string(&mut token).map_err(read)?; + if token.len() as u64 > LIMIT { + return Err(OfflineFileError::TooLarge { path: path.to_owned() }); + } + Ok(token) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_the_token_and_refuses_what_cannot_be_one() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let path = dir.path().join("offline.jwt"); + std::fs::write(&path, "header.claims.signature\n").expect("the file"); + let large = dir.path().join("large.jwt"); + std::fs::write(&large, vec![b'a'; 64 * 1024 + 1]).expect("the file"); + let binary = dir.path().join("binary.jwt"); + std::fs::write(&binary, [0xff, 0xfe]).expect("the file"); + + let token = read_offline_file_blocking(&path).expect("a token"); + let missing = read_offline_file_blocking(&dir.path().join("absent.jwt")); + let too_large = read_offline_file_blocking(&large); + let not_text = read_offline_file_blocking(&binary); + + assert_eq!(token, "header.claims.signature\n"); + assert!(matches!(missing, Err(OfflineFileError::Read { .. }))); + assert!(matches!(too_large, Err(OfflineFileError::TooLarge { path: named }) if named == large)); + assert!(matches!(not_text, Err(OfflineFileError::Read { .. }))); + } +} diff --git a/core/crates/account/src/profile.rs b/core/crates/account/src/profile.rs new file mode 100644 index 0000000000..ae91d71972 --- /dev/null +++ b/core/crates/account/src/profile.rs @@ -0,0 +1,213 @@ +use chrono::{DateTime, Utc}; + +use crate::cloud_api::v2::types; +use crate::entitlement::{LicenseClaim, TeamClaim}; + +// Codes stay strings: a plan, cycle, status or role the app does not know still shows. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Profile { + pub id: String, + pub email: String, + pub name: Option, + pub licenses: Vec, + pub teams: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Membership { + pub team: TeamClaim, + pub role: String, + pub has_seat: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Device { + pub id: String, + pub machine_id: String, + pub platform: String, + pub name: String, + pub app_version: String, + pub os_version: String, + pub last_seen_at: DateTime, + pub licensed_at: Option>, + pub offline_until: Option>, +} + +impl Device { + // Removing it before then is refused with offline_file_active. + pub fn offline_file_active(&self, now: DateTime) -> bool { + self.offline_until.is_some_and(|until| until > now) + } +} + +impl From for Profile { + fn from(me: types::Me) -> Self { + Self { + id: me.account.id, + email: me.account.email, + name: me.account.name.filter(|name| !name.trim().is_empty()), + licenses: me.licenses.into_iter().map(license).collect(), + teams: me + .teams + .into_iter() + .map(|membership| Membership { + team: team(membership.team), + role: membership.role, + has_seat: membership.has_seat, + }) + .collect(), + } + } +} + +impl From for Device { + fn from(device: types::Device) -> Self { + Self { + id: device.id, + machine_id: device.machine_id, + platform: device.platform, + name: device.name, + app_version: device.app_version, + os_version: device.os_version, + last_seen_at: device.last_seen_at, + licensed_at: device.licensed_at, + offline_until: device.offline_until, + } + } +} + +impl From for Device { + fn from(device: types::LicensedDevice) -> Self { + Self { + id: device.id, + machine_id: device.machine_id, + platform: device.platform, + name: device.name, + app_version: device.app_version, + os_version: device.os_version, + last_seen_at: device.last_seen_at, + licensed_at: Some(device.licensed_at), + offline_until: device.offline_until, + } + } +} + +fn license(license: types::License) -> LicenseClaim { + LicenseClaim { + id: license.id, + plan: license.plan, + cycle: license.cycle, + source: license.source, + status: license.status, + renews_at: license.renews_at, + ends_at: license.ends_at, + team: license.team.map(team), + } +} + +fn team(team: types::TeamRef) -> TeamClaim { + TeamClaim { + id: team.id, + name: team.name, + } +} + +#[cfg(test)] +mod tests { + use chrono::TimeDelta; + + use super::*; + + fn me(json: &str) -> Profile { + serde_json::from_str::(json).expect("a /v2/me answer").into() + } + + #[test] + fn me_reads_as_the_wire_codes() { + let profile = me(r#"{ + "account": { "id": "acct_01", "email": "ada@m17.example", "name": "Ada" }, + "licenses": [{ + "id": "lic_01", "plan": "team", "cycle": "yearly", "source": "subscription", + "status": "past_due", "team": { "id": "team_01", "name": "Acme" } + }, { + "id": "lic_02", "plan": "starter", "cycle": "monthly", "source": "subscription", + "status": "active", "renews_at": "2026-11-14T10:00:00Z" + }], + "teams": [{ "team": { "id": "team_01", "name": "Acme" }, "role": "admin", "has_seat": true }] + }"#); + + assert_eq!(profile.name.as_deref(), Some("Ada")); + let license = &profile.licenses[0]; + assert_eq!( + (license.plan.as_str(), license.cycle.as_str(), license.status.as_str()), + ("team", "yearly", "past_due") + ); + assert_eq!(license.team.as_ref().map(|team| team.name.as_str()), Some("Acme")); + assert_eq!(license.renews_at, None); + assert_eq!( + profile.licenses[1].renews_at.map(|at| at.to_rfc3339()).as_deref(), + Some("2026-11-14T10:00:00+00:00") + ); + assert_eq!(profile.teams[0].role, "admin"); + assert!(profile.teams[0].has_seat); + } + + #[test] + fn a_code_this_build_does_not_know_reads_as_sent() { + let profile = me(r#"{ + "account": { "id": "acct_01", "email": "ada@m17.example" }, + "licenses": [{ + "id": "lic_01", "plan": "enterprise", "cycle": "quarterly", "source": "reseller", "status": "paused" + }], + "teams": [{ "team": { "id": "team_01", "name": "Acme" }, "role": "billing", "has_seat": false }] + }"#); + + let license = &profile.licenses[0]; + assert_eq!( + [ + &license.plan, + &license.cycle, + &license.source, + &license.status, + &profile.teams[0].role + ], + ["enterprise", "quarterly", "reseller", "paused", "billing"] + ); + } + + #[test] + fn a_blank_name_reads_as_none() { + let profile = me(r#"{ + "account": { "id": "acct_01", "email": "ada@m17.example", "name": " " }, + "licenses": [], "teams": [] + }"#); + + assert_eq!(profile.name, None); + } + + #[test] + fn an_offline_file_holds_the_device_until_it_expires() { + let now = Utc::now(); + let device = Device { + id: "dev_01".to_owned(), + machine_id: "0".repeat(64), + platform: "linux".to_owned(), + name: "Lab".to_owned(), + app_version: "0.1.0".to_owned(), + os_version: "Ubuntu 26.04".to_owned(), + last_seen_at: now, + licensed_at: Some(now), + offline_until: Some(now + TimeDelta::days(30)), + }; + + assert!(device.offline_file_active(now)); + assert!(!device.offline_file_active(now + TimeDelta::days(31))); + assert!( + !Device { + offline_until: None, + ..device + } + .offline_file_active(now) + ); + } +} diff --git a/core/crates/account/src/token_vault.rs b/core/crates/account/src/token_vault.rs new file mode 100644 index 0000000000..df12d2cb8c --- /dev/null +++ b/core/crates/account/src/token_vault.rs @@ -0,0 +1,13 @@ +use async_trait::async_trait; +use secrecy::SecretString; +use tablepro_core::credentials::SecretError; + +// Each app keeps the refresh token in its platform's secret store. +#[async_trait] +pub trait RefreshTokenVault: Send + Sync { + async fn load_refresh_token(&self) -> Result, SecretError>; + + async fn store_refresh_token(&self, token: &SecretString) -> Result<(), SecretError>; + + async fn delete_refresh_token(&self) -> Result<(), SecretError>; +} diff --git a/core/crates/account/tests/browser/portal.cjs b/core/crates/account/tests/browser/portal.cjs new file mode 100644 index 0000000000..0965a4cfa2 --- /dev/null +++ b/core/crates/account/tests/browser/portal.cjs @@ -0,0 +1,210 @@ +'use strict'; + +// The browser steps of tests/cloud_stack.rs. CommonJS: NODE_PATH reaches require() only. + +const fs = require('node:fs'); +const puppeteer = require('puppeteer'); +const { Client } = require('pg'); + +function env(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is not set`); + } + return value; +} + +const accountURL = env('TABLEPRO_CLOUD_ACCOUNT_URL'); +const mailURL = env('TABLEPRO_CLOUD_MAIL_URL'); +const databaseURL = env('TABLEPRO_CLOUD_DATABASE_URL'); +const chrome = env('TABLEPRO_CLOUD_CHROME'); +const email = env('TABLEPRO_CLOUD_EMAIL'); +const machineName = env('TABLEPRO_CLOUD_MACHINE_NAME'); +const profile = env('TABLEPRO_CLOUD_PROFILE'); +const grant = process.env.TABLEPRO_CLOUD_GRANT ?? ''; +// Licensed devices to give the account before the app asks, the first with an offline file. +const seedDevices = Number(process.env.TABLEPRO_CLOUD_SEED_DEVICES ?? '0'); +// Where the redirect to the app goes instead of to the app, for an app the browser cannot reach. +const callbackFile = process.env.TABLEPRO_CLOUD_CALLBACK_FILE ?? ''; +const [step, authorizeURL] = process.argv.slice(2); +const signInSubject = /^Sign in to TablePro$/; + +async function signInEmails() { + const response = await fetch(`${mailURL}/_e2e/emails?to=${encodeURIComponent(email)}`); + if (!response.ok) { + throw new Error(`the fake Resend answered ${response.status}`); + } + return (await response.json()).filter((sent) => signInSubject.test(sent.subject)); +} + +async function codeAfter(seen) { + const deadline = Date.now() + 20_000; + while (Date.now() < deadline) { + const sent = (await signInEmails())[seen]; + const code = sent && /^\s*(\d{6})\s*$/m.exec(sent.text)?.[1]; + if (code) { + return code; + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`no sign-in code reached ${email}`); +} + +// The stack sends 5 sign-in emails an hour per address and 20 per client IP. +async function hasSession(page) { + await page.goto(`${accountURL}/`); + await page.waitForFunction( + () => + location.pathname.startsWith('/sign-in') || + [...document.querySelectorAll('button')].some( + (button) => (button.getAttribute('aria-label') ?? button.textContent).trim() === 'Sign out', + ), + ); + return !new URL(page.url()).pathname.startsWith('/sign-in'); +} + +async function signInToPortal(page) { + if (await hasSession(page)) { + return; + } + const seen = (await signInEmails()).length; + await page.goto(`${accountURL}/sign-in`); + await page.locator('::-p-aria(Email)').fill(email); + await page.locator('::-p-aria([name="Continue with email"][role="button"])').click(); + await page.locator('::-p-aria([name="Check your email"][role="heading"])').wait(); + await page.locator('::-p-aria(6-digit code)').fill(await codeAfter(seen)); + await page.locator('::-p-aria([name="Sign in"][role="button"])').click(); + await page.waitForFunction(() => location.pathname === '/sign-in/passkey' || !location.pathname.startsWith('/sign-in')); + if (new URL(page.url()).pathname === '/sign-in/passkey') { + await page.locator('::-p-aria([name="Skip"][role="button"])').click(); + } + await page.locator('::-p-aria([name="Sign out"][role="button"])').wait(); +} + +// The rows an admin grant writes, as tablepro-cloud's web/e2e/src/seed.ts does. +async function grantStarter() { + const client = new Client({ connectionString: databaseURL }); + await client.connect(); + try { + const product = await client.query( + "select id from products where plan = 'starter' and not archived order by cycle = 'lifetime' desc, polar_created_at desc limit 1", + ); + const account = await client.query('select id from accounts where email = $1', [email]); + if (product.rows.length === 0 || account.rows.length === 0) { + throw new Error(`no Starter product or no account for ${email}`); + } + await client.query( + "insert into licenses (account_id, product_id, source, starts_at, note) values ($1, $2, 'manual', now(), 'tablepro-account stack test')", + [account.rows[0].id, product.rows[0].id], + ); + } finally { + await client.end(); + } +} + +// Seeded as a machine on another account's license would be: the rows POST /v2/devices and an offline file write. +async function seedLicensedDevices() { + const client = new Client({ connectionString: databaseURL }); + await client.connect(); + try { + const account = await client.query('select id from accounts where email = $1', [email]); + for (let index = 0; index < seedDevices; index += 1) { + const machineID = [...crypto.getRandomValues(new Uint8Array(32))].map((byte) => byte.toString(16).padStart(2, '0')).join(''); + await client.query( + `insert into devices (account_id, machine_id, platform, name, app_version, os_version, licensed_at, last_seen_at, offline_until) + values ($1, $2, 'linux', $3, '0.1.0', 'Ubuntu 26.04', now() - interval '1 day', now() - interval '2 hours', $4)`, + [account.rows[0].id, machineID, `Seeded ${index + 1}`, index === 0 ? new Date(Date.now() + 30 * 86_400_000) : null], + ); + } + } finally { + await client.end(); + } +} + +async function approve(page) { + const callback = new URL('/oauth/callback', new URL(authorizeURL).searchParams.get('redirect_uri')); + if (callbackFile !== '') { + await page.setRequestInterception(true); + page.on('request', (request) => { + if (request.url().startsWith(callback.href)) { + fs.writeFileSync(callbackFile, request.url()); + request.respond({ status: 200, contentType: 'text/plain', body: 'Back in the app' }); + } else { + request.continue(); + } + }); + } + await page.goto(authorizeURL); + const heading = await page.locator('h1').map((element) => element.textContent).wait(); + if (!heading.includes(machineName)) { + throw new Error(`the confirmation screen does not name the machine: ${heading}`); + } + const landed = page.waitForNavigation({ waitUntil: 'load' }); + await page.locator('::-p-aria([name="Sign in to TablePro for Linux"][role="button"])').click(); + await landed; + if (!page.url().startsWith(callback.origin)) { + throw new Error(`the browser went to ${page.url()} instead of ${callback.origin}`); + } +} + +async function offlineFile(page) { + const [status, body] = await page.evaluate( + async (machineID, name) => { + const response = await fetch('/api/offline-entitlements', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ machine_id: machineID, platform: 'linux', name }), + }); + return [response.status, await response.text()]; + }, + env('TABLEPRO_CLOUD_MACHINE_ID'), + machineName, + ); + if (status !== 200) { + throw new Error(`the portal answered ${status}: ${body}`); + } + fs.writeFileSync(env('TABLEPRO_CLOUD_OUTPUT'), body); +} + +async function removeDevice(page) { + await page.goto(`${accountURL}/devices`); + await page.locator(`::-p-aria([name="More actions for ${machineName}"][role="button"])`).click(); + await page.locator('::-p-aria([name="Remove device…"][role="menuitem"])').click(); + await page.locator('::-p-aria([name="Remove device"][role="button"])').click(); + await page.waitForFunction((name) => document.body.innerText.includes(`Removed ${name}.`), {}, machineName); +} + +async function main() { + const browser = await puppeteer.launch({ + executablePath: chrome, + headless: 'shell', + defaultViewport: { width: 1280, height: 800 }, + userDataDir: profile, + }); + try { + const page = await browser.newPage(); + await signInToPortal(page); + if (step === 'approve') { + if (grant === 'starter') { + await grantStarter(); + } + if (seedDevices > 0) { + await seedLicensedDevices(); + } + await approve(page); + } else if (step === 'remove-device') { + await removeDevice(page); + } else if (step === 'offline-file') { + await offlineFile(page); + } else { + throw new Error(`no step ${step}: approve , remove-device or offline-file`); + } + } finally { + await browser.close(); + } +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/core/crates/account/tests/cloud_stack.rs b/core/crates/account/tests/cloud_stack.rs new file mode 100644 index 0000000000..0502603f04 --- /dev/null +++ b/core/crates/account/tests/cloud_stack.rs @@ -0,0 +1,607 @@ +//! The account against a running tablepro-cloud: linux/docs/testing.md, tablepro-cloud stack. + +#![expect( + clippy::expect_used, + clippy::panic, + reason = "the setup helpers fail a test the way the test functions themselves do" +)] + +use std::path::Path; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use chrono::Utc; +use secrecy::{ExposeSecret, SecretString}; +use tablepro_account::{ + Account, AccountError, AccountSettings, AccountStatus, BrowserLauncher, CallbackPage, DeviceDescription, + EntitlementKeys, EntitlementProblem, FeatureAccess, MachineId, RefreshTokenVault, SignedIn, +}; +use tablepro_core::credentials::SecretError; +use tokio::process::{Child, Command}; +use url::Url; + +const STARTER_FEATURE: &str = "env_var_references"; +const TEAM_FEATURE: &str = "team_library"; + +struct Stack { + account_url: String, + api_url: String, + entitlement_key: String, + email_domain: String, +} + +impl Stack { + fn from_env() -> Self { + let read = + |name: &str| std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set; see linux/docs/testing.md")); + Self { + account_url: read("TABLEPRO_CLOUD_ACCOUNT_URL"), + api_url: read("TABLEPRO_CLOUD_API_URL"), + entitlement_key: read("TABLEPRO_CLOUD_ENTITLEMENT_KEY"), + email_domain: read("TABLEPRO_CLOUD_EMAIL_DOMAIN"), + } + } +} + +struct Machine { + name: String, + machine_id: MachineId, + dir: tempfile::TempDir, +} + +impl Machine { + fn new(label: &str) -> Self { + let machine_id = MachineId::derive(&format!("{}\n", random_hex(16))).expect("a machine ID"); + Self { + name: format!("M17 {label} {}", random_hex(2)), + machine_id, + dir: tempfile::tempdir().expect("a temporary directory"), + } + } + + fn account_file(&self) -> std::path::PathBuf { + self.dir.path().join("account.json") + } + + fn account(&self, stack: &Stack, vault: Arc, browser: Arc) -> Account { + let settings = AccountSettings { + client: tablepro_account::AppClient::LINUX, + endpoints: tablepro_account::Endpoints::new(&stack.account_url, &stack.api_url).expect("the endpoints"), + device: DeviceDescription { + name: self.name.clone(), + app_version: "0.1.0-test".to_owned(), + os_version: "Ubuntu 26.04".to_owned(), + }, + machine_id: self.machine_id.clone(), + keys: EntitlementKeys::from_public_keys([stack.entitlement_key.as_str()]).expect("the stack's key"), + account_file: self.account_file(), + callback_page: CallbackPage { + finished: "You can close this tab.".to_owned(), + unknown: "This is not TablePro's sign-in.".to_owned(), + }, + }; + let status = tokio::sync::watch::Sender::new(AccountStatus::SignedOut); + Account::new(settings, vault, browser, status).expect("an account") + } +} + +// Started and left running, as a desktop hands the URL to a browser. +struct Browser { + user: Arc, + machine_name: String, + grant: bool, + seed_devices: usize, + running: Mutex>, +} + +// The profile keeps the portal session: the stack sends 5 sign-in emails an hour per address. +struct User { + email: String, + profile: tempfile::TempDir, +} + +impl User { + fn new(stack: &Stack, label: &str) -> Arc { + Arc::new(Self { + email: format!("linux-{label}-{}@{}", random_hex(4), stack.email_domain), + profile: tempfile::tempdir().expect("a browser profile"), + }) + } +} + +impl Browser { + fn new(user: &Arc, machine: &Machine, grant: bool) -> Arc { + Arc::new(Self { + user: user.clone(), + machine_name: machine.name.clone(), + grant, + seed_devices: 0, + running: Mutex::new(None), + }) + } + + // The account holds the license on `count` other devices before this one asks. + fn over_the_limit(user: &Arc, machine: &Machine, count: usize) -> Arc { + Arc::new(Self { + user: user.clone(), + machine_name: machine.name.clone(), + grant: true, + seed_devices: count, + running: Mutex::new(None), + }) + } + + fn exited(&self) -> Option { + let mut running = self.running.lock().expect("the browser slot"); + running.as_mut()?.try_wait().expect("the browser's state") + } + + fn portal(&self, step: &str) -> Command { + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/browser/portal.cjs"); + let mut command = Command::new("node"); + command + .arg(script) + .arg(step) + .env("TABLEPRO_CLOUD_EMAIL", &self.user.email) + .env("TABLEPRO_CLOUD_PROFILE", self.user.profile.path()) + .env("TABLEPRO_CLOUD_MACHINE_NAME", &self.machine_name) + .env("TABLEPRO_CLOUD_GRANT", if self.grant { "starter" } else { "" }) + .env("TABLEPRO_CLOUD_SEED_DEVICES", self.seed_devices.to_string()) + .kill_on_drop(true); + command + } + + // The portal makes the file the way its Devices page does, for a machine that never signs in. + async fn offline_file(&self, machine: &Machine) -> String { + let output = machine.dir.path().join("tablepro-offline.jwt"); + let status = self + .portal("offline-file") + .env("TABLEPRO_CLOUD_MACHINE_ID", machine.machine_id.as_str()) + .env("TABLEPRO_CLOUD_MACHINE_NAME", &machine.name) + .env("TABLEPRO_CLOUD_OUTPUT", &output) + .status() + .await + .expect("the browser ran"); + assert!(status.success(), "the portal made no offline file: {status}"); + std::fs::read_to_string(output).expect("the offline file") + } + + async fn remove_device(&self) { + let status = self.portal("remove-device").status().await.expect("the browser ran"); + assert!(status.success(), "the device was not removed in the portal: {status}"); + } + + async fn finished(&self) { + let child = self.running.lock().expect("the browser slot").take(); + let status = child + .expect("the browser ran") + .wait() + .await + .expect("the browser's exit"); + assert!(status.success(), "the browser step failed: {status}"); + } +} + +#[async_trait] +impl BrowserLauncher for Browser { + async fn open(&self, url: &Url) -> Result<(), String> { + let child = self + .portal("approve") + .arg(url.as_str()) + .spawn() + .map_err(|error| error.to_string())?; + *self.running.lock().map_err(|error| error.to_string())? = Some(child); + Ok(()) + } +} + +// Lets a test read the token the account holds. +#[derive(Default)] +struct MemoryKeyring(Mutex>); + +#[async_trait] +impl RefreshTokenVault for MemoryKeyring { + async fn load_refresh_token(&self) -> Result, SecretError> { + Ok(self.0.lock().expect("the keyring").clone()) + } + + async fn store_refresh_token(&self, token: &SecretString) -> Result<(), SecretError> { + *self.0.lock().expect("the keyring") = Some(token.clone()); + Ok(()) + } + + async fn delete_refresh_token(&self) -> Result<(), SecretError> { + *self.0.lock().expect("the keyring") = None; + Ok(()) + } +} + +#[derive(Default)] +struct CountingKeyring { + held: MemoryKeyring, + opened: std::sync::atomic::AtomicUsize, +} + +#[async_trait] +impl RefreshTokenVault for CountingKeyring { + async fn load_refresh_token(&self) -> Result, SecretError> { + self.opened.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.held.load_refresh_token().await + } + + async fn store_refresh_token(&self, token: &SecretString) -> Result<(), SecretError> { + self.opened.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.held.store_refresh_token(token).await + } + + async fn delete_refresh_token(&self) -> Result<(), SecretError> { + self.opened.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + self.held.delete_refresh_token().await + } +} + +struct NoKeyring; + +#[async_trait] +impl RefreshTokenVault for NoKeyring { + async fn load_refresh_token(&self) -> Result, SecretError> { + Err(unavailable()) + } + + async fn store_refresh_token(&self, _: &SecretString) -> Result<(), SecretError> { + Err(unavailable()) + } + + async fn delete_refresh_token(&self) -> Result<(), SecretError> { + Err(unavailable()) + } +} + +fn unavailable() -> SecretError { + SecretError::ServiceUnavailable { + detail: "org.freedesktop.secrets was not provided by any .service files".to_owned(), + } +} + +fn random_hex(bytes: usize) -> String { + let mut buffer = vec![0u8; bytes]; + getrandom::fill(&mut buffer).expect("random bytes"); + buffer.iter().map(|byte| format!("{byte:02x}")).collect() +} + +async fn stored_token(keyring: &MemoryKeyring) -> SecretString { + keyring + .load_refresh_token() + .await + .expect("the keyring") + .expect("a token") +} + +// The token endpoint's answer to a refresh with `token`, outside the account. +async fn refresh_answer(stack: &Stack, token: &SecretString) -> String { + let answer = reqwest::Client::new() + .post(format!("{}/oauth/token", stack.account_url)) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", tablepro_account::AppClient::LINUX.client_id), + ("refresh_token", token.expose_secret()), + ]) + .send() + .await + .expect("an answer"); + answer.text().await.expect("the body") +} + +fn signed_in(account: &Account) -> SignedIn { + match &*account.status().borrow() { + AccountStatus::SignedIn(signed_in) => (**signed_in).clone(), + other => panic!("the account is not signed in: {other:?}"), + } +} + +// Fails when the browser step does, not 10 minutes later when the sign-in times out. +async fn sign_in(account: &Account, browser: &Browser) -> Result<(), AccountError> { + let signing_in = account.sign_in(std::future::pending()); + tokio::pin!(signing_in); + loop { + tokio::select! { + result = &mut signing_in => { + browser.finished().await; + return result; + } + () = tokio::time::sleep(std::time::Duration::from_millis(250)) => { + if let Some(status) = browser.exited().filter(|status| !status.success()) { + panic!("the browser step failed: {status}"); + } + } + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires a tablepro-cloud stack"] +async fn a_licensed_machine_signs_in_keeps_its_entitlement_across_a_restart_and_signs_out() { + let stack = Stack::from_env(); + let user = User::new(&stack, "starter"); + let machine = Machine::new("Linux"); + let keyring = Arc::new(MemoryKeyring::default()); + let browser = Browser::new(&user, &machine, true); + let account = machine.account(&stack, keyring.clone(), browser.clone()); + + account.restore().await; + assert!(matches!(*account.status().borrow(), AccountStatus::SignedOut)); + sign_in(&account, &browser).await.expect("signed in"); + + let first = signed_in(&account); + let entitlement = first.entitlement.clone().expect("an entitlement"); + assert!(first.remembered); + assert!(first.problem.is_none(), "{:?}", first.problem); + assert_eq!(entitlement.claims().mid, machine.machine_id.as_str()); + assert_eq!(entitlement.claims().account.email, user.email); + assert!(entitlement.claims().dev.starts_with("dev_")); + let status = account.status().borrow().clone(); + assert_eq!( + status.feature_access(STARTER_FEATURE, Utc::now()), + FeatureAccess::Available + ); + assert_eq!( + status.feature_access(TEAM_FEATURE, Utc::now()), + FeatureAccess::NotIncluded + ); + let kept = std::fs::read_to_string(machine.account_file()).expect("the account file"); + assert!(kept.contains(&entitlement.claims().dev) && kept.contains(entitlement.token())); + + let restarted = machine.account(&stack, keyring.clone(), Browser::new(&user, &machine, false)); + restarted.restore().await; + assert_eq!(signed_in(&restarted).entitlement.as_ref(), Some(&entitlement)); + restarted.refresh_entitlement().await.expect("a refreshed entitlement"); + let refreshed = signed_in(&restarted).entitlement.expect("an entitlement"); + assert_ne!(refreshed.claims().jti, entitlement.claims().jti); + assert_eq!(refreshed.claims().dev, entitlement.claims().dev); + + let token = stored_token(&keyring).await; + restarted.sign_out().await.expect("signed out and revoked"); + assert!(matches!(*restarted.status().borrow(), AccountStatus::SignedOut)); + assert!(keyring.load_refresh_token().await.expect("the keyring").is_none()); + assert!(!machine.account_file().exists()); + let answer = refresh_answer(&stack, &token).await; + assert!(answer.contains("invalid_grant"), "{answer}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires a tablepro-cloud stack"] +async fn a_second_sign_in_revokes_the_first_one() { + let stack = Stack::from_env(); + let user = User::new(&stack, "again"); + let machine = Machine::new("Linux"); + let keyring = Arc::new(MemoryKeyring::default()); + let browser = Browser::new(&user, &machine, false); + let account = machine.account(&stack, keyring.clone(), browser.clone()); + sign_in(&account, &browser).await.expect("signed in"); + let first = stored_token(&keyring).await; + + sign_in(&account, &browser).await.expect("signed in again"); + + let second = stored_token(&keyring).await; + assert_ne!(first.expose_secret(), second.expose_secret()); + let answer = refresh_answer(&stack, &first).await; + assert!(answer.contains("invalid_grant"), "{answer}"); + account + .refresh_entitlement() + .await + .expect("a refresh on the second sign-in"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires a tablepro-cloud stack"] +async fn a_device_removed_in_the_portal_signs_the_account_out_at_its_next_refresh() { + let stack = Stack::from_env(); + let user = User::new(&stack, "removed"); + let machine = Machine::new("Linux"); + let keyring = Arc::new(MemoryKeyring::default()); + let browser = Browser::new(&user, &machine, true); + let account = machine.account(&stack, keyring.clone(), browser.clone()); + sign_in(&account, &browser).await.expect("signed in"); + assert!(machine.account_file().exists()); + + browser.remove_device().await; + let refreshed = account.refresh_entitlement().await; + + assert!(matches!(refreshed, Err(AccountError::DeviceRevoked)), "{refreshed:?}"); + assert!(matches!(*account.status().borrow(), AccountStatus::SignedOut)); + assert!(keyring.load_refresh_token().await.expect("the keyring").is_none()); + assert!(!machine.account_file().exists()); + let again = account.refresh_entitlement().await; + assert!(matches!(again, Err(AccountError::SignedOut)), "{again:?}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires a tablepro-cloud stack"] +async fn without_a_keyring_a_free_account_signs_in_until_the_app_quits() { + let stack = Stack::from_env(); + let user = User::new(&stack, "free"); + let machine = Machine::new("Linux"); + let browser = Browser::new(&user, &machine, false); + let account = machine.account(&stack, Arc::new(NoKeyring), browser.clone()); + + sign_in(&account, &browser).await.expect("signed in"); + + let held = signed_in(&account); + assert!(!held.remembered); + let entitlement = held.entitlement.expect("an entitlement"); + assert!( + entitlement.claims().features.is_empty(), + "{:?}", + entitlement.claims().features + ); + let status = account.status().borrow().clone(); + assert_eq!( + status.feature_access(STARTER_FEATURE, Utc::now()), + FeatureAccess::NotIncluded + ); + account + .refresh_entitlement() + .await + .expect("a refresh with the token held in memory"); + + let next_launch = machine.account(&stack, Arc::new(NoKeyring), Browser::new(&user, &machine, false)); + next_launch.restore().await; + assert!(matches!(*next_launch.status().borrow(), AccountStatus::SignedOut)); + assert_eq!( + next_launch + .status() + .borrow() + .feature_access(STARTER_FEATURE, Utc::now()), + FeatureAccess::SignedOut + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires a tablepro-cloud stack"] +async fn a_fourth_machine_is_told_the_device_limit_with_the_three_that_hold_the_license() { + let stack = Stack::from_env(); + let user = User::new(&stack, "limit"); + let mut holders = Vec::new(); + for (index, label) in ["One", "Two", "Three"].into_iter().enumerate() { + let machine = Machine::new(label); + let browser = Browser::new(&user, &machine, index == 0); + let account = machine.account(&stack, Arc::new(MemoryKeyring::default()), browser.clone()); + sign_in(&account, &browser).await.expect("signed in"); + assert!(signed_in(&account).entitlement.is_some()); + holders.push(machine.name.clone()); + } + let fourth = Machine::new("Four"); + let browser = Browser::new(&user, &fourth, false); + let account = fourth.account(&stack, Arc::new(MemoryKeyring::default()), browser.clone()); + + let refused = sign_in(&account, &browser).await; + + let Err(AccountError::DeviceLimitReached { devices }) = refused else { + panic!("not the device limit: {refused:?}"); + }; + let names: Vec = devices.into_iter().map(|device| device.name).collect(); + assert_eq!(names, holders); + let held = signed_in(&account); + assert!(held.entitlement.is_none()); + assert!(matches!(held.problem, Some(EntitlementProblem::DeviceLimitReached(ref listed)) if listed.len() == 3)); + assert_eq!( + account.status().borrow().feature_access(STARTER_FEATURE, Utc::now()), + FeatureAccess::DeviceLimitReached + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires a tablepro-cloud stack"] +async fn the_profile_and_devices_name_the_account_and_an_offline_file_unlocks_a_machine_that_never_signed_in() { + let stack = Stack::from_env(); + let user = User::new(&stack, "profile"); + let machine = Machine::new("Linux"); + let browser = Browser::new(&user, &machine, true); + let account = machine.account(&stack, Arc::new(MemoryKeyring::default()), browser.clone()); + sign_in(&account, &browser).await.expect("signed in"); + + let profile = account.profile().await.expect("the profile"); + let devices = account.devices().await.expect("the devices"); + + assert_eq!(profile.email, user.email); + assert!(profile.id.starts_with("acct_"), "{}", profile.id); + assert_eq!(profile.licenses.len(), 1); + assert_eq!( + (profile.licenses[0].plan.as_str(), profile.licenses[0].status.as_str()), + ("starter", "active") + ); + assert!(profile.teams.is_empty()); + assert_eq!( + signed_in(&account).entitlement.map(|it| it.claims().sub.clone()), + Some(profile.id) + ); + let here = devices + .iter() + .find(|device| device.machine_id == machine.machine_id.as_str()) + .expect("this machine among the devices"); + assert_eq!(here.name, machine.name); + assert_eq!(here.platform, "linux"); + assert!(here.licensed_at.is_some()); + + let offline = Machine::new("Offline"); + let token = browser.offline_file(&offline).await; + let keyring = Arc::new(CountingKeyring::default()); + let never_online = offline.account(&stack, keyring.clone(), Browser::new(&user, &offline, false)); + never_online.restore().await; + never_online.import_offline_file(&token).await.expect("imported"); + + let AccountStatus::Offline(entitlement) = never_online.status().borrow().clone() else { + panic!("not on the offline file"); + }; + assert!(entitlement.claims().offline); + assert_eq!(entitlement.claims().mid, offline.machine_id.as_str()); + assert_eq!( + never_online + .status() + .borrow() + .feature_access(STARTER_FEATURE, Utc::now()), + FeatureAccess::Available + ); + let restarted = offline.account(&stack, keyring.clone(), Browser::new(&user, &offline, false)); + restarted.restore().await; + assert!(matches!(*restarted.status().borrow(), AccountStatus::Offline(_))); + assert_eq!(keyring.opened.load(std::sync::atomic::Ordering::SeqCst), 0); + let elsewhere = machine.account( + &stack, + Arc::new(MemoryKeyring::default()), + Browser::new(&user, &machine, false), + ); + let refused = elsewhere.import_offline_file(&token).await; + assert!( + matches!( + refused, + Err(AccountError::Entitlement(tablepro_account::EntitlementError::Machine)) + ), + "{refused:?}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires a tablepro-cloud stack"] +async fn over_the_device_limit_a_removal_moves_the_license_here_and_an_offline_device_stays() { + let stack = Stack::from_env(); + let user = User::new(&stack, "limit"); + let machine = Machine::new("Linux"); + let browser = Browser::over_the_limit(&user, &machine, 3); + let account = machine.account(&stack, Arc::new(MemoryKeyring::default()), browser.clone()); + + let signed = sign_in(&account, &browser).await; + + let Err(AccountError::DeviceLimitReached { devices: holding }) = signed else { + panic!("not over the limit: {signed:?}"); + }; + assert_eq!(holding.len(), 3); + let held = signed_in(&account); + assert!(held.entitlement.is_none()); + assert!(matches!(held.problem, Some(EntitlementProblem::DeviceLimitReached(_)))); + let kept = holding + .iter() + .find(|device| device.offline_file_active(Utc::now())) + .expect("device_limit_reached names the device with an offline file and its offline_until"); + + let refused = account.remove_device(&kept.id).await; + let other = holding + .iter() + .find(|device| device.id != kept.id) + .expect("another holder"); + let removed = account.remove_device(&other.id).await; + + assert!( + matches!(&refused, Err(AccountError::Refused { status: 409, code, .. }) if code == "offline_file_active"), + "{refused:?}" + ); + removed.expect("removed, and the license moved here"); + let moved = signed_in(&account); + assert!(moved.problem.is_none(), "{:?}", moved.problem); + assert_eq!( + account.status().borrow().feature_access(STARTER_FEATURE, Utc::now()), + FeatureAccess::Available + ); + let after = account.devices().await.expect("the devices"); + assert!(after.iter().all(|device| device.id != other.id)); +} diff --git a/core/crates/account/tests/data/entitlement_vectors.json b/core/crates/account/tests/data/entitlement_vectors.json new file mode 100644 index 0000000000..e78f793f44 --- /dev/null +++ b/core/crates/account/tests/data/entitlement_vectors.json @@ -0,0 +1,266 @@ +{ + "description": "Entitlement tokens signed with the RFC 8037 appendix A.1 key. Check each token at now (Unix seconds) for machine_id, with nbf allowed up to 300 seconds ahead: every valid token passes and decodes to its claims; every invalid one fails the check it names.", + "key": { + "kid": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + }, + "machine_id": "e4338b8603de2523305fdfcb87319be2033974658ad97f0c975834d209715658", + "now": 1792922400, + "valid": [ + { + "name": "lifetime license and a team seat that ends in 6 days", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImtQcktfcW14VldhWVZBOXd3QkY2SXVvM3ZWeno3VHhIQ1R3WEJ5Z3JTNGsiLCJ0eXAiOiJ0YWJsZXByby1lbnRpdGxlbWVudCtqd3QifQ.eyJpc3MiOiJodHRwczovL2FwaS50YWJsZXByby5hcHAiLCJhdWQiOiJ0YWJsZXByby1hcHAiLCJzdWIiOiJhY2N0XzAxazhkNndrMDBmOHhzcjc5ZTd4MDUycmtrIiwianRpIjoiMDE5YTFhNmUtOWU1MC03ZjgwLTgxNjItMDAwMDAwMDAwMDAxIiwiaWF0IjoxNzkyOTE4ODAwLCJuYmYiOjE3OTI5MTg4MDAsImV4cCI6MTc5MzQ0MDgwMCwiZGV2IjoiZGV2XzAxazhkNndwZ2dmZDY4dGJoejgxOHA0d3c0IiwibWlkIjoiZTQzMzhiODYwM2RlMjUyMzMwNWZkZmNiODczMTliZTIwMzM5NzQ2NThhZDk3ZjBjOTc1ODM0ZDIwOTcxNTY1OCIsImZlYXR1cmVzIjpbImVuY3J5cHRlZF9leHBvcnQiLCJpY2xvdWRfc3luYyIsInF1ZXJ5X2luc2lnaHRzIiwidGVhbV9jYXRhbG9nIiwidGVhbV9saWJyYXJ5Il0sImxpY2Vuc2VzIjpbeyJpZCI6ImxpY18wMWs4ZDZ3dHMwZmhlc3dmdDBhNWg3NzE0biIsInBsYW4iOiJzdGFydGVyIiwiY3ljbGUiOiJsaWZldGltZSIsInNvdXJjZSI6Im9yZGVyIiwic3RhdHVzIjoiYWN0aXZlIiwicmVuZXdzX2F0IjpudWxsLCJlbmRzX2F0IjpudWxsLCJ0ZWFtIjpudWxsfSx7ImlkIjoibGljXzAxazhkNnd6MWdmbnE4eWcyaGM5c3I5NWQ2IiwicGxhbiI6InRlYW0iLCJjeWNsZSI6Im1vbnRobHkiLCJzb3VyY2UiOiJzdWJzY3JpcHRpb24iLCJzdGF0dXMiOiJjYW5jZWxpbmciLCJyZW5ld3NfYXQiOm51bGwsImVuZHNfYXQiOiIyMDI2LTEwLTMxVDEwOjAwOjAwWiIsInRlYW0iOnsiaWQiOiJ0ZWFtXzAxazhkNngzYTBmc3pzMG1iMmVlMjliOW5xIiwibmFtZSI6IkFjbWUgSW5jLiJ9fV0sImFjY291bnQiOnsiZW1haWwiOiJkYXRAZXhhbXBsZS5jb20iLCJuYW1lIjoiTmfDtCBRdeG7kWMgxJDhuqF0In0sIm9mZmxpbmUiOmZhbHNlfQ.OF09XCbqvpO5j1KEGRvLyyixH5EWjTqq3AKEK_ei8h1bZU3CnYFRirTSeOpJrffW7fWVHYn06GhJRMwDSJCfCA", + "header": { + "alg": "EdDSA", + "kid": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", + "typ": "tablepro-entitlement+jwt" + }, + "claims": { + "iss": "https://api.tablepro.app", + "aud": "tablepro-app", + "sub": "acct_01k8d6wk00f8xsr79e7x052rkk", + "jti": "019a1a6e-9e50-7f80-8162-000000000001", + "iat": 1792918800, + "nbf": 1792918800, + "exp": 1793440800, + "dev": "dev_01k8d6wpggfd68tbhz818p4ww4", + "mid": "e4338b8603de2523305fdfcb87319be2033974658ad97f0c975834d209715658", + "features": [ + "encrypted_export", + "icloud_sync", + "query_insights", + "team_catalog", + "team_library" + ], + "licenses": [ + { + "id": "lic_01k8d6wts0fheswft0a5h7714n", + "plan": "starter", + "cycle": "lifetime", + "source": "order", + "status": "active", + "renews_at": null, + "ends_at": null, + "team": null + }, + { + "id": "lic_01k8d6wz1gfnq8yg2hc9sr95d6", + "plan": "team", + "cycle": "monthly", + "source": "subscription", + "status": "canceling", + "renews_at": null, + "ends_at": "2026-10-31T10:00:00Z", + "team": { + "id": "team_01k8d6x3a0fszs0mb2ee29b9nq", + "name": "Acme Inc." + } + } + ], + "account": { + "email": "dat@example.com", + "name": "Ngô Quốc Đạt" + }, + "offline": false + } + }, + { + "name": "no license", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImtQcktfcW14VldhWVZBOXd3QkY2SXVvM3ZWeno3VHhIQ1R3WEJ5Z3JTNGsiLCJ0eXAiOiJ0YWJsZXByby1lbnRpdGxlbWVudCtqd3QifQ.eyJpc3MiOiJodHRwczovL2FwaS50YWJsZXByby5hcHAiLCJhdWQiOiJ0YWJsZXByby1hcHAiLCJzdWIiOiJhY2N0XzAxazhkNndrMDBmOHhzcjc5ZTd4MDUycmtrIiwianRpIjoiMDE5YTFhNmUtOWU1MC03ZjgwLTgxNjItMDAwMDAwMDAwMDAyIiwiaWF0IjoxNzkyOTIyNDAwLCJuYmYiOjE3OTI5MjI0MDAsImV4cCI6MTc5NDEzMjAwMCwiZGV2IjoiZGV2XzAxazhkNndwZ2dmZDY4dGJoejgxOHA0d3c0IiwibWlkIjoiZTQzMzhiODYwM2RlMjUyMzMwNWZkZmNiODczMTliZTIwMzM5NzQ2NThhZDk3ZjBjOTc1ODM0ZDIwOTcxNTY1OCIsImZlYXR1cmVzIjpbXSwibGljZW5zZXMiOltdLCJhY2NvdW50Ijp7ImVtYWlsIjoiZGF0QGV4YW1wbGUuY29tIiwibmFtZSI6Ik5nw7QgUXXhu5FjIMSQ4bqhdCJ9LCJvZmZsaW5lIjpmYWxzZX0.jFgg27XlFGdglFq1UTrIlSRDjcRFnw7yr7HeV7HPJAIkaJ0cXo_DFe3QyuYtiqEqu9l--0dyo89A4HXSxw8vAg", + "header": { + "alg": "EdDSA", + "kid": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", + "typ": "tablepro-entitlement+jwt" + }, + "claims": { + "iss": "https://api.tablepro.app", + "aud": "tablepro-app", + "sub": "acct_01k8d6wk00f8xsr79e7x052rkk", + "jti": "019a1a6e-9e50-7f80-8162-000000000002", + "iat": 1792922400, + "nbf": 1792922400, + "exp": 1794132000, + "dev": "dev_01k8d6wpggfd68tbhz818p4ww4", + "mid": "e4338b8603de2523305fdfcb87319be2033974658ad97f0c975834d209715658", + "features": [], + "licenses": [], + "account": { + "email": "dat@example.com", + "name": "Ngô Quốc Đạt" + }, + "offline": false + } + }, + { + "name": "offline file for a lifetime license", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImtQcktfcW14VldhWVZBOXd3QkY2SXVvM3ZWeno3VHhIQ1R3WEJ5Z3JTNGsiLCJ0eXAiOiJ0YWJsZXByby1lbnRpdGxlbWVudCtqd3QifQ.eyJpc3MiOiJodHRwczovL2FwaS50YWJsZXByby5hcHAiLCJhdWQiOiJ0YWJsZXByby1hcHAiLCJzdWIiOiJhY2N0XzAxazhkNndrMDBmOHhzcjc5ZTd4MDUycmtrIiwianRpIjoiMDE5YTFhNmUtOWU1MC03ZjgwLTgxNjItMDAwMDAwMDAwMDAzIiwiaWF0IjoxNzkyOTIyNDAwLCJuYmYiOjE3OTI5MjI0MDAsImV4cCI6MTgyNDQ1ODQwMCwiZGV2IjoiZGV2XzAxazhkNndwZ2dmZDY4dGJoejgxOHA0d3c0IiwibWlkIjoiZTQzMzhiODYwM2RlMjUyMzMwNWZkZmNiODczMTliZTIwMzM5NzQ2NThhZDk3ZjBjOTc1ODM0ZDIwOTcxNTY1OCIsImZlYXR1cmVzIjpbImVuY3J5cHRlZF9leHBvcnQiLCJpY2xvdWRfc3luYyIsInF1ZXJ5X2luc2lnaHRzIl0sImxpY2Vuc2VzIjpbeyJpZCI6ImxpY18wMWs4ZDZ3dHMwZmhlc3dmdDBhNWg3NzE0biIsInBsYW4iOiJzdGFydGVyIiwiY3ljbGUiOiJsaWZldGltZSIsInNvdXJjZSI6Im9yZGVyIiwic3RhdHVzIjoiYWN0aXZlIiwicmVuZXdzX2F0IjpudWxsLCJlbmRzX2F0IjpudWxsLCJ0ZWFtIjpudWxsfV0sImFjY291bnQiOnsiZW1haWwiOiJkYXRAZXhhbXBsZS5jb20iLCJuYW1lIjoiTmfDtCBRdeG7kWMgxJDhuqF0In0sIm9mZmxpbmUiOnRydWV9.o3JehoEfSUiXJflmS_ZG6XdzJyzKKfoOOGlKva8XFlC8snnFMoPlT49yG4HQaM8tXXJOSImN93FteKOCM6xTCA", + "header": { + "alg": "EdDSA", + "kid": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", + "typ": "tablepro-entitlement+jwt" + }, + "claims": { + "iss": "https://api.tablepro.app", + "aud": "tablepro-app", + "sub": "acct_01k8d6wk00f8xsr79e7x052rkk", + "jti": "019a1a6e-9e50-7f80-8162-000000000003", + "iat": 1792922400, + "nbf": 1792922400, + "exp": 1824458400, + "dev": "dev_01k8d6wpggfd68tbhz818p4ww4", + "mid": "e4338b8603de2523305fdfcb87319be2033974658ad97f0c975834d209715658", + "features": [ + "encrypted_export", + "icloud_sync", + "query_insights" + ], + "licenses": [ + { + "id": "lic_01k8d6wts0fheswft0a5h7714n", + "plan": "starter", + "cycle": "lifetime", + "source": "order", + "status": "active", + "renews_at": null, + "ends_at": null, + "team": null + } + ], + "account": { + "email": "dat@example.com", + "name": "Ngô Quốc Đạt" + }, + "offline": true + } + }, + { + "name": "nbf 4 minutes ahead, inside the leeway", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImtQcktfcW14VldhWVZBOXd3QkY2SXVvM3ZWeno3VHhIQ1R3WEJ5Z3JTNGsiLCJ0eXAiOiJ0YWJsZXByby1lbnRpdGxlbWVudCtqd3QifQ.eyJpc3MiOiJodHRwczovL2FwaS50YWJsZXByby5hcHAiLCJhdWQiOiJ0YWJsZXByby1hcHAiLCJzdWIiOiJhY2N0XzAxazhkNndrMDBmOHhzcjc5ZTd4MDUycmtrIiwianRpIjoiMDE5YTFhNmUtOWU1MC03ZjgwLTgxNjItMDAwMDAwMDAwMDA0IiwiaWF0IjoxNzkyOTIyNjQwLCJuYmYiOjE3OTI5MjI2NDAsImV4cCI6MTc5NDEzMjI0MCwiZGV2IjoiZGV2XzAxazhkNndwZ2dmZDY4dGJoejgxOHA0d3c0IiwibWlkIjoiZTQzMzhiODYwM2RlMjUyMzMwNWZkZmNiODczMTliZTIwMzM5NzQ2NThhZDk3ZjBjOTc1ODM0ZDIwOTcxNTY1OCIsImZlYXR1cmVzIjpbImVuY3J5cHRlZF9leHBvcnQiLCJpY2xvdWRfc3luYyIsInF1ZXJ5X2luc2lnaHRzIl0sImxpY2Vuc2VzIjpbeyJpZCI6ImxpY18wMWs4ZDZ3dHMwZmhlc3dmdDBhNWg3NzE0biIsInBsYW4iOiJzdGFydGVyIiwiY3ljbGUiOiJsaWZldGltZSIsInNvdXJjZSI6Im9yZGVyIiwic3RhdHVzIjoiYWN0aXZlIiwicmVuZXdzX2F0IjpudWxsLCJlbmRzX2F0IjpudWxsLCJ0ZWFtIjpudWxsfV0sImFjY291bnQiOnsiZW1haWwiOiJkYXRAZXhhbXBsZS5jb20iLCJuYW1lIjoiTmfDtCBRdeG7kWMgxJDhuqF0In0sIm9mZmxpbmUiOmZhbHNlfQ.j-rjKsWqLF94rWXSQuGLRWoiEE3OCOCWaHJ2q3ORDH8qXUDYja1lTSYI8Zk9Malj20ho8NgZBYpy9DVCNQPOCw", + "header": { + "alg": "EdDSA", + "kid": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", + "typ": "tablepro-entitlement+jwt" + }, + "claims": { + "iss": "https://api.tablepro.app", + "aud": "tablepro-app", + "sub": "acct_01k8d6wk00f8xsr79e7x052rkk", + "jti": "019a1a6e-9e50-7f80-8162-000000000004", + "iat": 1792922640, + "nbf": 1792922640, + "exp": 1794132240, + "dev": "dev_01k8d6wpggfd68tbhz818p4ww4", + "mid": "e4338b8603de2523305fdfcb87319be2033974658ad97f0c975834d209715658", + "features": [ + "encrypted_export", + "icloud_sync", + "query_insights" + ], + "licenses": [ + { + "id": "lic_01k8d6wts0fheswft0a5h7714n", + "plan": "starter", + "cycle": "lifetime", + "source": "order", + "status": "active", + "renews_at": null, + "ends_at": null, + "team": null + } + ], + "account": { + "email": "dat@example.com", + "name": "Ngô Quốc Đạt" + }, + "offline": false + } + }, + { + "name": "a monthly subscription that renews in 20 days", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImtQcktfcW14VldhWVZBOXd3QkY2SXVvM3ZWeno3VHhIQ1R3WEJ5Z3JTNGsiLCJ0eXAiOiJ0YWJsZXByby1lbnRpdGxlbWVudCtqd3QifQ.eyJpc3MiOiJodHRwczovL2FwaS50YWJsZXByby5hcHAiLCJhdWQiOiJ0YWJsZXByby1hcHAiLCJzdWIiOiJhY2N0XzAxazhkNndrMDBmOHhzcjc5ZTd4MDUycmtrIiwianRpIjoiMDE5YTFhNmUtOWU1MC03ZjgwLTgxNjItMDAwMDAwMDAwMDA4IiwiaWF0IjoxNzkyOTIyNDAwLCJuYmYiOjE3OTI5MjI0MDAsImV4cCI6MTc5NDEzMjAwMCwiZGV2IjoiZGV2XzAxazhkNndwZ2dmZDY4dGJoejgxOHA0d3c0IiwibWlkIjoiZTQzMzhiODYwM2RlMjUyMzMwNWZkZmNiODczMTliZTIwMzM5NzQ2NThhZDk3ZjBjOTc1ODM0ZDIwOTcxNTY1OCIsImZlYXR1cmVzIjpbImljbG91ZF9zeW5jIl0sImxpY2Vuc2VzIjpbeyJpZCI6ImxpY18wMWs4ZDZ4YnYwZTI4cjR3dzRqcGtiZmo2cyIsInBsYW4iOiJzdGFydGVyIiwiY3ljbGUiOiJtb250aGx5Iiwic291cmNlIjoic3Vic2NyaXB0aW9uIiwic3RhdHVzIjoiYWN0aXZlIiwicmVuZXdzX2F0IjoiMjAyNi0xMS0xNFQxMDowMDowMFoiLCJlbmRzX2F0IjpudWxsLCJ0ZWFtIjpudWxsfV0sImFjY291bnQiOnsiZW1haWwiOiJkYXRAZXhhbXBsZS5jb20iLCJuYW1lIjoiTmfDtCBRdeG7kWMgxJDhuqF0In0sIm9mZmxpbmUiOmZhbHNlfQ.a4Wm-oy9XUQvpMalYJhdYkkddlDho4ULkY-UopyWFgFZ5FtwbjKPQjLhrZOsPMkg2OUYL2zisUVb2iqXS3TmDw", + "header": { + "alg": "EdDSA", + "kid": "kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k", + "typ": "tablepro-entitlement+jwt" + }, + "claims": { + "iss": "https://api.tablepro.app", + "aud": "tablepro-app", + "sub": "acct_01k8d6wk00f8xsr79e7x052rkk", + "jti": "019a1a6e-9e50-7f80-8162-000000000008", + "iat": 1792922400, + "nbf": 1792922400, + "exp": 1794132000, + "dev": "dev_01k8d6wpggfd68tbhz818p4ww4", + "mid": "e4338b8603de2523305fdfcb87319be2033974658ad97f0c975834d209715658", + "features": [ + "icloud_sync" + ], + "licenses": [ + { + "id": "lic_01k8d6xbv0e28r4ww4jpkbfj6s", + "plan": "starter", + "cycle": "monthly", + "source": "subscription", + "status": "active", + "renews_at": "2026-11-14T10:00:00Z", + "ends_at": null, + "team": null + } + ], + "account": { + "email": "dat@example.com", + "name": "Ngô Quốc Đạt" + }, + "offline": false + } + } + ], + "invalid": [ + { + "name": "alg none without a signature", + "check": "alg", + "token": "eyJhbGciOiJub25lIiwia2lkIjoia1ByS19xbXhWV2FZVkE5d3dCRjZJdW8zdlZ6ejdUeEhDVHdYQnlnclM0ayIsInR5cCI6InRhYmxlcHJvLWVudGl0bGVtZW50K2p3dCJ9.eyJpc3MiOiJodHRwczovL2FwaS50YWJsZXByby5hcHAiLCJhdWQiOiJ0YWJsZXByby1hcHAiLCJzdWIiOiJhY2N0XzAxazhkNndrMDBmOHhzcjc5ZTd4MDUycmtrIiwianRpIjoiMDE5YTFhNmUtOWU1MC03ZjgwLTgxNjItMDAwMDAwMDAwMDAxIiwiaWF0IjoxNzkyOTIyNDAwLCJuYmYiOjE3OTI5MjI0MDAsImV4cCI6MTc5NDEzMjAwMCwiZGV2IjoiZGV2XzAxazhkNndwZ2dmZDY4dGJoejgxOHA0d3c0IiwibWlkIjoiZTQzMzhiODYwM2RlMjUyMzMwNWZkZmNiODczMTliZTIwMzM5NzQ2NThhZDk3ZjBjOTc1ODM0ZDIwOTcxNTY1OCIsImZlYXR1cmVzIjpbImVuY3J5cHRlZF9leHBvcnQiLCJpY2xvdWRfc3luYyIsInF1ZXJ5X2luc2lnaHRzIl0sImxpY2Vuc2VzIjpbeyJpZCI6ImxpY18wMWs4ZDZ3dHMwZmhlc3dmdDBhNWg3NzE0biIsInBsYW4iOiJzdGFydGVyIiwiY3ljbGUiOiJsaWZldGltZSIsInNvdXJjZSI6Im9yZGVyIiwic3RhdHVzIjoiYWN0aXZlIiwicmVuZXdzX2F0IjpudWxsLCJlbmRzX2F0IjpudWxsLCJ0ZWFtIjpudWxsfV0sImFjY291bnQiOnsiZW1haWwiOiJkYXRAZXhhbXBsZS5jb20iLCJuYW1lIjoiTmfDtCBRdeG7kWMgxJDhuqF0In0sIm9mZmxpbmUiOmZhbHNlfQ." + }, + { + "name": "HS256 keyed with the public key", + "check": "alg", + "token": "eyJhbGciOiJIUzI1NiIsImtpZCI6ImtQcktfcW14VldhWVZBOXd3QkY2SXVvM3ZWeno3VHhIQ1R3WEJ5Z3JTNGsiLCJ0eXAiOiJ0YWJsZXByby1lbnRpdGxlbWVudCtqd3QifQ.eyJpc3MiOiJodHRwczovL2FwaS50YWJsZXByby5hcHAiLCJhdWQiOiJ0YWJsZXByby1hcHAiLCJzdWIiOiJhY2N0XzAxazhkNndrMDBmOHhzcjc5ZTd4MDUycmtrIiwianRpIjoiMDE5YTFhNmUtOWU1MC03ZjgwLTgxNjItMDAwMDAwMDAwMDAxIiwiaWF0IjoxNzkyOTIyNDAwLCJuYmYiOjE3OTI5MjI0MDAsImV4cCI6MTc5NDEzMjAwMCwiZGV2IjoiZGV2XzAxazhkNndwZ2dmZDY4dGJoejgxOHA0d3c0IiwibWlkIjoiZTQzMzhiODYwM2RlMjUyMzMwNWZkZmNiODczMTliZTIwMzM5NzQ2NThhZDk3ZjBjOTc1ODM0ZDIwOTcxNTY1OCIsImZlYXR1cmVzIjpbImVuY3J5cHRlZF9leHBvcnQiLCJpY2xvdWRfc3luYyIsInF1ZXJ5X2luc2lnaHRzIl0sImxpY2Vuc2VzIjpbeyJpZCI6ImxpY18wMWs4ZDZ3dHMwZmhlc3dmdDBhNWg3NzE0biIsInBsYW4iOiJzdGFydGVyIiwiY3ljbGUiOiJsaWZldGltZSIsInNvdXJjZSI6Im9yZGVyIiwic3RhdHVzIjoiYWN0aXZlIiwicmVuZXdzX2F0IjpudWxsLCJlbmRzX2F0IjpudWxsLCJ0ZWFtIjpudWxsfV0sImFjY291bnQiOnsiZW1haWwiOiJkYXRAZXhhbXBsZS5jb20iLCJuYW1lIjoiTmfDtCBRdeG7kWMgxJDhuqF0In0sIm9mZmxpbmUiOmZhbHNlfQ.7iWVoghTVo4Ppc4zjP5QhzVvTNRbwkOe_nufJpvRFaM" + }, + { + "name": "typ JWT", + "check": "typ", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImtQcktfcW14VldhWVZBOXd3QkY2SXVvM3ZWeno3VHhIQ1R3WEJ5Z3JTNGsiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FwaS50YWJsZXByby5hcHAiLCJhdWQiOiJ0YWJsZXByby1hcHAiLCJzdWIiOiJhY2N0XzAxazhkNndrMDBmOHhzcjc5ZTd4MDUycmtrIiwianRpIjoiMDE5YTFhNmUtOWU1MC03ZjgwLTgxNjItMDAwMDAwMDAwMDAxIiwiaWF0IjoxNzkyOTIyNDAwLCJuYmYiOjE3OTI5MjI0MDAsImV4cCI6MTc5NDEzMjAwMCwiZGV2IjoiZGV2XzAxazhkNndwZ2dmZDY4dGJoejgxOHA0d3c0IiwibWlkIjoiZTQzMzhiODYwM2RlMjUyMzMwNWZkZmNiODczMTliZTIwMzM5NzQ2NThhZDk3ZjBjOTc1ODM0ZDIwOTcxNTY1OCIsImZlYXR1cmVzIjpbImVuY3J5cHRlZF9leHBvcnQiLCJpY2xvdWRfc3luYyIsInF1ZXJ5X2luc2lnaHRzIl0sImxpY2Vuc2VzIjpbeyJpZCI6ImxpY18wMWs4ZDZ3dHMwZmhlc3dmdDBhNWg3NzE0biIsInBsYW4iOiJzdGFydGVyIiwiY3ljbGUiOiJsaWZldGltZSIsInNvdXJjZSI6Im9yZGVyIiwic3RhdHVzIjoiYWN0aXZlIiwicmVuZXdzX2F0IjpudWxsLCJlbmRzX2F0IjpudWxsLCJ0ZWFtIjpudWxsfV0sImFjY291bnQiOnsiZW1haWwiOiJkYXRAZXhhbXBsZS5jb20iLCJuYW1lIjoiTmfDtCBRdeG7kWMgxJDhuqF0In0sIm9mZmxpbmUiOmZhbHNlfQ.1UKp8SswXHA8__sIE4mzTgSzogzz51jVJI4sIj9R3G4ygojjplAzGPqulEQOcHarUD3MT4K5nEm9ZJPKNlHVCg" + }, + { + "name": "kid of a key the app does not have", + "check": "kid", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6InJldGlyZWQta2V5IiwidHlwIjoidGFibGVwcm8tZW50aXRsZW1lbnQrand0In0.eyJpc3MiOiJodHRwczovL2FwaS50YWJsZXByby5hcHAiLCJhdWQiOiJ0YWJsZXByby1hcHAiLCJzdWIiOiJhY2N0XzAxazhkNndrMDBmOHhzcjc5ZTd4MDUycmtrIiwianRpIjoiMDE5YTFhNmUtOWU1MC03ZjgwLTgxNjItMDAwMDAwMDAwMDAxIiwiaWF0IjoxNzkyOTIyNDAwLCJuYmYiOjE3OTI5MjI0MDAsImV4cCI6MTc5NDEzMjAwMCwiZGV2IjoiZGV2XzAxazhkNndwZ2dmZDY4dGJoejgxOHA0d3c0IiwibWlkIjoiZTQzMzhiODYwM2RlMjUyMzMwNWZkZmNiODczMTliZTIwMzM5NzQ2NThhZDk3ZjBjOTc1ODM0ZDIwOTcxNTY1OCIsImZlYXR1cmVzIjpbImVuY3J5cHRlZF9leHBvcnQiLCJpY2xvdWRfc3luYyIsInF1ZXJ5X2luc2lnaHRzIl0sImxpY2Vuc2VzIjpbeyJpZCI6ImxpY18wMWs4ZDZ3dHMwZmhlc3dmdDBhNWg3NzE0biIsInBsYW4iOiJzdGFydGVyIiwiY3ljbGUiOiJsaWZldGltZSIsInNvdXJjZSI6Im9yZGVyIiwic3RhdHVzIjoiYWN0aXZlIiwicmVuZXdzX2F0IjpudWxsLCJlbmRzX2F0IjpudWxsLCJ0ZWFtIjpudWxsfV0sImFjY291bnQiOnsiZW1haWwiOiJkYXRAZXhhbXBsZS5jb20iLCJuYW1lIjoiTmfDtCBRdeG7kWMgxJDhuqF0In0sIm9mZmxpbmUiOmZhbHNlfQ.ZspaFO1AorI7Ni571Xt6L6VSWRxFM7mI-IifJzKZN4BL6UNRXveuoND4gurr9R_zZjXzv4paQnhQvvDUhP8VBg" + }, + { + "name": "exp moved a year later after signing", + "check": "signature", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImtQcktfcW14VldhWVZBOXd3QkY2SXVvM3ZWeno3VHhIQ1R3WEJ5Z3JTNGsiLCJ0eXAiOiJ0YWJsZXByby1lbnRpdGxlbWVudCtqd3QifQ.eyJpc3MiOiJodHRwczovL2FwaS50YWJsZXByby5hcHAiLCJhdWQiOiJ0YWJsZXByby1hcHAiLCJzdWIiOiJhY2N0XzAxazhkNndrMDBmOHhzcjc5ZTd4MDUycmtrIiwianRpIjoiMDE5YTFhNmUtOWU1MC03ZjgwLTgxNjItMDAwMDAwMDAwMDAxIiwiaWF0IjoxNzkyOTE4ODAwLCJuYmYiOjE3OTI5MTg4MDAsImV4cCI6MTgyNDk3NjgwMCwiZGV2IjoiZGV2XzAxazhkNndwZ2dmZDY4dGJoejgxOHA0d3c0IiwibWlkIjoiZTQzMzhiODYwM2RlMjUyMzMwNWZkZmNiODczMTliZTIwMzM5NzQ2NThhZDk3ZjBjOTc1ODM0ZDIwOTcxNTY1OCIsImZlYXR1cmVzIjpbImVuY3J5cHRlZF9leHBvcnQiLCJpY2xvdWRfc3luYyIsInF1ZXJ5X2luc2lnaHRzIiwidGVhbV9jYXRhbG9nIiwidGVhbV9saWJyYXJ5Il0sImxpY2Vuc2VzIjpbeyJpZCI6ImxpY18wMWs4ZDZ3dHMwZmhlc3dmdDBhNWg3NzE0biIsInBsYW4iOiJzdGFydGVyIiwiY3ljbGUiOiJsaWZldGltZSIsInNvdXJjZSI6Im9yZGVyIiwic3RhdHVzIjoiYWN0aXZlIiwicmVuZXdzX2F0IjpudWxsLCJlbmRzX2F0IjpudWxsLCJ0ZWFtIjpudWxsfSx7ImlkIjoibGljXzAxazhkNnd6MWdmbnE4eWcyaGM5c3I5NWQ2IiwicGxhbiI6InRlYW0iLCJjeWNsZSI6Im1vbnRobHkiLCJzb3VyY2UiOiJzdWJzY3JpcHRpb24iLCJzdGF0dXMiOiJjYW5jZWxpbmciLCJyZW5ld3NfYXQiOm51bGwsImVuZHNfYXQiOiIyMDI2LTEwLTMxVDEwOjAwOjAwWiIsInRlYW0iOnsiaWQiOiJ0ZWFtXzAxazhkNngzYTBmc3pzMG1iMmVlMjliOW5xIiwibmFtZSI6IkFjbWUgSW5jLiJ9fV0sImFjY291bnQiOnsiZW1haWwiOiJkYXRAZXhhbXBsZS5jb20iLCJuYW1lIjoiTmfDtCBRdeG7kWMgxJDhuqF0In0sIm9mZmxpbmUiOmZhbHNlfQ.OF09XCbqvpO5j1KEGRvLyyixH5EWjTqq3AKEK_ei8h1bZU3CnYFRirTSeOpJrffW7fWVHYn06GhJRMwDSJCfCA" + }, + { + "name": "aud of the OAuth access tokens", + "check": "aud", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImtQcktfcW14VldhWVZBOXd3QkY2SXVvM3ZWeno3VHhIQ1R3WEJ5Z3JTNGsiLCJ0eXAiOiJ0YWJsZXByby1lbnRpdGxlbWVudCtqd3QifQ.eyJpc3MiOiJodHRwczovL2FwaS50YWJsZXByby5hcHAiLCJhdWQiOiJodHRwczovL2FwaS50YWJsZXByby5hcHAiLCJzdWIiOiJhY2N0XzAxazhkNndrMDBmOHhzcjc5ZTd4MDUycmtrIiwianRpIjoiMDE5YTFhNmUtOWU1MC03ZjgwLTgxNjItMDAwMDAwMDAwMDAxIiwiaWF0IjoxNzkyOTIyNDAwLCJuYmYiOjE3OTI5MjI0MDAsImV4cCI6MTc5NDEzMjAwMCwiZGV2IjoiZGV2XzAxazhkNndwZ2dmZDY4dGJoejgxOHA0d3c0IiwibWlkIjoiZTQzMzhiODYwM2RlMjUyMzMwNWZkZmNiODczMTliZTIwMzM5NzQ2NThhZDk3ZjBjOTc1ODM0ZDIwOTcxNTY1OCIsImZlYXR1cmVzIjpbImVuY3J5cHRlZF9leHBvcnQiLCJpY2xvdWRfc3luYyIsInF1ZXJ5X2luc2lnaHRzIl0sImxpY2Vuc2VzIjpbeyJpZCI6ImxpY18wMWs4ZDZ3dHMwZmhlc3dmdDBhNWg3NzE0biIsInBsYW4iOiJzdGFydGVyIiwiY3ljbGUiOiJsaWZldGltZSIsInNvdXJjZSI6Im9yZGVyIiwic3RhdHVzIjoiYWN0aXZlIiwicmVuZXdzX2F0IjpudWxsLCJlbmRzX2F0IjpudWxsLCJ0ZWFtIjpudWxsfV0sImFjY291bnQiOnsiZW1haWwiOiJkYXRAZXhhbXBsZS5jb20iLCJuYW1lIjoiTmfDtCBRdeG7kWMgxJDhuqF0In0sIm9mZmxpbmUiOmZhbHNlfQ.qt-lNmJVXObfwGmDgKH9U2mGFbl90PiYnydWyN-y9g9ZFj-cyeyPq-6w-ETDXs0_G3Dfiu2jKEEiVfOqkt8pAQ" + }, + { + "name": "issued to another machine", + "check": "mid", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImtQcktfcW14VldhWVZBOXd3QkY2SXVvM3ZWeno3VHhIQ1R3WEJ5Z3JTNGsiLCJ0eXAiOiJ0YWJsZXByby1lbnRpdGxlbWVudCtqd3QifQ.eyJpc3MiOiJodHRwczovL2FwaS50YWJsZXByby5hcHAiLCJhdWQiOiJ0YWJsZXByby1hcHAiLCJzdWIiOiJhY2N0XzAxazhkNndrMDBmOHhzcjc5ZTd4MDUycmtrIiwianRpIjoiMDE5YTFhNmUtOWU1MC03ZjgwLTgxNjItMDAwMDAwMDAwMDA1IiwiaWF0IjoxNzkyOTIyNDAwLCJuYmYiOjE3OTI5MjI0MDAsImV4cCI6MTc5NDEzMjAwMCwiZGV2IjoiZGV2XzAxazhkNndwZ2dmZDY4dGJoejgxOHA0d3c0IiwibWlkIjoiNGJjYTM5YjIyOGJmNjZmZjdkN2U3ZTFkMjQyZmNhY2EzOTA3ZWMwMWI1OTZmYWZjMDhiNDEzNDJlOTY1MGI3NCIsImZlYXR1cmVzIjpbImVuY3J5cHRlZF9leHBvcnQiLCJpY2xvdWRfc3luYyIsInF1ZXJ5X2luc2lnaHRzIl0sImxpY2Vuc2VzIjpbeyJpZCI6ImxpY18wMWs4ZDZ3dHMwZmhlc3dmdDBhNWg3NzE0biIsInBsYW4iOiJzdGFydGVyIiwiY3ljbGUiOiJsaWZldGltZSIsInNvdXJjZSI6Im9yZGVyIiwic3RhdHVzIjoiYWN0aXZlIiwicmVuZXdzX2F0IjpudWxsLCJlbmRzX2F0IjpudWxsLCJ0ZWFtIjpudWxsfV0sImFjY291bnQiOnsiZW1haWwiOiJkYXRAZXhhbXBsZS5jb20iLCJuYW1lIjoiTmfDtCBRdeG7kWMgxJDhuqF0In0sIm9mZmxpbmUiOmZhbHNlfQ.Kzo158SowCw0Zk98HfCvxVIWqNhB1NTcg_5KYET2yQlBxrOdd0JCKiigYbxPQZ5D9KRu2M7cWeOUqXlom5JXBg" + }, + { + "name": "nbf 6 minutes ahead", + "check": "nbf", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImtQcktfcW14VldhWVZBOXd3QkY2SXVvM3ZWeno3VHhIQ1R3WEJ5Z3JTNGsiLCJ0eXAiOiJ0YWJsZXByby1lbnRpdGxlbWVudCtqd3QifQ.eyJpc3MiOiJodHRwczovL2FwaS50YWJsZXByby5hcHAiLCJhdWQiOiJ0YWJsZXByby1hcHAiLCJzdWIiOiJhY2N0XzAxazhkNndrMDBmOHhzcjc5ZTd4MDUycmtrIiwianRpIjoiMDE5YTFhNmUtOWU1MC03ZjgwLTgxNjItMDAwMDAwMDAwMDA2IiwiaWF0IjoxNzkyOTIyNzYwLCJuYmYiOjE3OTI5MjI3NjAsImV4cCI6MTc5NDEzMjM2MCwiZGV2IjoiZGV2XzAxazhkNndwZ2dmZDY4dGJoejgxOHA0d3c0IiwibWlkIjoiZTQzMzhiODYwM2RlMjUyMzMwNWZkZmNiODczMTliZTIwMzM5NzQ2NThhZDk3ZjBjOTc1ODM0ZDIwOTcxNTY1OCIsImZlYXR1cmVzIjpbImVuY3J5cHRlZF9leHBvcnQiLCJpY2xvdWRfc3luYyIsInF1ZXJ5X2luc2lnaHRzIl0sImxpY2Vuc2VzIjpbeyJpZCI6ImxpY18wMWs4ZDZ3dHMwZmhlc3dmdDBhNWg3NzE0biIsInBsYW4iOiJzdGFydGVyIiwiY3ljbGUiOiJsaWZldGltZSIsInNvdXJjZSI6Im9yZGVyIiwic3RhdHVzIjoiYWN0aXZlIiwicmVuZXdzX2F0IjpudWxsLCJlbmRzX2F0IjpudWxsLCJ0ZWFtIjpudWxsfV0sImFjY291bnQiOnsiZW1haWwiOiJkYXRAZXhhbXBsZS5jb20iLCJuYW1lIjoiTmfDtCBRdeG7kWMgxJDhuqF0In0sIm9mZmxpbmUiOmZhbHNlfQ.H5MwHegRBBtHdWz21DF9ikol0xbinMOTbTcrU5v4qwqy9PLmOl87pGlX_mwP5oieuvS0ISN1tu4h5nv7MjyIAQ" + }, + { + "name": "expired a day ago", + "check": "exp", + "token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImtQcktfcW14VldhWVZBOXd3QkY2SXVvM3ZWeno3VHhIQ1R3WEJ5Z3JTNGsiLCJ0eXAiOiJ0YWJsZXByby1lbnRpdGxlbWVudCtqd3QifQ.eyJpc3MiOiJodHRwczovL2FwaS50YWJsZXByby5hcHAiLCJhdWQiOiJ0YWJsZXByby1hcHAiLCJzdWIiOiJhY2N0XzAxazhkNndrMDBmOHhzcjc5ZTd4MDUycmtrIiwianRpIjoiMDE5YTFhNmUtOWU1MC03ZjgwLTgxNjItMDAwMDAwMDAwMDA3IiwiaWF0IjoxNzkxNjI2NDAwLCJuYmYiOjE3OTE2MjY0MDAsImV4cCI6MTc5MjgzNjAwMCwiZGV2IjoiZGV2XzAxazhkNndwZ2dmZDY4dGJoejgxOHA0d3c0IiwibWlkIjoiZTQzMzhiODYwM2RlMjUyMzMwNWZkZmNiODczMTliZTIwMzM5NzQ2NThhZDk3ZjBjOTc1ODM0ZDIwOTcxNTY1OCIsImZlYXR1cmVzIjpbImVuY3J5cHRlZF9leHBvcnQiLCJpY2xvdWRfc3luYyIsInF1ZXJ5X2luc2lnaHRzIl0sImxpY2Vuc2VzIjpbeyJpZCI6ImxpY18wMWs4ZDZ3dHMwZmhlc3dmdDBhNWg3NzE0biIsInBsYW4iOiJzdGFydGVyIiwiY3ljbGUiOiJsaWZldGltZSIsInNvdXJjZSI6Im9yZGVyIiwic3RhdHVzIjoiYWN0aXZlIiwicmVuZXdzX2F0IjpudWxsLCJlbmRzX2F0IjpudWxsLCJ0ZWFtIjpudWxsfV0sImFjY291bnQiOnsiZW1haWwiOiJkYXRAZXhhbXBsZS5jb20iLCJuYW1lIjoiTmfDtCBRdeG7kWMgxJDhuqF0In0sIm9mZmxpbmUiOmZhbHNlfQ.ubahaJSEiWvHa9qM9mah4_iPHxSmZ9ku5BQ1IDAi0VfQEUDo9aLRjm76cYN2UGWcbd3bTkHf2vTq5m1HNKB3Dw" + } + ] +} diff --git a/core/crates/storage/src/paths.rs b/core/crates/storage/src/paths.rs index 61117a9b31..bcf4721904 100644 --- a/core/crates/storage/src/paths.rs +++ b/core/crates/storage/src/paths.rs @@ -58,6 +58,16 @@ impl StoragePaths { pub fn crashes_dir(&self) -> PathBuf { self.state.join("crashes") } + + // The entitlement in it is signed, so it needs no keyring. + pub fn account_file(&self) -> PathBuf { + self.state.join("account.json") + } + + // The usage data's anonymous ID and what it sent once. + pub fn telemetry_file(&self) -> PathBuf { + self.state.join("telemetry.json") + } } #[cfg(test)] @@ -76,6 +86,8 @@ mod tests { assert_eq!(paths.history_database(), root.join("state/tablepro/history.db")); assert_eq!(paths.connections_file(), root.join("config/tablepro/connections.json")); assert_eq!(paths.snippets_dir(), root.join("data/tablepro/snippets")); + assert_eq!(paths.account_file(), root.join("state/tablepro/account.json")); + assert_eq!(paths.telemetry_file(), root.join("state/tablepro/telemetry.json")); } #[test] diff --git a/core/docs/decisions/0016-tablepro-accounts.md b/core/docs/decisions/0016-tablepro-accounts.md new file mode 100644 index 0000000000..f0e5927546 --- /dev/null +++ b/core/docs/decisions/0016-tablepro-accounts.md @@ -0,0 +1,48 @@ +# 0016: TablePro accounts live in the core, with features gated by the entitlement + +- **Status**: Accepted +- **Date**: 2026-11-05 + +## Context + +TablePro's licensing moved to accounts on tablepro-cloud, whose `docs/design/clients.md` is the spec for every app. An app signs in through the browser with OAuth, registers its machine as a device, and holds an entitlement: a JWS signed with Ed25519 that lists the feature codes the account's licenses unlock. The macOS app moved to it in #4045. On 2026-11-05 the maintainer decided the Linux app is licensed as the Mac is, built now, while the paid features come to Linux later. The Windows app will take the same flow, and the Mac can move onto the core per [0010](0010-macos-on-the-core.md). + +clients.md gives Linux the `/v2` flow only, since it had no licensing to migrate: the public client `tablepro-linux` with a loopback redirect on any port (RFC 8252 section 7.3), the device platform `linux`, and no license key exchange. + +## Decision + +`core/crates/account` (`tablepro-account`) does what the macOS `Account` service does, without a GUI and without a platform service: sign-in and sign-out, the tokens, the device, the entitlement, the refresh schedule and the gate. It depends on `core` and `storage`. Each app passes in what its platform does its own way: + +| Seam | Linux app | +| --- | --- | +| `RefreshTokenVault` | the keyring item `account_refresh_token`, through `tablepro_linux_platform::SecretStore` | +| `BrowserLauncher` | `gtk::UriLauncher` | +| `MachineId` | `MachineId::derive` of the text `tablepro_linux_platform::read_machine_id` reads | +| `AppClient` | `AppClient::LINUX`: client `tablepro-linux`, platform `linux` | +| `AccountSettings::account_file` | `account.json` in the state directory | + +## How it works + +- **OAuth.** `oauth2` 5 with PKCE (S256) for the authorization URL, the code exchange and the refresh, sent through the workspace's reqwest 0.13 with redirects off; rustls on aws-lc-rs with rustls-platform-verifier ([Linux ADR 0009](../../../linux/docs/decisions/0009-crypto-provider-and-sources.md)). Scopes `openid email offline_access app`. The redirect comes back to a listener on `127.0.0.1:0` that serves requests until one carries this sign-in's `state`. The authorization URL carries `machine_name`, `app_version` and `os_version`, the values the device is registered with. Revocation (RFC 7009) is a form POST of our own: oauth2 refuses a revocation endpoint that is not https. +- **One sign-in at a time.** Each sign-in and sign-out moves a counter. A sign-in whose browser comes back after a later one, or after a sign-out, has its tokens revoked, never adopted. Cancel ends only the wait for the browser: once the code is back, its tokens are stored. A sign-in whose answer has no refresh token replaces nothing. +- **Tokens.** The access token lives in memory until its expiry on the wall clock, since `Instant` stops while the machine sleeps; a 401 drops it, so the next call refreshes it. The refresh token lives in the vault. When no vault takes it, it stays in memory and the next launch starts signed out. One operation holds the session at a time, so a refresh is single-flight. +- **The `/v2` client** is generated by progenitor 0.15 from tablepro-cloud's `api/v2.openapi-3.0.json`, copied into `openapi/`. Every enum in an answer is a string, so a value this build does not know still reads. An answer that does not match the document is reported by where it stopped matching, never with its body, which can hold the entitlement or the account's details. +- **Machine ID.** Linux's is HMAC-SHA256 of the 16 bytes `/etc/machine-id` holds, keyed with `app.tablepro.TablePro`, as 64 lowercase hex characters (clients.md, Linux): machine-id(5) asks that the ID never be sent as it is, only hashed with a fixed application-specific key. The crate derives it; the platform reads the file. Flatpak binds the host's `/etc/machine-id`, or D-Bus's `/var/lib/dbus/machine-id` where there is none, into every sandbox at `/etc/machine-id` (`flatpak_run_setup_base_argv` in `common/flatpak-run.c`), and the host build reads the two in the same order, so the installed app and the Flatpak are one device. +- **Device and entitlement.** After a sign-in: `POST /v2/devices`, a refresh so the access token names the device, then `POST /v2/devices/{id}/entitlement`. A refresh that fails after the registration leaves no token behind, so the next try gets one that names the device. The device ID and the entitlement go to the account file. The entitlement is refreshed at launch and after a sign-in; 24 hours after a refresh that worked, an hour after one that failed or met the device limit, counted from the last refresh whatever asked for it, on the wall clock too, so one that came due while the machine slept runs within five minutes of the wake; when the app comes back while past the limit; on Refresh or Try Again; and after a device is removed (clients.md, Shared rules). A 429 holds that call until its `Retry-After` has passed on the wall clock. +- **Verification.** `jsonwebtoken` 11 on aws-lc-rs checks the EdDSA signature with the key for the token's `kid`, and `aud`. `alg`, `typ` and `kid` are read from the header by hand first, and `mid`, `nbf` (300 seconds of leeway) and `exp` against the caller's clock after. The public keys ship in `entitlement_keys.rs`, by the RFC 7638 thumbprint of each. The checks run against tablepro-cloud's shared vectors. An entitlement kept on disk that has expired is read back as it was before it expired: it still says who has paid, as macOS keeps it, and only its features stop. +- **Gate.** `AccountStatus::feature_access(code, now)` answers `Available`, `SignedOut`, `NotIncluded`, `Expired`, `RefreshFailed` or `DeviceLimitReached` from the feature codes only; an app knows no plan or tier. +- **Errors** carry a stable `code()` (`account.device_limit_reached` and the like); each app words them. + +## Consequences + +- Linux has none of the ten paid features yet, so nothing is gated: a feature that comes to Linux asks `AppServices::feature_access` with its code. +- Another app adds its `AppClient` once tablepro-cloud registers its client and platform; the macOS client's redirect is a custom scheme this crate does not listen on. +- Windows reaches the account through `ffi` when it adopts it, with a `RefreshTokenVault` over its Credential Manager store. +- `linux/build-aux/update-cloud-api.sh` copies a newer `/v2` document and the vectors in; `/v2` only grows, so an older copy keeps working. + +## Alternatives considered + +- **`linux/crates/account`, as clients.md first named it.** Nothing in the crate is Linux's own once the keyring, the browser and the file read come in from the app, and the Windows app needs the same flow. +- **`SecretItem::AccountRefreshToken` behind the core's `SecretItems`.** It changes the FFI's `SecretItemTag` and the Windows bindings before Windows uses the account; the trait is the smaller seam until then. +- **The systemd app-specific ID as it is (`sd_id128_get_machine_app_specific`).** It keys the HMAC with the machine ID and keeps 128 bits; the server takes 64 hex characters. +- **A hand-written `/v2` client.** clients.md asks for generated clients only. diff --git a/core/docs/decisions/README.md b/core/docs/decisions/README.md index c4b5c7371e..a78abbad3b 100644 --- a/core/docs/decisions/README.md +++ b/core/docs/decisions/README.md @@ -22,3 +22,4 @@ The format, the statuses and how a later ADR changes an earlier one follow [the | [0012](0012-copies-before-a-format-upgrade.md) | A file in an older format is copied beside itself before its first write in the newer one | Accepted | `write_document_blocking` and the history's open copy a file an older build wrote to `.v-` before replacing it, the history through `VACUUM INTO`; three kept per file; no copy, no write. | | [0013](0013-catalog-operations.md) | Truncating a table and creating or dropping a schema or database | Accepted | `DdlDialect` plans TRUNCATE and CREATE and DROP for schemas and databases per engine, MongoDB through its shell; a database change runs outside any transaction as `WriteKind::DatabaseChanges`; a login to a missing database fails as `DatabaseNotFound` and parks. | | [0015](0015-editing-a-query-result.md) | A query result edits when its statement reads one table and its driver traces the columns | Accepted | The parser accepts one row-preserving SELECT over one table; each driver sets `ResultColumn::origin` only for a direct column of a permanent base table, from the wire or a lookup on its own connection; the result maps to the catalog with its full primary key and saves through `plan_save`. | +| [0016](0016-tablepro-accounts.md) | TablePro accounts live in the core, with features gated by the entitlement | Accepted | `tablepro-account` signs in through the browser with OAuth and a loopback redirect, registers the device, verifies the Ed25519 entitlement and gates features by its codes; each app passes its keyring, browser, machine ID and OAuth client in. | diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index e74652fbc9..df1511b24c 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -62,7 +62,7 @@ Views cross between the two through `crate::runtime` (`spawn_and_deliver`, `spaw `TableProApplication` (`crates/app/src/application/`) is an `adw::Application` subclass. `run()` builds it with the `AppServices` every window shares: the driver registry, the connector and its deadlines, the liveness policy, the `SessionRegistry`, the settings, the storage, the saved connection list and its editor, the SSH services, the credential sources, the file revealer, `Tasks`, the process hold, the open-sessions record and the query history. - **One process per profile.** GApplication owns the application id on the session bus. A second launch parses its own options, sends the running process an activate, or an open with the files it was given, and exits. With `--new-window` (`-n`), `handle_local_options` sends `app.new-window` instead. The development profile has an id of its own and runs beside the stable app. -- **Startup**, in the primary process only, adds the app actions (`new-window`, `quit`, `preferences`, `about`, `focus-tab`) and installs the application accelerators from the accelerator table, opens the query history, checks the askpass helper, ends SSH masters a crashed instance left running, starts the [MCP server](#mcp-server) when its preference is on, and starts `SessionSignals`, which tells every session about network changes and a resume from suspend. +- **Startup**, in the primary process only, adds the app actions (`new-window`, `quit`, `preferences`, `about`, `focus-tab`) and installs the application accelerators from the accelerator table, opens the query history, checks the askpass helper, ends SSH masters a crashed instance left running, starts the [MCP server](#mcp-server) when its preference is on, starts the [TablePro account](#tablepro-account) and the [usage data](#usage-data), counts the day for the license ask, and starts `SessionSignals`, which tells every session about network changes and a resume from suspend. - **Windows.** `open_window` builds a `SessionWindow` for a `WindowLaunch`: the picker, a saved connection, a file, or a session to restore. The application keeps each window in its `WindowRegistry` by window id, and a window reaches the others through its `WindowHost`, which holds the application weakly. - **Holds.** A `ProcessHold` keeps the process alive while work that must finish runs: a session closing, a save, a durable write, a file being resolved. The app's `GioProcessHold` is `gio::Application::hold`, so the process ends once the last window and the last hold are gone. - **Quit.** `request_quit` runs `QuitSequence`, which asks each window in turn through its close prompts and never calls `g_application_quit`. A second Quit while a window asks brings that window forward. At logout, `query-end` brings forward the first window that holds the session back. @@ -199,6 +199,21 @@ A tool reads the saved connections from the `ConnectionStore` on each call, so a The app's `AiService` (`crates/app/src/services/ai_service.rs`) reads the provider, model and address from GSettings and the key from the keyring, and runs every request on the runtime. Each window's `AssistantAccess` (`crates/app/src/ui/assistant/assistant_access.rs`) is the gate: it reads the connection's `AiPolicy` from the saved connections before every request, asks once under Ask Each Time, naming the provider's host, and keeps the answer until the window's session ends. The pane, the editor's Explain, Review, Optimize and Fix, and the suggestions while typing all go through it; the context comes from the session's catalog through the completion's reader (`schema_reader.rs`), so it shares the sidebar's reads. +## TablePro account + +`tablepro-account` signs in to the TablePro account and holds what it unlocks ([core ADR 0016](../core/docs/decisions/0016-tablepro-accounts.md)). `Account::sign_in` binds a listener on `127.0.0.1:0`, hands the authorization URL to the `BrowserLauncher`, and waits up to 10 minutes for a redirect carrying its `state`, answering any other request and waiting on. It exchanges the code with PKCE, registers the device with `POST /v2/devices`, refreshes the token so it names the device, and asks for the entitlement. OAuth goes through `oauth2` and the `/v2` calls through the client `build.rs` generates with progenitor, both on one reqwest client that follows no redirect, on rustls with aws-lc-rs and the platform verifier. Every operation holds the account's session while it runs, so one token refresh happens at a time. The refresh token is a keyring item through `RefreshTokenVault`; the device ID, the last entitlement token and the account it names are in `account.json` ([docs/storage.md](docs/storage.md)). `Entitlement::verify` checks the token against the keys in `entitlement_keys.rs` and this machine's `MachineId`. A sign-in over an earlier one revokes the earlier refresh token once the new one is accepted, and a sign-in that comes back after a later one or a sign-out is revoked, not adopted. A refused refresh token or a removed device signs the account out; `device_limit_reached` is `AccountError::DeviceLimitReached` with the devices that hold the license, and any other failed refresh keeps its `AccountError` in the status for the app to word. + +The account publishes an `AccountStatus` on a `watch`. `feature_access` reads a feature code against it at the moment of the call, so an entitlement that expires while the app runs stops unlocking then. The app's `AccountService` (`crates/app/src/services/account_service.rs`) builds the account on the runtime at startup, runs `Account::run` (restore, then a refresh at once, and again 24 hours after the last refresh that worked, or an hour after one that failed or met the device limit, whichever call made it), and answers `AppServices::feature_access`. `AppServices::app_became_active` refreshes past the device limit when one of the app's windows takes the focus after none had it for a second or more (`application/app_activity.rs`). Restore reads `account.json` first and opens the keyring only when it exists, so a user who never signed in gets no keyring prompt. The device's `os_version` is the host's `PRETTY_NAME`: inside Flatpak, from `/run/host/os-release`, since the sandbox's own is the runtime's. Its `BrowserLauncher` opens the page with `gtk::UriLauncher` on the GTK thread. + +Preferences > Account (`crates/app/src/ui/preferences_dialog/account_page.rs`) works through the `AccountHandle` the service hands out: the status, whether the account started, and the address of the account pages ([ADR 0038](docs/decisions/0038-account-page-and-license-ask.md)). `account_view.rs` turns the status, `Account::profile` (`/v2/me`) and `Account::devices` (`/v2/devices`) into the page's rows and notices, as plain data the unit tests read; the page fetches the two while it is shown. Over the device limit, `device_limit_dialog.rs` lists the devices `device_limit_reached` names, with their `offline_until`, and removes one through `Account::remove_device`, which refreshes the entitlement after any removal. `Account::import_offline_file` verifies an offline file as it would an online entitlement; signed in, it refuses a file of another account than the sign-in's, and signed out, the status is `AccountStatus::Offline` and the gate reads that file. + +The license ask (`application/license_ask_prompt.rs`) counts each local day of use in GSettings at startup and whenever a window takes the focus, and `services/license_ask.rs` says when the ask is due: from the 30th day without a license, then 90 days after the last ask, or a year after I Use It Personally. It shows a toast (`LaunchNotice::LicenseAsk`) on the focused `SessionWindow` five minutes or more after launch, never over a dialog or a tab whose page is loading. + + +## Usage data + +`Telemetry` (`crates/app/src/services/telemetry.rs`) sends the usage events to PostHog through posthog-rs's blocking client, only in a build made with `-Dposthog_key`, and while Share Usage Data is on, which it is by default ([ADR 0039](docs/decisions/0039-usage-data.md)); Preferences > General > Privacy turns it off. `AppServices::start` starts it and checks `app_opened` against the open sessions, `TelemetryConnector` counts the first connect around the app's `Connector`, the query history service counts the first editor run that worked, and the account's status, or `/v2/me` on Preferences > Account, identifies or resets it. The application's shutdown flushes it. + ## Build & CI Meson is the build system and Cargo is the Rust compiler; see [ADR 0006](docs/decisions/0006-meson-cargo-build.md). `meson setup _build && meson compile -C _build` produces `tablepro` and `tablepro-askpass`, and `meson test -C _build` runs the `unit`, `gtk`, `data` and `packaging` suites. diff --git a/linux/README.md b/linux/README.md index 3643774dd2..1b2d1dedaa 100644 --- a/linux/README.md +++ b/linux/README.md @@ -113,7 +113,7 @@ flatpak install ./tablepro-0.1.0-x86_64.flatpak ## Privacy -TablePro makes no network requests of its own: no telemetry, no update check and no crash upload. DuckDB fetches no extension unless your own SQL runs `INSTALL`. It connects to the database servers and SSH hosts you set up, and nothing else. With a provider chosen for the [AI assistant](#ai-assistant), it also sends what the assistant asks to that provider: Anthropic (`api.anthropic.com`), OpenAI (`api.openai.com`), or the Ollama or other server whose address you enter. That is your questions, your SQL, the structure of the tables involved and, for Fix with AI, the server's error message, and only from connections whose AI Assistant setting allows it; it reads no rows for it. With the [MCP server](#mcp-server) on, it also listens on `127.0.0.1` for MCP clients on this computer, which send what they read wherever their own settings say. Crash reports stay on your disk until you attach one to a bug report. Updates come from apt, dnf or Flatpak, and the Flatpak ones from `flatpak.tablepro.app`. +Besides the usage data below, TablePro makes no network requests of its own until you sign in to a TablePro account: no update check and no crash upload. Signing in opens `account.tablepro.app` in your browser. From then on TablePro reaches `account.tablepro.app` and `api.tablepro.app` to keep the sign-in, to register this computer as one of the account's devices (its host name, the TablePro and system versions, and a machine ID made by hashing `/etc/machine-id` with a key of TablePro's own, never the ID itself), to check your plan at launch and once a day, or hourly while that fails, and, while Preferences > Account is open, to list your teams and devices. The packages from the PPA and COPR carry a PostHog key and share usage data by default, as TablePro for macOS does; **Preferences > General > Privacy > Share Usage Data** turns it off, and nothing more goes from then on. While it is on, the events [ADR 0039](docs/decisions/0039-usage-data.md) lists go to PostHog (`eu.i.posthog.com`): a launch once a day with the kinds and number of open connections, the first connection and the first query with the kind of database, a sign-in and sign-out, and clicks on Upgrade and Buy a License, with the license ask. Each carries a random ID for this installation and the system's name and version; signed in, your account's ID instead, with the TablePro and system versions, the processor type and the interface language. PostHog sees the network address they come from, which it uses for a rough location. Never a host name, a password, a query or a row. A build from source sends none, unless it was given a key with `-Dposthog_key`. The days you use TablePro, which decide when it asks about a license, are counted on this computer and never sent. DuckDB fetches no extension unless your own SQL runs `INSTALL`. It connects to the database servers and SSH hosts you set up, and nothing else. With a provider chosen for the [AI assistant](#ai-assistant), it also sends what the assistant asks to that provider: Anthropic (`api.anthropic.com`), OpenAI (`api.openai.com`), or the Ollama or other server whose address you enter. That is your questions, your SQL, the structure of the tables involved and, for Fix with AI, the server's error message, and only from connections whose AI Assistant setting allows it; it reads no rows for it. With the [MCP server](#mcp-server) on, it also listens on `127.0.0.1` for MCP clients on this computer, which send what they read wherever their own settings say. Crash reports stay on your disk until you attach one to a bug report. Updates come from apt, dnf or Flatpak, and the Flatpak ones from `flatpak.tablepro.app`. ## Stack @@ -572,6 +572,14 @@ A client works on a connection open in a TablePro window; its `connect` tool ope The tools are `list_connections`, `connect`, `list_schemas`, `list_tables`, `describe_table`, `get_table_ddl`, `browse_table`, `count_rows` and `execute_query`. An answer keeps at most 20 result sets of 1,000 rows each, and a statement stops at 300 seconds. +## TablePro account + +TablePro for Linux is licensed as the macOS app is: the same plans and accounts, on up to 3 devices across Mac and Linux. The paid features are in TablePro for macOS for now, and everything in TablePro for Linux works without an account ([ADR 0038](docs/decisions/0038-account-page-and-license-ask.md)). + +**Preferences > Account > Sign In** opens `account.tablepro.app` in your browser, which sends you back to TablePro once you approve. The page lists your plan, the paid features, your devices with **Remove**, and your teams. A computer that never reaches the internet imports an offline file made for the Machine ID the page shows. What is sent, and when: [Privacy](#privacy). + +From the 30th day you use TablePro without a license, a toast asks whether you would buy one, with **Buy a License**, then at most every 90 days. Nothing changes whatever you do. + ## Documentation index | Topic | File | diff --git a/linux/ROADMAP.md b/linux/ROADMAP.md index b69b138859..dc556bba6f 100644 --- a/linux/ROADMAP.md +++ b/linux/ROADMAP.md @@ -157,6 +157,15 @@ The app runs on GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor | External Clients | A level per connection: Blocked hides it, Read Only (the default) lets clients read, Read & Write lets them write after the user approves each statement in Safe Mode's dialog. Safe Mode Read-Only refuses every client write | | Tools | List connections, open one in a window, list schemas and tables, describe a table and show its DDL, browse and count rows with filters, and run one statement; runs go into the query history | +### TablePro account + +| Capability | Detail | +|---|---| +| Sign-in | Preferences > Account signs in to a TablePro account through the browser, as on macOS; the sign-in is kept in the keyring ([ADR 0038](docs/decisions/0038-account-page-and-license-ask.md)) | +| Plan, devices and teams | The plan and its status, the ten paid features (all in TablePro for macOS for now, so nothing is gated), the account's devices with Remove, the device limit dialog, teams, and offline files for a computer with no internet | +| License ask | From the 30th day of use without a license, a toast asks whether you would buy one, then at most every 90 days | +| Usage data | On by default, as on macOS: the clients.md events go to PostHog until Preferences > General > Privacy turns it off ([ADR 0039](docs/decisions/0039-usage-data.md)) | + ### Packaging and platform | Capability | Detail | @@ -205,7 +214,7 @@ Microsoft's mssql-tds is checked again each release as a SQL Server client. Toda ### Ideas -Not committed: server monitoring (sessions, locks, slow queries), maintenance commands, backup and restore through the engines' own tools (`pg_dump`, `mysqldump`), replication status, comparing result sets, schema diff and data sync between connections, drawn query plans, scheduled queries, and opt-in usage telemetry. +Not committed: server monitoring (sessions, locks, slow queries), maintenance commands, backup and restore through the engines' own tools (`pg_dump`, `mysqldump`), replication status, comparing result sets, schema diff and data sync between connections, drawn query plans and scheduled queries. ## Release criteria diff --git a/linux/about.toml b/linux/about.toml index 178ee9fa93..1e4cdb7d4a 100644 --- a/linux/about.toml +++ b/linux/about.toml @@ -223,3 +223,10 @@ license = "MIT" start = "Copyright (c) 2024-2025 Model Context Protocol" end = "SOFTWARE." checksum = "a403f7ef905cd68df03a304a8c169dc7b3c5c50e4dba17c0ce8c58421055bd43" + +# cargo-about does not identify the crate's LICENSE, the Apache-2.0 text. +[sync_wrapper.clarify] +license = "Apache-2.0" +[[sync_wrapper.clarify.files]] +path = "LICENSE" +checksum = "0d542e0c8804e39aa7f37eb00da5a762149dc682d7829451287e11b938e94594" diff --git a/linux/build-aux/about/source.json b/linux/build-aux/about/source.json index 5b5842b05c..b336834ddf 100644 --- a/linux/build-aux/about/source.json +++ b/linux/build-aux/about/source.json @@ -49,6 +49,14 @@ "tests/archives/*.tar" ] }, + { + "crate": "async-compression", + "version": "0.4.52", + "own": [ + "tests/artifacts/lib.rs.zst", + "tests/artifacts/long-window-size-lib.rs.zst" + ] + }, { "crate": "aws-lc-sys", "version": "0.45.0", @@ -636,6 +644,21 @@ } ] }, + { + "crate": "objc2-foundation", + "version": "0.3.2", + "own": [ + "src/copying.rs", + "src/tests/copying.rs" + ] + }, + { + "crate": "openapiv3", + "version": "2.2.0", + "own": [ + "src/license.rs" + ] + }, { "crate": "polyval", "version": "0.7.3", diff --git a/linux/build-aux/debian/README.source b/linux/build-aux/debian/README.source index e1ae57d9bb..746a913973 100644 --- a/linux/build-aux/debian/README.source +++ b/linux/build-aux/debian/README.source @@ -14,7 +14,9 @@ tablepro_X.Y.Z.orig-vendor.tar.xz holds vendor/ from cargo vendor --versioned-dirs, with the source replacement cargo printed as vendor/cargo-config.toml. dpkg-source unpacks it into vendor/, and debian/rules copies the replacement to .cargo/config.toml before it -configures, so the build needs no network. +configures, so the build needs no network. A release's vendor tarball +also holds vendor/posthog-key, the PostHog project key debian/rules passes +to Meson as -Dposthog_key. Without it the package sends no usage data. Toolchain: the package build-depends on rustc-X.Y and cargo-X.Y, X.Y being the workspace rust-version, and debian/rules puts /usr/lib/rust-X.Y/bin first diff --git a/linux/build-aux/debian/copyright b/linux/build-aux/debian/copyright index fc710c383c..800d5c6bc2 100644 --- a/linux/build-aux/debian/copyright +++ b/linux/build-aux/debian/copyright @@ -1246,10 +1246,13 @@ Files: vendor/anyhow-1.0.104/* vendor/rustversion-1.0.23/* vendor/semver-1.0.28/* vendor/serde_bytes-0.11.19/* + vendor/serde_path_to_error-0.1.20/* vendor/serde_repr-0.1.21/* vendor/syn-2.0.119/* vendor/syn-3.0.5/* + vendor/thiserror-1.0.69/* vendor/thiserror-2.0.20/* + vendor/thiserror-impl-1.0.69/* vendor/thiserror-impl-2.0.20/* Copyright: David Tolnay License: Apache-2.0 or Expat @@ -1776,6 +1779,7 @@ Files: vendor/astral-tokio-tar-0.6.4/* vendor/futures-timer-3.0.4/* vendor/jobserver-0.1.35/* vendor/js-sys-0.3.105/* + vendor/oauth2-5.0.0/* vendor/openssl-probe-0.2.1/* vendor/pkg-config-0.3.34/* vendor/socket2-0.6.5/* @@ -1943,6 +1947,61 @@ License: Apache-2.0 or Expat IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/async-compression-0.4.52/* + vendor/compression-codecs-0.4.47/* + vendor/compression-core-0.4.34/* +Copyright: 2018 the rustasync developers +License: Apache-2.0 or Expat + On Debian systems, the complete text of the Apache License, Version 2.0 + can be found in "/usr/share/common-licenses/Apache-2.0". + . + APPENDIX: How to apply the Apache License to your work. + . + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + . + Copyright [yyyy] [name of copyright owner] + . + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + . + http://www.apache.org/licenses/LICENSE-2.0 + . + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + . + The MIT License (MIT) + . + Copyright (c) 2018 the rustasync developers + . + 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. + Files: vendor/async-recursion-1.1.1/* Copyright: Robert Usher <266585+dcchut@users.noreply.github.com> License: Apache-2.0 or Expat @@ -3340,6 +3399,12 @@ License: Apache-2.0 or Expat IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/block2-0.6.2/* + vendor/objc2-0.6.5/* + vendor/objc2-encode-4.1.0/* +Copyright: Mads Marquart +License: Expat + Files: vendor/blocking-1.7.0/* Copyright: the blocking authors License: Apache-2.0 or Expat @@ -6590,8 +6655,11 @@ License: Apache-2.0 or Expat IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -Files: vendor/darling-0.24.1/* +Files: vendor/darling-0.20.11/* + vendor/darling-0.24.1/* + vendor/darling_core-0.20.11/* vendor/darling_core-0.24.1/* + vendor/darling_macro-0.20.11/* vendor/darling_macro-0.24.1/* Copyright: 2017 Ted Driggs License: Expat @@ -7006,6 +7074,61 @@ License: Apache-2.0 or Expat OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/derive_builder-0.20.2/* + vendor/derive_builder_core-0.20.2/* + vendor/derive_builder_macro-0.20.2/* +Copyright: 2016 rust-derive-builder contributors +License: Apache-2.0 or Expat + On Debian systems, the complete text of the Apache License, Version 2.0 + can be found in "/usr/share/common-licenses/Apache-2.0". + . + APPENDIX: How to apply the Apache License to your work. + . + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + . + Copyright {yyyy} {name of copyright owner} + . + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + . + http://www.apache.org/licenses/LICENSE-2.0 + . + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + . + The MIT License (MIT) + . + Copyright (c) 2016 rust-derive-builder 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. + Files: vendor/derive_more-2.1.1/* vendor/derive_more-impl-2.1.1/* Copyright: 2016 Jelte Fennema @@ -7210,6 +7333,11 @@ License: Apache-2.0 or Expat IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/dispatch2-0.3.1/* +Copyright: Mads Marquart + Mary +License: Apache-2.0 or Expat or Zlib + Files: vendor/displaydoc-0.2.7/* Copyright: Jane Lusby License: Apache-2.0 or Expat @@ -12195,6 +12323,31 @@ Files: vendor/jni-sys-macros-0.4.1/* Copyright: Robert Bragg License: Apache-2.0 or Expat +Files: vendor/jsonwebtoken-11.1.0/* +Copyright: 2015 Vincent Prouillet +License: Expat + The MIT License (MIT) + . + Copyright (c) 2015 Vincent Prouillet + . + 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. + Files: vendor/keccak-0.2.2/* Copyright: 2018-2026 The RustCrypto Project Developers License: Apache-2.0 or Expat @@ -13232,6 +13385,65 @@ License: Apache-2.0 or Expat IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/lru-slab-0.1.3/* +Copyright: 2024 The lru-slab Developers +License: Apache-2.0 or Expat or Zlib + On Debian systems, the complete text of the Apache License, Version 2.0 + can be found in "/usr/share/common-licenses/Apache-2.0". + . + APPENDIX: How to apply the Apache License to your work. + . + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + . + Copyright [yyyy] [name of copyright owner] + . + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + . + http://www.apache.org/licenses/LICENSE-2.0 + . + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + . + Copyright (c) 2024 The lru-slab Developers + . + 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. + . + Copyright (c) 2024 The lru-slab Developers + . + This software is provided 'as-is', without any express or implied warranty. In + no event will the authors be held liable for any damages arising from the use of + this software. + . + Permission is granted to anyone to use this software for any purpose, including + commercial applications, and to alter it and redistribute it freely, subject to + the following restrictions: + . + 1. The origin of this software must not be misrepresented; you must not claim + that you wrote the original software. If you use this software in a product, an + acknowledgment in the product documentation would be appreciated but is not + required. + . + 2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + . + 3. This notice may not be removed or altered from any source distribution. + Files: vendor/lz4_flex-0.14.0/* Copyright: 2020 Pascal Seitz License: Expat @@ -14369,14 +14581,58 @@ License: Expat OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/objc2-cloud-kit-0.3.2/* +Copyright: the objc2-cloud-kit authors +License: Apache-2.0 or Expat or Zlib + +Files: vendor/objc2-core-data-0.3.2/* +Copyright: the objc2-core-data authors +License: Apache-2.0 or Expat or Zlib + Files: vendor/objc2-core-foundation-0.3.2/* Copyright: the objc2-core-foundation authors License: Apache-2.0 or Expat or Zlib +Files: vendor/objc2-core-graphics-0.3.2/* +Copyright: the objc2-core-graphics authors +License: Apache-2.0 or Expat or Zlib + +Files: vendor/objc2-core-image-0.3.2/* +Copyright: the objc2-core-image authors +License: Apache-2.0 or Expat or Zlib + +Files: vendor/objc2-core-location-0.3.2/* +Copyright: the objc2-core-location authors +License: Apache-2.0 or Expat or Zlib + +Files: vendor/objc2-core-text-0.3.2/* +Copyright: the objc2-core-text authors +License: Apache-2.0 or Expat or Zlib + +Files: vendor/objc2-foundation-0.3.2/* +Copyright: the objc2-foundation authors +License: Expat + +Files: vendor/objc2-io-surface-0.3.2/* +Copyright: the objc2-io-surface authors +License: Apache-2.0 or Expat or Zlib + +Files: vendor/objc2-quartz-core-0.3.2/* +Copyright: the objc2-quartz-core authors +License: Apache-2.0 or Expat or Zlib + Files: vendor/objc2-system-configuration-0.3.2/* Copyright: the objc2-system-configuration authors License: Apache-2.0 or Expat or Zlib +Files: vendor/objc2-ui-kit-0.3.2/* +Copyright: the objc2-ui-kit authors +License: Apache-2.0 or Expat or Zlib + +Files: vendor/objc2-user-notifications-0.3.2/* +Copyright: the objc2-user-notifications authors +License: Apache-2.0 or Expat or Zlib + Files: vendor/once_cell-1.21.4/* Copyright: Aleksey Kladov License: Apache-2.0 or Expat @@ -14457,9 +14713,8 @@ License: Expat OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -Files: vendor/ordered-stream-0.2.0/* -Copyright: Daniel De Graaf - Zeeshan Ali Khan +Files: vendor/openapiv3-2.2.0/* +Copyright: Glade Miller License: Apache-2.0 or Expat On Debian systems, the complete text of the Apache License, Version 2.0 can be found in "/usr/share/common-licenses/Apache-2.0". @@ -14513,8 +14768,9 @@ License: Apache-2.0 or Expat IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -Files: vendor/p384-0.14.0/* -Copyright: 2020-2021 RustCrypto Developers +Files: vendor/ordered-stream-0.2.0/* +Copyright: Daniel De Graaf + Zeeshan Ali Khan License: Apache-2.0 or Expat On Debian systems, the complete text of the Apache License, Version 2.0 can be found in "/usr/share/common-licenses/Apache-2.0". @@ -14536,7 +14792,7 @@ License: Apache-2.0 or Expat you may not use this file except in compliance with the License. You may obtain a copy of the License at . - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 . Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -14544,8 +14800,6 @@ License: Apache-2.0 or Expat See the License for the specific language governing permissions and limitations under the License. . - Copyright (c) 2020-2021 RustCrypto Developers - . 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 @@ -14570,12 +14824,33 @@ License: Apache-2.0 or Expat IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -Files: vendor/pageant-0.2.5/* -Copyright: Eugene -License: Apache-2.0 +Files: vendor/os_info-3.15.0/* +Copyright: 2017 Stanislav Tkach +License: Expat + The MIT License (MIT) + . + Copyright (c) 2017 Stanislav Tkach + . + 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. -Files: vendor/parking-2.2.1/* -Copyright: 2014-2020 The Rust Project Developers +Files: vendor/p384-0.14.0/* +Copyright: 2020-2021 RustCrypto Developers License: Apache-2.0 or Expat On Debian systems, the complete text of the Apache License, Version 2.0 can be found in "/usr/share/common-licenses/Apache-2.0". @@ -14597,7 +14872,7 @@ License: Apache-2.0 or Expat you may not use this file except in compliance with the License. You may obtain a copy of the License at . - http://www.apache.org/licenses/LICENSE-2.0 + http://www.apache.org/licenses/LICENSE-2.0 . Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, @@ -14605,6 +14880,8 @@ License: Apache-2.0 or Expat See the License for the specific language governing permissions and limitations under the License. . + Copyright (c) 2020-2021 RustCrypto Developers + . 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 @@ -14628,15 +14905,74 @@ License: Apache-2.0 or Expat OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - . - =============================================================================== - . - Copyright 2014-2020 The Rust Project Developers - . - Licensed under the Apache License, Version 2.0 or the MIT license - , at your - option. All files in the project carrying such notice may not be + +Files: vendor/pageant-0.2.5/* +Copyright: Eugene +License: Apache-2.0 + +Files: vendor/parking-2.2.1/* +Copyright: 2014-2020 The Rust Project Developers +License: Apache-2.0 or Expat + On Debian systems, the complete text of the Apache License, Version 2.0 + can be found in "/usr/share/common-licenses/Apache-2.0". + . + APPENDIX: How to apply the Apache License to your work. + . + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + . + Copyright [yyyy] [name of copyright owner] + . + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + . + http://www.apache.org/licenses/LICENSE-2.0 + . + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + . + 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. + . + =============================================================================== + . + Copyright 2014-2020 The Rust Project Developers + . + Licensed under the Apache License, Version 2.0 or the MIT license + , at your + option. All files in the project carrying such notice may not be copied, modified, or distributed except according to those terms. Files: vendor/parse-display-0.9.1/* @@ -15187,6 +15523,31 @@ License: Apache-2.0 or Expat OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/posthog-rs-0.27.1/* +Copyright: 2020-2021 OpenQuery Inc. +License: Expat + MIT License + . + Copyright (c) 2020-2021 OpenQuery Inc. + . + 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. + Files: vendor/powerfmt-0.2.0/* Copyright: 2023 Jacob Pratt et al. License: Apache-2.0 or Expat @@ -15560,6 +15921,391 @@ License: Apache-2.0 or Expat IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/progenitor-0.15.1/* +Copyright: the progenitor authors +License: MPL-2.0 + Mozilla Public License Version 2.0 + ================================== + . + 1. Definitions + -------------- + . + 1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + . + 1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + . + 1.3. "Contribution" + means Covered Software of a particular Contributor. + . + 1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + . + 1.5. "Incompatible With Secondary Licenses" + means + . + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + . + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + . + 1.6. "Executable Form" + means any form of the work other than Source Code Form. + . + 1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + . + 1.8. "License" + means this document. + . + 1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + . + 1.10. "Modifications" + means any of the following: + . + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + . + (b) any new file in Source Code Form that contains any Covered + Software. + . + 1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + . + 1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + . + 1.13. "Source Code Form" + means the form of the work preferred for making modifications. + . + 1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + . + 2. License Grants and Conditions + -------------------------------- + . + 2.1. Grants + . + Each Contributor hereby grants You a world-wide, royalty-free, + non-exclusive license: + . + (a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + . + (b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + . + 2.2. Effective Date + . + The licenses granted in Section 2.1 with respect to any Contribution + become effective for each Contribution on the date the Contributor first + distributes such Contribution. + . + 2.3. Limitations on Grant Scope + . + The licenses granted in this Section 2 are the only rights granted under + this License. No additional rights or licenses will be implied from the + distribution or licensing of Covered Software under this License. + Notwithstanding Section 2.1(b) above, no patent license is granted by a + Contributor: + . + (a) for any code that a Contributor has removed from Covered Software; + or + . + (b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + . + (c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + . + This License does not grant any rights in the trademarks, service marks, + or logos of any Contributor (except as may be necessary to comply with + the notice requirements in Section 3.4). + . + 2.4. Subsequent Licenses + . + No Contributor makes additional grants as a result of Your choice to + distribute the Covered Software under a subsequent version of this + License (see Section 10.2) or under the terms of a Secondary License (if + permitted under the terms of Section 3.3). + . + 2.5. Representation + . + Each Contributor represents that the Contributor believes its + Contributions are its original creation(s) or it has sufficient rights + to grant the rights to its Contributions conveyed by this License. + . + 2.6. Fair Use + . + This License is not intended to limit any rights You have under + applicable copyright doctrines of fair use, fair dealing, or other + equivalents. + . + 2.7. Conditions + . + Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted + in Section 2.1. + . + 3. Responsibilities + ------------------- + . + 3.1. Distribution of Source Form + . + All distribution of Covered Software in Source Code Form, including any + Modifications that You create or to which You contribute, must be under + the terms of this License. You must inform recipients that the Source + Code Form of the Covered Software is governed by the terms of this + License, and how they can obtain a copy of this License. You may not + attempt to alter or restrict the recipients' rights in the Source Code + Form. + . + 3.2. Distribution of Executable Form + . + If You distribute Covered Software in Executable Form then: + . + (a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + . + (b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + . + 3.3. Distribution of a Larger Work + . + You may create and distribute a Larger Work under terms of Your choice, + provided that You also comply with the requirements of this License for + the Covered Software. If the Larger Work is a combination of Covered + Software with a work governed by one or more Secondary Licenses, and the + Covered Software is not Incompatible With Secondary Licenses, this + License permits You to additionally distribute such Covered Software + under the terms of such Secondary License(s), so that the recipient of + the Larger Work may, at their option, further distribute the Covered + Software under the terms of either this License or such Secondary + License(s). + . + 3.4. Notices + . + You may not remove or alter the substance of any license notices + (including copyright notices, patent notices, disclaimers of warranty, + or limitations of liability) contained within the Source Code Form of + the Covered Software, except that You may alter any license notices to + the extent required to remedy known factual inaccuracies. + . + 3.5. Application of Additional Terms + . + You may choose to offer, and to charge a fee for, warranty, support, + indemnity or liability obligations to one or more recipients of Covered + Software. However, You may do so only on Your own behalf, and not on + behalf of any Contributor. You must make it absolutely clear that any + such warranty, support, indemnity, or liability obligation is offered by + You alone, and You hereby agree to indemnify every Contributor for any + liability incurred by such Contributor as a result of warranty, support, + indemnity or liability terms You offer. You may include additional + disclaimers of warranty and limitations of liability specific to any + jurisdiction. + . + 4. Inability to Comply Due to Statute or Regulation + --------------------------------------------------- + . + If it is impossible for You to comply with any of the terms of this + License with respect to some or all of the Covered Software due to + statute, judicial order, or regulation then You must: (a) comply with + the terms of this License to the maximum extent possible; and (b) + describe the limitations and the code they affect. Such description must + be placed in a text file included with all distributions of the Covered + Software under this License. Except to the extent prohibited by statute + or regulation, such description must be sufficiently detailed for a + recipient of ordinary skill to be able to understand it. + . + 5. Termination + -------------- + . + 5.1. The rights granted under this License will terminate automatically + if You fail to comply with any of its terms. However, if You become + compliant, then the rights granted under this License from a particular + Contributor are reinstated (a) provisionally, unless and until such + Contributor explicitly and finally terminates Your grants, and (b) on an + ongoing basis, if such Contributor fails to notify You of the + non-compliance by some reasonable means prior to 60 days after You have + come back into compliance. Moreover, Your grants from a particular + Contributor are reinstated on an ongoing basis if such Contributor + notifies You of the non-compliance by some reasonable means, this is the + first time You have received notice of non-compliance with this License + from such Contributor, and You become compliant prior to 30 days after + Your receipt of the notice. + . + 5.2. If You initiate litigation against any entity by asserting a patent + infringement claim (excluding declaratory judgment actions, + counter-claims, and cross-claims) alleging that a Contributor Version + directly or indirectly infringes any patent, then the rights granted to + You by any and all Contributors for the Covered Software under Section + 2.1 of this License shall terminate. + . + 5.3. In the event of termination under Sections 5.1 or 5.2 above, all + end user license agreements (excluding distributors and resellers) which + have been validly granted by You or Your distributors under this License + prior to termination shall survive termination. + . + ************************************************************************ + * * + * 6. Disclaimer of Warranty * + * ------------------------- * + * * + * Covered Software is provided under this License on an "as is" * + * basis, without warranty of any kind, either expressed, implied, or * + * statutory, including, without limitation, warranties that the * + * Covered Software is free of defects, merchantable, fit for a * + * particular purpose or non-infringing. The entire risk as to the * + * quality and performance of the Covered Software is with You. * + * Should any Covered Software prove defective in any respect, You * + * (not any Contributor) assume the cost of any necessary servicing, * + * repair, or correction. This disclaimer of warranty constitutes an * + * essential part of this License. No use of any Covered Software is * + * authorized under this License except under this disclaimer. * + * * + ************************************************************************ + . + ************************************************************************ + * * + * 7. Limitation of Liability * + * -------------------------- * + * * + * Under no circumstances and under no legal theory, whether tort * + * (including negligence), contract, or otherwise, shall any * + * Contributor, or anyone who distributes Covered Software as * + * permitted above, be liable to You for any direct, indirect, * + * special, incidental, or consequential damages of any character * + * including, without limitation, damages for lost profits, loss of * + * goodwill, work stoppage, computer failure or malfunction, or any * + * and all other commercial damages or losses, even if such party * + * shall have been informed of the possibility of such damages. This * + * limitation of liability shall not apply to liability for death or * + * personal injury resulting from such party's negligence to the * + * extent applicable law prohibits such limitation. Some * + * jurisdictions do not allow the exclusion or limitation of * + * incidental or consequential damages, so this exclusion and * + * limitation may not apply to You. * + * * + ************************************************************************ + . + 8. Litigation + ------------- + . + Any litigation relating to this License may be brought only in the + courts of a jurisdiction where the defendant maintains its principal + place of business and such litigation shall be governed by laws of that + jurisdiction, without reference to its conflict-of-law provisions. + Nothing in this Section shall prevent a party's ability to bring + cross-claims or counter-claims. + . + 9. Miscellaneous + ---------------- + . + This License represents the complete agreement concerning the subject + matter hereof. If any provision of this License is held to be + unenforceable, such provision shall be reformed only to the extent + necessary to make it enforceable. Any law or regulation which provides + that the language of a contract shall be construed against the drafter + shall not be used to construe this License against a Contributor. + . + 10. Versions of the License + --------------------------- + . + 10.1. New Versions + . + Mozilla Foundation is the license steward. Except as provided in Section + 10.3, no one other than the license steward has the right to modify or + publish new versions of this License. Each version will be given a + distinguishing version number. + . + 10.2. Effect of New Versions + . + You may distribute the Covered Software under the terms of the version + of the License under which You originally received the Covered Software, + or under the terms of any subsequent version published by the license + steward. + . + 10.3. Modified Versions + . + If you create software not governed by this License, and you want to + create a new license for such software, you may create and use a + modified version of this License if you rename the license and remove + any references to the name of the license steward (except to note that + such modified license differs from this License). + . + 10.4. Distributing Source Code Form that is Incompatible With Secondary + Licenses + . + If You choose to distribute Source Code Form that is Incompatible With + Secondary Licenses under the terms of this version of the License, the + notice described in Exhibit B of this License must be attached. + . + Exhibit A - Source Code Form License Notice + ------------------------------------------- + . + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + . + If it is not possible or desirable to put the notice in a particular + file, then You may include the notice in a location (such as a LICENSE + file in a relevant directory) where a recipient would be likely to look + for such a notice. + . + You may add additional accurate notices of copyright ownership. + . + Exhibit B - "Incompatible With Secondary Licenses" Notice + --------------------------------------------------------- + . + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. + +Files: vendor/progenitor-client-0.15.1/* +Copyright: the progenitor-client authors +License: MPL-2.0 + +Files: vendor/progenitor-impl-0.15.1/* +Copyright: the progenitor-impl authors +License: MPL-2.0 + Files: vendor/proptest-1.11.0/* vendor/rusty-fork-0.3.1/* Copyright: 2016 FullContact, Inc @@ -15745,17 +16491,58 @@ License: Expat 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 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. + +Files: vendor/quinn-0.11.12/* + vendor/quinn-proto-0.11.19/* + vendor/quinn-udp-0.5.16/* +Copyright: 2018 The quinn Developers +License: Apache-2.0 or Expat + On Debian systems, the complete text of the Apache License, Version 2.0 + can be found in "/usr/share/common-licenses/Apache-2.0". + . + APPENDIX: How to apply the Apache License to your work. + . + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + . + Copyright [yyyy] [name of copyright owner] + . + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + . + http://www.apache.org/licenses/LICENSE-2.0 + . + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + . + Copyright (c) 2018 The quinn Developers + . + 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. + 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. Files: vendor/r-efi-5.3.0/* vendor/r-efi-6.0.0/* @@ -15788,6 +16575,7 @@ License: Expat SOFTWARE. Files: vendor/rand-0.10.2/* + vendor/rand-0.8.8/* vendor/rand-0.9.5/* vendor/rand_chacha-0.9.0/* vendor/rand_xorshift-0.4.0/* @@ -15840,6 +16628,7 @@ License: Apache-2.0 or Expat Files: vendor/rand-0.7.3/* vendor/rand_chacha-0.2.2/* + vendor/rand_chacha-0.3.1/* vendor/rand_core-0.5.1/* Copyright: assignment is required to contribute to the Rand project. 2018 Developers of the Rand project @@ -15967,7 +16756,8 @@ License: Apache-2.0 or Expat IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -Files: vendor/rand_core-0.9.5/* +Files: vendor/rand_core-0.6.4/* + vendor/rand_core-0.9.5/* Copyright: assignment is required to contribute to the Rand project. 2018 Developers of the Rand project 2014 The Rust Project Developers @@ -16097,7 +16887,8 @@ License: Apache-2.0 or Expat IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -Files: vendor/rand_pcg-0.9.0/* +Files: vendor/rand_pcg-0.10.2/* + vendor/rand_pcg-0.9.0/* Copyright: assignment is required to contribute to the Rand project. 2014-2017 Melissa O'Neill and PCG Project contributors 2018 Developers of the Rand project @@ -16263,6 +17054,111 @@ License: Expat OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/regress-0.12.0/* +Copyright: 2020 ridiculous_fish +License: Apache-2.0 or Expat + On Debian systems, the complete text of the Apache License, Version 2.0 + can be found in "/usr/share/common-licenses/Apache-2.0". + . + APPENDIX: How to apply the Apache License to your work. + . + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + . + Copyright 2020 ridiculous_fish + . + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + . + http://www.apache.org/licenses/LICENSE-2.0 + . + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + . + MIT License + . + Copyright (c) 2020 ridiculous_fish + . + 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. + +Files: vendor/reqwest-0.13.5/* +Copyright: 2016 Sean McArthur + 2016-2026 Sean McArthur +License: Apache-2.0 or Expat + On Debian systems, the complete text of the Apache License, Version 2.0 + can be found in "/usr/share/common-licenses/Apache-2.0". + . + APPENDIX: How to apply the Apache License to your work. + . + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + . + Copyright 2016 Sean McArthur + . + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + . + http://www.apache.org/licenses/LICENSE-2.0 + . + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + . + Copyright (c) 2016-2026 Sean McArthur + . + 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. + Files: vendor/resolv-conf-0.7.6/* Copyright: 2015-2016 The resolv-conf Developers License: Apache-2.0 or Expat @@ -17308,8 +18204,10 @@ License: Expat . 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. -Files: vendor/schemars-0.9.0/* +Files: vendor/schemars-0.8.22/* + vendor/schemars-0.9.0/* vendor/schemars-1.2.2/* + vendor/schemars_derive-0.8.22/* vendor/schemars_derive-1.2.2/* Copyright: 2019 Graham Esau License: Expat @@ -17740,6 +18638,37 @@ License: Apache-2.0 or Expat IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/serde_tokenstream-0.3.0/* +Copyright: the serde_tokenstream authors +License: Apache-2.0 + On Debian systems, the complete text of the Apache License, Version 2.0 + can be found in "/usr/share/common-licenses/Apache-2.0". + . + APPENDIX: How to apply the Apache License to your work. + . + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + . + Copyright [yyyy] [name of copyright owner] + . + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + . + http://www.apache.org/licenses/LICENSE-2.0 + . + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + Files: vendor/serde_urlencoded-0.7.1/* Copyright: 2016 Anthony Ramine License: Apache-2.0 or Expat @@ -18068,6 +18997,63 @@ License: Apache-2.0 or Expat IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/signature-2.2.0/* +Copyright: 2018-2023 RustCrypto Developers +License: Apache-2.0 or Expat + On Debian systems, the complete text of the Apache License, Version 2.0 + can be found in "/usr/share/common-licenses/Apache-2.0". + . + APPENDIX: How to apply the Apache License to your work. + . + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + . + Copyright [yyyy] [name of copyright owner] + . + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + . + http://www.apache.org/licenses/LICENSE-2.0 + . + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + . + Copyright (c) 2018-2023 RustCrypto Developers + . + 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. + Files: vendor/signature-3.0.0/* Copyright: 2018-2026 RustCrypto Developers License: Apache-2.0 or Expat @@ -19974,6 +20960,35 @@ License: Expat IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/tower-http-0.6.11/* +Copyright: 2019-2021 Tower Contributors +License: Expat + Copyright (c) 2019-2021 Tower 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. + Files: vendor/tracing-0.1.44/* vendor/tracing-attributes-0.1.31/* vendor/tracing-core-0.1.36/* @@ -20200,6 +21215,18 @@ License: Apache-2.0 or Expat OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Files: vendor/typify-0.8.0/* +Copyright: the typify authors +License: Apache-2.0 + +Files: vendor/typify-impl-0.8.0/* +Copyright: the typify-impl authors +License: Apache-2.0 + +Files: vendor/typify-macro-0.8.0/* +Copyright: the typify-macro authors +License: Apache-2.0 + Files: vendor/uds_windows-1.2.1/* Copyright: Microsoft Corporation. All rights reserved. 2016 The Rust Project Developers @@ -21514,6 +22541,36 @@ Files: vendor/wasite-1.0.2/* Copyright: the wasite authors License: Apache-2.0 or BSL-1.0 or Expat +Files: vendor/wasm-streams-0.5.0/* +Copyright: Mattias Buelens +License: Apache-2.0 or Expat + On Debian systems, the complete text of the Apache License, Version 2.0 + can be found in "/usr/share/common-licenses/Apache-2.0". + . + 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. + Files: vendor/web-time-1.1.0/* Copyright: 2023 dAxpeDDa License: Apache-2.0 or Expat diff --git a/linux/build-aux/debian/rules b/linux/build-aux/debian/rules index 33f6e62de6..aa3480bb38 100755 --- a/linux/build-aux/debian/rules +++ b/linux/build-aux/debian/rules @@ -62,10 +62,12 @@ override_dh_clean: # Meson runs cargo from the build directory under the top of this tree, and # Cargo reads .cargo/config.toml from the directories above where it runs. +# A release's vendor tarball also carries the PostHog key; without it the +# app never offers to share usage data. override_dh_auto_configure: mkdir -p .cargo cp vendor/cargo-config.toml .cargo/config.toml - dh_auto_configure -- -Dprofile=default -Dkerberos=true + dh_auto_configure -- -Dprofile=default -Dkerberos=true -Dposthog_key=$(shell cat vendor/posthog-key 2>/dev/null) # The gtk suite opens windows on a display and owns D-Bus names. override_dh_auto_test: diff --git a/linux/build-aux/packaging/make-sources.sh b/linux/build-aux/packaging/make-sources.sh index ae8c39c768..27f006ece4 100755 --- a/linux/build-aux/packaging/make-sources.sh +++ b/linux/build-aux/packaging/make-sources.sh @@ -7,17 +7,53 @@ # --install-deps first installs, as root in a throwaway ubuntu:26.04, git # and the build dependencies in build-aux/debian/control, the toolchain # from ppa:rust-toolchain/staging among them. -# Usage: make-sources.sh [--install-deps] +# --posthog-key writes the PostHog project key to vendor/posthog-key, which +# debian/rules and the RPM spec pass to Meson as -Dposthog_key. The release +# gives it from a repository variable. Without one, or with an empty one, the +# packages send no usage data; with it, they send it unless the user turns it +# off. +# Usage: make-sources.sh [--install-deps] [--posthog-key KEY] set -eu +usage() { + echo "usage: make-sources.sh [--install-deps] [--posthog-key KEY] " >&2 + exit 2 +} + +not_a_key() { + echo "make-sources.sh: --posthog-key takes a PostHog project key, phc_ then letters and digits" >&2 + exit 2 +} + here="$(cd "$(dirname "$0")" && pwd)" root="$(cd "$here/../.." && pwd)" workspace="$(cd "$root/.." && pwd)" container="" -if [ "${1:-}" = "--install-deps" ]; then - shift - container=1 +posthog_key="" +while [ $# -gt 0 ]; do + case "$1" in + --install-deps) + container=1 + shift + ;; + --posthog-key) + [ $# -ge 2 ] || usage + posthog_key="$2" + shift 2 + ;; + *) break ;; + esac +done +[ $# -eq 1 ] || usage +# debian/rules puts the key on Meson's command line unquoted. +case "$posthog_key" in + *[!0-9A-Za-z_-]*) not_a_key ;; + "" | phc_?*) ;; + *) not_a_key ;; +esac + +if [ -n "$container" ]; then export DEBIAN_FRONTEND=noninteractive apt-get update apt-get install -y --no-install-recommends ca-certificates git xz-utils @@ -29,10 +65,6 @@ if [ "${1:-}" = "--install-deps" ]; then PATH="/usr/lib/rust-$rust/bin:$PATH" fi -if [ $# -ne 1 ]; then - echo "usage: make-sources.sh [--install-deps] " >&2 - exit 2 -fi mkdir -p "$1" out="$(cd "$1" && pwd)" @@ -57,6 +89,9 @@ mkdir "$work/vendor-root" cargo vendor --locked --versioned-dirs --manifest-path "$workspace/Cargo.toml" vendor ) > "$work/cargo-config.toml" mv "$work/cargo-config.toml" "$work/vendor-root/vendor/cargo-config.toml" +if [ -n "$posthog_key" ]; then + printf '%s\n' "$posthog_key" > "$work/vendor-root/vendor/posthog-key" +fi mtime="$(git -C "$root" log -1 --format=%ct)" XZ_OPT=-T0 tar -C "$work/vendor-root" --sort=name --owner=0 --group=0 --numeric-owner \ diff --git a/linux/build-aux/rpm/tablepro.spec b/linux/build-aux/rpm/tablepro.spec index 27837377e8..861bc1aff2 100644 --- a/linux/build-aux/rpm/tablepro.spec +++ b/linux/build-aux/rpm/tablepro.spec @@ -49,6 +49,7 @@ License: %{shrink: (LGPL-2.1-or-later OR MPL-2.0) AND MIT AND (MIT OR Unlicense) AND + MPL-2.0 AND Unicode-3.0 AND Unicode-DFS-2015 AND Zlib @@ -177,7 +178,9 @@ export CARGO_BUILD_JOBS=%{_smp_build_ncpus} # this binary; thin LTO fits a builder. %%check repeats it, since meson # test rebuilds the binary first. export CARGO_PROFILE_RELEASE_LTO=thin -%meson -Dprofile=default -Dkerberos=true +# A release's vendor tarball carries the PostHog key; without it the app +# sends no usage data. +%meson -Dprofile=default -Dkerberos=true -Dposthog_key="$(cat vendor/posthog-key 2>/dev/null)" %meson_build # cargo2rpm's own call behind %%cargo_license, on the two packages Meson # builds for this architecture. The macro covers every workspace member on diff --git a/linux/build-aux/tests/test_make_sources.py b/linux/build-aux/tests/test_make_sources.py new file mode 100644 index 0000000000..865a95ce04 --- /dev/null +++ b/linux/build-aux/tests/test_make_sources.py @@ -0,0 +1,32 @@ +import subprocess +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parent.parent / "packaging" / "make-sources.sh" + +NOT_KEYS = ["key", "phc_", "phc_abc def", "phc_abc\n", "phc_abc$(id)", "phc_abc'", "-Dprofile=development"] + + +def run(*args): + with tempfile.TemporaryDirectory() as work: + out = Path(work, "sources") + result = subprocess.run( + ["sh", str(SCRIPT), *args, str(out)], capture_output=True, text=True, check=False + ) + return result, out.exists() + + +class MakeSourcesTest(unittest.TestCase): + def test_a_posthog_key_that_is_not_one_is_refused_before_anything_runs(self): + for key in NOT_KEYS: + with self.subTest(key=key): + result, made = run("--posthog-key", key) + + self.assertEqual(result.returncode, 2) + self.assertIn("takes a PostHog project key", result.stderr) + self.assertFalse(made) + + +if __name__ == "__main__": + unittest.main() diff --git a/linux/build-aux/update-cloud-api.sh b/linux/build-aux/update-cloud-api.sh new file mode 100755 index 0000000000..a2bf3dad8d --- /dev/null +++ b/linux/build-aux/update-cloud-api.sh @@ -0,0 +1,34 @@ +#!/bin/sh +# Copies what core/crates/account vendors from a tablepro-cloud checkout: the /v2 +# document build.rs generates the client from, and the entitlement test +# vectors. The apps' CI cannot reach that private repository, so these are +# copies, never links. Prints the tablepro-cloud commit for the commit message. +# +# Usage: build-aux/update-cloud-api.sh [tablepro-cloud checkout] +# The default is the tablepro-cloud checkout beside this repository. +set -eu + +root="$(cd "$(dirname "$0")/.." && pwd)" +cloud="${1:-$root/../../tablepro-cloud}" +document=api/v2.openapi-3.0.json +vectors=internal/licensing/adapter/jws/testdata/entitlement_vectors.json + +for source in "$document" "$vectors"; do + if [ ! -f "$cloud/$source" ]; then + echo "update-cloud-api.sh: $cloud/$source does not exist; pass the tablepro-cloud checkout as the first argument" >&2 + exit 1 + fi +done + +copy() { + if cmp -s "$cloud/$1" "$root/$2"; then + echo "$2 already matches" + else + cp "$cloud/$1" "$root/$2" + echo "updated $2" + fi +} + +copy "$document" ../core/crates/account/openapi/v2.openapi-3.0.json +copy "$vectors" ../core/crates/account/tests/data/entitlement_vectors.json +echo "tablepro-cloud commit: $(git -C "$cloud" rev-parse --short HEAD 2>/dev/null || echo unknown)" diff --git a/linux/build-aux/update-third-party-licenses.sh b/linux/build-aux/update-third-party-licenses.sh index a3495c7d8d..d6be646656 100755 --- a/linux/build-aux/update-third-party-licenses.sh +++ b/linux/build-aux/update-third-party-licenses.sh @@ -35,13 +35,16 @@ generate() { # A crate with no license file cargo-about can read gets the SPDX template # text, which names no copyright holder, and --fail lets that through. +# MPL-2.0's text has no holder to name, so its SPDX text is the license as +# the crate ships it. progenitor-client packages no LICENSE, and cargo-about +# 0.9.2 cannot fetch its repository's: the manifest's URL ends in .git. fallbacks="$(mktemp)" metadata="$(mktemp)" trap 'rm -f "$fallbacks" "$metadata"' EXIT generate "$root/build-aux/about/fallbacks.hbs" "$fallbacks" -if grep -q '[^[:space:]]' "$fallbacks"; then +if grep -v ': MPL-2.0$' "$fallbacks" | grep -q '[^[:space:]]'; then echo "update-third-party-licenses.sh: no license file read for these crates; add or fix their [.clarify] entry in about.toml:" >&2 - cat "$fallbacks" >&2 + grep -v ': MPL-2.0$' "$fallbacks" >&2 exit 1 fi diff --git a/linux/changelog.d/4053.added.1.md b/linux/changelog.d/4053.added.1.md new file mode 100644 index 0000000000..3f6607163d --- /dev/null +++ b/linux/changelog.d/4053.added.1.md @@ -0,0 +1 @@ +TablePro shares usage data with its developers, as TablePro for macOS does; Preferences > General > Privacy turns it off. diff --git a/linux/changelog.d/4053.added.2.md b/linux/changelog.d/4053.added.2.md new file mode 100644 index 0000000000..e8e46a0a1f --- /dev/null +++ b/linux/changelog.d/4053.added.2.md @@ -0,0 +1 @@ +After 30 days of use without a license, a message asks whether you would buy one. diff --git a/linux/changelog.d/4053.added.md b/linux/changelog.d/4053.added.md new file mode 100644 index 0000000000..6c5fc68a1e --- /dev/null +++ b/linux/changelog.d/4053.added.md @@ -0,0 +1 @@ +Sign in to a TablePro account in Preferences > Account to see your plan, devices and teams, as on macOS. diff --git a/linux/crates/app/Cargo.toml b/linux/crates/app/Cargo.toml index 845832fd32..8a8fd21ecf 100644 --- a/linux/crates/app/Cargo.toml +++ b/linux/crates/app/Cargo.toml @@ -58,6 +58,7 @@ default = ["kerberos"] kerberos = ["tablepro-driver-mssql/kerberos"] [dependencies] +tablepro-account.workspace = true tablepro-ai.workspace = true tablepro-core.workspace = true tablepro-driver-cassandra.workspace = true @@ -108,6 +109,9 @@ icu_locale_core = { version = "2.3", features = ["alloc"] } indexmap = "2.14" # A selection's sum and average, exact past what i128 holds. num-bigint.workspace = true +# Usage data (docs/decisions/0039-usage-data.md): the blocking client, whose worker is a thread of +# its own. Error tracking stays off: crash reports stay on disk. +posthog-rs = { version = "0.27.0", default-features = false } # The assistant's replies are Markdown; the HTML writer stays off. pulldown-cmark = { version = "0.13", default-features = false } semver = "1" @@ -115,6 +119,7 @@ sqlformat = "0.5" [dev-dependencies] proptest.workspace = true +tablepro-account = { workspace = true, features = ["fakes"] } tablepro-session = { workspace = true, features = ["fakes"] } tablepro-test-fixtures.workspace = true tablepro-test-support.workspace = true diff --git a/linux/crates/app/meson.build b/linux/crates/app/meson.build index 5901ae94ad..866b6ef32a 100644 --- a/linux/crates/app/meson.build +++ b/linux/crates/app/meson.build @@ -74,6 +74,9 @@ gtk_env = app_env if profile != 'development' gtk_env.set('CARGO_PROFILE_RELEASE_LTO', 'off') endif +# Tests never reach PostHog. The integration tests and the binary they +# start are not built with cfg(test), so they get no key from here. +gtk_env.set('TABLEPRO_POSTHOG_KEY', '') gtk_env.set('GSK_RENDERER', 'cairo') gtk_env.set('GTK_A11Y', 'test') # The key tests press real keys through XTEST, which only an X11 display diff --git a/linux/crates/app/src/application/app_actions.rs b/linux/crates/app/src/application/app_actions.rs index 789ac97267..8aa4678072 100644 --- a/linux/crates/app/src/application/app_actions.rs +++ b/linux/crates/app/src/application/app_actions.rs @@ -22,13 +22,17 @@ pub fn install_app_actions(app: &TableProApplication) { let about = gio::ActionEntry::builder("about") .activate(|app: &TableProApplication, _, _| present_about(app)) .build(); + // The license ask's Buy a License. + let buy_license = gio::ActionEntry::builder("buy-license") + .activate(|app: &TableProApplication, _, _| open_pricing(app)) + .build(); // A notification's default action, with the window and the tab it // is about. let focus_tab = gio::ActionEntry::builder("focus-tab") .parameter_type(Some(&<(u32, String)>::static_variant_type())) .activate(|app: &TableProApplication, _, target| focus_tab(app, target)) .build(); - app.add_action_entries([new_window, quit, preferences, about, focus_tab]); + app.add_action_entries([new_window, quit, preferences, about, buy_license, focus_tab]); } /// Bring the tab forward in its window. A notification can outlive both, @@ -66,10 +70,29 @@ fn present_preferences(app: &TableProApplication) { services.file_revealer.clone(), services.mcp(), services.ai.clone(), + services.account(), + services.telemetry(), ) .present(app.active_window().as_ref()); } +fn open_pricing(app: &TableProApplication) { + if let Some(services) = app.services() { + let telemetry = services.telemetry(); + telemetry.support_ask_answered("buy"); + telemetry.upgrade_clicked("app-ask"); + } + gtk4::UriLauncher::new(super::license_ask_prompt::PRICING).launch( + app.active_window().as_ref(), + gio::Cancellable::NONE, + |launched| { + if let Err(error) = launched { + tracing::warn!(%error, "the pricing page was not opened"); + } + }, + ); +} + fn present_about(app: &TableProApplication) { let Some(services) = app.services() else { return; diff --git a/linux/crates/app/src/application/app_activity.rs b/linux/crates/app/src/application/app_activity.rs new file mode 100644 index 0000000000..044fa7c318 --- /dev/null +++ b/linux/crates/app/src/application/app_activity.rs @@ -0,0 +1,135 @@ +use std::time::{Duration, Instant}; + +use gtk4 as gtk; +use gtk4::gio; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +use super::TableProApplication; + +// Focus moving between TablePro's own windows leaves none active for a moment; a trip to the portal takes longer. +const RETURN_AFTER: Duration = Duration::from_secs(1); + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(super) struct Activity { + away_since: Option, +} + +impl Activity { + // True when the app comes back from elsewhere. + fn changed(&mut self, any_window_active: bool, now: Instant) -> bool { + match (any_window_active, self.away_since) { + (false, None) => { + self.away_since = Some(now); + false + } + (true, Some(since)) => { + self.away_since = None; + now.duration_since(since) >= RETURN_AFTER + } + (false, Some(_)) | (true, None) => false, + } + } +} + +impl TableProApplication { + // Every toplevel, since a window GTK does not add to the application, like the file chooser's fallback, holds the focus too. + pub(super) fn follow_activity(&self) { + let toplevels = gtk::Window::toplevels(); + self.follow_windows(&toplevels, 0, toplevels.n_items()); + let app = self.downgrade(); + toplevels.connect_items_changed(move |toplevels, position, _, added| { + if let Some(app) = app.upgrade() { + app.follow_windows(toplevels, position, added); + } + }); + } + + fn follow_windows(&self, toplevels: &gio::ListModel, position: u32, count: u32) { + let windows = + (position..position + count).filter_map(|index| toplevels.item(index).and_downcast::()); + for window in windows { + let app = self.downgrade(); + window.connect_is_active_notify(move |_| { + if let Some(app) = app.upgrade() { + app.activity_changed(); + } + }); + } + } + + fn activity_changed(&self) { + let any_window_active = gtk::Window::list_toplevels() + .iter() + .filter_map(|widget| widget.downcast_ref::()) + .any(GtkWindowExt::is_active); + let imp = self.imp(); + let mut activity = imp.activity.get(); + let back = activity.changed(any_window_active, Instant::now()); + imp.activity.set(activity); + if back && let Some(services) = imp.services.get() { + services.app_became_active(); + } + if any_window_active { + self.window_activated(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn moved(activity: &mut Activity, steps: &[(bool, Duration)], start: Instant) -> Vec { + steps + .iter() + .map(|&(active, at)| activity.changed(active, start + at)) + .collect() + } + + #[test] + fn coming_back_from_elsewhere_counts_once() { + let start = Instant::now(); + let mut activity = Activity::default(); + + let returns = moved( + &mut activity, + &[ + (true, Duration::ZERO), + (false, Duration::from_secs(1)), + (false, Duration::from_secs(2)), + (true, Duration::from_secs(30)), + (true, Duration::from_secs(31)), + ], + start, + ); + + assert_eq!(returns, [false, false, false, true, false]); + } + + #[test] + fn the_first_window_at_launch_is_no_return() { + let mut activity = Activity::default(); + + assert!(!activity.changed(true, Instant::now())); + } + + #[test] + fn focus_moving_between_the_apps_windows_is_no_return() { + let start = Instant::now(); + let mut activity = Activity::default(); + + let returns = moved( + &mut activity, + &[ + (false, Duration::ZERO), + (true, Duration::from_millis(40)), + (false, Duration::from_secs(5)), + (true, Duration::from_secs(5) + RETURN_AFTER - Duration::from_millis(1)), + ], + start, + ); + + assert_eq!(returns, [false, false, false, false]); + } +} diff --git a/linux/crates/app/src/application/app_services.rs b/linux/crates/app/src/application/app_services.rs index 6e721ca83e..c789ef17bd 100644 --- a/linux/crates/app/src/application/app_services.rs +++ b/linux/crates/app/src/application/app_services.rs @@ -1,9 +1,12 @@ -use std::cell::OnceCell; +use std::cell::{OnceCell, RefCell}; use std::path::Path; use std::rc::Rc; use std::sync::Arc; +use std::time::Duration; +use gio::prelude::SettingsExt as _; use gtk4::glib::prelude::IsA; +use tablepro_account::{AccountStatus, FeatureAccess, RefreshTokenVault}; use tablepro_core::{ConnectEnvironment, DriverRegistry, LivenessPolicy}; use tablepro_linux_platform::{AppSettings, SecretStore}; use tablepro_mcp::{McpHost, TokenVault, ToolContext}; @@ -16,6 +19,7 @@ use tablepro_storage::{QueryHistory, StoragePaths}; use crate::config; use crate::runtime::GioProcessHold; +use crate::services::account_service::{AccountHandle, AccountService, DesktopBrowser, KeyringRefreshToken}; use crate::services::active_session::ActiveSession; use crate::services::ai_service::AiService; use crate::services::connection_deletion::ConnectionDeletion; @@ -26,6 +30,8 @@ use crate::services::saved_connection_editor::SavedConnectionEditor; use crate::services::saved_connection_list::SavedConnectionList; use crate::services::session_context::{CredentialSourceFactory, KeyringSources, SessionContext}; use crate::services::snippet_library::SnippetLibrary; +use crate::services::telemetry::{Opened, PersonProperties, Telemetry, TelemetrySettings, architecture}; +use crate::services::telemetry_connector::TelemetryConnector; use crate::storage::{AppStorage, SharedStorage}; use crate::ui::credential_prompt::{GtkCredentialPrompter, PromptQueue}; use crate::ui::file_reveal::{FileLauncherRevealer, FileRevealer}; @@ -65,6 +71,28 @@ pub struct AppServices { /// none, which then runs no server. mcp_tokens: Option>, mcp: OnceCell, + // None in a test, which then stays signed out. + account_tokens: Option>, + account: RefCell, + telemetry: Telemetry, +} + +// The usage data follows the account, which a sign-out, a removed device or a refused sign-in leaves +// with none. Signed in without an entitlement, only /v2/me names it, which the account page reads. +// The value before the account restores is not one of these. +fn follow_account(handle: AccountHandle, telemetry: Telemetry) { + let mut status = handle.status; + glib::spawn_future_local(async move { + while status.changed().await.is_ok() { + let account_id = match &*status.borrow_and_update() { + AccountStatus::SignedIn(held) => held.entitlement.as_ref().map(|it| Some(it.claims().sub.clone())), + AccountStatus::SignedOut | AccountStatus::Offline(_) => Some(None), + }; + if let Some(account_id) = account_id { + telemetry.account_changed(account_id.as_deref()); + } + } + }); } /// What `AppServices::new` builds from, for a caller that brings its own @@ -120,6 +148,9 @@ impl AppServices { ssh: None, mcp_tokens: None, mcp: OnceCell::new(), + account_tokens: None, + account: RefCell::new(AccountService::signed_out()), + telemetry: Telemetry::default(), } } @@ -132,6 +163,7 @@ impl AppServices { ); let mcp_tokens: Arc = secrets.clone(); let ai_keys: Arc = secrets.clone(); + let account_tokens: Arc = Arc::new(KeyringRefreshToken(secrets.clone())); let storage = AppStorage::new(paths, secrets, &tasks); let ssh = SshServices::system(storage.paths().runtime.clone(), Path::new(config::LIBEXECDIR)); let liveness = LivenessPolicy::DESKTOP; @@ -144,6 +176,8 @@ impl AppServices { }, deadlines.clone(), ); + let telemetry = Telemetry::default(); + let connector = TelemetryConnector::new(Arc::new(connector), telemetry.clone()); let mut services = Self::new(AppServicesParts { drivers, connector: Arc::new(connector), @@ -155,6 +189,8 @@ impl AppServices { }); services.ssh = Some(ssh); services.mcp_tokens = Some(mcp_tokens); + services.account_tokens = Some(account_tokens); + services.telemetry = telemetry; services.ai = AiService::new(services.settings.clone(), services.tasks.clone(), Some(ai_keys)); services } @@ -167,6 +203,17 @@ impl AppServices { /// otherwise keep their tunnels open until the next reboot. pub(crate) fn start(&self) { drop(self.history()); + self.start_telemetry(); + if let Some(tokens) = self.account_tokens.clone() { + let account = AccountService::start( + &self.tasks, + &self.storage.paths().account_file(), + tokens, + Arc::new(DesktopBrowser), + ); + follow_account(account.handle(), self.telemetry.clone()); + drop(self.account.replace(account)); + } let Some(ssh) = self.ssh.clone() else { return; }; @@ -181,6 +228,68 @@ impl AppServices { })); } + // The paid feature gate, by the server's feature code. No feature on Linux asks it yet. + pub fn feature_access(&self, feature: &str) -> FeatureAccess { + self.account.borrow().feature_access(feature) + } + + pub(crate) fn account(&self) -> AccountHandle { + self.account.borrow().handle() + } + + // Back from the portal or another device, where a license slot may have been freed. + pub(crate) fn app_became_active(&self) { + if let Some(account) = self.account().account() { + drop( + self.tasks + .spawn_task(async move { account.refresh_past_device_limit().await }), + ); + } + } + + pub(crate) fn telemetry(&self) -> Telemetry { + self.telemetry.clone() + } + + // An empty key, as in every build without -Dposthog_key, makes no client at all. + fn start_telemetry(&self) { + self.start_telemetry_with(config::POSTHOG_KEY, config::POSTHOG_HOST); + } + + pub(super) fn start_telemetry_with(&self, key: &str, host: &str) { + let settings = TelemetrySettings { + key: key.to_owned(), + host: host.to_owned(), + state_file: self.storage.paths().telemetry_file(), + person: person_properties(), + }; + self.telemetry + .begin(settings, self.settings.share_usage_data(), &self.tasks); + if !self.telemetry.has_project_key() { + return; + } + let sessions = self.sessions.clone(); + let telemetry = self.telemetry.clone(); + let app_opened = move || telemetry.app_opened(open_sessions(&sessions)); + let opened = Rc::new(app_opened); + let telemetry = self.telemetry.clone(); + let check = opened.clone(); + self.settings.gio().connect_changed( + Some(tablepro_linux_platform::settings::keys::SHARE_USAGE_DATA), + move |settings, key| { + let sharing = settings.boolean(key); + telemetry.set_sharing(sharing); + if sharing { + check(); + } + }, + ); + check_app_opened(APP_OPENED_DELAY, APP_OPENED_CHECK, move || { + opened(); + glib::ControlFlow::Continue + }); + } + /// The database migrates and prunes on open, which is too slow to /// hold up the first window. The menu items that need it stay /// disabled until the service says how the open went. @@ -189,6 +298,7 @@ impl AppServices { .get_or_init(|| { let preferences = HistoryPreferences::follow(&self.settings); HistoryService::start(self.storage.paths().clone(), preferences, &self.tasks) + .with_telemetry(self.telemetry.clone()) }) .clone() } @@ -304,6 +414,50 @@ impl AppServices { } } +const APP_OPENED_DELAY: Duration = Duration::from_secs(10); +const APP_OPENED_CHECK: Duration = Duration::from_secs(60 * 60); + +// Restored sessions open first, as on macOS. The day is checked again each interval, so an app left open +// sends on each day it runs. +fn check_app_opened(delay: Duration, interval: Duration, mut check: impl FnMut() -> glib::ControlFlow + 'static) { + glib::timeout_add_local_once(delay, move || { + if check() == glib::ControlFlow::Continue { + glib::timeout_add_local(interval, check); + } + }); +} + +// clients.md counts the connections open, as macOS's heartbeat did. +fn open_sessions(sessions: &SessionRegistry) -> Opened { + let attached = sessions.attached_sessions(); + Opened { + driver_ids: attached + .iter() + .map(|(_, session)| session.metadata().driver_id.clone()) + .collect(), + connection_count: attached.len(), + } +} + +fn person_properties() -> PersonProperties { + PersonProperties { + app_version: config::VERSION.to_owned(), + os_version: crate::services::account_service::os_name(), + architecture: architecture(std::env::consts::ARCH).to_owned(), + locale: interface_locale(), + } +} + +// Linux has no language setting of its own: the interface follows LC_MESSAGES, here without its codeset. +fn interface_locale() -> String { + glib::language_names_with_category("LC_MESSAGES") + .iter() + .map(glib::GString::as_str) + .find(|name| !name.contains(['.', '@'])) + .unwrap_or("C") + .to_owned() +} + impl std::fmt::Debug for AppServices { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("AppServices") @@ -313,3 +467,306 @@ impl std::fmt::Debug for AppServices { .finish_non_exhaustive() } } + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::time::Instant; + + use chrono::{TimeDelta, Utc}; + use tablepro_account::{ + Account, AccountError, AccountSettings, AppClient, CallbackPage, DeviceDescription, Endpoints, MachineId, fakes, + }; + use tablepro_session::SessionKey; + use tablepro_session::fakes::FakeConnector; + use tablepro_test_support::ConnectionScript; + use tokio::sync::watch; + + use super::*; + use crate::runtime::AppRuntime; + use crate::services::account_service::AccountStart; + use crate::test_support::{ + Approving, CloudStub, FakeSession, MemoryKeyring, PostHogCollector, finish_on_main, test_runtime, wait_until, + }; + + const WAIT: Duration = Duration::from_secs(20); + // The sub fakes::claims gives. + const ACCOUNT: &str = "acct_01k8d6wk00f8xsr79e7x052rkk"; + + #[derive(Clone, Copy)] + enum Ending { + RefusedRefreshToken, + RemovedDevice, + } + + // Signs in until `ended`, then ends the sign-in as `ending` says. The access token lasts less + // than the margin the account keeps, so every call refreshes it first. + fn cloud(machine: &MachineId, ending: Ending, ended: Arc) -> CloudStub { + let mut claims = fakes::claims(Utc::now(), TimeDelta::days(14), &[]); + claims.mid = machine.as_str().to_owned(); + claims.dev = "dev_here".to_owned(); + let entitlement = format!( + r#"{{"token":"{}","expires_at":"2026-11-12T00:00:00Z","refresh_after":"2026-10-30T00:00:00Z"}}"#, + fakes::sign(&claims).expect("a token") + ); + let device = format!( + r#"{{"id":"dev_here","machine_id":"{}","platform":"linux","name":"Ada's laptop","app_version":"0.1.0","os_version":"Ubuntu 26.04","created_at":"2026-10-29T00:00:00Z","last_seen_at":"2026-10-29T00:00:00Z"}}"#, + machine.as_str() + ); + let tokens = + r#"{"access_token":"stub-access","token_type":"Bearer","expires_in":30,"refresh_token":"stub-refresh"}"#; + let revoked = r#"{"type":"about:blank","title":"Device revoked","status":403,"code":"device_revoked"}"#; + CloudStub::start(move |method, path| { + let ended = ended.load(Ordering::SeqCst); + match (method, path, ending) { + ("POST", "/oauth/token", Ending::RefusedRefreshToken) if ended => { + (400, r#"{"error":"invalid_grant"}"#.to_owned()) + } + ("POST", "/oauth/token", _) => (200, tokens.to_owned()), + ("POST", "/v2/devices", _) => (201, device.clone()), + ("POST", "/v2/devices/dev_here/entitlement", Ending::RemovedDevice) if ended => { + (403, revoked.to_owned()) + } + ("POST", "/v2/devices/dev_here/entitlement", _) => (200, entitlement.clone()), + _ => (404, String::new()), + } + }) + } + + fn account(stub: &CloudStub, machine: MachineId, dir: &Path) -> (Account, AccountHandle) { + let settings = AccountSettings { + client: AppClient::LINUX, + endpoints: Endpoints::new(&stub.url, &format!("{}/v2", stub.url)).expect("the endpoints"), + device: DeviceDescription { + name: "Ada's laptop".to_owned(), + app_version: "0.1.0".to_owned(), + os_version: "Ubuntu 26.04".to_owned(), + }, + machine_id: machine, + keys: fakes::keys().expect("the test key"), + account_file: dir.join("account.json"), + callback_page: CallbackPage { + finished: String::new(), + unknown: String::new(), + }, + }; + let (status, receiver) = watch::channel(AccountStatus::SignedOut); + let account = Account::new( + settings, + Arc::new(MemoryKeyring::default()), + Arc::new(Approving), + status, + ) + .expect("an account"); + let handle = AccountHandle { + status: receiver, + start: watch::channel(AccountStart::Starting).1, + portal: String::new(), + }; + (account, handle) + } + + fn usage_data(collector: &PostHogCollector, state_file: &Path, runtime: &AppRuntime) -> Telemetry { + let settings = TelemetrySettings { + key: "phc_test".to_owned(), + host: collector.url.clone(), + state_file: state_file.to_owned(), + person: PersonProperties { + app_version: "0.1.0-test".to_owned(), + os_version: "Ubuntu 26.04".to_owned(), + architecture: "arm64".to_owned(), + locale: "en_US".to_owned(), + }, + }; + Telemetry::start(settings, true, &runtime.tasks()) + } + + // Who the events go out as: the anonymous ID, and the account once identified. + fn kept_ids(state_file: &Path) -> Option<(String, Option)> { + let state: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(state_file).ok()?).ok()?; + Some(( + state["anonymous_id"].as_str()?.to_owned(), + state["identified"].as_str().map(str::to_owned), + )) + } + + // The GTK thread's futures, follow_account's among them, run while it waits. + fn run( + runtime: &AppRuntime, + work: impl Future> + Send + 'static, + ) -> Result<(), AccountError> { + finish_on_main(WAIT, runtime.tasks().spawn_task(work)) + .expect("the work finished") + .expect("the task ran") + } + + fn signed_in_then_ended(ending: Ending) -> Result<(), AccountError> { + let runtime = test_runtime(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let collector = PostHogCollector::start(); + let state_file = dir.path().join("telemetry.json"); + let telemetry = usage_data(&collector, &state_file, &runtime); + let machine = MachineId::derive("4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f20\n").expect("a machine ID"); + let ended = Arc::new(AtomicBool::new(false)); + let stub = cloud(&machine, ending, ended.clone()); + let (account, handle) = account(&stub, machine, dir.path()); + follow_account(handle, telemetry.clone()); + wait_until(WAIT, || telemetry.is_sending()).expect("the usage data started"); + run(&runtime, { + let account = account.clone(); + async move { account.sign_in(std::future::pending()).await } + }) + .expect("signed in"); + wait_until(WAIT, || { + kept_ids(&state_file).is_some_and(|(_, account_id)| account_id.as_deref() == Some(ACCOUNT)) + }) + .expect("identified as the account"); + let (anonymous_id, _) = kept_ids(&state_file).expect("the usage data's state"); + + ended.store(true, Ordering::SeqCst); + let refreshed = run(&runtime, { + let account = account.clone(); + async move { account.refresh_entitlement().await } + }); + + wait_until(WAIT, || { + kept_ids(&state_file) + .is_some_and(|(anonymous, account_id)| account_id.is_none() && anonymous != anonymous_id) + }) + .expect("reset to a new anonymous ID"); + telemetry.connection_attempted("sqlite"); + telemetry.flush(); + wait_until(WAIT, || !collector.named("connection_attempted").is_empty()).expect("the event sent"); + let (new_id, _) = kept_ids(&state_file).expect("the usage data's state"); + assert_eq!(collector.named("$identify")[0]["distinct_id"], ACCOUNT); + // Only the user's own sign-out is an event: the account may be gone. + assert!(collector.named("signed_out").is_empty()); + assert_eq!( + collector.named("connection_attempted")[0]["distinct_id"], + new_id.as_str() + ); + assert!(matches!(*account.status().borrow(), AccountStatus::SignedOut)); + refreshed + } + + #[gtk4::test] + fn a_refused_refresh_token_only_resets_the_usage_data() { + let refreshed = signed_in_then_ended(Ending::RefusedRefreshToken); + + assert!(matches!(refreshed, Err(AccountError::SignedOut)), "{refreshed:?}"); + } + + #[gtk4::test] + fn a_removed_device_only_resets_the_usage_data() { + let refreshed = signed_in_then_ended(Ending::RemovedDevice); + + assert!(matches!(refreshed, Err(AccountError::DeviceRevoked)), "{refreshed:?}"); + } + + #[test] + fn app_opened_counts_each_attached_session_with_its_driver() { + let runtime = test_runtime(); + let registry = SessionRegistry::new(); + let attached: Vec<_> = ["postgres", "sqlite", "postgres"] + .into_iter() + .map(|driver_id| { + let mut request = FakeConnector::request().expect("a request"); + request.metadata.driver_id = driver_id.to_owned(); + let fake = FakeSession::start_for(request, ConnectionScript::default(), [], runtime.tasks()); + let claim = registry + .claim(SessionKey::Saved(uuid::Uuid::new_v4()), 1) + .expect("a claim"); + claim.attach(&fake.session); + (claim, fake) + }) + .collect(); + let _connecting = registry + .claim(SessionKey::Saved(uuid::Uuid::new_v4()), 2) + .expect("a claim"); + + let mut opened = open_sessions(®istry); + opened.driver_ids.sort(); + + assert_eq!(opened.driver_ids, ["postgres", "postgres", "sqlite"]); + assert_eq!(opened.connection_count, 3); + drop(attached); + } + + #[gtk4::test] + fn app_opened_is_checked_after_the_delay_then_each_interval() { + let started = Instant::now(); + let checks = Rc::new(RefCell::new(Vec::new())); + let kept = checks.clone(); + check_app_opened(Duration::from_millis(300), Duration::from_millis(100), move || { + kept.borrow_mut().push(started.elapsed()); + match kept.borrow().len() { + 3 => glib::ControlFlow::Break, + _ => glib::ControlFlow::Continue, + } + }); + + wait_until(WAIT, || checks.borrow().len() == 3).expect("three checks"); + let fourth = wait_until(Duration::from_millis(300), || checks.borrow().len() > 3); + + assert!(fourth.is_err(), "a check after the schedule stopped"); + let checks = checks.borrow(); + // glib times each interval from its dispatch, which is a moment before the check reads the clock. + let slack = Duration::from_millis(10); + assert!(checks[0] + slack >= Duration::from_millis(300), "{checks:?}"); + assert!( + checks[1] - checks[0] + slack >= Duration::from_millis(100), + "{checks:?}" + ); + assert!( + checks[2] - checks[1] + slack >= Duration::from_millis(100), + "{checks:?}" + ); + } + + #[test] + fn this_build_sends_its_architecture_as_macos_names_it() { + let sent = if cfg!(target_arch = "aarch64") { + "arm64" + } else { + std::env::consts::ARCH + }; + + assert_eq!(person_properties().architecture, sent); + } + + // glib reads the locale variables, which no test may change in a process running others. + #[test] + fn the_locale_is_the_interfaces_language_without_its_codeset() { + let cases: [(&[(&str, &str)], &str); 5] = [ + (&[("LC_MESSAGES", "vi_VN.UTF-8"), ("LANG", "en_US.UTF-8")], "vi_VN"), + (&[("LANGUAGE", "pt_BR:vi"), ("LC_MESSAGES", "vi_VN.UTF-8")], "pt_BR"), + (&[("LC_MESSAGES", "sr_RS.UTF-8@latin")], "sr_RS"), + (&[("LC_ALL", "de_DE.UTF-8"), ("LC_MESSAGES", "vi_VN.UTF-8")], "de_DE"), + (&[], "C"), + ]; + let module = module_path!().split_once("::").map_or("", |(_, path)| path); + let name = format!("{module}::print_the_interface_locale"); + + for (variables, expected) in cases { + let mut child = std::process::Command::new(std::env::current_exe().expect("the test binary")); + child.args(["--exact", &name, "--ignored", "--nocapture", "--test-threads=1"]); + for variable in ["LANGUAGE", "LC_ALL", "LC_MESSAGES", "LANG"] { + child.env_remove(variable); + } + child.envs(variables.iter().copied()); + let output = child.output().expect("the child ran"); + let printed = String::from_utf8_lossy(&output.stdout); + let locale = printed + .split_once("interface locale: ") + .and_then(|(_, rest)| rest.split_whitespace().next()); + + assert_eq!(locale, Some(expected), "{variables:?}: {printed}"); + } + } + + #[test] + #[ignore = "the_locale_is_the_interfaces_language_without_its_codeset runs it with its own locale variables"] + fn print_the_interface_locale() { + println!("interface locale: {}", interface_locale()); + } +} diff --git a/linux/crates/app/src/application/imp.rs b/linux/crates/app/src/application/imp.rs index 8ced134848..fd609deb55 100644 --- a/linux/crates/app/src/application/imp.rs +++ b/linux/crates/app/src/application/imp.rs @@ -9,6 +9,7 @@ use gtk4::subclass::prelude::*; use libadwaita as adw; use libadwaita::subclass::prelude::*; +use super::app_activity::Activity; use super::session_signals::SessionSignals; use super::{AppServices, WindowRegistry, install_app_actions, install_application_accelerators}; use crate::workspace::CloseDecision; @@ -37,6 +38,9 @@ pub struct TableProApplication { pub(super) launch_notices: RefCell>, /// Answers the MCP server's questions about windows and sessions. pub(super) mcp_requests: RefCell>>, + pub(super) activity: Cell, + // Set at the primary instance's startup; the license ask counts its quiet period from it. + pub(super) launched_at: Cell>, } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] @@ -87,8 +91,10 @@ impl ApplicationImpl for TableProApplication { let app = self.obj(); install_app_actions(&app); install_application_accelerators(&*app); + app.follow_activity(); if let Some(services) = self.services.get() { services.start(); + app.start_license_ask(); app.start_mcp(); app.look_for_crash_report(); app.note_launch_version(); @@ -154,6 +160,8 @@ impl ApplicationImpl for TableProApplication { // them, so the next launch still reopens their sessions. if let Some(services) = self.services.get() { services.open_sessions().freeze_for_quit(); + // Bounded by the request timeout; without a PostHog key there is nothing to flush. + services.telemetry().flush(); } self.obj().stop_mcp(); self.windows.clear(); diff --git a/linux/crates/app/src/application/launch_notice.rs b/linux/crates/app/src/application/launch_notice.rs index 39323388c2..371c9c6fb0 100644 --- a/linux/crates/app/src/application/launch_notice.rs +++ b/linux/crates/app/src/application/launch_notice.rs @@ -91,7 +91,7 @@ impl TableProApplication { } // The windows of a restored session may still be on their way, so the first one to open says it. - fn announce(&self, notice: LaunchNotice) { + pub(super) fn announce(&self, notice: LaunchNotice) { match self.front_session_window() { Some(window) => window.announce(notice), None => self.imp().launch_notices.borrow_mut().push(notice), diff --git a/linux/crates/app/src/application/license_ask_prompt.rs b/linux/crates/app/src/application/license_ask_prompt.rs new file mode 100644 index 0000000000..5ff178fbfa --- /dev/null +++ b/linux/crates/app/src/application/license_ask_prompt.rs @@ -0,0 +1,127 @@ +use std::time::Instant; + +use chrono::Local; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita::prelude::*; + +use super::TableProApplication; +use crate::services::license_ask::{LAUNCH_QUIET_PERIOD, LicenseAsk, day_stamp, is_quiet_moment}; +use crate::ui::{LaunchNotice, SessionWindow}; + +// The query goes before the fragment, where the site reads it; app-ask is the referrer macOS's ask sends. +pub(super) const PRICING: &str = "https://tablepro.app/?ref=app-ask#pricing"; + +impl TableProApplication { + // From the primary instance's startup: the day counts, and the ask waits out the launch. + pub(super) fn start_license_ask(&self) { + let imp = self.imp(); + if imp.launched_at.get().is_some() { + return; + } + imp.launched_at.set(Some(Instant::now())); + self.record_usage_day(); + let app = self.downgrade(); + glib::timeout_add_local_once(LAUNCH_QUIET_PERIOD, move || { + if let Some(app) = app.upgrade() { + app.ask_for_license_if_due(); + } + }); + } + + // Each time one of the windows takes the focus, which happens soon after any moment the ask could use. + pub(super) fn window_activated(&self) { + if self.imp().launched_at.get().is_none() { + return; + } + self.record_usage_day(); + self.ask_for_license_if_due(); + } + + fn record_usage_day(&self) { + let Some(services) = self.imp().services.get() else { + return; + }; + if let Err(error) = services + .settings + .record_usage_day(&day_stamp(Local::now().date_naive())) + { + tracing::warn!(%error, "the day of use was not counted"); + } + } + + // A toast, not a dialog: GNOME's guidelines never let a dialog open unless the user asked for one. + fn ask_for_license_if_due(&self) { + let imp = self.imp(); + let (Some(services), Some(launched)) = (imp.services.get(), imp.launched_at.get()) else { + return; + }; + let today = Local::now().date_naive(); + let ask = LicenseAsk::read(&services.settings, services.account().has_license()); + if !ask.is_due(today) { + return; + } + // Only a window someone works in, with nothing presented over it. + let Some(window) = self.active_window().and_downcast::() else { + return; + }; + if !window.is_active() || window.visible_dialog().is_some() { + return; + } + if !is_quiet_moment(launched.elapsed(), any_tab_loading(window.upcast_ref())) { + return; + } + // Recorded as it shows, so quitting while it shows still counts as an ask. + if let Err(error) = services.settings.set_license_asked(&day_stamp(today)) { + tracing::warn!(%error, "the license ask was not recorded, so it is not shown"); + return; + } + tracing::info!(days = ask.usage_days, "asking about a license"); + services.telemetry().support_ask_shown(); + window.announce(LaunchNotice::LicenseAsk(ask.usage_days)); + } +} + +// A running query or a page still loading shows as its tab's spinner; the ask never lands on one. +fn any_tab_loading(root: >k4::Widget) -> bool { + let mut pending = vec![root.clone()]; + while let Some(widget) = pending.pop() { + if let Some(view) = widget.downcast_ref::() { + let pages = view.pages(); + let loading = (0..pages.n_items()) + .filter_map(|index| pages.item(index).and_downcast::()) + .any(|page| page.is_loading()); + if loading { + return true; + } + } + let mut child = widget.first_child(); + while let Some(next) = child { + child = next.next_sibling(); + pending.push(next); + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn a_loading_tab_anywhere_in_the_window_counts() { + let view = libadwaita::TabView::new(); + let page = view.append(>k4::Label::new(Some("tab"))); + let outer = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + inner.append(&view); + outer.append(&inner); + + let idle = any_tab_loading(outer.upcast_ref()); + page.set_loading(true); + + assert!(!idle); + assert!(any_tab_loading(outer.upcast_ref())); + } +} diff --git a/linux/crates/app/src/application/mod.rs b/linux/crates/app/src/application/mod.rs index a03767408e..0aa248ac88 100644 --- a/linux/crates/app/src/application/mod.rs +++ b/linux/crates/app/src/application/mod.rs @@ -6,12 +6,14 @@ mod accelerator; mod accelerator_scope; mod accelerators; mod app_actions; +mod app_activity; mod app_services; mod app_window_host; mod debug_info; mod imp; mod launch_notice; mod legal_section; +mod license_ask_prompt; mod mcp_bridge; #[cfg(test)] mod metainfo_release; diff --git a/linux/crates/app/src/application/tests.rs b/linux/crates/app/src/application/tests.rs index 56ce9500f3..0b9c56b182 100644 --- a/linux/crates/app/src/application/tests.rs +++ b/linux/crates/app/src/application/tests.rs @@ -280,11 +280,66 @@ fn handle_local_options_without_new_window_does_not_register() { fn startup_installs_the_app_actions_and_their_accels() { let test = TestApp::started("Actions"); - for action in ["new-window", "quit", "preferences", "about"] { + for action in ["new-window", "quit", "preferences", "about", "buy-license"] { assert!(test.app.lookup_action(action).is_some(), "app.{action} is missing"); } } +// The app's own wiring, with a key as a release has one: usage data goes from the start, as on macOS, and nothing +// reaches PostHog once Share Usage Data is off, until it is on again. +#[gtk4::test] +fn usage_data_goes_by_default_and_nothing_leaves_once_it_is_turned_off() { + let (services, _backing) = fake_services(); + let collector = crate::test_support::PostHogCollector::start(); + services.start_telemetry_with("phc_test", &collector.url); + let telemetry = services.telemetry(); + let pump = |time: u64| { + let _ = wait_until(Duration::from_millis(time), || false); + }; + let everything = || { + telemetry.connection_attempted("sqlite"); + telemetry.connection_succeeded("sqlite"); + telemetry.query_executed("sqlite"); + telemetry.upgrade_clicked("app-settings"); + telemetry.support_ask_shown(); + telemetry.signed_in("acct_01k8d6wk00f8xsr79e7x052rkk"); + telemetry.signed_out(); + }; + let sent = || (collector.connections(), collector.bodies().len()); + + telemetry.upgrade_clicked("app-ask"); + wait_until(Duration::from_secs(20), || { + !collector.named("upgrade_clicked").is_empty() + }) + .expect("sent by default"); + telemetry.flush(); + pump(300); + let by_default = sent(); + services.settings.set_share_usage_data(false).expect("turned off"); + pump(300); + everything(); + telemetry.flush(); + pump(500); + let while_off = sent(); + services.settings.set_share_usage_data(true).expect("turned on again"); + telemetry.upgrade_clicked("app-settings"); + wait_until(Duration::from_secs(20), || { + collector.named("upgrade_clicked").len() == 2 + }) + .expect("sent once on again"); + + assert!(by_default.1 > 0); + assert_eq!(while_off, by_default); +} + +#[gtk4::test] +fn startup_in_a_test_build_has_no_posthog_key() { + let test = TestApp::started("NoPostHogKey"); + + assert_eq!(crate::config::POSTHOG_KEY, ""); + assert!(!test.services().telemetry().has_project_key()); +} + #[gtk4::test] fn installed_application_accels_match_table() { use crate::test_support::shortcut_keys::keystroke; diff --git a/linux/crates/app/src/config.rs b/linux/crates/app/src/config.rs index f10421dfba..6de5e26642 100644 --- a/linux/crates/app/src/config.rs +++ b/linux/crates/app/src/config.rs @@ -3,6 +3,18 @@ pub const VERSION: &str = default_env(option_env!("TABLEPRO_VERSION"), "unknown" pub const PROFILE: &str = default_env(option_env!("TABLEPRO_PROFILE"), "development"); pub const LOCALEDIR: &str = default_env(option_env!("TABLEPRO_LOCALEDIR"), "/usr/share/locale"); pub const LIBEXECDIR: &str = default_env(option_env!("TABLEPRO_LIBEXECDIR"), "/usr/libexec"); +// Meson passes these from -Ddev_account_url and the like, for a development build only. +pub const DEV_ACCOUNT_URL: &str = default_env(option_env!("TABLEPRO_DEV_ACCOUNT_URL"), ""); +pub const DEV_API_URL: &str = default_env(option_env!("TABLEPRO_DEV_API_URL"), ""); +pub const DEV_ENTITLEMENT_KEYS: &str = default_env(option_env!("TABLEPRO_DEV_ENTITLEMENT_KEYS"), ""); +// Empty, so no usage data leaves the machine, unless the build was given -Dposthog_key; with one, nothing is +// sent until the user chooses to share it. Tests never reach PostHog: a unit test build has no key whatever Cargo +// was given, and meson.build builds the integration tests and the binary they start without one. +#[cfg(not(test))] +pub const POSTHOG_KEY: &str = default_env(option_env!("TABLEPRO_POSTHOG_KEY"), ""); +#[cfg(test)] +pub const POSTHOG_KEY: &str = ""; +pub const POSTHOG_HOST: &str = "https://eu.i.posthog.com"; pub const GETTEXT_PACKAGE: &str = env!("CARGO_PKG_NAME"); pub const RESOURCE_BASE_PATH: &str = "/app/tablepro/TablePro"; diff --git a/linux/crates/app/src/services/account_service.rs b/linux/crates/app/src/services/account_service.rs new file mode 100644 index 0000000000..19beab9aee --- /dev/null +++ b/linux/crates/app/src/services/account_service.rs @@ -0,0 +1,425 @@ +use std::path::Path; +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::Utc; +use secrecy::SecretString; +use tablepro_account::{ + Account, AccountError, AccountSettings, AccountStatus, AppClient, BrowserLauncher, CallbackPage, DeviceDescription, + Endpoints, EntitlementKeys, FeatureAccess, MachineId, RefreshTokenVault, Url, +}; +use tablepro_core::credentials::SecretError; +use tablepro_linux_platform::{MACHINE_ID_FILES, SecretStore, read_machine_id}; +use tablepro_session::runtime::Tasks; +use tokio::sync::{oneshot, watch}; +use tokio_util::task::AbortOnDropHandle; + +use crate::config; +use crate::i18n::gettext; + +// Flatpak binds the host's first file into every sandbox at the first path, or else its second, as D-Bus's older +// copy, so the host build reads them in the same order and both builds are one device. +// Flatpak writes the first at the root of every sandbox, and shows the host's os-release(5) at the second. +const FLATPAK_INFO: &str = "/.flatpak-info"; +const HOST_OS_RELEASE: &str = "/run/host/os-release"; +const PRODUCTION_PORTAL: &str = "https://account.tablepro.app"; + +// Whether the account could be built; it needs the machine ID and the service's address. +#[derive(Clone)] +pub enum AccountStart { + Starting, + Ready(Account), + NoMachineId(String), + Misconfigured(String), + Failed(AccountError), +} + +// What the Preferences page works through. +#[derive(Clone)] +pub struct AccountHandle { + pub status: watch::Receiver, + pub start: watch::Receiver, + // account.tablepro.app, or the development build's account service. + pub portal: String, +} + +impl AccountHandle { + // Whether the person has paid, which losing the network cannot undo: a lapsed entitlement still names its + // licenses, and only an account with a license meets the device limit (api.md, Devices). + pub fn has_license(&self) -> bool { + let status = self.status.borrow(); + status.past_device_limit() + || status + .entitlement() + .is_some_and(|entitlement| !entitlement.claims().licenses.is_empty()) + } + + pub fn account(&self) -> Option { + match &*self.start.borrow() { + AccountStart::Ready(account) => Some(account.clone()), + _ => None, + } + } +} + +pub struct AccountService { + handle: AccountHandle, + _running: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Cloud { + endpoints: Endpoints, + keys_x: Vec, + portal: String, +} + +impl AccountService { + // Until startup, in an instance that only hands its command line over, and in tests. + pub fn signed_out() -> Self { + Self { + handle: AccountHandle { + status: watch::channel(AccountStatus::SignedOut).1, + start: watch::channel(AccountStart::Starting).1, + portal: PRODUCTION_PORTAL.to_owned(), + }, + _running: None, + } + } + + pub fn start( + tasks: &Tasks, + account_file: &Path, + vault: Arc, + browser: Arc, + ) -> Self { + let (sender, status) = watch::channel(AccountStatus::SignedOut); + let (started, start) = watch::channel(AccountStart::Starting); + let callback_page = CallbackPage { + finished: gettext("You can close this tab and go back to TablePro."), + unknown: gettext("TablePro is not waiting for this sign-in. Start it again from TablePro."), + }; + let cloud = cloud( + config::profile(), + config::DEV_ACCOUNT_URL, + config::DEV_API_URL, + config::DEV_ENTITLEMENT_KEYS, + ); + let portal = cloud + .as_ref() + .map_or(PRODUCTION_PORTAL, |cloud| cloud.portal.as_str()) + .to_owned(); + let account_file = account_file.to_owned(); + let running = tasks.spawn_owned(async move { + let machine_id = match this_machine() { + Ok(machine_id) => machine_id, + Err(error) => { + tracing::warn!(%error, "the TablePro account needs a machine ID"); + started.send_replace(AccountStart::NoMachineId(error.to_string())); + return; + } + }; + let (endpoints, keys) = match cloud.and_then(|cloud| cloud.keys().map(|keys| (cloud.endpoints, keys))) { + Ok(settings) => settings, + Err(detail) => { + tracing::error!(%detail, "the development build's account settings are not valid"); + started.send_replace(AccountStart::Misconfigured(detail)); + return; + } + }; + let settings = AccountSettings { + client: AppClient::LINUX, + endpoints, + device: this_device(), + machine_id, + keys, + account_file, + callback_page, + }; + match Account::new(settings, vault, browser, sender) { + Ok(account) => { + started.send_replace(AccountStart::Ready(account.clone())); + account.run().await; + } + Err(error) => { + tracing::warn!(%error, "the TablePro account did not start"); + started.send_replace(AccountStart::Failed(error)); + } + } + }); + Self { + handle: AccountHandle { status, start, portal }, + _running: Some(running), + } + } + + pub fn handle(&self) -> AccountHandle { + self.handle.clone() + } + + pub fn feature_access(&self, feature: &str) -> FeatureAccess { + self.handle.status.borrow().feature_access(feature, Utc::now()) + } +} + +fn this_machine() -> Result { + let text = read_machine_id(&MACHINE_ID_FILES.map(Path::new)).map_err(|error| error.to_string())?; + MachineId::derive(&text).ok_or_else(|| format!("{} does not hold a machine ID", MACHINE_ID_FILES[0])) +} + +impl Cloud { + fn keys(&self) -> Result { + if self.keys_x.is_empty() { + return Ok(EntitlementKeys::release()); + } + EntitlementKeys::from_public_keys(self.keys_x.iter().map(String::as_str)).map_err(|error| error.to_string()) + } +} + +// A release build always signs in to tablepro.app, whatever it was built with. +fn cloud(profile: config::Profile, account: &str, api: &str, keys: &str) -> Result { + let production = || Cloud { + endpoints: Endpoints::production(), + keys_x: Vec::new(), + portal: PRODUCTION_PORTAL.to_owned(), + }; + let (account, api) = (account.trim(), api.trim()); + if profile == config::Profile::Default || (account.is_empty() && api.is_empty() && keys.trim().is_empty()) { + return Ok(production()); + } + if account.is_empty() || api.is_empty() { + return Err("dev_account_url and dev_api_url go together".to_owned()); + } + let endpoints = Endpoints::new(account, api).map_err(|error| format!("{account} or {api}: {error}"))?; + Ok(Cloud { + endpoints, + keys_x: keys + .split(',') + .map(str::trim) + .filter(|x| !x.is_empty()) + .map(str::to_owned) + .collect(), + portal: account.trim_end_matches('/').to_owned(), + }) +} + +fn this_device() -> DeviceDescription { + DeviceDescription { + name: glib::host_name().to_string(), + app_version: config::VERSION.to_owned(), + os_version: os_name(), + } +} + +pub(crate) fn os_name() -> String { + os_release_value("PRETTY_NAME").unwrap_or_else(|| "Linux".to_owned()) +} + +fn os_release_value(key: &str) -> Option { + match host_os_release(Path::new(FLATPAK_INFO), Path::new(HOST_OS_RELEASE)) { + Some(os_release) => value_of(&os_release, key), + None => glib::os_info(key) + .map(String::from) + .filter(|value| !value.trim().is_empty()), + } +} + +// Inside Flatpak, the os-release GLib reads is the runtime's, such as "GNOME 50". +fn host_os_release(flatpak_info: &Path, host_os_release: &Path) -> Option { + if !flatpak_info.exists() { + return None; + } + std::fs::read_to_string(host_os_release).ok() +} + +// GLib unquotes os-release values the same way. +fn value_of(os_release: &str, key: &str) -> Option { + let prefix = format!("{key}="); + let value = os_release + .lines() + .find_map(|line| line.trim().strip_prefix(prefix.as_str()))?; + let value = glib::shell_unquote(value).ok()?.into_string().ok()?; + (!value.trim().is_empty()).then_some(value) +} + +// The refresh token is one keyring item of its own, like the MCP token. +pub(crate) struct KeyringRefreshToken(pub Arc); + +#[async_trait] +impl RefreshTokenVault for KeyringRefreshToken { + async fn load_refresh_token(&self) -> Result, SecretError> { + self.0.load_account_token().await + } + + async fn store_refresh_token(&self, token: &SecretString) -> Result<(), SecretError> { + self.0 + .store_account_token(token, &gettext("TablePro account sign-in")) + .await + } + + async fn delete_refresh_token(&self) -> Result<(), SecretError> { + self.0.delete_account_token().await + } +} + +// UriLauncher belongs to the GTK thread, so the call goes there and its answer comes back. +pub(crate) struct DesktopBrowser; + +#[async_trait] +impl BrowserLauncher for DesktopBrowser { + async fn open(&self, url: &Url) -> Result<(), String> { + let (answer, answered) = oneshot::channel(); + let uri = url.to_string(); + glib::MainContext::default().invoke(move || { + gtk4::UriLauncher::new(&uri).launch(None::<>k4::Window>, gio::Cancellable::NONE, move |result| { + let _ = answer.send(result.map_err(|error| error.to_string())); + }); + }); + answered.await.map_err(|_| "the application stopped".to_owned())? + } +} + +#[cfg(test)] +mod tests { + use tablepro_account::{EntitlementProblem, SignedIn}; + + use super::*; + + const FEDORA: &str = + "NAME=\"Fedora Linux\"\nVERSION_ID=43\nPRETTY_NAME=\"Fedora Linux 43 (Workstation Edition)\"\n"; + + #[test] + fn an_account_over_the_device_limit_has_a_license() { + let (status, receiver) = watch::channel(AccountStatus::SignedOut); + let handle = AccountHandle { + status: receiver, + start: watch::channel(AccountStart::Starting).1, + portal: PRODUCTION_PORTAL.to_owned(), + }; + let signed_out = handle.has_license(); + + status.send_replace(AccountStatus::SignedIn(Box::new(SignedIn { + entitlement: None, + problem: Some(EntitlementProblem::DeviceLimitReached(Vec::new())), + remembered: true, + }))); + + assert!(!signed_out); + assert!(handle.has_license()); + } + + #[test] + fn the_pretty_name_is_read_quoted_or_bare() { + let cases = [ + (FEDORA, Some("Fedora Linux 43 (Workstation Edition)")), + ( + "PRETTY_NAME='Debian GNU/Linux 13 (trixie)'\n", + Some("Debian GNU/Linux 13 (trixie)"), + ), + ("PRETTY_NAME=Arch\n", Some("Arch")), + ("PRETTY_NAME=\"Say \\\"hi\\\"\"\n", Some("Say \"hi\"")), + ("NAME=Linux\nPRETTY_NAME=\"\"\n", None), + ("NAME=Linux\n", None), + ]; + + for (os_release, name) in cases { + assert_eq!(value_of(os_release, "PRETTY_NAME").as_deref(), name, "{os_release:?}"); + } + } + + #[test] + fn the_host_name_comes_from_run_host_inside_flatpak_only() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let (flatpak_info, host) = (dir.path().join(".flatpak-info"), dir.path().join("os-release")); + std::fs::write(&host, FEDORA).expect("the host's os-release"); + + let outside = host_os_release(&flatpak_info, &host); + std::fs::write(&flatpak_info, "[Application]\nname=app.tablepro.TablePro\n").expect("the Flatpak info"); + let inside = host_os_release(&flatpak_info, &host); + std::fs::remove_file(&host).expect("removed"); + let without_host = host_os_release(&flatpak_info, &host); + + assert_eq!(outside, None); + assert_eq!( + inside + .as_deref() + .and_then(|text| value_of(text, "PRETTY_NAME")) + .as_deref(), + Some("Fedora Linux 43 (Workstation Edition)") + ); + assert_eq!( + inside + .as_deref() + .and_then(|text| value_of(text, "VERSION_ID")) + .as_deref(), + Some("43") + ); + assert_eq!(without_host, None); + } + + #[test] + fn a_release_build_signs_in_to_tablepro_app_whatever_it_was_built_with() { + let production = cloud( + config::Profile::Default, + "http://account.localhost:5284", + "http://api.localhost:5284/v2", + "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + ) + .expect("the settings"); + let unset = cloud(config::Profile::Development, "", " ", "").expect("the settings"); + + for chosen in [production, unset] { + assert_eq!(chosen.endpoints, Endpoints::production()); + assert_eq!(chosen.portal, PRODUCTION_PORTAL); + assert!(chosen.keys_x.is_empty()); + } + } + + #[test] + fn a_development_build_takes_a_local_stack_and_its_keys() { + let local = cloud( + config::Profile::Development, + "http://account.localhost:5284/", + "http://api.localhost:5284/v2", + " 11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo, ", + ) + .expect("the settings"); + + assert_eq!( + local.endpoints, + Endpoints::new("http://account.localhost:5284", "http://api.localhost:5284/v2").expect("the endpoints") + ); + assert_eq!(local.portal, "http://account.localhost:5284"); + assert_eq!(local.keys().expect("the keys").kids().count(), 1); + } + + #[test] + fn half_a_local_stack_or_a_bad_key_is_refused_rather_than_production() { + let half = cloud(config::Profile::Development, "http://account.localhost:5284", "", ""); + let keyless = cloud( + config::Profile::Development, + "", + "", + "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + ); + let bad_key = cloud( + config::Profile::Development, + "http://account.localhost:5284", + "http://api.localhost:5284/v2", + "not a key", + ) + .expect("the settings") + .keys(); + + assert!(half.is_err()); + assert!(keyless.is_err()); + assert!(bad_key.is_err()); + } + + #[test] + fn before_the_account_starts_every_feature_is_signed_out() { + let service = AccountService::signed_out(); + + assert_eq!(service.feature_access("env_var_references"), FeatureAccess::SignedOut); + } +} diff --git a/linux/crates/app/src/services/history_service.rs b/linux/crates/app/src/services/history_service.rs index b7b48571f0..d7fb6bb0ee 100644 --- a/linux/crates/app/src/services/history_service.rs +++ b/linux/crates/app/src/services/history_service.rs @@ -4,12 +4,13 @@ use std::time::Duration; use tablepro_core::dialect::SqlDialect; use tablepro_core::sql_syntax::HistoryRecording; use tablepro_session::runtime::Tasks; -use tablepro_storage::query_history::{MAX_QUERY_BYTES, NewEntry}; +use tablepro_storage::query_history::{MAX_QUERY_BYTES, NewEntry, Outcome}; use tablepro_storage::{QueryHistory, StoragePaths}; use tokio::sync::watch; use super::history_availability::HistoryAvailability; use super::history_preferences::HistoryPreferences; +use super::telemetry::Telemetry; /// How often unpinned history is pruned while the app runs. Startup does /// one pass, and this keeps a long session from growing without bound. @@ -27,6 +28,8 @@ pub struct HistoryService { store: Arc>>, save: watch::Receiver, _worker: Arc>, + // Every editor run comes through here, history on or off, so the first query that worked is counted here. + telemetry: Telemetry, } impl HistoryService { @@ -72,9 +75,15 @@ impl HistoryService { store, save, _worker: Arc::new(worker), + telemetry: Telemetry::default(), } } + pub(crate) fn with_telemetry(mut self, telemetry: Telemetry) -> Self { + self.telemetry = telemetry; + self + } + /// Record one run once the database is open, unless the user turned /// the history off or the SQL can carry a credential. /// @@ -87,6 +96,9 @@ impl HistoryService { entry: NewEntry, dialect: Option>, ) -> impl Future + Send + use<> { + if matches!(entry.outcome, Outcome::Success) { + self.telemetry.query_executed(&entry.driver_id); + } let saving = *self.save.borrow(); let store = self.ready_store(); async move { @@ -348,6 +360,55 @@ mod tests { } } + // A plain test thread may not take GLib's default context, which the GTK tests hold, so this polls. + fn until(condition: impl Fn() -> bool) { + let started = std::time::Instant::now(); + while !condition() { + assert!(started.elapsed() < Duration::from_secs(20), "gave up waiting"); + std::thread::sleep(Duration::from_millis(20)); + } + } + + #[test] + fn only_a_run_that_worked_counts_as_the_first_query() { + let runtime = crate::test_support::test_runtime(); + let root = tempfile::tempdir().expect("tempdir"); + let collector = crate::test_support::PostHogCollector::start(); + let telemetry = Telemetry::default(); + telemetry.begin( + crate::services::telemetry::TelemetrySettings { + key: "phc_test".to_owned(), + host: collector.url.clone(), + state_file: root.path().join("telemetry.json"), + person: crate::services::telemetry::PersonProperties { + app_version: "0.1.0-test".to_owned(), + os_version: "Ubuntu 26.04".to_owned(), + architecture: "arm64".to_owned(), + locale: "en_US".to_owned(), + }, + }, + true, + &runtime.tasks(), + ); + until(|| telemetry.is_sending()); + // The history is off: the run is still counted. + let service = HistoryService::start(paths(&root), preferences(30, false), &runtime.tasks()) + .with_telemetry(telemetry.clone()); + let mut failed = run_of("SELECT * FROM boom"); + failed.outcome = Outcome::Error("relation \"boom\" does not exist".to_owned()); + + drop(service.record(failed, fake_dialect())); + telemetry.flush(); + let after_the_failure = collector.named("first_query_executed").len(); + drop(service.record(run_of("SELECT 1"), fake_dialect())); + drop(service.record(run_of("SELECT 2"), fake_dialect())); + telemetry.flush(); + + until(|| !collector.named("first_query_executed").is_empty()); + assert_eq!(after_the_failure, 0); + assert_eq!(collector.named("first_query_executed").len(), 1); + } + fn fake_dialect() -> Option> { Some(Arc::new(tablepro_test_support::FakeDialect)) } diff --git a/linux/crates/app/src/services/license_ask.rs b/linux/crates/app/src/services/license_ask.rs new file mode 100644 index 0000000000..f3142f6dbb --- /dev/null +++ b/linux/crates/app/src/services/license_ask.rs @@ -0,0 +1,117 @@ +use std::time::Duration; + +use chrono::{Days, NaiveDate}; +use tablepro_linux_platform::AppSettings; + +// The 30th day of use opens it; each ask closes it for 90 days, and a license closes it for good. The macOS app +// asks on the same days (#3922). +pub(crate) const FIRST_ASK_DAY: u32 = 30; +const DAYS_BETWEEN_ASKS: u64 = 90; +// Someone who just launched the app came to do something; the ask waits until they have. +pub(crate) const LAUNCH_QUIET_PERIOD: Duration = Duration::from_secs(5 * 60); +const DAY_FORMAT: &str = "%Y-%m-%d"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LicenseAsk { + pub usage_days: u32, + pub has_license: bool, + pub last_asked: Option, +} + +impl LicenseAsk { + pub(crate) fn read(settings: &AppSettings, has_license: bool) -> Self { + Self { + usage_days: settings.usage_days(), + has_license, + last_asked: parse_day(&settings.license_ask_day()), + } + } + + pub(crate) fn is_due(&self, today: NaiveDate) -> bool { + if self.has_license || self.usage_days < FIRST_ASK_DAY { + return false; + } + let Some(last_asked) = self.last_asked else { + return true; + }; + last_asked + .checked_add_days(Days::new(DAYS_BETWEEN_ASKS)) + .is_some_and(|next| today >= next) + } +} + +// Never over a query or a page someone is waiting for, and not in the first minutes after launch. +pub(crate) fn is_quiet_moment(since_launch: Duration, loading: bool) -> bool { + !loading && since_launch >= LAUNCH_QUIET_PERIOD +} + +pub(crate) fn day_stamp(day: NaiveDate) -> String { + day.format(DAY_FORMAT).to_string() +} + +fn parse_day(text: &str) -> Option { + NaiveDate::parse_from_str(text, DAY_FORMAT).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn day(text: &str) -> NaiveDate { + parse_day(text).expect("a day") + } + + fn ask(usage_days: u32, last_asked: Option<&str>) -> LicenseAsk { + LicenseAsk { + usage_days, + has_license: false, + last_asked: last_asked.map(day), + } + } + + #[test] + fn the_thirtieth_day_of_use_opens_it() { + let today = day("2026-11-05"); + + assert!(!ask(29, None).is_due(today)); + assert!(ask(30, None).is_due(today)); + assert!(ask(400, None).is_due(today)); + } + + #[test] + fn a_license_closes_it_for_good() { + let mut licensed = ask(300, None); + licensed.has_license = true; + + assert!(!licensed.is_due(day("2026-11-05"))); + } + + #[test] + fn each_ask_waits_ninety_days() { + let asked = ask(40, Some("2026-01-01")); + + assert!(!asked.is_due(day("2026-03-31"))); + assert!(asked.is_due(day("2026-04-01"))); + } + + #[test] + fn a_clock_set_back_never_asks_early() { + assert!(!ask(40, Some("2026-06-01")).is_due(day("2026-05-01"))); + } + + #[test] + fn it_waits_out_the_launch_and_any_loading_tab() { + let minute = Duration::from_secs(60); + + assert!(!is_quiet_moment(4 * minute, false)); + assert!(!is_quiet_moment(5 * minute, true)); + assert!(is_quiet_moment(5 * minute, false)); + } + + #[test] + fn days_round_trip_as_the_schema_stores_them() { + assert_eq!(day_stamp(day("2026-01-09")), "2026-01-09"); + assert_eq!(parse_day(""), None); + assert_eq!(parse_day("09/01/2026"), None); + } +} diff --git a/linux/crates/app/src/services/mod.rs b/linux/crates/app/src/services/mod.rs index 2dc37d3e0c..f641fba034 100644 --- a/linux/crates/app/src/services/mod.rs +++ b/linux/crates/app/src/services/mod.rs @@ -1,3 +1,4 @@ +pub mod account_service; pub mod active_session; pub(crate) mod ai_service; pub mod cell_overlay; @@ -20,6 +21,7 @@ pub mod history_preferences; pub mod history_service; pub mod import_file; pub(crate) mod library_outline; +pub(crate) mod license_ask; pub mod mcp_service; pub mod mcp_status; pub mod pending_structure; @@ -42,6 +44,8 @@ pub mod structure_tracker_handle; pub mod structure_tracker_registry; pub mod tab_registry; pub mod tab_tracker_handle; +pub mod telemetry; +pub mod telemetry_connector; pub mod touch; pub mod tracker_delta; pub mod tracker_edit; diff --git a/linux/crates/app/src/services/telemetry.rs b/linux/crates/app/src/services/telemetry.rs new file mode 100644 index 0000000000..6a333202f3 --- /dev/null +++ b/linux/crates/app/src/services/telemetry.rs @@ -0,0 +1,997 @@ +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError}; + +use chrono::{Local, NaiveDate}; +use posthog_rs::{ClientOptionsBuilder, Event}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tablepro_session::runtime::Tasks; + +// A request PostHog does not answer holds a flush, and so the exit, this long at most. +const REQUEST_TIMEOUT_SECONDS: u64 = 5; +const SHUTDOWN_TIMEOUT_MS: u64 = 2_000; +// Each event carries the turn of Share Usage Data it was captured in until before_send takes it off. +const TURN: &str = "tablepro_turn"; + +// The keys macOS sets, so one PostHog person reads the same from either app. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PersonProperties { + pub app_version: String, + pub os_version: String, + pub architecture: String, + pub locale: String, +} + +pub struct TelemetrySettings { + pub key: String, + pub host: String, + pub state_file: PathBuf, + pub person: PersonProperties, +} + +const SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(5); + +// clients.md, macOS Telemetry, without feature_gate_shown. Without a key there is nothing at all, +// and the PostHog client is made at the first time Share Usage Data is on. Clones share one. +#[derive(Clone, Default)] +pub struct Telemetry { + inner: Arc>>, +} + +struct Inner { + key: String, + host: String, + client: OnceLock, + kept: Mutex, + state_file: PathBuf, + // The generation on disk: writes run on the blocking pool, so an older one may finish last. + written: Arc>, + person: PersonProperties, + tasks: Tasks, + // The turn posthog-rs's worker checks each event against as it goes out, 0 while Share Usage Data is off. + turn: Arc, +} + +struct Kept { + state: State, + generation: u64, + sharing: bool, + // Moved each time Share Usage Data turns on, so an event queued before it went off stays unsent once it is on + // again. + turn: u64, + connecting: bool, + // Captured while the client was still being made, off the GTK thread. + pending: Vec, + account: Account, + identified_this_run: Option, + // From the user's Sign Out until the status says signed out, an answer still on its way names that account. + signing_out: bool, +} + +// Unknown until the account has restored, so a launch never resets an identity it has not checked. +#[derive(Clone)] +enum Account { + Unknown, + Known(Option), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct State { + anonymous_id: String, + // The account PostHog knows this install as, until a reset. + #[serde(default)] + identified: Option, + #[serde(default)] + opened_on: Option, + #[serde(default)] + sent_once: BTreeSet, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Opened { + pub driver_ids: Vec, + pub connection_count: usize, +} + +impl Telemetry { + #[cfg(test)] + pub fn start(settings: TelemetrySettings, sharing: bool, tasks: &Tasks) -> Self { + let telemetry = Self::default(); + telemetry.begin(settings, sharing, tasks); + telemetry + } + + pub fn begin(&self, settings: TelemetrySettings, sharing: bool, tasks: &Tasks) { + let key = settings.key.trim().to_owned(); + if key.is_empty() || self.inner.get().is_some() { + return; + } + // A few bytes, read before any event can need the ID. + let state = read_state(&settings.state_file); + let inner = Arc::new(Inner { + key, + host: settings.host, + client: OnceLock::new(), + kept: Mutex::new(Kept { + state: state.clone().unwrap_or_else(State::fresh), + generation: 0, + sharing: false, + turn: 0, + connecting: false, + pending: Vec::new(), + account: Account::Unknown, + identified_this_run: None, + signing_out: false, + }), + state_file: settings.state_file, + written: Arc::new(Mutex::new(0)), + person: settings.person, + tasks: tasks.clone(), + turn: Arc::new(AtomicU64::new(0)), + }); + if self.inner.set(inner.clone()).is_err() { + return; + } + if state.is_none() { + inner.save(&mut inner.kept()); + } + inner.set_sharing(sharing); + } + + pub fn has_project_key(&self) -> bool { + self.inner.get().is_some() + } + + #[cfg(test)] + pub fn is_sending(&self) -> bool { + self.inner + .get() + .is_some_and(|inner| inner.client.get().is_some() && inner.kept().sharing) + } + + pub fn set_sharing(&self, sharing: bool) { + if let Some(inner) = self.inner.get() { + inner.set_sharing(sharing); + } + } + + // The account's ID while signed in, None signed out. + pub fn account_changed(&self, account_id: Option<&str>) { + let Some(inner) = self.inner.get() else { + return; + }; + let mut kept = inner.kept(); + match account_id { + Some(_) if kept.signing_out => return, + Some(_) => {} + None => kept.signing_out = false, + } + kept.account = Account::Known(account_id.map(str::to_owned)); + inner.reconcile(&mut kept); + } + + pub fn signed_in(&self, account_id: &str) { + if let Some(inner) = self.inner.get() { + inner.kept().signing_out = false; + } + self.account_changed(Some(account_id)); + self.capture("signed_in", vec![("method", Value::from("interactive"))]); + } + + // The user's own sign-out: the event goes out as the account, what follows under a new anonymous ID. + pub fn signed_out(&self) { + let Some(inner) = self.inner.get() else { + return; + }; + let mut kept = inner.kept(); + inner.capture(&mut kept, "signed_out", Vec::new()); + kept.account = Account::Known(None); + kept.signing_out = true; + inner.reconcile(&mut kept); + } + + // Once per local day, while Share Usage Data is on. + pub fn app_opened(&self, opened: Opened) { + self.app_opened_on(Local::now().date_naive(), opened); + } + + pub fn connection_attempted(&self, driver_id: &str) { + self.capture_once("connection_attempted", driver_id); + } + + pub fn connection_succeeded(&self, driver_id: &str) { + self.capture_once("connection_succeeded", driver_id); + } + + pub fn query_executed(&self, driver_id: &str) { + self.capture_once("first_query_executed", driver_id); + } + + pub fn upgrade_clicked(&self, source: &str) { + self.capture("upgrade_clicked", vec![("source", Value::from(source))]); + } + + // The license ask, with no properties: the count of days used stays on this computer (clients.md, macOS). + pub fn support_ask_shown(&self) { + self.capture("support_ask_shown", Vec::new()); + } + + // Only `buy`: a toast that goes on its own is no answer, and it has no I Use It Personally. + pub fn support_ask_answered(&self, answer: &str) { + self.capture("support_ask_answered", vec![("answer", Value::from(answer))]); + } + + // posthog-rs's flush waits for every event still queued, each up to its own request timeout, so the + // wait is bounded here as a whole: quitting never holds for more than SHUTDOWN_WAIT. + pub fn flush(&self) { + let Some(inner) = self.inner.get().filter(|inner| inner.client.get().is_some()).cloned() else { + return; + }; + let (done, flushed) = std::sync::mpsc::channel(); + let flushing = std::thread::Builder::new() + .name("usage-data-flush".to_owned()) + .spawn(move || { + if let Some(client) = inner.client.get() { + client.flush(); + } + let _ = done.send(()); + }); + if let Err(error) = flushing { + tracing::warn!(%error, "the usage data was not flushed"); + return; + } + if flushed.recv_timeout(SHUTDOWN_WAIT).is_err() { + tracing::info!("usage data still waiting for PostHog was left unsent"); + } + } + + #[cfg(test)] + fn has_client(&self) -> bool { + self.inner.get().is_some_and(|inner| inner.client.get().is_some()) + } + + fn app_opened_on(&self, today: NaiveDate, opened: Opened) { + let Some(inner) = self.inner.get() else { + return; + }; + let mut kept = inner.kept(); + if !kept.sharing || kept.state.opened_on == Some(today) { + return; + } + kept.state.opened_on = Some(today); + inner.save(&mut kept); + let mut database_types: Vec<&str> = opened.driver_ids.iter().map(|id| database_type(id)).collect(); + database_types.sort_unstable(); + database_types.dedup(); + let properties = vec![ + ("database_types", Value::from(database_types)), + ("connection_count", Value::from(opened.connection_count)), + ]; + inner.capture(&mut kept, "app_opened", properties); + } + + // As on macOS, the first one counts whether it went out or Share Usage Data was off. + fn capture_once(&self, name: &str, driver_id: &str) { + let Some(inner) = self.inner.get() else { + return; + }; + let mut kept = inner.kept(); + if !kept.state.sent_once.insert(name.to_owned()) { + return; + } + inner.save(&mut kept); + let properties = vec![("database_type", Value::from(database_type(driver_id)))]; + inner.capture(&mut kept, name, properties); + } + + fn capture(&self, name: &str, properties: Vec<(&str, Value)>) { + if let Some(inner) = self.inner.get() { + inner.capture(&mut inner.kept(), name, properties); + } + } +} + +impl Inner { + fn kept(&self) -> MutexGuard<'_, Kept> { + self.kept.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn set_sharing(self: &Arc, sharing: bool) { + let mut kept = self.kept(); + if sharing && !kept.sharing { + kept.turn += 1; + } + kept.sharing = sharing; + self.turn.store(if sharing { kept.turn } else { 0 }, Ordering::SeqCst); + if !sharing { + kept.pending.clear(); + // An $identify still queued is dropped with the rest, so turning it on again sends it again. + kept.identified_this_run = None; + return; + } + self.reconcile(&mut kept); + if self.client.get().is_some() || kept.connecting { + return; + } + kept.connecting = true; + let inner = self.clone(); + // reqwest's blocking client starts a runtime of its own, which a runtime worker may not drop. + drop(self.tasks.spawn_blocking_task(move || inner.connect())); + } + + fn connect(&self) { + let turn = self.turn.clone(); + let options = ClientOptionsBuilder::default() + .api_key(self.key.clone()) + .host(self.host.clone()) + .is_server(false) + .request_timeout_seconds(REQUEST_TIMEOUT_SECONDS) + .shutdown_timeout_ms(SHUTDOWN_TIMEOUT_MS) + // Sent as captured and never tried again, so a failed one is lost rather than kept. + .flush_at(1_usize) + .max_capture_attempts(1_u32) + // The worker sends one request at a time, so what was captured behind a slow one is dropped when sharing + // goes off, and stays dropped once it is on again. + .before_send(move |mut event| { + let given = event.remove_prop(TURN).and_then(|turn| turn.as_u64()); + let current = turn.load(Ordering::SeqCst); + (current != 0 && given == Some(current)).then_some(event) + }) + .build(); + let options = match options { + Ok(options) => options, + Err(error) => { + tracing::warn!(%error, "usage data stays off"); + self.kept().pending.clear(); + return; + } + }; + let client = posthog_rs::client(options); + let mut kept = self.kept(); + if self.client.set(client).is_err() { + return; + } + if let Some(client) = self.client.get() { + for event in kept.pending.drain(..) { + client.capture(event); + } + } + } + + // While sharing, PostHog's person follows the account: a reset when it changed, an $identify once a run. + fn reconcile(&self, kept: &mut Kept) { + let Account::Known(account) = kept.account.clone() else { + return; + }; + if !kept.sharing { + return; + } + if kept.state.identified.is_some() && kept.state.identified != account { + kept.state.identified = None; + kept.state.anonymous_id = State::fresh().anonymous_id; + kept.identified_this_run = None; + self.save(kept); + } + let Some(account) = account else { + return; + }; + if kept.identified_this_run.as_ref() == Some(&account) { + return; + } + let anonymous_id = kept.state.anonymous_id.clone(); + kept.state.identified = Some(account.clone()); + kept.identified_this_run = Some(account.clone()); + self.save(kept); + let mut event = Event::new("$identify".to_owned(), account); + if let Err(error) = event + .insert_prop("$anon_distinct_id", anonymous_id) + .and_then(|()| event.insert_prop("$set", &self.person)) + { + tracing::debug!(%error, "the identify event was not built"); + return; + } + self.send(kept, event); + } + + fn capture(&self, kept: &mut Kept, name: &str, properties: Vec<(&str, Value)>) { + if !kept.sharing { + return; + } + let mut event = match &kept.state.identified { + Some(account) => Event::new(name.to_owned(), account.clone()), + // As posthog-ios's identifiedOnly: an anonymous event makes no person. + None => { + let mut event = Event::new(name.to_owned(), kept.state.anonymous_id.clone()); + if let Err(error) = event.insert_prop("$process_person_profile", false) { + tracing::debug!(%error, name, "a usage property was not added"); + } + event + } + }; + for (key, value) in properties { + if let Err(error) = event.insert_prop(key, value) { + tracing::debug!(%error, name, "a usage property was not added"); + } + } + self.send(kept, event); + } + + fn send(&self, kept: &mut Kept, mut event: Event) { + if let Err(error) = event.insert_prop(TURN, kept.turn) { + tracing::debug!(%error, "a usage event was not tagged"); + return; + } + match self.client.get() { + Some(client) => client.capture(event), + None => kept.pending.push(event), + } + } + + fn save(&self, kept: &mut Kept) { + let bytes = match serde_json::to_vec_pretty(&kept.state) { + Ok(bytes) => bytes, + Err(error) => { + tracing::warn!(%error, "the usage data state was not written"); + return; + } + }; + kept.generation += 1; + let generation = kept.generation; + let (path, written) = (self.state_file.clone(), self.written.clone()); + drop(self.tasks.spawn_blocking_task(move || { + let mut written = written.lock().unwrap_or_else(PoisonError::into_inner); + if *written > generation { + return; + } + *written = generation; + if let Err(error) = tablepro_storage::fs::write_private_blocking(&path, &bytes) { + tracing::warn!(%error, "the usage data state was not written"); + } + })); + } +} + +impl State { + fn fresh() -> Self { + Self { + anonymous_id: uuid::Uuid::new_v4().to_string(), + identified: None, + opened_on: None, + sent_once: BTreeSet::new(), + } + } +} + +// macOS's DatabaseType names, so a breakdown by database type reads the same from either app. MariaDB goes +// through the MySQL driver and ScyllaDB through Cassandra's, so neither is told apart here. +fn database_type(driver_id: &str) -> &str { + match driver_id { + "cassandra" => "Cassandra", + "clickhouse" => "ClickHouse", + "duckdb" => "DuckDB", + "mongodb" => "MongoDB", + "mssql" => "SQL Server", + "mysql" => "MySQL", + "postgres" => "PostgreSQL", + "redis" => "Redis", + "sqlite" => "SQLite", + other => other, + } +} + +// clients.md lists macOS's arm64 or x86_64, and Rust names arm64 aarch64. +pub(crate) fn architecture(target_arch: &str) -> &str { + match target_arch { + "aarch64" => "arm64", + other => other, + } +} + +fn read_state(path: &Path) -> Option { + let bytes = std::fs::read(path).ok()?; + serde_json::from_slice(&bytes) + .inspect_err(|error| tracing::warn!(%error, "the usage data state does not read; starting a new one")) + .ok() +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use super::*; + use crate::test_support::{PostHogCollector as Collector, test_runtime}; + + const WAIT: Duration = Duration::from_secs(20); + const ACCOUNT: &str = "acct_01k8d6wk00f8xsr79e7x052rkk"; + + fn person() -> PersonProperties { + PersonProperties { + app_version: "0.1.0-test".to_owned(), + os_version: "Ubuntu 26.04 LTS".to_owned(), + architecture: "arm64".to_owned(), + locale: "vi_VN".to_owned(), + } + } + + fn start(key: &str, sharing: bool, collector: &Collector, dir: &tempfile::TempDir, tasks: &Tasks) -> Telemetry { + let settings = TelemetrySettings { + key: key.to_owned(), + host: collector.url.clone(), + state_file: dir.path().join("telemetry.json"), + person: person(), + }; + Telemetry::start(settings, sharing, tasks) + } + + fn until(condition: impl Fn() -> bool) { + let started = Instant::now(); + while !condition() { + assert!(started.elapsed() < WAIT, "gave up waiting"); + std::thread::sleep(Duration::from_millis(20)); + } + } + + fn kept(dir: &tempfile::TempDir) -> Option { + serde_json::from_str(&std::fs::read_to_string(dir.path().join("telemetry.json")).ok()?).ok() + } + + // Writes run on the blocking pool, so the state shows a change a little after the call. + fn kept_when(dir: &tempfile::TempDir, condition: impl Fn(&serde_json::Value) -> bool) -> serde_json::Value { + let started = Instant::now(); + loop { + if let Some(state) = kept(dir).filter(|state| condition(state)) { + return state; + } + assert!(started.elapsed() < WAIT, "gave up waiting"); + std::thread::sleep(Duration::from_millis(20)); + } + } + + fn opened() -> Opened { + Opened { + driver_ids: ["postgres", "mysql", "postgres"].map(str::to_owned).to_vec(), + connection_count: 3, + } + } + + fn everything(telemetry: &Telemetry) { + telemetry.app_opened(opened()); + telemetry.connection_attempted("postgres"); + telemetry.connection_succeeded("postgres"); + telemetry.query_executed("postgres"); + telemetry.upgrade_clicked("app-settings"); + telemetry.signed_in(ACCOUNT); + telemetry.signed_out(); + telemetry.flush(); + } + + fn names(collector: &Collector) -> Vec { + collector + .events() + .iter() + .map(|event| event["event"].as_str().unwrap_or_default().to_owned()) + .collect() + } + + // The properties the app sets, without those posthog-rs adds to every event. + fn own_properties(event: &serde_json::Value) -> Vec { + let properties: std::collections::BTreeMap = + serde_json::from_value(event["properties"].clone()).unwrap_or_default(); + properties + .into_keys() + .filter(|key| !key.starts_with("$lib") && key != "$os" && key != "$os_version") + .collect() + } + + #[test] + fn without_a_project_key_nothing_leaves_the_machine() { + let runtime = test_runtime(); + let collector = Collector::start(); + let dir = tempfile::tempdir().expect("a temporary directory"); + + let telemetry = start(" ", true, &collector, &dir, &runtime.tasks()); + everything(&telemetry); + telemetry.account_changed(Some(ACCOUNT)); + std::thread::sleep(Duration::from_millis(500)); + + assert!(!telemetry.has_project_key()); + assert!(!telemetry.has_client()); + assert_eq!(collector.connections(), 0); + assert!(!dir.path().join("telemetry.json").exists()); + } + + #[test] + fn launched_with_usage_data_off_no_client_is_made_and_the_identify_waits_until_it_is_on() { + let runtime = test_runtime(); + let collector = Collector::start(); + let dir = tempfile::tempdir().expect("a temporary directory"); + + let telemetry = start("phc_test", false, &collector, &dir, &runtime.tasks()); + everything(&telemetry); + // The status the sign-out ends in, then a later sign-in. + telemetry.account_changed(None); + telemetry.account_changed(Some(ACCOUNT)); + std::thread::sleep(Duration::from_millis(500)); + let made_while_off = telemetry.has_client(); + let sent_while_off = collector.connections(); + telemetry.set_sharing(true); + until(|| !collector.named("$identify").is_empty()); + + assert!(telemetry.has_project_key()); + assert!(!made_while_off); + assert_eq!(sent_while_off, 0); + assert_eq!(names(&collector), ["$identify"]); + assert_eq!(collector.named("$identify")[0]["distinct_id"], ACCOUNT); + } + + #[test] + fn each_event_carries_the_properties_clients_md_lists() { + let runtime = test_runtime(); + let collector = Collector::start(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let telemetry = start("phc_test", true, &collector, &dir, &runtime.tasks()); + until(|| telemetry.is_sending()); + + everything(&telemetry); + + until(|| !collector.named("signed_out").is_empty()); + assert_eq!( + names(&collector), + [ + "app_opened", + "connection_attempted", + "connection_succeeded", + "first_query_executed", + "upgrade_clicked", + "$identify", + "signed_in", + "signed_out" + ] + ); + let event = |name: &str| collector.named(name).remove(0); + let opened = event("app_opened"); + assert_eq!( + opened["properties"]["database_types"], + serde_json::json!(["MySQL", "PostgreSQL"]) + ); + assert_eq!(opened["properties"]["connection_count"], 3); + assert_eq!(opened["properties"]["$process_person_profile"], false); + assert_eq!( + own_properties(&opened), + ["$process_person_profile", "connection_count", "database_types"] + ); + for name in ["connection_attempted", "connection_succeeded", "first_query_executed"] { + let once = event(name); + assert_eq!(once["properties"]["database_type"], "PostgreSQL", "{name}"); + assert_eq!( + own_properties(&once), + ["$process_person_profile", "database_type"], + "{name}" + ); + } + assert_eq!(event("upgrade_clicked")["properties"]["source"], "app-settings"); + let identify = event("$identify"); + assert_eq!(identify["distinct_id"], ACCOUNT); + assert_eq!(identify["properties"]["$anon_distinct_id"], opened["distinct_id"]); + let person: std::collections::BTreeMap = + serde_json::from_value(identify["properties"]["$set"].clone()).expect("the person properties"); + let expected = [ + ("app_version", "0.1.0-test"), + ("architecture", "arm64"), + ("locale", "vi_VN"), + ("os_version", "Ubuntu 26.04 LTS"), + ] + .map(|(key, value)| (key.to_owned(), value.to_owned())); + assert_eq!(person, std::collections::BTreeMap::from(expected)); + let signed_in = event("signed_in"); + assert_eq!(signed_in["distinct_id"], ACCOUNT); + assert_eq!(own_properties(&signed_in), ["method"]); + assert_eq!(signed_in["properties"]["method"], "interactive"); + let signed_out = event("signed_out"); + assert_eq!(signed_out["distinct_id"], ACCOUNT); + assert!(own_properties(&signed_out).is_empty(), "{signed_out}"); + assert!(collector.bodies()[0].contains("phc_test")); + } + + #[test] + fn an_event_leaves_as_it_is_captured_without_a_flush() { + let runtime = test_runtime(); + let collector = Collector::start(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let telemetry = start("phc_test", true, &collector, &dir, &runtime.tasks()); + until(|| telemetry.is_sending()); + + let captured = Instant::now(); + telemetry.upgrade_clicked("app-settings"); + while collector.named("upgrade_clicked").is_empty() { + // posthog-rs's own batching would hold it for 5 seconds. + assert!( + captured.elapsed() < Duration::from_secs(4), + "the event waited in the queue" + ); + std::thread::sleep(Duration::from_millis(20)); + } + } + + #[test] + fn the_first_connection_and_query_count_once_per_install_and_the_launch_once_a_day() { + let runtime = test_runtime(); + let collector = Collector::start(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let telemetry = start("phc_test", true, &collector, &dir, &runtime.tasks()); + telemetry.app_opened(opened()); + telemetry.connection_attempted("sqlite"); + telemetry.connection_attempted("postgres"); + telemetry.query_executed("sqlite"); + until(|| !collector.named("first_query_executed").is_empty()); + let today = Local::now().date_naive().to_string(); + until(|| { + kept(&dir).is_some_and(|state| { + state["opened_on"] == today.as_str() + && state["sent_once"].as_array().is_some_and(|sent| sent.len() == 2) + }) + }); + + let restarted = start("phc_test", true, &collector, &dir, &runtime.tasks()); + until(|| restarted.is_sending()); + restarted.app_opened(opened()); + restarted.connection_attempted("redis"); + restarted.query_executed("redis"); + restarted.connection_succeeded("redis"); + until(|| !collector.named("connection_succeeded").is_empty()); + + assert_eq!(collector.named("app_opened").len(), 1); + let attempts = collector.named("connection_attempted"); + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0]["properties"]["database_type"], "SQLite"); + assert_eq!(collector.named("first_query_executed").len(), 1); + assert_eq!( + collector.named("connection_succeeded")[0]["distinct_id"], + attempts[0]["distinct_id"] + ); + } + + #[test] + fn turned_off_nothing_is_captured_identified_or_reset_and_a_sign_out_meanwhile_resets_once_on_again() { + let runtime = test_runtime(); + let collector = Collector::start(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let telemetry = start("phc_test", true, &collector, &dir, &runtime.tasks()); + until(|| telemetry.is_sending()); + telemetry.signed_in(ACCOUNT); + until(|| !collector.named("signed_in").is_empty()); + let anonymous = kept_when(&dir, |state| state["identified"] == ACCOUNT)["anonymous_id"].clone(); + + telemetry.set_sharing(false); + telemetry.signed_out(); + telemetry.connection_attempted("sqlite"); + telemetry.upgrade_clicked("app-settings"); + telemetry.signed_in("acct_01k8d6x3q0f8xsr79e7x052zzz"); + telemetry.account_changed(None); + telemetry.flush(); + std::thread::sleep(Duration::from_millis(500)); + let while_off = names(&collector); + let state_while_off = kept_when(&dir, |state| { + state["sent_once"].as_array().is_some_and(|sent| !sent.is_empty()) + }); + + telemetry.set_sharing(true); + telemetry.upgrade_clicked("app-settings"); + until(|| !collector.named("upgrade_clicked").is_empty()); + + assert_eq!(while_off, ["$identify", "signed_in"]); + assert_eq!(state_while_off["identified"], ACCOUNT); + assert_eq!(state_while_off["anonymous_id"], anonymous); + assert_eq!(names(&collector), ["$identify", "signed_in", "upgrade_clicked"]); + let after = &collector.named("upgrade_clicked")[0]; + assert_ne!(after["distinct_id"], anonymous); + assert_ne!(after["distinct_id"], ACCOUNT); + assert_eq!(after["properties"]["$process_person_profile"], false); + kept_when(&dir, |state| state["identified"].is_null()); + } + + #[test] + fn a_launch_signed_in_identifies_once_and_a_restored_sign_out_resets_without_an_event() { + let runtime = test_runtime(); + let collector = Collector::start(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let telemetry = start("phc_test", true, &collector, &dir, &runtime.tasks()); + telemetry.account_changed(Some(ACCOUNT)); + telemetry.account_changed(Some(ACCOUNT)); + until(|| collector.named("$identify").len() == 1); + kept_when(&dir, |state| state["identified"] == ACCOUNT); + + let restarted = start("phc_test", true, &collector, &dir, &runtime.tasks()); + restarted.upgrade_clicked("app-settings"); + restarted.account_changed(None); + restarted.connection_attempted("sqlite"); + until(|| !collector.named("connection_attempted").is_empty()); + + assert_eq!(collector.named("$identify").len(), 1); + assert_eq!(collector.named("upgrade_clicked")[0]["distinct_id"], ACCOUNT); + let after = &collector.named("connection_attempted")[0]; + assert_ne!(after["distinct_id"], ACCOUNT); + assert!(collector.named("signed_out").is_empty()); + } + + #[test] + fn every_driver_the_app_registers_reports_the_name_macos_sends() { + let registry = crate::build_registry(); + + let mut reported: Vec<(&str, &str)> = registry + .iter() + .map(|driver| (driver.id(), database_type(driver.id()))) + .collect(); + reported.sort_unstable(); + + assert_eq!( + reported, + [ + ("cassandra", "Cassandra"), + ("clickhouse", "ClickHouse"), + ("duckdb", "DuckDB"), + ("mongodb", "MongoDB"), + ("mssql", "SQL Server"), + ("mysql", "MySQL"), + ("postgres", "PostgreSQL"), + ("redis", "Redis"), + ("sqlite", "SQLite"), + ] + ); + assert_eq!(database_type("a-driver-from-later"), "a-driver-from-later"); + } + + #[test] + fn the_architecture_is_the_name_macos_sends() { + let cases = [("aarch64", "arm64"), ("x86_64", "x86_64"), ("riscv64", "riscv64")]; + + for (target_arch, sent) in cases { + assert_eq!(architecture(target_arch), sent, "{target_arch}"); + } + } + + #[test] + fn a_running_app_sends_app_opened_again_on_the_next_local_day() { + let runtime = test_runtime(); + let collector = Collector::start(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let telemetry = start("phc_test", true, &collector, &dir, &runtime.tasks()); + let day = NaiveDate::from_ymd_opt(2026, 10, 30).expect("a day"); + let next = day.succ_opt().expect("the next day"); + + telemetry.app_opened_on(day, opened()); + telemetry.app_opened_on(day, opened()); + telemetry.app_opened_on(next, opened()); + telemetry.app_opened_on(next, opened()); + + until(|| collector.named("app_opened").len() == 2); + telemetry.flush(); + assert_eq!(collector.named("app_opened").len(), 2); + kept_when(&dir, |state| state["opened_on"] == "2026-10-31"); + } + + #[test] + fn a_send_posthog_refuses_is_not_tried_again() { + let runtime = test_runtime(); + let collector = Collector::answering(503); + let dir = tempfile::tempdir().expect("a temporary directory"); + let telemetry = start("phc_test", true, &collector, &dir, &runtime.tasks()); + until(|| telemetry.is_sending()); + + telemetry.upgrade_clicked("app-settings"); + telemetry.flush(); + // posthog-rs's default would try twice more, 200 and 400 ms apart. + std::thread::sleep(Duration::from_millis(1_500)); + telemetry.flush(); + + assert_eq!(collector.bodies().len(), 1); + } + + #[test] + fn an_event_waiting_behind_a_slow_request_is_dropped_when_turned_off() { + let runtime = test_runtime(); + let collector = Collector::start(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let telemetry = start("phc_test", true, &collector, &dir, &runtime.tasks()); + until(|| telemetry.is_sending()); + collector.hold(); + + telemetry.upgrade_clicked("app-settings"); + until(|| !collector.named("upgrade_clicked").is_empty()); + telemetry.connection_attempted("sqlite"); + telemetry.set_sharing(false); + collector.release(); + telemetry.flush(); + + assert_eq!(names(&collector), ["upgrade_clicked"]); + } + + #[test] + fn an_event_queued_before_turning_off_stays_unsent_once_on_again() { + let runtime = test_runtime(); + let collector = Collector::start(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let telemetry = start("phc_test", true, &collector, &dir, &runtime.tasks()); + until(|| telemetry.is_sending()); + collector.hold(); + + telemetry.upgrade_clicked("app-settings"); + until(|| !collector.named("upgrade_clicked").is_empty()); + telemetry.connection_attempted("sqlite"); + telemetry.set_sharing(false); + telemetry.set_sharing(true); + telemetry.query_executed("sqlite"); + collector.release(); + telemetry.flush(); + + assert_eq!(names(&collector), ["upgrade_clicked", "first_query_executed"]); + } + + #[test] + fn an_identify_dropped_when_turned_off_goes_out_once_on_again() { + let runtime = test_runtime(); + let collector = Collector::start(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let telemetry = start("phc_test", true, &collector, &dir, &runtime.tasks()); + until(|| telemetry.is_sending()); + collector.hold(); + + telemetry.upgrade_clicked("app-settings"); + until(|| !collector.named("upgrade_clicked").is_empty()); + telemetry.account_changed(Some(ACCOUNT)); + telemetry.set_sharing(false); + telemetry.set_sharing(true); + collector.release(); + telemetry.flush(); + + assert_eq!(names(&collector), ["upgrade_clicked", "$identify"]); + } + + #[test] + fn quitting_waits_for_posthog_at_most_the_shutdown_wait_however_many_events_queue() { + let runtime = test_runtime(); + let collector = Collector::start(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let telemetry = start("phc_test", true, &collector, &dir, &runtime.tasks()); + until(|| telemetry.is_sending()); + collector.hold(); + for source in ["app-settings", "app-ask", "app-settings", "app-ask"] { + telemetry.upgrade_clicked(source); + } + + let started = Instant::now(); + telemetry.flush(); + let waited = started.elapsed(); + collector.release(); + + assert!(waited < SHUTDOWN_WAIT + Duration::from_secs(1), "{waited:?}"); + } + + #[test] + fn after_the_users_sign_out_an_answer_still_naming_the_account_identifies_nobody() { + let runtime = test_runtime(); + let collector = Collector::start(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let telemetry = start("phc_test", true, &collector, &dir, &runtime.tasks()); + until(|| telemetry.is_sending()); + telemetry.signed_in(ACCOUNT); + + telemetry.signed_out(); + telemetry.account_changed(Some(ACCOUNT)); + telemetry.upgrade_clicked("app-settings"); + until(|| !collector.named("upgrade_clicked").is_empty()); + telemetry.flush(); + let identified_meanwhile = collector.named("$identify").len(); + telemetry.account_changed(None); + telemetry.account_changed(Some(ACCOUNT)); + until(|| collector.named("$identify").len() == 2); + + assert_eq!(identified_meanwhile, 1); + assert_eq!(collector.named("signed_out").len(), 1); + let after = &collector.named("upgrade_clicked")[0]; + assert_ne!(after["distinct_id"], ACCOUNT); + assert_eq!(after["properties"]["$process_person_profile"], false); + } +} diff --git a/linux/crates/app/src/services/telemetry_connector.rs b/linux/crates/app/src/services/telemetry_connector.rs new file mode 100644 index 0000000000..496afe7ccc --- /dev/null +++ b/linux/crates/app/src/services/telemetry_connector.rs @@ -0,0 +1,119 @@ +use std::sync::Arc; + +use futures::future::BoxFuture; +use tablepro_core::credentials::CredentialInteraction; +use tablepro_session::{ConnectError, ConnectRequest, Connector, PendingSession}; +use tokio_util::sync::CancellationToken; + +use super::telemetry::Telemetry; + +// Every connect goes through the app's connector, so one wrapper counts them all by engine. +pub struct TelemetryConnector { + inner: Arc, + telemetry: Telemetry, +} + +impl TelemetryConnector { + pub fn new(inner: Arc, telemetry: Telemetry) -> Self { + Self { inner, telemetry } + } +} + +impl Connector for TelemetryConnector { + fn establish( + &self, + request: ConnectRequest, + interaction: CredentialInteraction, + cancel: CancellationToken, + ) -> BoxFuture<'static, Result> { + let driver_id = request.metadata.driver_id.clone(); + self.telemetry.connection_attempted(&driver_id); + let telemetry = self.telemetry.clone(); + let connecting = self.inner.establish(request, interaction, cancel); + Box::pin(async move { + let connected = connecting.await; + if connected.is_ok() { + telemetry.connection_succeeded(&driver_id); + } + connected + }) + } +} + +#[cfg(test)] +mod tests { + use std::time::{Duration, Instant}; + + use tablepro_session::fakes::{FakeConnector, FakeEstablish}; + use tablepro_test_support::ConnectionScript; + + use super::*; + use crate::services::telemetry::{PersonProperties, TelemetrySettings}; + use crate::test_support::{PostHogCollector, test_runtime}; + + #[test] + #[expect( + clippy::disallowed_methods, + reason = "a test has no runtime of its own, so it drives the app's from outside" + )] + fn the_first_attempt_and_the_first_success_count_once_each() { + let runtime = test_runtime(); + let collector = PostHogCollector::start(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let settings = TelemetrySettings { + key: "phc_test".to_owned(), + host: collector.url.clone(), + state_file: dir.path().join("telemetry.json"), + person: PersonProperties { + app_version: "0.1.0-test".to_owned(), + os_version: "Ubuntu 26.04".to_owned(), + architecture: "arm64".to_owned(), + locale: "en_US".to_owned(), + }, + }; + let telemetry = Telemetry::start(settings, true, &runtime.tasks()); + let started = Instant::now(); + while !telemetry.is_sending() { + assert!(started.elapsed() < Duration::from_secs(20), "the client never started"); + std::thread::sleep(Duration::from_millis(20)); + } + let fake = FakeConnector::new([ + FakeEstablish::Fail(ConnectError::Cancelled), + FakeEstablish::Connect(ConnectionScript::default()), + FakeEstablish::Connect(ConnectionScript::default()), + ]); + let connector = TelemetryConnector::new(Arc::new(fake), telemetry.clone()); + let request = || { + let mut request = FakeConnector::request().expect("a request"); + request.metadata.driver_id = "postgres".to_owned(); + request + }; + + let tasks = runtime.tasks(); + let connected: Vec = (0..3) + .map(|_| { + tasks + .handle() + .block_on(connector.establish( + request(), + CredentialInteraction::Unattended, + CancellationToken::new(), + )) + .is_ok() + }) + .collect(); + telemetry.flush(); + + assert_eq!(connected, [false, true, true]); + let started = Instant::now(); + while collector.named("connection_succeeded").is_empty() { + assert!(started.elapsed() < Duration::from_secs(20), "the success never arrived"); + std::thread::sleep(Duration::from_millis(20)); + } + let attempted = collector.named("connection_attempted"); + let succeeded = collector.named("connection_succeeded"); + assert_eq!((attempted.len(), succeeded.len()), (1, 1)); + assert_eq!(attempted[0]["properties"]["database_type"], "PostgreSQL"); + assert_eq!(succeeded[0]["properties"]["database_type"], "PostgreSQL"); + } +} diff --git a/linux/crates/app/src/test_support/account_fakes.rs b/linux/crates/app/src/test_support/account_fakes.rs new file mode 100644 index 0000000000..8b2f47a0c7 --- /dev/null +++ b/linux/crates/app/src/test_support/account_fakes.rs @@ -0,0 +1,66 @@ +use std::sync::Mutex; + +use async_trait::async_trait; +use secrecy::SecretString; +use tablepro_account::{BrowserLauncher, RefreshTokenVault, Url}; +use tablepro_core::credentials::SecretError; + +#[derive(Default)] +pub(crate) struct MemoryKeyring(Mutex>); + +#[async_trait] +impl RefreshTokenVault for MemoryKeyring { + async fn load_refresh_token(&self) -> Result, SecretError> { + Ok(self.0.lock().expect("the keyring").clone()) + } + + async fn store_refresh_token(&self, token: &SecretString) -> Result<(), SecretError> { + *self.0.lock().expect("the keyring") = Some(token.clone()); + Ok(()) + } + + async fn delete_refresh_token(&self) -> Result<(), SecretError> { + *self.0.lock().expect("the keyring") = None; + Ok(()) + } +} + +// Approves at once, as the account service's consent screen would. +pub(crate) struct Approving; + +#[async_trait] +impl BrowserLauncher for Approving { + async fn open(&self, url: &Url) -> Result<(), String> { + let asked = |name: &str| { + url.query_pairs() + .find(|(key, _)| key == name) + .map(|(_, value)| value.into_owned()) + .ok_or(format!("no {name}")) + }; + let mut redirect = Url::parse(&asked("redirect_uri")?).map_err(|error| error.to_string())?; + redirect + .query_pairs_mut() + .append_pair("code", "stub-code") + .append_pair("state", &asked("state")?); + std::thread::spawn(move || deliver(&redirect)); + Ok(()) + } +} + +// Hands the loopback listener the redirect the browser would follow. +pub(crate) fn deliver(redirect: &Url) { + let target = format!( + "{}:{}", + redirect.host_str().expect("a host"), + redirect.port().expect("a port") + ); + let mut stream = std::net::TcpStream::connect(target).expect("the loopback listener"); + let path = format!("{}?{}", redirect.path(), redirect.query().unwrap_or_default()); + std::io::Write::write_all( + &mut stream, + format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n").as_bytes(), + ) + .expect("the redirect sent"); + let mut reply = String::new(); + std::io::Read::read_to_string(&mut stream, &mut reply).expect("the listener's answer"); +} diff --git a/linux/crates/app/src/test_support/cloud_stub.rs b/linux/crates/app/src/test_support/cloud_stub.rs new file mode 100644 index 0000000000..ac0dd93ad7 --- /dev/null +++ b/linux/crates/app/src/test_support/cloud_stub.rs @@ -0,0 +1,75 @@ +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::sync::{Arc, Mutex}; + +type Answer = dyn Fn(&str, &str) -> (u16, String) + Send + Sync; + +// Stands in for tablepro-cloud: `answer` gives the status and JSON body for a method and path. +pub(crate) struct CloudStub { + pub(crate) url: String, + requests: Arc>>, +} + +impl CloudStub { + pub(crate) fn start(answer: impl Fn(&str, &str) -> (u16, String) + Send + Sync + 'static) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("a free port"); + let url = format!("http://{}", listener.local_addr().expect("its address")); + let requests = Arc::new(Mutex::new(Vec::new())); + let kept = requests.clone(); + let answer: Arc = Arc::new(answer); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + let (kept, answer) = (kept.clone(), answer.clone()); + std::thread::spawn(move || serve(stream, &kept, &*answer)); + } + }); + Self { url, requests } + } + + // "METHOD /path" for each request, in the order they came. + pub(crate) fn requests(&self) -> Vec { + self.requests.lock().expect("the requests").clone() + } +} + +fn serve(stream: TcpStream, kept: &Mutex>, answer: &Answer) { + let mut reader = BufReader::new(stream.try_clone().expect("a second handle")); + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + return; + } + let mut words = line.split_whitespace(); + let method = words.next().unwrap_or_default().to_owned(); + let path = words + .next() + .unwrap_or_default() + .split('?') + .next() + .unwrap_or_default() + .to_owned(); + let mut length = 0; + loop { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + return; + } + if line == "\r\n" { + break; + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + length = value.trim().parse().unwrap_or(0); + } + } + let mut body = vec![0; length]; + if reader.read_exact(&mut body).is_err() { + return; + } + kept.lock().expect("the requests").push(format!("{method} {path}")); + let (status, body) = answer(&method, &path); + let reply = format!( + "HTTP/1.1 {status} Stub\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", + body.len() + ); + let mut writer = stream; + let _ = writer.write_all(reply.as_bytes()); +} diff --git a/linux/crates/app/src/test_support/mod.rs b/linux/crates/app/src/test_support/mod.rs index 91f0cf43dc..ba07943d6d 100644 --- a/linux/crates/app/src/test_support/mod.rs +++ b/linux/crates/app/src/test_support/mod.rs @@ -1,7 +1,9 @@ mod access_keys; mod accessible_label; +mod account_fakes; mod alert_response; mod application_end; +mod cloud_stub; mod connection_groups; mod fake_clocks; mod fake_reachability; @@ -14,6 +16,7 @@ mod log_lines; mod main_loop; mod memory_settings; mod owned_window; +mod posthog_collector; #[path = "../../tests/support/settings_schema.rs"] pub(crate) mod settings_schema; pub(crate) mod shortcut_keys; @@ -26,8 +29,10 @@ mod widget_lookup; pub(crate) use access_keys::{access_key_problems, assert_access_keys, menu_pages}; pub(crate) use accessible_label::{assert_labelled, test_backend_records_properties}; +pub(crate) use account_fakes::{Approving, MemoryKeyring, deliver}; pub(crate) use alert_response::{click_response, respond}; pub(crate) use application_end::end_application; +pub(crate) use cloud_stub::CloudStub; pub(crate) use connection_groups::{NestedLibrary, group_id, library, save_filed, saved_connection, seed_library}; pub(crate) use fake_clocks::FakeClocks; pub(crate) use fake_reachability::{Attached, FakeProbe, attach_remote}; @@ -42,6 +47,7 @@ pub(crate) use log_lines::LogLines; pub(crate) use main_loop::{after_next_frame, drain_main_context, finish_on_main, wait_until}; pub(crate) use memory_settings::MemorySettings; pub(crate) use owned_window::{OwnedWindow, drop_focus}; +pub(crate) use posthog_collector::PostHogCollector; pub(crate) use signal_log::SignalLog; pub(crate) use stall_probe::StallProbe; pub(crate) use unlabelled_widget::UnlabelledWidget; diff --git a/linux/crates/app/src/test_support/posthog_collector.rs b/linux/crates/app/src/test_support/posthog_collector.rs new file mode 100644 index 0000000000..a919f25bc7 --- /dev/null +++ b/linux/crates/app/src/test_support/posthog_collector.rs @@ -0,0 +1,115 @@ +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpListener; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +// Stands in for PostHog: answers every request and keeps each body. +pub(crate) struct PostHogCollector { + pub(crate) url: String, + connections: Arc>, + bodies: Arc>>, + held: Arc, +} + +impl PostHogCollector { + pub(crate) fn start() -> Self { + Self::answering(200) + } + + pub(crate) fn answering(status: u16) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").expect("a free port"); + let url = format!("http://{}", listener.local_addr().expect("its address")); + let connections = Arc::new(Mutex::new(0)); + let bodies = Arc::new(Mutex::new(Vec::new())); + let held = Arc::new(AtomicBool::new(false)); + let (counted, kept, holding) = (connections.clone(), bodies.clone(), held.clone()); + std::thread::spawn(move || { + for stream in listener.incoming().flatten() { + *counted.lock().expect("the count") += 1; + let (kept, holding) = (kept.clone(), holding.clone()); + std::thread::spawn(move || answer(stream, &kept, status, &holding)); + } + }); + Self { + url, + connections, + bodies, + held, + } + } + + // Until release, a request is kept but not answered, as by a slow PostHog. + pub(crate) fn hold(&self) { + self.held.store(true, Ordering::SeqCst); + } + + pub(crate) fn release(&self) { + self.held.store(false, Ordering::SeqCst); + } + + pub(crate) fn connections(&self) -> usize { + *self.connections.lock().expect("the count") + } + + pub(crate) fn bodies(&self) -> Vec { + self.bodies.lock().expect("the bodies").clone() + } + + pub(crate) fn events(&self) -> Vec { + self.bodies + .lock() + .expect("the bodies") + .iter() + .filter_map(|body| serde_json::from_str::(body).ok()) + .flat_map(|batch| batch["batch"].as_array().cloned().unwrap_or_default()) + .collect() + } + + pub(crate) fn named(&self, name: &str) -> Vec { + self.events() + .into_iter() + .filter(|event| event["event"] == name) + .collect() + } +} + +fn answer(stream: std::net::TcpStream, kept: &Mutex>, status: u16, held: &AtomicBool) { + let mut reader = BufReader::new(stream.try_clone().expect("a second handle")); + let mut writer = stream; + loop { + let mut length = 0; + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + return; + } + loop { + line.clear(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + return; + } + if line == "\r\n" { + break; + } + if let Some(value) = line.to_ascii_lowercase().strip_prefix("content-length:") { + length = value.trim().parse().unwrap_or(0); + } + } + let mut body = vec![0; length]; + if reader.read_exact(&mut body).is_err() { + return; + } + kept.lock() + .expect("the bodies") + .push(String::from_utf8_lossy(&body).into_owned()); + while held.load(Ordering::SeqCst) { + std::thread::sleep(Duration::from_millis(10)); + } + let reply = format!( + "HTTP/1.1 {status} Collector\r\ncontent-type: application/json\r\ncontent-length: 12\r\n\r\n{{\"status\":1}}" + ); + if writer.write_all(reply.as_bytes()).is_err() { + return; + } + } +} diff --git a/linux/crates/app/src/ui/error_text/account.rs b/linux/crates/app/src/ui/error_text/account.rs new file mode 100644 index 0000000000..898268f292 --- /dev/null +++ b/linux/crates/app/src/ui/error_text/account.rs @@ -0,0 +1,239 @@ +use tablepro_account::{AccountError, EntitlementError, OfflineFileError}; + +use crate::i18n::{gettext, gettext_f, ngettext_f}; + +// The OAuth error a person gets by pressing Cancel on the confirmation screen. +const ACCESS_DENIED: &str = "access_denied"; + +pub fn account_error_message(error: &AccountError) -> String { + match error { + AccountError::Unreachable(_) => { + gettext("TablePro could not reach its account service. Check the internet connection and try again.") + } + AccountError::Browser(_) => gettext("TablePro could not open the sign-in page in a web browser."), + AccountError::SignInTimedOut => { + gettext("The sign-in was not finished in the browser within 10 minutes. Start it again.") + } + AccountError::SignInCanceled => gettext("The sign-in was canceled."), + AccountError::SignInRefused { error, .. } if error == ACCESS_DENIED => { + gettext("The sign-in was declined in the browser.") + } + AccountError::SignInRefused { error, .. } => { + gettext_f("The account service refused the sign-in ({code}).", &[("code", error)]) + } + AccountError::SignedOut => gettext("You were signed out. Sign in again."), + AccountError::DeviceRevoked => { + gettext("This computer was removed from your account, so TablePro signed out here.") + } + AccountError::DeviceLimitReached { devices } => { + let count = u32::try_from(devices.len()).unwrap_or(u32::MAX); + ngettext_f( + "Your license is in use on {n} other device. Remove one to use it on this computer.", + "Your license is in use on {n} other devices. Remove one to use it on this computer.", + count, + &[("n", &count.to_string())], + ) + } + AccountError::OtherAccount => gettext( + "This file was made for another account. Make one with the account signed in here, or sign out first.", + ), + AccountError::Refused { status, code, .. } => refused_message(*status, code), + AccountError::RateLimited { + retry_after: Some(wait), + } => { + // Rounded up: until then the account answers the same without asking the service. + let minutes = u32::try_from(wait.as_millis().div_ceil(60_000)) + .unwrap_or(u32::MAX) + .max(1); + ngettext_f( + "The account service is busy. Try again in {n} minute.", + "The account service is busy. Try again in {n} minutes.", + minutes, + &[("n", &minutes.to_string())], + ) + } + AccountError::RateLimited { retry_after: None } => { + gettext("The account service is busy. Try again in a few minutes.") + } + AccountError::Entitlement(error) => entitlement_message(error), + AccountError::Storage { path, .. } => gettext_f( + "TablePro could not keep the account’s state in {path}.", + &[("path", path)], + ), + AccountError::Protocol(_) => gettext("TablePro could not read the account service’s answer. Try again later."), + } +} + +pub fn offline_file_message(error: &OfflineFileError) -> String { + match error { + OfflineFileError::Read { .. } => gettext("The offline file could not be read."), + OfflineFileError::TooLarge { .. } => gettext("This is not an offline file: it is far too large."), + } +} + +fn refused_message(status: u16, code: &str) -> String { + match code { + "offline_file_active" => gettext( + "That device has an offline file that is still valid, so it keeps its license slot until the file expires.", + ), + "temporarily_unavailable" => gettext("The account service is busy. Try again in a few minutes."), + "account_locked" => gettext("Your account is locked. Contact TablePro support."), + "not_found" => gettext("The account service no longer has it. Refresh and try again."), + "revoke" => gettext("TablePro signed out here, but could not tell the account service."), + _ if status >= 500 => gettext_f( + "The account service had a problem (error {status}). Try again later.", + &[("status", &status.to_string())], + ), + _ => gettext_f( + "The account service refused the request (error {status}).", + &[("status", &status.to_string())], + ), + } +} + +fn entitlement_message(error: &EntitlementError) -> String { + match error { + EntitlementError::Machine => { + gettext("This file was made for another computer. Make one for the machine ID this computer shows.") + } + EntitlementError::Expired(at) => gettext_f( + "It expired on {date}.", + &[("date", &crate::ui::preferences_dialog::account_view::day(*at))], + ), + EntitlementError::NotYetValid(_) => { + gettext("This computer’s clock is behind. Set the date and time, then try again.") + } + EntitlementError::UnknownKey(_) => { + gettext("This build of TablePro cannot check it: it is signed with a key the build does not have.") + } + EntitlementError::Malformed(_) + | EntitlementError::Algorithm(_) + | EntitlementError::Type(_) + | EntitlementError::Signature + | EntitlementError::Audience => gettext("This is not a TablePro entitlement, or it was changed."), + } +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + + use super::*; + + fn refused(status: u16, code: &str) -> AccountError { + AccountError::Refused { + status, + code: code.to_owned(), + detail: String::new(), + } + } + + #[test] + fn every_error_gets_a_sentence_of_its_own_and_none_the_detail() { + let errors = [ + AccountError::Unreachable("tcp connect error: secret detail".to_owned()), + AccountError::Browser("secret detail".to_owned()), + AccountError::SignInTimedOut, + AccountError::SignInCanceled, + AccountError::SignInRefused { + error: ACCESS_DENIED.to_owned(), + description: None, + }, + AccountError::SignInRefused { + error: "invalid_scope".to_owned(), + description: Some("secret detail".to_owned()), + }, + AccountError::SignedOut, + AccountError::DeviceRevoked, + AccountError::DeviceLimitReached { devices: Vec::new() }, + AccountError::OtherAccount, + refused(409, "offline_file_active"), + AccountError::RateLimited { + retry_after: Some(std::time::Duration::from_secs(30)), + }, + AccountError::RateLimited { retry_after: None }, + refused(403, "account_locked"), + refused(404, "not_found"), + refused(400, "revoke"), + refused(503, "unavailable"), + refused(422, "validation_failed"), + AccountError::Entitlement(EntitlementError::Machine), + AccountError::Entitlement(EntitlementError::Expired(Utc::now())), + AccountError::Entitlement(EntitlementError::NotYetValid(Utc::now())), + AccountError::Entitlement(EntitlementError::UnknownKey(None)), + AccountError::Entitlement(EntitlementError::Signature), + AccountError::Storage { + path: "/home/ada/.local/state/tablepro/account.json".to_owned(), + detail: "secret detail".to_owned(), + }, + AccountError::Protocol("secret detail".to_owned()), + ]; + + let messages: Vec = errors.iter().map(account_error_message).collect(); + + for (error, message) in errors.iter().zip(&messages) { + assert!(!message.contains("secret detail"), "{error:?}: {message}"); + assert!(message.ends_with('.'), "{error:?}: {message}"); + } + let distinct: std::collections::HashSet<&String> = messages.iter().collect(); + assert_eq!(distinct.len(), messages.len(), "{messages:#?}"); + } + + #[test] + fn a_declined_sign_in_reads_as_declined_and_a_device_limit_counts_the_devices() { + let declined = account_error_message(&AccountError::SignInRefused { + error: ACCESS_DENIED.to_owned(), + description: None, + }); + let other = account_error_message(&AccountError::SignInRefused { + error: "invalid_scope".to_owned(), + description: None, + }); + + let device = tablepro_account::Device { + id: "dev_01".to_owned(), + machine_id: "0".repeat(64), + platform: "macos".to_owned(), + name: "Ada's Mac".to_owned(), + app_version: "2.0.0".to_owned(), + os_version: "26.1".to_owned(), + last_seen_at: Utc::now(), + licensed_at: Some(Utc::now()), + offline_until: None, + }; + let limited = account_error_message(&AccountError::DeviceLimitReached { + devices: vec![device.clone(), device], + }); + + assert_eq!(declined, "The sign-in was declined in the browser."); + assert_eq!(other, "The account service refused the sign-in (invalid_scope)."); + assert_eq!( + limited, + "Your license is in use on 2 other devices. Remove one to use it on this computer." + ); + } + + #[test] + fn a_rate_limit_names_its_wait_in_minutes_rounded_up() { + let limited = + |wait: Option| account_error_message(&AccountError::RateLimited { retry_after: wait }); + let seconds = |seconds: u64| Some(std::time::Duration::from_secs(seconds)); + + assert_eq!( + limited(seconds(3600)), + "The account service is busy. Try again in 60 minutes." + ); + assert_eq!( + limited(seconds(61)), + "The account service is busy. Try again in 2 minutes." + ); + assert_eq!( + limited(Some(std::time::Duration::from_millis(500))), + "The account service is busy. Try again in 1 minute." + ); + assert_eq!( + limited(None), + "The account service is busy. Try again in a few minutes." + ); + } +} diff --git a/linux/crates/app/src/ui/error_text/mod.rs b/linux/crates/app/src/ui/error_text/mod.rs index 64ee708ebf..fc29a47904 100644 --- a/linux/crates/app/src/ui/error_text/mod.rs +++ b/linux/crates/app/src/ui/error_text/mod.rs @@ -5,6 +5,7 @@ //! adds later still lands in a branch that says what to do next; the //! variant only adds detail. +mod account; mod ddl; mod driver; mod edit; @@ -24,6 +25,7 @@ mod tls_remedy; mod transfer; mod unsupported; +pub use account::{account_error_message, offline_file_message}; pub use ddl::{build_error_text, refusal_text, save_blocker_text}; pub use driver::{config_message, driver_message}; pub use edit::{edit_parse_message, row_error_message}; diff --git a/linux/crates/app/src/ui/icons.rs b/linux/crates/app/src/ui/icons.rs index c13f5ac819..aa2c179ddf 100644 --- a/linux/crates/app/src/ui/icons.rs +++ b/linux/crates/app/src/ui/icons.rs @@ -1,6 +1,7 @@ //! Every icon name the app asks the icon theme for. Keeping them in //! one place is what lets `all_icons_resolve` prove the set is real. +pub const ACCOUNT: &str = "avatar-default-symbolic"; pub const ACTION_UNAVAILABLE: &str = "action-unavailable-symbolic"; pub const APPLICATIONS_ENGINEERING: &str = "applications-engineering-symbolic"; pub const APPLICATIONS_SCIENCE: &str = "applications-science-symbolic"; @@ -82,6 +83,7 @@ pub const ZOOM_OUT: &str = "zoom-out-symbolic"; ) )] pub const ALL: &[&str] = &[ + ACCOUNT, ACTION_UNAVAILABLE, APPLICATIONS_ENGINEERING, APPLICATIONS_SCIENCE, diff --git a/linux/crates/app/src/ui/preferences_dialog/account_page.rs b/linux/crates/app/src/ui/preferences_dialog/account_page.rs new file mode 100644 index 0000000000..b1a13fc405 --- /dev/null +++ b/linux/crates/app/src/ui/preferences_dialog/account_page.rs @@ -0,0 +1,2294 @@ +use std::cell::{Cell, OnceCell, RefCell}; +use std::rc::Rc; +use std::sync::OnceLock; + +use chrono::Utc; +use glib::subclass::Signal; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_account::{AccountError, AccountStatus, Device, Profile, read_offline_file_blocking}; +use tablepro_session::runtime::Tasks; +use tokio_util::sync::CancellationToken; + +use super::account_view::{self, Facts, Fetch, Listing, Mode, Notice, NoticeAction, Plan, PlanRow, Removal}; +use super::device_limit_dialog::DeviceLimitDialog; +use crate::i18n::{gettext, gettext_f}; +use crate::services::account_service::{AccountHandle, AccountStart}; +use crate::services::telemetry::Telemetry; +use crate::ui::error_text::{account_error_message, offline_file_message}; +use crate::ui::icons; + +const CANCEL: &str = "cancel"; +const REMOVE: &str = "remove"; +// The source macOS's Settings > Account sends, so upgrade_clicked and the pricing page's referrer read the same +// from both apps; the query goes before the fragment, where the site reads it. +const UPGRADE_SOURCE: &str = "app-settings"; +const PRICING: &str = "https://tablepro.app/?ref=app-settings#pricing"; +const LICENSING_GUIDE: &str = "https://docs.tablepro.app/features/licensing"; + +mod imp { + use libadwaita::subclass::prelude::*; + + use super::*; + + #[derive(Default, gtk4::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/preferences-account-page.ui")] + pub struct AccountPage { + #[template_child] + pub account_group: TemplateChild, + #[template_child] + pub plan_group: TemplateChild, + #[template_child] + pub features_group: TemplateChild, + #[template_child] + pub devices_group: TemplateChild, + #[template_child] + pub manage_devices_button: TemplateChild, + #[template_child] + pub teams_group: TemplateChild, + #[template_child] + pub offline_group: TemplateChild, + pub(super) deps: OnceCell, + pub(super) rows: RefCell>, + pub(super) profile: RefCell>, + pub(super) devices: RefCell>>, + pub(super) signing_in: RefCell>, + // Moved by each sign-in, so one that a later one replaced ends without touching the page. + pub(super) sign_in_attempt: Cell, + pub(super) failure: RefCell>, + pub(super) busy: Cell, + pub(super) shown_mode: Cell>, + // A sign-in started here sends signed_in once the account's ID is known. + pub(super) signed_in_pending: Cell, + pub(super) limit_dialog: RefCell>>, + pub(super) following: RefCell>>, + } + + #[glib::object_subclass] + impl ObjectSubclass for AccountPage { + const NAME: &'static str = "TableProAccountPage"; + type Type = super::AccountPage; + type ParentType = adw::PreferencesPage; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for AccountPage { + fn dispose(&self) { + for following in self.following.take() { + following.abort(); + } + if let Some(signing_in) = self.signing_in.take() { + signing_in.cancel(); + } + } + + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("toast") + .param_types([adw::Toast::static_type()]) + .build(), + ] + }) + } + } + + impl WidgetImpl for AccountPage { + fn map(&self) { + self.parent_map(); + self.obj().details_shown(); + } + } + + impl PreferencesPageImpl for AccountPage {} +} + +pub(super) struct Deps { + handle: AccountHandle, + tasks: Tasks, + telemetry: Telemetry, +} + +glib::wrapper! { + pub struct AccountPage(ObjectSubclass) + @extends adw::PreferencesPage, gtk4::Widget, + @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget; +} + +impl Default for AccountPage { + fn default() -> Self { + glib::Object::new() + } +} + +impl AccountPage { + pub(super) fn bind(&self, handle: AccountHandle, tasks: Tasks, telemetry: Telemetry) { + let imp = self.imp(); + let page = self.downgrade(); + imp.manage_devices_button.connect_clicked(move |_| { + if let Some(page) = page.upgrade() { + page.open_portal("/devices"); + } + }); + let mut status = handle.status.clone(); + let mut start = handle.start.clone(); + if imp + .deps + .set(Deps { + handle, + tasks, + telemetry, + }) + .is_err() + { + return; + } + let page = self.downgrade(); + let follow_status = glib::spawn_future_local(async move { + while status.changed().await.is_ok() { + let Some(page) = page.upgrade() else { + return; + }; + page.status_changed(); + } + }); + let page = self.downgrade(); + let follow_start = glib::spawn_future_local(async move { + while start.changed().await.is_ok() { + let Some(page) = page.upgrade() else { + return; + }; + page.status_changed(); + } + }); + imp.following.replace(vec![follow_status, follow_start]); + self.status_changed(); + } + + pub(super) fn connect_toast(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "toast", + false, + glib::closure_local!(move |page: &Self, toast: adw::Toast| f(page, &toast)), + ) + } + + // What /v2/me and /v2/devices answered; the page fetches them itself while it is shown. + pub(crate) fn show_details(&self, profile: Fetch, devices: Fetch>) { + let imp = self.imp(); + if let (Fetch::Loaded(loaded), Some(deps)) = (&profile, imp.deps.get()) { + deps.telemetry.account_changed(Some(&loaded.id)); + if imp.signed_in_pending.replace(false) { + deps.telemetry.signed_in(&loaded.id); + } + } + imp.profile.replace(profile); + imp.devices.replace(devices); + self.render(); + } + + pub(crate) fn sign_in(&self) { + let imp = self.imp(); + let Some((deps, account)) = self.ready() else { + return; + }; + let cancel = CancellationToken::new(); + if let Some(earlier) = imp.signing_in.replace(Some(cancel.clone())) { + earlier.cancel(); + } + let attempt = imp.sign_in_attempt.get() + 1; + imp.sign_in_attempt.set(attempt); + imp.failure.replace(None); + self.render(); + // Cancel ends the wait for the browser only, so tokens already on their way are still stored. + let signing_in = deps + .tasks + .spawn_task(async move { account.sign_in(cancel.cancelled_owned()).await }); + let page = self.downgrade(); + glib::spawn_future_local(async move { + let result = signing_in.await; + let Some(page) = page.upgrade() else { + return; + }; + if page.imp().sign_in_attempt.get() != attempt { + return; + } + page.imp().signing_in.replace(None); + match result { + Ok(Err(AccountError::SignInCanceled)) => page.render(), + Ok(result) => page.signed_in(result), + Err(error) => { + tracing::warn!(%error, "the sign-in did not finish"); + page.render(); + } + } + }); + } + + pub(crate) fn cancel_sign_in(&self) { + if let Some(signing_in) = self.imp().signing_in.take() { + signing_in.cancel(); + } + self.render(); + } + + pub(crate) fn sign_out(&self) { + let Some((deps, account)) = self.ready() else { + return; + }; + self.imp().signed_in_pending.set(false); + deps.telemetry.signed_out(); + self.run(async move { account.sign_out().await }, |page, result| { + if let Err(error) = result { + page.toast(&account_error_message(&error)); + } + }); + } + + pub(crate) fn refresh(&self) { + let Some((_, account)) = self.ready() else { + return; + }; + self.run(async move { account.refresh_entitlement().await }, |page, result| { + if let Err(error) = result + && !matches!(error, AccountError::DeviceLimitReached { .. }) + { + page.toast(&account_error_message(&error)); + } + page.reload_details(); + }); + } + + pub(crate) fn import_offline_file(&self, token: String) { + let Some((_, account)) = self.ready() else { + return; + }; + self.run( + async move { account.import_offline_file(&token).await }, + |page, result| match result { + Ok(()) => { + let until = page + .status() + .entitlement() + .map(|entitlement| account_view::day(entitlement.expires_at())) + .unwrap_or_default(); + page.toast(&gettext_f( + "Offline file imported. It is valid until {date}.", + &[("date", &until)], + )); + } + Err(error) => page.toast(&gettext_f( + "The offline file was not imported. {reason}", + &[("reason", &account_error_message(&error))], + )), + }, + ); + } + + pub(crate) fn present_device_limit(&self) { + let imp = self.imp(); + let Some(deps) = imp.deps.get() else { + return; + }; + if imp.limit_dialog.borrow().is_some() { + return; + } + let Some(holding) = deps.handle.status.borrow().device_limit().map(<[Device]>::to_vec) else { + return; + }; + let page = self.downgrade(); + let dialog = DeviceLimitDialog::new( + holding, + deps.handle.clone(), + deps.tasks.clone(), + move |name, unverified| { + if let Some(page) = page.upgrade() { + page.toast(&match unverified { + None => gettext_f( + "Removed {name}. The paid features work on this computer now.", + &[("name", name)], + ), + Some(error) => gettext_f( + "Removed {name}. Your plan could not be verified yet: {reason}", + &[("name", name), ("reason", &account_error_message(error))], + ), + }); + page.reload_details(); + } + }, + ); + let page = self.downgrade(); + dialog.dialog.connect_closed(move |_| { + if let Some(page) = page.upgrade() { + page.imp().limit_dialog.replace(None); + } + }); + dialog.dialog.present(Some(self)); + imp.limit_dialog.replace(Some(dialog)); + } + + #[cfg(test)] + pub(crate) fn device_limit_dialog(&self) -> Option> { + self.imp().limit_dialog.borrow().clone() + } + + fn ready(&self) -> Option<(&Deps, tablepro_account::Account)> { + let deps = self.imp().deps.get()?; + Some((deps, deps.handle.account()?)) + } + + fn status(&self) -> AccountStatus { + self.imp() + .deps + .get() + .map(|deps| deps.handle.status.borrow().clone()) + .unwrap_or_default() + } + + fn signed_in(&self, result: Result<(), AccountError>) { + let imp = self.imp(); + match &result { + Ok(()) => {} + Err(AccountError::DeviceLimitReached { .. }) => self.present_device_limit(), + // Signed in after all: the notices say what went wrong. + Err(_) if matches!(self.status(), AccountStatus::SignedIn(_)) => {} + Err(error) => { + imp.failure.replace(Some(account_error_message(error))); + self.render(); + return; + } + } + match self.status().entitlement().map(|it| it.claims().sub.clone()) { + Some(account_id) => { + if let Some(deps) = imp.deps.get() { + deps.telemetry.signed_in(&account_id); + } + } + None => imp.signed_in_pending.set(true), + } + self.reload_details(); + } + + fn run(&self, work: F, done: impl FnOnce(&Self, T) + 'static) + where + F: Future + Send + 'static, + T: Send + 'static, + { + let imp = self.imp(); + let Some(deps) = imp.deps.get() else { + return; + }; + imp.busy.set(true); + self.render(); + let running = deps.tasks.spawn_task(work); + let page = self.downgrade(); + glib::spawn_future_local(async move { + let result = running.await; + let Some(page) = page.upgrade() else { + return; + }; + page.imp().busy.set(false); + match result { + Ok(result) => done(&page, result), + Err(error) => tracing::warn!(%error, "an account task did not finish"), + } + page.render(); + }); + } + + fn status_changed(&self) { + let imp = self.imp(); + let mode = account_view::mode(&self.status()); + if imp.shown_mode.replace(Some(mode)) != Some(mode) { + imp.profile.replace(Fetch::Idle); + imp.devices.replace(Fetch::Idle); + if mode == Mode::SignedIn { + self.load_details(); + } + } + self.render(); + } + + fn reload_details(&self) { + let imp = self.imp(); + imp.profile.replace(Fetch::Idle); + imp.devices.replace(Fetch::Idle); + self.load_details(); + } + + // Each time the page is shown, so what changed in the portal or on another device shows then. + fn details_shown(&self) { + if matches!(*self.imp().profile.borrow(), Fetch::Loading) { + return; + } + self.reload_details(); + } + + fn load_details(&self) { + let imp = self.imp(); + if !self.is_mapped() || !matches!(*imp.profile.borrow(), Fetch::Idle) { + return; + } + let Some((deps, account)) = self.ready() else { + return; + }; + if account_view::mode(&self.status()) != Mode::SignedIn { + return; + } + imp.profile.replace(Fetch::Loading); + imp.devices.replace(Fetch::Loading); + self.render(); + let fetching = deps + .tasks + .spawn_task(async move { (account.profile().await, account.devices().await) }); + let page = self.downgrade(); + glib::spawn_future_local(async move { + let fetched = fetching.await; + let Some(page) = page.upgrade() else { + return; + }; + // A sign-out while the answers were on their way leaves nothing to show them in. + if account_view::mode(&page.status()) != Mode::SignedIn { + return; + } + let (profile, devices) = match fetched { + Ok((profile, devices)) => (fetch(profile), fetch(devices)), + Err(error) => { + tracing::warn!(%error, "the account details were not fetched"); + (Fetch::Idle, Fetch::Idle) + } + }; + page.show_details(profile, devices); + }); + } + + fn render(&self) { + let imp = self.imp(); + for (group, row) in imp.rows.take() { + group.remove(&row); + } + let status = self.status(); + let machine_id = self + .ready() + .map(|(_, account)| account.machine_id().as_str().to_owned()); + let profile = imp.profile.borrow(); + let devices = imp.devices.borrow(); + let facts = Facts { + status: &status, + profile: &profile, + devices: &devices, + machine_id: machine_id.as_deref(), + now: Utc::now(), + }; + let mode = account_view::mode(&status); + self.render_account(&facts, mode); + imp.plan_group.set_visible(mode != Mode::SignedOut); + if mode != Mode::SignedOut { + self.render_plan(&facts); + } + for feature in account_view::features(&facts) { + let row = adw::ActionRow::builder().title(&feature.title).build(); + if let Some(subtitle) = &feature.subtitle { + row.set_subtitle(subtitle); + } + row.add_suffix(&included(feature.included)); + self.add(&imp.features_group, &row); + } + if mode == Mode::SignedOut { + let buy = self.web_row(&gettext("Buy a License"), PRICING); + let page = self.downgrade(); + buy.connect_activated(move |_| { + if let Some(page) = page.upgrade() { + page.upgrade_clicked(); + } + }); + self.add(&imp.features_group, &buy); + } + let guide = self.web_row(&gettext("Learn More About Licensing"), LICENSING_GUIDE); + self.add(&imp.features_group, &guide); + imp.devices_group.set_visible(mode == Mode::SignedIn); + imp.teams_group.set_visible(mode == Mode::SignedIn); + if mode == Mode::SignedIn { + self.render_devices(&facts); + self.render_teams(&profile); + } + self.render_offline(&facts, machine_id.as_deref()); + } + + fn render_account(&self, facts: &Facts<'_>, mode: Mode) { + let imp = self.imp(); + let group = imp.account_group.get(); + let start = imp.deps.get().map(|deps| deps.handle.start.borrow().clone()); + let unavailable = match &start { + None | Some(AccountStart::Starting | AccountStart::Ready(_)) => None, + Some(AccountStart::NoMachineId(detail)) => Some(gettext_f( + "TablePro accounts need this computer’s machine ID, which could not be read: {detail}", + &[("detail", detail)], + )), + Some(AccountStart::Misconfigured(detail)) => Some(gettext_f( + "This development build’s account settings are not valid: {detail}", + &[("detail", detail)], + )), + Some(AccountStart::Failed(error)) => Some(account_error_message(error)), + }; + if let Some(reason) = unavailable { + self.add(&group, &warning_row(&gettext("Accounts Unavailable"), &reason)); + return; + } + let ready = matches!(start, Some(AccountStart::Ready(_))); + let identity = account_view::identity(facts); + match (mode, identity) { + (Mode::SignedIn, Some(identity)) => { + let row = identity_row(&identity.name, identity.email.as_deref()); + row.add_suffix(&self.sign_out_button()); + self.add(&group, &row); + self.add(&group, &self.link_row(&gettext("Manage Account"), None, "/")); + } + // Without an entitlement only /v2/me names the account, and it can fail. + (Mode::SignedIn, None) => { + let row = match facts.profile { + Fetch::Failed(error) => { + let row = warning_row(&gettext("Signed In"), &account_error_message(error)); + row.add_suffix(&self.try_again_button()); + row + } + Fetch::Idle | Fetch::Loading | Fetch::Loaded(_) => { + let row = adw::ActionRow::builder() + .title(gettext("Signed In")) + .subtitle(gettext("Loading the account…")) + .build(); + row.add_suffix(&adw::Spinner::new()); + row + } + }; + row.add_suffix(&self.sign_out_button()); + self.add(&group, &row); + } + (Mode::Offline, identity) => { + let name = identity.map_or_else(|| gettext("Offline File"), |identity| identity.name); + let signing_in = imp.signing_in.borrow().is_some(); + let subtitle = match signing_in { + true => gettext("Finish signing in in the web browser that opened."), + false => gettext("Using an offline file"), + }; + let row = identity_row(&name, Some(&subtitle)); + match signing_in { + true => self.add_sign_in_progress(&row), + false => row.add_suffix(&self.sign_in_button(ready, false)), + } + self.add(&group, &row); + } + (Mode::SignedOut, _) => self.render_sign_in(&group, ready), + } + if let Some(failure) = imp.failure.borrow().as_ref() { + self.add(&group, &warning_row(&gettext("Sign-In Failed"), failure)); + } + } + + fn render_sign_in(&self, group: &adw::PreferencesGroup, ready: bool) { + let signing_in = self.imp().signing_in.borrow().is_some(); + let row = adw::ActionRow::builder() + .title(gettext("Sign In to TablePro")) + .subtitle(match signing_in { + true => gettext("Finish signing in in the web browser that opened."), + false => { + gettext("Use your plan’s paid features on this computer. Everything else works without an account.") + } + }) + .build(); + if signing_in { + self.add_sign_in_progress(&row); + } else { + row.add_suffix(&self.sign_in_button(ready, true)); + } + self.add(group, &row); + } + + fn add_sign_in_progress(&self, row: &adw::ActionRow) { + row.add_suffix(&adw::Spinner::new()); + let cancel = gtk4::Button::builder() + .label(gettext("Cancel")) + .valign(gtk4::Align::Center) + .build(); + let page = self.downgrade(); + cancel.connect_clicked(move |_| { + if let Some(page) = page.upgrade() { + page.cancel_sign_in(); + } + }); + row.add_suffix(&cancel); + } + + fn sign_out_button(&self) -> gtk4::Button { + let button = gtk4::Button::builder() + .label(gettext("Sign Out")) + .valign(gtk4::Align::Center) + .sensitive(!self.imp().busy.get()) + .build(); + let page = self.downgrade(); + button.connect_clicked(move |_| { + if let Some(page) = page.upgrade() { + page.sign_out(); + } + }); + button + } + + // Refreshes the entitlement too, as every Try Again does (clients.md, Shared rules). + fn try_again_button(&self) -> gtk4::Button { + let button = gtk4::Button::builder() + .label(gettext("Try Again")) + .valign(gtk4::Align::Center) + .sensitive(!self.imp().busy.get()) + .build(); + let page = self.downgrade(); + button.connect_clicked(move |_| { + if let Some(page) = page.upgrade() { + page.refresh(); + } + }); + button + } + + fn sign_in_button(&self, ready: bool, suggested: bool) -> gtk4::Button { + let button = gtk4::Button::builder() + .label(gettext("Sign In…")) + .valign(gtk4::Align::Center) + .sensitive(ready && !self.imp().busy.get()) + .build(); + if suggested { + button.add_css_class("suggested-action"); + } + let page = self.downgrade(); + button.connect_clicked(move |_| { + if let Some(page) = page.upgrade() { + page.sign_in(); + } + }); + button + } + + fn render_plan(&self, facts: &Facts<'_>) { + let group = self.imp().plan_group.get(); + for notice in account_view::notices(facts) { + let row = self.notice_row(¬ice); + self.add(&group, &row); + } + match account_view::plan(facts) { + Ok(Plan::Free) => { + let row = adw::ActionRow::builder() + .title(gettext("Free")) + .subtitle(gettext( + "Starter or a seat in a team adds the paid features on 3 of your devices.", + )) + .build(); + let upgrade = gtk4::Button::builder() + .label(gettext("Upgrade…")) + .valign(gtk4::Align::Center) + .build(); + let page = self.downgrade(); + upgrade.connect_clicked(move |_| { + if let Some(page) = page.upgrade() { + page.upgrade_clicked(); + page.open_portal("/billing"); + } + }); + row.add_suffix(&upgrade); + self.add(&group, &row); + } + Ok(Plan::Licenses(rows)) => { + for license in rows { + let row = self.plan_row(&license); + self.add(&group, &row); + } + } + Err(listing) => self.add_listing_state(&group, listing), + } + } + + fn plan_row(&self, license: &PlanRow) -> adw::ActionRow { + let row = plain_row(&license.title, Some(&license.subtitle)); + let Some(path) = license.billing.clone() else { + return row; + }; + let billing = gtk4::Button::builder() + .label(gettext("Billing…")) + .valign(gtk4::Align::Center) + .tooltip_text(gettext("Opens in your web browser")) + .build(); + let page = self.downgrade(); + billing.connect_clicked(move |_| { + if let Some(page) = page.upgrade() { + page.open_portal(&path); + } + }); + row.add_suffix(&billing); + row + } + + fn render_devices(&self, facts: &Facts<'_>) { + let group = self.imp().devices_group.get(); + let rows = match account_view::devices(facts) { + Listing::Rows(rows) => rows, + other => return self.add_listing_state(&group, other), + }; + let busy = self.imp().busy.get(); + for device in rows { + let row = plain_row(&device.name, Some(&device.subtitle)); + match &device.removal { + Removal::ThisDevice => {} + Removal::KeptUntil(until) => row.add_suffix(&dim_label(until)), + Removal::Allowed => { + let remove = gtk4::Button::builder() + .label(gettext("Remove")) + .valign(gtk4::Align::Center) + .sensitive(!busy) + .build(); + remove.update_property(&[gtk4::accessible::Property::Label(&gettext_f( + "Remove {name}", + &[("name", &device.name)], + ))]); + let page = self.downgrade(); + let (id, name) = (device.id.clone(), device.name.clone()); + remove.connect_clicked(move |_| { + if let Some(page) = page.upgrade() { + page.confirm_removal(id.clone(), name.clone()); + } + }); + row.add_suffix(&remove); + } + } + self.add(&group, &row); + } + } + + fn render_teams(&self, profile: &Fetch) { + let group = self.imp().teams_group.get(); + match account_view::teams(profile) { + Listing::Rows(rows) if rows.is_empty() => { + let row = adw::ActionRow::builder() + .title(gettext("Not in a Team")) + .subtitle(gettext("A team’s owner or admin can invite you.")) + .build(); + self.add(&group, &row); + } + Listing::Rows(rows) => { + for team in rows { + let row = self.link_row(&team.title, Some(&team.subtitle), &team.path); + self.add(&group, &row); + } + } + other => self.add_listing_state(&group, other), + } + } + + fn render_offline(&self, facts: &Facts<'_>, machine_id: Option<&str>) { + let imp = self.imp(); + let group = imp.offline_group.get(); + let busy = imp.busy.get(); + if let Some(verification) = account_view::verification(facts) { + let row = adw::ActionRow::builder() + .title(&verification.title) + .subtitle(&verification.subtitle) + .build(); + if account_view::mode(facts.status) == Mode::SignedIn { + let refresh = gtk4::Button::builder() + .label(gettext("Refresh")) + .valign(gtk4::Align::Center) + .sensitive(!busy) + .build(); + let page = self.downgrade(); + refresh.connect_clicked(move |_| { + if let Some(page) = page.upgrade() { + page.refresh(); + } + }); + row.add_suffix(&refresh); + } + self.add(&group, &row); + } + let import = adw::ButtonRow::builder() + .title(gettext("Import Offline File…")) + .sensitive(self.ready().is_some() && !busy) + .build(); + let page = self.downgrade(); + import.connect_activated(move |_| { + if let Some(page) = page.upgrade() { + page.choose_offline_file(); + } + }); + if let Some(machine_id) = machine_id { + // Copy gives the exact value; selecting would take the line break too. + let row = adw::ActionRow::builder() + .use_markup(true) + .title(gettext("Machine ID")) + .subtitle(machine_id_markup(machine_id)) + .build(); + row.add_css_class("property"); + let copy = gtk4::Button::builder() + .icon_name(icons::EDIT_COPY) + .tooltip_text(gettext("Copy Machine ID")) + .valign(gtk4::Align::Center) + .build(); + copy.add_css_class("flat"); + copy.update_property(&[gtk4::accessible::Property::Label(&gettext("Copy Machine ID"))]); + let page = self.downgrade(); + let text = machine_id.to_owned(); + copy.connect_clicked(move |_| { + if let Some(page) = page.upgrade() { + crate::ui::clipboard::copy_text(&page, text.clone(), |message| page.toast(message)); + } + }); + row.add_suffix(©); + self.add(&group, &row); + } + self.add(&group, &import); + } + + fn notice_row(&self, notice: &Notice) -> adw::ActionRow { + let row = warning_row(¬ice.title, ¬ice.body); + let Some(action) = ¬ice.action else { + return row; + }; + let label = match action { + NoticeAction::ChooseDevice => gettext("Remove a Device…"), + NoticeAction::Refresh => gettext("Try Again"), + NoticeAction::Open { label, .. } => label.clone(), + }; + let button = gtk4::Button::builder() + .label(label) + .valign(gtk4::Align::Center) + .sensitive(!self.imp().busy.get()) + .build(); + let page = self.downgrade(); + let action = action.clone(); + button.connect_clicked(move |_| { + let Some(page) = page.upgrade() else { + return; + }; + match &action { + NoticeAction::ChooseDevice => page.present_device_limit(), + NoticeAction::Refresh => page.refresh(), + NoticeAction::Open { path, .. } => page.open_portal(path), + } + }); + row.add_suffix(&button); + row + } + + fn web_row(&self, title: &str, address: &'static str) -> adw::ActionRow { + let row = plain_row(title, None); + row.set_activatable(true); + row.add_suffix(&decoration(icons::WEB_BROWSER)); + row.set_tooltip_text(Some(&gettext("Opens in your web browser"))); + let page = self.downgrade(); + row.connect_activated(move |_| { + if let Some(page) = page.upgrade() { + page.open_uri(address.to_owned()); + } + }); + row + } + + fn link_row(&self, title: &str, subtitle: Option<&str>, path: &str) -> adw::ActionRow { + let row = plain_row(title, subtitle); + row.set_activatable(true); + row.add_suffix(&decoration(icons::WEB_BROWSER)); + row.set_tooltip_text(Some(&gettext("Opens in your web browser"))); + let page = self.downgrade(); + let path = path.to_owned(); + row.connect_activated(move |_| { + if let Some(page) = page.upgrade() { + page.open_portal(&path); + } + }); + row + } + + fn add_listing_state(&self, group: &adw::PreferencesGroup, listing: Listing) { + match listing { + Listing::Loading => { + let row = adw::ActionRow::builder().title(gettext("Loading…")).build(); + row.add_suffix(&adw::Spinner::new()); + self.add(group, &row); + } + Listing::Failed(reason) => { + let row = warning_row(&gettext("Could Not Load"), &reason); + row.add_suffix(&self.try_again_button()); + self.add(group, &row); + } + Listing::Rows(_) => {} + } + } + + fn add(&self, group: &adw::PreferencesGroup, row: &impl IsA) { + group.add(row); + self.imp().rows.borrow_mut().push((group.clone(), row.clone().upcast())); + } + + fn confirm_removal(&self, id: String, name: String) { + let alert = adw::AlertDialog::new( + Some(&gettext_f("Remove {name}?", &[("name", &name)])), + Some(&gettext( + "TablePro on it signs out the next time it checks in. You can sign in on it again later.", + )), + ); + alert.add_response(CANCEL, &gettext("_Cancel")); + alert.add_response(REMOVE, &gettext("_Remove")); + alert.set_response_appearance(REMOVE, adw::ResponseAppearance::Destructive); + alert.set_default_response(Some(CANCEL)); + alert.set_close_response(CANCEL); + let page = self.clone(); + glib::spawn_future_local(async move { + let parent: gtk4::Widget = page.clone().upcast(); + if crate::workspace::respond(&alert, &parent).await != REMOVE { + return; + } + let Some((_, account)) = page.ready() else { + return; + }; + page.run(async move { account.remove_device(&id).await }, move |page, result| { + match result { + Ok(()) => page.toast(&gettext_f("Removed {name}.", &[("name", &name)])), + Err(error) => page.toast(&account_error_message(&error)), + } + page.reload_details(); + }); + }); + } + + fn choose_offline_file(&self) { + let filter = gtk4::FileFilter::new(); + filter.set_name(Some(&gettext("Offline Files"))); + filter.add_pattern("*.jwt"); + let filters = gio::ListStore::new::(); + filters.append(&filter); + let dialog = gtk4::FileDialog::builder() + .title(gettext("Import Offline File")) + .modal(true) + .filters(&filters) + .default_filter(&filter) + .build(); + let window = self.root().and_downcast::(); + let page = self.downgrade(); + glib::spawn_future_local(async move { + let Ok(file) = dialog.open_future(window.as_ref()).await else { + return; + }; + let (Some(page), Some(path)) = (page.upgrade(), file.path()) else { + return; + }; + let Some(deps) = page.imp().deps.get() else { + return; + }; + let reading = deps + .tasks + .spawn_blocking_task(move || read_offline_file_blocking(&path)); + match reading.await { + Ok(Ok(token)) => page.import_offline_file(token), + Ok(Err(error)) => page.toast(&offline_file_message(&error)), + Err(error) => tracing::warn!(%error, "the offline file was not read"), + } + }); + } + + fn open_portal(&self, path: &str) { + let Some(deps) = self.imp().deps.get() else { + return; + }; + self.open_uri(format!("{}{path}", deps.handle.portal)); + } + + fn open_uri(&self, address: String) { + let window = self.root().and_downcast::(); + let page = self.downgrade(); + gtk4::UriLauncher::new(&address).launch(window.as_ref(), gio::Cancellable::NONE, move |launched| { + if let (Err(error), Some(page)) = (launched, page.upgrade()) { + tracing::warn!(%error, "the account page was not opened"); + page.toast(&gettext_f("Could not open {address}", &[("address", &address)])); + } + }); + } + + fn upgrade_clicked(&self) { + if let Some(deps) = self.imp().deps.get() { + deps.telemetry.upgrade_clicked(UPGRADE_SOURCE); + } + } + + fn toast(&self, message: &str) { + self.emit_by_name::<()>("toast", &[&crate::ui::plain_toast(message)]); + } +} + +fn fetch(result: Result) -> Fetch { + match result { + Ok(value) => Fetch::Loaded(value), + Err(error) => Fetch::Failed(error), + } +} + +// Two even lines in a fixed-width font, for someone who types it on another computer. +fn machine_id_markup(machine_id: &str) -> String { + let (first, second) = machine_id + .split_at_checked(machine_id.len() / 2) + .unwrap_or((machine_id, "")); + format!( + "{}\n{}", + glib::markup_escape_text(first), + glib::markup_escape_text(second) + ) +} + +fn identity_row(name: &str, email: Option<&str>) -> adw::ActionRow { + let row = plain_row(name, email); + let avatar = adw::Avatar::builder() + .size(40) + .text(name) + .show_initials(true) + .accessible_role(gtk4::AccessibleRole::Presentation) + .build(); + row.add_prefix(&avatar); + row +} + +// Names and messages from the account service are never markup. The text goes in after construction, since the labels +// take use-markup only once the row's own notifications are let out. +pub(super) fn plain_row(title: &str, subtitle: Option<&str>) -> adw::ActionRow { + let row = adw::ActionRow::builder().use_markup(false).build(); + row.set_title(title); + if let Some(subtitle) = subtitle { + row.set_subtitle(subtitle); + } + row +} + +fn warning_row(title: &str, body: &str) -> adw::ActionRow { + let row = plain_row(title, Some(body)); + let icon = decoration(icons::DIALOG_WARNING); + icon.add_css_class("warning"); + row.add_prefix(&icon); + row +} + +fn decoration(icon: &str) -> gtk4::Image { + gtk4::Image::builder() + .icon_name(icon) + .valign(gtk4::Align::Center) + .accessible_role(gtk4::AccessibleRole::Presentation) + .build() +} + +fn included(included: bool) -> gtk4::Widget { + match included { + true => dim_label(&gettext("Included")).upcast(), + false => { + let icon = gtk4::Image::from_icon_name(icons::CHANGES_PREVENT); + icon.add_css_class("dim-label"); + icon.set_tooltip_text(Some(&gettext("Not included"))); + icon.update_property(&[gtk4::accessible::Property::Label(&gettext("Not included"))]); + icon.upcast() + } + } +} + +fn dim_label(text: &str) -> gtk4::Label { + let label = gtk4::Label::builder().label(text).valign(gtk4::Align::Center).build(); + label.add_css_class("dim-label"); + label +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + + use async_trait::async_trait; + use chrono::TimeDelta; + use tablepro_account::{ + Account, AccountSettings, AppClient, BrowserLauncher, CallbackPage, DeviceDescription, Endpoints, + EntitlementClaims, EntitlementProblem, LicenseClaim, MachineId, Membership, SignedIn, TeamClaim, Url, fakes, + }; + use tokio::sync::watch; + + use super::*; + use crate::runtime::AppRuntime; + use crate::test_support::{ + Approving, CloudStub, MemoryKeyring, OwnedWindow, assert_labelled, deliver, descendants, test_runtime, + unlabelled_icon_buttons, wait_until, + }; + + const WAIT: Duration = Duration::from_secs(20); + const PORTAL: &str = "http://account.localhost:5284"; + + // Keeps the URL the page asked to open, and answers with `refusal` when given one. + struct Browser { + opened: Mutex>, + refusal: Option, + } + + #[async_trait] + impl BrowserLauncher for Browser { + async fn open(&self, url: &Url) -> Result<(), String> { + self.opened.lock().expect("the URLs").push(url.clone()); + self.refusal.clone().map_or(Ok(()), Err) + } + } + + struct Harness { + page: AccountPage, + status: watch::Sender, + start: watch::Sender, + browser: Arc, + dir: tempfile::TempDir, + // Without a key until a test begins it. + telemetry: Telemetry, + runtime: AppRuntime, + } + + impl Harness { + fn new(refusal: Option<&str>) -> Self { + crate::register_test_resources(); + let runtime = test_runtime(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let (status, status_receiver) = watch::channel(AccountStatus::SignedOut); + let (start, start_receiver) = watch::channel(AccountStart::Starting); + let browser = Arc::new(Browser { + opened: Mutex::new(Vec::new()), + refusal: refusal.map(str::to_owned), + }); + let page = AccountPage::default(); + let telemetry = Telemetry::default(); + page.bind( + AccountHandle { + status: status_receiver, + start: start_receiver, + portal: PORTAL.to_owned(), + }, + runtime.tasks(), + telemetry.clone(), + ); + Self { + page, + status, + start, + browser, + dir, + telemetry, + runtime, + } + } + + // An account whose service nothing answers, so only what a test sends shows. + fn ready(&self) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("a free port"); + let base = format!("http://{}", listener.local_addr().expect("its address")); + drop(listener); + let endpoints = Endpoints::new(&base, &format!("{base}/v2")).expect("the endpoints"); + let machine = MachineId::derive("4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f20\n").expect("a machine ID"); + self.ready_with( + endpoints, + fakes::keys().expect("the test key"), + machine, + self.browser.clone(), + ); + } + + fn ready_with( + &self, + endpoints: Endpoints, + keys: tablepro_account::EntitlementKeys, + machine_id: MachineId, + browser: Arc, + ) { + let settings = AccountSettings { + client: AppClient::LINUX, + endpoints, + device: DeviceDescription { + name: "Ada's laptop".to_owned(), + app_version: "0.1.0".to_owned(), + os_version: "Ubuntu 26.04".to_owned(), + }, + machine_id, + keys, + account_file: self.dir.path().join("account.json"), + callback_page: CallbackPage { + finished: String::new(), + unknown: String::new(), + }, + }; + let account = Account::new( + settings, + Arc::new(MemoryKeyring::default()), + browser, + self.status.clone(), + ) + .expect("an account"); + self.start.send_replace(AccountStart::Ready(account)); + self.settle(); + } + + fn send(&self, status: AccountStatus) { + self.status.send_replace(status); + self.settle(); + } + + fn share_usage_data(&self, collector: &crate::test_support::PostHogCollector) { + let settings = crate::services::telemetry::TelemetrySettings { + key: "phc_test".to_owned(), + host: collector.url.clone(), + state_file: self.dir.path().join("telemetry.json"), + person: crate::services::telemetry::PersonProperties { + app_version: "0.1.0".to_owned(), + os_version: "Ubuntu 26.04".to_owned(), + architecture: "arm64".to_owned(), + locale: "en_US".to_owned(), + }, + }; + self.telemetry.begin(settings, true, &self.runtime.tasks()); + } + + fn settle(&self) { + crate::test_support::drain_main_context(); + } + + fn rows(&self, group: &adw::PreferencesGroup) -> Vec { + descendants(group) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .collect() + } + + fn titles(&self, group: &adw::PreferencesGroup) -> Vec { + self.rows(group).iter().map(|row| row.title().to_string()).collect() + } + + fn row(&self, title: &str) -> adw::ActionRow { + descendants(&self.page) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|row| row.title() == title) + .unwrap_or_else(|| panic!("no row {title}")) + } + + fn button(&self, root: &impl IsA, label: &str) -> gtk4::Button { + descendants(root) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().is_some_and(|text| text == label)) + .unwrap_or_else(|| panic!("no button {label}")) + } + + fn audit(&self) { + for row in descendants(&self.page) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + { + assert_labelled(&row).unwrap_or_else(|problem| panic!("{}: {problem}", row.title())); + } + assert_eq!(unlabelled_icon_buttons(&self.page), Vec::::new()); + } + } + + fn license(plan: &str, cycle: &str, status: &str, team: Option<(&str, &str)>) -> LicenseClaim { + LicenseClaim { + id: "lic_01".to_owned(), + plan: plan.to_owned(), + cycle: cycle.to_owned(), + source: "subscription".to_owned(), + status: status.to_owned(), + renews_at: None, + ends_at: None, + team: team.map(|(id, name)| TeamClaim { + id: id.to_owned(), + name: name.to_owned(), + }), + } + } + + fn signed_in(licenses: Vec, features: &[&str]) -> AccountStatus { + let mut claims: EntitlementClaims = + fakes::claims(Utc::now() - TimeDelta::hours(2), TimeDelta::days(14), features); + claims.licenses = licenses; + claims.account.email = "ada@m17.example".to_owned(); + claims.account.name = "Ada Lovelace".to_owned(); + AccountStatus::SignedIn(Box::new(SignedIn { + entitlement: Some(fakes::entitlement(&claims).expect("an entitlement")), + problem: None, + remembered: true, + })) + } + + fn profile(licenses: Vec, teams: Vec) -> Profile { + Profile { + id: "acct_01k8d6wk00f8xsr79e7x052rkk".to_owned(), + email: "ada@m17.example".to_owned(), + name: Some("Ada Lovelace".to_owned()), + licenses, + teams, + } + } + + fn device(id: &str, name: &str, machine_id: &str) -> Device { + Device { + id: id.to_owned(), + machine_id: machine_id.to_owned(), + platform: "linux".to_owned(), + name: name.to_owned(), + app_version: "0.1.0".to_owned(), + os_version: "Fedora Linux 43".to_owned(), + last_seen_at: Utc::now() - TimeDelta::hours(5), + licensed_at: Some(Utc::now()), + offline_until: None, + } + } + + #[gtk4::test] + fn signed_out_the_page_offers_sign_in_the_locked_features_and_the_offline_file() { + let harness = Harness::new(None); + let imp = harness.page.imp(); + assert!( + !harness.button(&harness.page, "Sign In…").is_sensitive(), + "Sign In before the account started" + ); + + harness.ready(); + + assert!(harness.button(&harness.page, "Sign In…").is_sensitive()); + assert_eq!(harness.titles(&imp.account_group), ["Sign In to TablePro"]); + assert!(!imp.plan_group.is_visible()); + assert!(!imp.devices_group.is_visible()); + assert!(!imp.teams_group.is_visible()); + let features = harness.titles(&imp.features_group); + assert_eq!(features.len(), 12); + assert_eq!(features[10..], ["Buy a License", "Learn More About Licensing"]); + assert!( + descendants(&imp.features_group.get()) + .iter() + .filter_map(|widget| widget.downcast_ref::()) + .all(|label| label.label() != "Included") + ); + assert_eq!( + harness.titles(&imp.offline_group), + ["Machine ID", "Import Offline File…"] + ); + let machine_id = harness + .page + .ready() + .map(|(_, account)| account.machine_id().as_str().to_owned()) + .expect("an account"); + let shown: Vec = descendants(&harness.row("Machine ID")) + .iter() + .filter_map(|widget| widget.downcast_ref::()) + .map(|label| label.text().to_string()) + .collect(); + assert!( + shown.contains(&format!("{}\n{}", &machine_id[..32], &machine_id[32..])), + "{shown:?}" + ); + harness.audit(); + } + + #[gtk4::test] + fn without_a_machine_id_the_page_says_why_accounts_are_unavailable() { + let harness = Harness::new(None); + + harness + .start + .send_replace(AccountStart::NoMachineId("cannot read /etc/machine-id".to_owned())); + harness.settle(); + + let row = harness.row("Accounts Unavailable"); + assert!(row.subtitle().is_some_and(|text| text.contains("/etc/machine-id"))); + assert_eq!( + harness.titles(&harness.page.imp().account_group), + ["Accounts Unavailable"] + ); + } + + #[gtk4::test] + fn a_starter_account_shows_its_plan_features_devices_and_no_team() { + let harness = Harness::new(None); + harness.ready(); + let imp = harness.page.imp(); + let this_machine = harness + .page + .ready() + .map(|(_, account)| account.machine_id().as_str().to_owned()) + .expect("an account"); + let mut starter = license("starter", "lifetime", "active", None); + starter.source = "order".to_owned(); + + harness.send(signed_in(vec![starter.clone()], &["env_var_references"])); + harness.page.show_details( + Fetch::Loaded(profile(vec![starter], Vec::new())), + Fetch::Loaded(vec![ + device("dev_mac", "Ada's Mac", &"a".repeat(64)), + device("dev_here", "Ada's laptop", &this_machine), + ]), + ); + + assert_eq!(harness.titles(&imp.account_group), ["Ada Lovelace", "Manage Account"]); + assert_eq!( + harness.row("Ada Lovelace").subtitle().as_deref(), + Some("ada@m17.example") + ); + assert!(harness.button(&imp.account_group.get(), "Sign Out").is_sensitive()); + assert_eq!(harness.titles(&imp.plan_group), ["Starter"]); + assert_eq!( + harness.row("Starter").subtitle().as_deref(), + Some("Lifetime · Personal") + ); + harness.button(&harness.row("Starter"), "Billing…"); + assert!(!harness.row("Starter").is_activatable()); + assert_eq!(harness.titles(&imp.devices_group), ["Ada's laptop", "Ada's Mac"]); + assert!( + harness + .row("Ada's laptop") + .subtitle() + .is_some_and(|text| text.starts_with("This computer")) + ); + assert!( + descendants(&harness.row("Ada's laptop")) + .iter() + .all(|widget| !widget.is::()) + ); + harness.button(&harness.row("Ada's Mac"), "Remove"); + assert_eq!(harness.titles(&imp.teams_group), ["Not in a Team"]); + assert!( + harness + .titles(&imp.offline_group) + .contains(&"Verified 2 hours ago".to_owned()) + ); + let included: Vec = harness + .rows(&imp.features_group) + .iter() + .filter(|row| { + descendants(*row).iter().any(|widget| { + widget + .downcast_ref::() + .is_some_and(|label| label.label() == "Included") + }) + }) + .map(|row| row.title().to_string()) + .collect(); + assert_eq!(included, ["Environment Variable References"]); + assert!( + !harness + .titles(&imp.features_group) + .contains(&"Buy a License".to_owned()) + ); + harness.audit(); + } + + #[gtk4::test] + fn a_team_member_with_a_long_team_name_and_address_sees_the_team_and_its_seat() { + let harness = Harness::new(None); + harness.ready(); + let imp = harness.page.imp(); + let team = "The Analytical Engine Society of Greater London & the Counties"; + let mut seat = license("team", "yearly", "active", Some(("team_01", team))); + let renews = Utc::now() + TimeDelta::days(200); + seat.renews_at = Some(renews); + let membership = Membership { + team: TeamClaim { + id: "team_01".to_owned(), + name: team.to_owned(), + }, + role: "member".to_owned(), + has_seat: true, + }; + let mut long = profile(vec![seat.clone()], vec![membership]); + long.email = "augusta.ada.king.countess.of.lovelace@m17.example".to_owned(); + + harness.send(signed_in(vec![seat], &["team_library"])); + harness + .page + .show_details(Fetch::Loaded(long), Fetch::Loaded(Vec::new())); + + assert_eq!( + harness.row("Team").subtitle().as_deref(), + Some(format!("Yearly · {team} · Renews on {}", account_view::day(renews)).as_str()) + ); + assert!( + descendants(&harness.row("Team")) + .iter() + .all(|widget| !widget.is::()), + "a member gets no Billing…" + ); + assert_eq!(harness.titles(&imp.teams_group), [team]); + let shown: Vec = descendants(&imp.teams_group.get()) + .iter() + .filter_map(|widget| widget.downcast_ref::()) + .map(|label| label.text().to_string()) + .collect(); + assert!(shown.contains(&team.to_owned()), "{shown:?}"); + assert_eq!(harness.row(team).subtitle().as_deref(), Some("Member · Holds a seat")); + assert!(harness.row(team).is_activatable()); + assert_eq!( + harness.row("Ada Lovelace").subtitle().as_deref(), + Some("augusta.ada.king.countess.of.lovelace@m17.example") + ); + harness.audit(); + } + + #[gtk4::test] + fn a_past_due_plan_shows_the_payment_notice_first() { + let harness = Harness::new(None); + harness.ready(); + let due = license("starter", "monthly", "past_due", None); + + harness.send(signed_in(vec![due.clone()], &["env_var_references"])); + harness + .page + .show_details(Fetch::Loaded(profile(vec![due], Vec::new())), Fetch::Loaded(Vec::new())); + + assert_eq!( + harness.titles(&harness.page.imp().plan_group), + ["Payment Problem", "Starter"] + ); + harness.button(&harness.row("Payment Problem"), "Update Payment Method…"); + assert_eq!( + harness.row("Starter").subtitle().as_deref(), + Some("Monthly · Personal · Payment problem") + ); + harness.audit(); + } + + #[gtk4::test] + fn over_the_device_limit_the_dialog_lists_the_holders_and_keeps_an_offline_one() { + let harness = Harness::new(None); + harness.ready(); + let mut holders = vec![ + device("dev_mac", "Ada's Mac", &"a".repeat(64)), + device("dev_lab", "Lab machine", &"b".repeat(64)), + device("dev_old", "Old laptop", &"c".repeat(64)), + ]; + holders[1].offline_until = Some(Utc::now() + TimeDelta::days(90)); + harness.send(AccountStatus::SignedIn(Box::new(SignedIn { + entitlement: None, + problem: Some(EntitlementProblem::DeviceLimitReached(holders.clone())), + remembered: true, + }))); + let window = OwnedWindow::presented(adw::Window::builder().default_width(900).default_height(700).build()); + window.set_content(Some(&harness.page)); + + harness + .button(&harness.row("Device Limit Reached"), "Remove a Device…") + .emit_clicked(); + let dialog = harness.page.device_limit_dialog().expect("the device limit dialog"); + + let rows = dialog.rows(); + let titles: Vec = rows.iter().map(|row| row.title().to_string()).collect(); + assert_eq!(titles, ["Ada's Mac", "Lab machine", "Old laptop"]); + assert!( + rows[0] + .subtitle() + .is_some_and(|text| text.contains("Last active 5 hours ago")) + ); + harness.button(&rows[0], "Remove"); + assert!(descendants(&rows[1]).iter().all(|widget| !widget.is::())); + assert!( + descendants(&rows[1]) + .iter() + .filter_map(|widget| widget.downcast_ref::()) + .any(|label| label.label().starts_with("Offline file until ")) + ); + assert_eq!(dialog.error(), None); + for row in &rows { + assert_labelled(row).expect("a labelled row"); + } + + harness.send(signed_in(Vec::new(), &[])); + wait_until(WAIT, || harness.page.device_limit_dialog().is_none()) + .expect("the dialog closed once the limit was gone"); + drop(window); + } + + #[gtk4::test] + fn signed_in_without_an_entitlement_an_account_that_did_not_load_keeps_sign_out_and_try_again() { + let harness = Harness::new(None); + harness.ready(); + let imp = harness.page.imp(); + let unreachable = || AccountError::Unreachable("connection refused".to_owned()); + let problems = [ + EntitlementProblem::DeviceLimitReached(vec![device("dev_mac", "Ada's Mac", &"a".repeat(64))]), + EntitlementProblem::RefreshFailed(unreachable()), + ]; + + for problem in problems { + harness.send(AccountStatus::SignedIn(Box::new(SignedIn { + entitlement: None, + problem: Some(problem), + remembered: true, + }))); + harness + .page + .show_details(Fetch::Failed(unreachable()), Fetch::Failed(unreachable())); + + assert_eq!(harness.titles(&imp.account_group), ["Signed In"]); + let row = harness.row("Signed In"); + assert_eq!( + row.subtitle().as_deref(), + Some("TablePro could not reach its account service. Check the internet connection and try again.") + ); + harness.button(&row, "Try Again"); + assert!(harness.button(&row, "Sign Out").is_sensitive()); + assert!( + descendants(&imp.account_group.get()) + .iter() + .all(|widget| !widget.is::()) + ); + harness.audit(); + } + + harness.button(&harness.row("Signed In"), "Sign Out").emit_clicked(); + wait_until(WAIT, || harness.titles(&imp.account_group) == ["Sign In to TablePro"]).expect("signed out"); + } + + #[gtk4::test] + fn over_the_device_limit_the_dialog_says_why_a_held_device_stays_and_closes_once_one_is_removed() { + let harness = Harness::new(None); + let machine = MachineId::derive("4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f20\n").expect("a machine ID"); + let stub = limit_stub(&machine); + let endpoints = Endpoints::new(&stub.url, &format!("{}/v2", stub.url)).expect("the endpoints"); + harness.ready_with( + endpoints, + fakes::keys().expect("the test key"), + machine, + Arc::new(Approving), + ); + let window = OwnedWindow::presented(adw::Window::builder().default_width(900).default_height(700).build()); + window.set_content(Some(&harness.page)); + let toasts = Rc::new(RefCell::new(Vec::new())); + let kept = toasts.clone(); + harness + .page + .connect_toast(move |_, toast| kept.borrow_mut().push(toast.title().unwrap_or_default().to_string())); + let remove = |name: &str| { + let dialog = harness.page.device_limit_dialog().expect("the device limit dialog"); + let row = dialog + .rows() + .into_iter() + .find(|row| row.title() == name) + .unwrap_or_else(|| panic!("no row {name}")); + harness.button(&row, "Remove").emit_clicked(); + wait_until(WAIT, || crate::workspace::answer(&window, "remove")).expect("the confirmation"); + }; + + harness.button(&harness.page, "Sign In…").emit_clicked(); + wait_until(WAIT, || harness.page.device_limit_dialog().is_some()).expect("the device limit dialog"); + let dialog = harness.page.device_limit_dialog().expect("the device limit dialog"); + let lab = dialog + .rows() + .into_iter() + .find(|row| row.title() == "Lab machine") + .expect("Lab machine"); + assert!(descendants(&lab).iter().all(|widget| !widget.is::())); + assert!( + descendants(&lab) + .iter() + .filter_map(|widget| widget.downcast_ref::()) + .any(|label| label.label().starts_with("Offline file until ")) + ); + remove("Old laptop"); + wait_until(WAIT, || dialog.error().is_some()).expect("the refusal shown"); + + assert_eq!( + dialog.error().as_deref(), + Some( + "That device has an offline file that is still valid, so it keeps its license slot until the file expires." + ) + ); + assert_eq!(dialog.rows().len(), 3); + // The removal and the status it changes both close the dialog; a second close is a critical. + let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL); + remove("Ada's Mac"); + wait_until(WAIT, || harness.page.device_limit_dialog().is_none()).expect("the dialog closed"); + crate::test_support::drain_main_context(); + glib::log_set_always_fatal(previous); + assert!( + toasts + .borrow() + .contains(&"Removed Ada's Mac. The paid features work on this computer now.".to_owned()), + "{:?}", + toasts.borrow() + ); + assert!(stub.requests().contains(&"DELETE /v2/devices/dev_mac".to_owned())); + let status = harness.status.borrow().clone(); + assert!( + matches!(&status, AccountStatus::SignedIn(held) if held.problem.is_none() && held.entitlement.is_some()), + "{status:?}" + ); + drop(window); + } + + #[gtk4::test] + fn a_removal_whose_check_fails_says_the_device_is_gone_and_the_plan_not_verified() { + let harness = Harness::new(None); + let machine = MachineId::derive("4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f20\n").expect("a machine ID"); + let stub = limit_stub_then(&machine, false); + let endpoints = Endpoints::new(&stub.url, &format!("{}/v2", stub.url)).expect("the endpoints"); + harness.ready_with( + endpoints, + fakes::keys().expect("the test key"), + machine, + Arc::new(Approving), + ); + let window = OwnedWindow::presented(adw::Window::builder().default_width(900).default_height(700).build()); + window.set_content(Some(&harness.page)); + let toasts = Rc::new(RefCell::new(Vec::new())); + let kept = toasts.clone(); + harness + .page + .connect_toast(move |_, toast| kept.borrow_mut().push(toast.title().unwrap_or_default().to_string())); + + harness.button(&harness.page, "Sign In…").emit_clicked(); + wait_until(WAIT, || harness.page.device_limit_dialog().is_some()).expect("the device limit dialog"); + let dialog = harness.page.device_limit_dialog().expect("the device limit dialog"); + let mac = dialog + .rows() + .into_iter() + .find(|row| row.title() == "Ada's Mac") + .expect("Ada's Mac"); + harness.button(&mac, "Remove").emit_clicked(); + wait_until(WAIT, || crate::workspace::answer(&window, "remove")).expect("the confirmation"); + let unverified = || { + toasts + .borrow() + .iter() + .any(|toast| toast.starts_with("Removed Ada's Mac. Your plan could not be verified yet: ")) + }; + let closed = wait_until(WAIT, || harness.page.device_limit_dialog().is_none() && unverified()); + + assert!(closed.is_ok(), "{:?}", toasts.borrow()); + assert!(stub.requests().contains(&"DELETE /v2/devices/dev_mac".to_owned())); + drop(window); + } + + #[gtk4::test] + fn try_again_refreshes_the_entitlement_and_loads_the_account_again() { + let harness = Harness::new(None); + let machine = MachineId::derive("4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f20\n").expect("a machine ID"); + let stub = limit_stub(&machine); + let endpoints = Endpoints::new(&stub.url, &format!("{}/v2", stub.url)).expect("the endpoints"); + harness.ready_with( + endpoints, + fakes::keys().expect("the test key"), + machine, + Arc::new(Approving), + ); + let window = OwnedWindow::presented(adw::Window::builder().default_width(900).default_height(700).build()); + window.set_content(Some(&harness.page)); + let refresh = "POST /v2/devices/dev_here/entitlement"; + let asked = |request: &str| stub.requests().iter().filter(|it| it.as_str() == request).count(); + let try_again = || { + harness.settle(); + descendants(&harness.page.imp().account_group.get()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().is_some_and(|label| label == "Try Again") && button.is_sensitive()) + }; + + harness.button(&harness.page, "Sign In…").emit_clicked(); + // The sign-in loads the account more than once; each load asks /v2/me, then /v2/devices. + wait_until(WAIT, || { + try_again().is_some() && asked("GET /v2/me") == asked("GET /v2/devices") + }) + .expect("the account did not load"); + assert_eq!(asked(refresh), 1); + try_again().expect("Try Again").emit_clicked(); + + wait_until(WAIT, || { + harness.settle(); + let requests = stub.requests(); + let refreshed = requests.iter().rposition(|it| it == refresh); + let loaded = requests.iter().rposition(|it| it == "GET /v2/me"); + asked(refresh) == 2 && matches!((refreshed, loaded), (Some(refreshed), Some(loaded)) if loaded > refreshed) + }) + .expect("refreshed, then loaded again"); + drop(window); + } + + #[gtk4::test] + fn sign_in_opens_the_authorization_page_and_cancel_takes_it_back() { + let harness = Harness::new(None); + harness.ready(); + + harness.button(&harness.page, "Sign In…").emit_clicked(); + wait_until(WAIT, || !harness.browser.opened.lock().expect("the URLs").is_empty()).expect("the browser opened"); + harness.settle(); + + let opened = harness.browser.opened.lock().expect("the URLs")[0].clone(); + assert_eq!(opened.path(), "/oauth/authorize"); + assert!( + opened + .query() + .is_some_and(|query| query.contains("client_id=tablepro-linux")) + ); + assert!( + harness + .row("Sign In to TablePro") + .subtitle() + .is_some_and(|text| text.contains("web browser")) + ); + harness.button(&harness.page, "Cancel").emit_clicked(); + harness.settle(); + wait_until(WAIT, || { + harness.settle(); + descendants(&harness.page) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .any(|button| button.label().is_some_and(|label| label == "Sign In…")) + }) + .expect("Sign In again after Cancel"); + } + + #[gtk4::test] + fn a_sign_in_started_after_a_cancel_keeps_its_cancel_when_the_first_one_ends() { + let harness = Harness::new(None); + harness.ready(); + let opened = || harness.browser.opened.lock().expect("the URLs").len(); + + harness.button(&harness.page, "Sign In…").emit_clicked(); + wait_until(WAIT, || opened() == 1).expect("the browser opened"); + harness.button(&harness.page, "Cancel").emit_clicked(); + harness.button(&harness.page, "Sign In…").emit_clicked(); + wait_until(WAIT, || opened() == 2).expect("the browser opened again"); + let _ = wait_until(Duration::from_millis(500), || { + harness.settle(); + false + }); + + assert!( + harness + .row("Sign In to TablePro") + .subtitle() + .is_some_and(|text| text.contains("web browser")) + ); + harness.button(&harness.page, "Cancel").emit_clicked(); + } + + #[gtk4::test] + fn a_sign_in_the_browser_could_not_open_says_so() { + let harness = Harness::new(Some("no handler for http")); + harness.ready(); + + harness.button(&harness.page, "Sign In…").emit_clicked(); + wait_until(WAIT, || { + harness.settle(); + descendants(&harness.page) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .any(|row| row.title() == "Sign-In Failed") + }) + .expect("the failure shown"); + + assert_eq!( + harness.row("Sign-In Failed").subtitle().as_deref(), + Some("TablePro could not open the sign-in page in a web browser.") + ); + } + + #[gtk4::test] + fn an_offline_file_unlocks_its_features_and_names_its_end() { + let harness = Harness::new(None); + harness.ready(); + let mut claims = fakes::claims(Utc::now(), TimeDelta::days(30), &["data_rewind"]); + claims.offline = true; + claims.licenses = vec![license("starter", "yearly", "active", None)]; + + harness.send(AccountStatus::Offline(Box::new( + fakes::entitlement(&claims).expect("an entitlement"), + ))); + + let imp = harness.page.imp(); + assert!(imp.plan_group.is_visible()); + assert!(!imp.devices_group.is_visible()); + assert!( + harness + .row("Dat") + .subtitle() + .is_some_and(|text| text == "Using an offline file") + ); + assert!( + harness + .row("Offline File") + .subtitle() + .is_some_and(|text| text.starts_with("Valid until ")) + ); + assert!(harness.button(&harness.row("Dat"), "Sign In…").is_sensitive()); + harness.audit(); + } + + #[gtk4::test] + fn signing_in_over_an_offline_file_shows_the_wait_and_its_cancel() { + let harness = Harness::new(None); + harness.ready(); + let mut claims = fakes::claims(Utc::now(), TimeDelta::days(30), &["data_rewind"]); + claims.offline = true; + harness.send(AccountStatus::Offline(Box::new( + fakes::entitlement(&claims).expect("an entitlement"), + ))); + + harness.button(&harness.row("Dat"), "Sign In…").emit_clicked(); + wait_until(WAIT, || !harness.browser.opened.lock().expect("the URLs").is_empty()).expect("the browser opened"); + + let row = harness.row("Dat"); + assert!(row.subtitle().is_some_and(|text| text.contains("web browser"))); + assert!( + descendants(&row) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .all(|button| button.label().is_none_or(|label| label != "Sign In…")) + ); + harness.button(&row, "Cancel").emit_clicked(); + wait_until(WAIT, || { + harness.settle(); + harness + .row("Dat") + .subtitle() + .is_some_and(|text| text == "Using an offline file") + }) + .expect("the offline file shown again after Cancel"); + } + + // The approval runs in Chrome through core/crates/account/tests/browser/portal.cjs, which hands back the + // redirect to the loopback listener instead of following it, so the browser need not reach this machine. + struct StackBrowser { + email: String, + profile: tempfile::TempDir, + } + + #[async_trait] + impl BrowserLauncher for StackBrowser { + async fn open(&self, url: &Url) -> Result<(), String> { + let callback = self.profile.path().join("callback"); + let portal = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../core/crates/account/tests/browser/portal.cjs"); + let mut command = std::process::Command::new("node"); + command + .arg(portal) + .arg("approve") + .arg(url.as_str()) + .env("TABLEPRO_CLOUD_EMAIL", &self.email) + .env("TABLEPRO_CLOUD_PROFILE", self.profile.path().join("chrome")) + .env("TABLEPRO_CLOUD_MACHINE_NAME", "Ada's laptop") + .env("TABLEPRO_CLOUD_GRANT", "starter") + .env("TABLEPRO_CLOUD_CALLBACK_FILE", &callback); + std::thread::spawn(move || { + let status = command.status().expect("the browser step ran"); + assert!(status.success(), "the browser step failed: {status}"); + let answer = std::fs::read_to_string(&callback).expect("the redirect"); + deliver(&Url::parse(&answer).expect("a redirect URL")); + }); + Ok(()) + } + } + + // Three devices hold the license: Lab machine with an offline file device_limit_reached names, Old laptop + // with one made since, which the server still refuses to remove, and Ada's Mac, whose removal frees a slot. + fn limit_stub(machine: &MachineId) -> CloudStub { + limit_stub_then(machine, true) + } + + // With `verifies` false, the entitlement asked for after a removal fails as an unavailable service's would. + fn limit_stub_then(machine: &MachineId, verifies: bool) -> CloudStub { + let now = Utc::now(); + let at = |delta: TimeDelta| (now + delta).to_rfc3339(); + let device = |id: &str, name: &str, platform: &str, machine_id: &str, more: &str| { + format!( + r#"{{"id":"{id}","machine_id":"{machine_id}","platform":"{platform}","name":"{name}","app_version":"0.1.0","os_version":"26.1","created_at":"{created}","last_seen_at":"{seen}","licensed_at":"{created}"{more}}}"#, + created = at(-TimeDelta::days(30)), + seen = at(-TimeDelta::hours(5)), + ) + }; + let offline = format!(r#","offline_until":"{}""#, at(TimeDelta::days(90))); + let holders = [ + device("dev_mac", "Ada's Mac", "macos", &"a".repeat(64), ""), + device("dev_lab", "Lab machine", "linux", &"b".repeat(64), &offline), + device("dev_old", "Old laptop", "linux", &"c".repeat(64), ""), + ] + .join(","); + let here = device("dev_here", "Ada's laptop", "linux", machine.as_str(), ""); + let mut claims = fakes::claims(now, TimeDelta::days(14), &["env_var_references"]); + claims.mid = machine.as_str().to_owned(); + claims.dev = "dev_here".to_owned(); + let entitlement = format!( + r#"{{"token":"{}","expires_at":"{}","refresh_after":"{}"}}"#, + fakes::sign(&claims).expect("a token"), + at(TimeDelta::days(14)), + at(TimeDelta::days(1)), + ); + let problem = |status: u16, code: &str, more: &str| { + format!(r#"{{"type":"about:blank","title":"{code}","status":{status},"code":"{code}"{more}}}"#) + }; + let limit = problem(409, "device_limit_reached", &format!(r#","devices":[{holders}]"#)); + let tokens = + r#"{"access_token":"stub-access","token_type":"Bearer","expires_in":900,"refresh_token":"stub-refresh"}"#; + let freed = AtomicBool::new(false); + CloudStub::start(move |method, path| match (method, path) { + ("POST", "/oauth/token") => (200, tokens.to_owned()), + ("POST", "/v2/devices") => (201, here.clone()), + ("POST", "/v2/devices/dev_here/entitlement") if freed.load(Ordering::SeqCst) && verifies => { + (200, entitlement.clone()) + } + ("POST", "/v2/devices/dev_here/entitlement") if freed.load(Ordering::SeqCst) => { + (503, problem(503, "service_unavailable", "")) + } + ("POST", "/v2/devices/dev_here/entitlement") => (409, limit.clone()), + ("DELETE", "/v2/devices/dev_old") => (409, problem(409, "offline_file_active", "")), + ("DELETE", "/v2/devices/dev_mac") => { + freed.store(true, Ordering::SeqCst); + (204, String::new()) + } + _ => (404, problem(404, "not_found", "")), + }) + } + + // Signs in with an entitlement at once; the revoke is answered only once `revoking` is false. + fn sign_out_stub(machine: &MachineId, revoking: Arc) -> CloudStub { + let now = Utc::now(); + let mut claims = fakes::claims(now, TimeDelta::days(14), &[]); + claims.mid = machine.as_str().to_owned(); + claims.dev = "dev_here".to_owned(); + let entitlement = format!( + r#"{{"token":"{}","expires_at":"{}","refresh_after":"{}"}}"#, + fakes::sign(&claims).expect("a token"), + (now + TimeDelta::days(14)).to_rfc3339(), + (now + TimeDelta::days(1)).to_rfc3339(), + ); + let here = format!( + r#"{{"id":"dev_here","machine_id":"{}","platform":"linux","name":"Ada's laptop","app_version":"0.1.0","os_version":"Ubuntu 26.04","created_at":"{now}","last_seen_at":"{now}"}}"#, + machine.as_str(), + now = now.to_rfc3339(), + ); + let tokens = + r#"{"access_token":"stub-access","token_type":"Bearer","expires_in":900,"refresh_token":"stub-refresh"}"#; + CloudStub::start(move |method, path| match (method, path) { + ("POST", "/oauth/token") => (200, tokens.to_owned()), + ("POST", "/v2/devices") => (201, here.clone()), + ("POST", "/v2/devices/dev_here/entitlement") => (200, entitlement.clone()), + ("POST", "/oauth/revoke") => { + while revoking.load(Ordering::SeqCst) { + std::thread::sleep(Duration::from_millis(10)); + } + (200, String::new()) + } + _ => ( + 404, + r#"{"type":"about:blank","title":"Not found","status":404,"code":"not_found"}"#.to_owned(), + ), + }) + } + + #[gtk4::test] + fn sign_out_waits_for_the_revocation_and_leaves_the_page_signed_out() { + let harness = Harness::new(None); + let machine = MachineId::derive("4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f20\n").expect("a machine ID"); + let revoking = Arc::new(AtomicBool::new(true)); + let stub = sign_out_stub(&machine, revoking.clone()); + let endpoints = Endpoints::new(&stub.url, &format!("{}/v2", stub.url)).expect("the endpoints"); + harness.ready_with( + endpoints, + fakes::keys().expect("the test key"), + machine, + Arc::new(Approving), + ); + let window = OwnedWindow::presented(adw::Window::builder().default_width(900).default_height(700).build()); + window.set_content(Some(&harness.page)); + let shows = |title: &str| { + harness.settle(); + descendants(&harness.page) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .any(|row| row.title() == title) + }; + harness.button(&harness.page, "Sign In…").emit_clicked(); + let sign_out = || { + descendants(&harness.page.imp().account_group.get()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().is_some_and(|text| text == "Sign Out")) + }; + wait_until(WAIT, || { + harness.settle(); + sign_out().is_some() + }) + .expect("signed in"); + + sign_out().expect("Sign Out").emit_clicked(); + wait_until(WAIT, || stub.requests().iter().any(|it| it == "POST /oauth/revoke")).expect("the revoke sent"); + let still_signed_in = !shows("Sign In to TablePro"); + revoking.store(false, Ordering::SeqCst); + + wait_until(WAIT, || shows("Sign In to TablePro")).expect("signed out"); + assert!(still_signed_in); + assert!(!harness.dir.path().join("account.json").exists()); + drop(window); + } + + #[gtk4::test] + fn sign_out_sends_signed_out_once_and_an_answer_landing_during_it_identifies_nobody() { + let harness = Harness::new(None); + let collector = crate::test_support::PostHogCollector::start(); + harness.share_usage_data(&collector); + let machine = MachineId::derive("4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f20\n").expect("a machine ID"); + let revoking = Arc::new(AtomicBool::new(true)); + let stub = sign_out_stub(&machine, revoking.clone()); + let endpoints = Endpoints::new(&stub.url, &format!("{}/v2", stub.url)).expect("the endpoints"); + harness.ready_with( + endpoints, + fakes::keys().expect("the test key"), + machine, + Arc::new(Approving), + ); + let window = OwnedWindow::presented(adw::Window::builder().default_width(900).default_height(700).build()); + window.set_content(Some(&harness.page)); + let shows = |title: &str| { + harness.settle(); + descendants(&harness.page) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .any(|row| row.title() == title) + }; + harness.button(&harness.page, "Sign In…").emit_clicked(); + wait_until(WAIT, || !collector.named("signed_in").is_empty()).expect("signed in"); + harness.settle(); + + harness + .button(&harness.page.imp().account_group.get(), "Sign Out") + .emit_clicked(); + wait_until(WAIT, || stub.requests().iter().any(|it| it == "POST /oauth/revoke")).expect("the revoke sent"); + // /v2/me answering while the revoke is still on its way. + harness + .page + .show_details(Fetch::Loaded(profile(Vec::new(), Vec::new())), Fetch::Idle); + revoking.store(false, Ordering::SeqCst); + wait_until(WAIT, || shows("Sign In to TablePro")).expect("signed out"); + harness.telemetry.flush(); + + let account = fakes::claims(Utc::now(), TimeDelta::days(1), &[]).sub; + assert_eq!(collector.named("$identify").len(), 1); + let signed_out = collector.named("signed_out"); + assert_eq!(signed_out.len(), 1); + assert_eq!(signed_out[0]["distinct_id"], account.as_str()); + drop(window); + } + + #[gtk4::test] + fn showing_the_page_again_loads_the_account_again() { + let harness = Harness::new(None); + let machine = MachineId::derive("4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f20\n").expect("a machine ID"); + let stub = sign_out_stub(&machine, Arc::new(AtomicBool::new(false))); + let endpoints = Endpoints::new(&stub.url, &format!("{}/v2", stub.url)).expect("the endpoints"); + harness.ready_with( + endpoints, + fakes::keys().expect("the test key"), + machine, + Arc::new(Approving), + ); + let window = OwnedWindow::presented(adw::Window::builder().default_width(900).default_height(700).build()); + window.set_content(Some(&harness.page)); + let asked_me = || stub.requests().iter().filter(|it| *it == "GET /v2/me").count(); + let settled = || { + harness.settle(); + !matches!(*harness.page.imp().profile.borrow(), Fetch::Idle | Fetch::Loading) + }; + harness.button(&harness.page, "Sign In…").emit_clicked(); + wait_until(WAIT, || settled() && asked_me() >= 1).expect("the account loaded"); + let first = asked_me(); + + window.set_content(None::<>k4::Widget>); + harness.settle(); + window.set_content(Some(&harness.page)); + + wait_until(WAIT, || settled() && asked_me() > first).expect("the account loaded again"); + drop(window); + } + + #[gtk4::test] + #[ignore = "requires a tablepro-cloud stack"] + fn the_page_signs_in_through_the_browser_shows_the_account_and_signs_out() { + let read = + |name: &str| std::env::var(name).unwrap_or_else(|_| panic!("{name} is not set; see linux/docs/testing.md")); + let email = format!( + "linux-page-{}@{}", + &uuid::Uuid::new_v4().simple().to_string()[..8], + read("TABLEPRO_CLOUD_EMAIL_DOMAIN") + ); + let harness = Harness::new(None); + let endpoints = Endpoints::new(&read("TABLEPRO_CLOUD_ACCOUNT_URL"), &read("TABLEPRO_CLOUD_API_URL")) + .expect("the endpoints"); + let keys = + tablepro_account::EntitlementKeys::from_public_keys([read("TABLEPRO_CLOUD_ENTITLEMENT_KEY").as_str()]) + .expect("the stack's key"); + let machine = MachineId::derive(&format!("{}\n", uuid::Uuid::new_v4().simple())).expect("a machine ID"); + let browser = Arc::new(StackBrowser { + email: email.clone(), + profile: tempfile::tempdir().expect("a browser profile"), + }); + harness.ready_with(endpoints, keys, machine, browser); + let collector = crate::test_support::PostHogCollector::start(); + harness.share_usage_data(&collector); + let window = OwnedWindow::presented(adw::Window::builder().default_width(900).default_height(900).build()); + window.set_content(Some(&harness.page)); + let shows = |title: &str| { + harness.settle(); + descendants(&harness.page) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .any(|row| row.title() == title) + }; + + harness.button(&harness.page, "Sign In…").emit_clicked(); + wait_until(Duration::from_secs(120), || shows("Starter") && shows("Ada's laptop")) + .expect("signed in, with the plan and this computer"); + + let imp = harness.page.imp(); + assert_eq!(harness.titles(&imp.account_group), [email.as_str(), "Manage Account"]); + assert!( + harness + .row("Ada's laptop") + .subtitle() + .is_some_and(|text| text.starts_with("This computer")) + ); + // An admin's grant, as the browser step writes it: no cycle and no Billing…. + assert_eq!(harness.row("Starter").subtitle().as_deref(), Some("Personal · Active")); + assert!( + descendants(&harness.row("Starter")) + .iter() + .all(|widget| !widget.is::()) + ); + assert!( + harness + .titles(&imp.offline_group) + .iter() + .any(|title| title.starts_with("Verified")) + ); + + harness.button(&imp.account_group.get(), "Sign Out").emit_clicked(); + wait_until(WAIT, || shows("Sign In to TablePro")).expect("signed out"); + wait_until(WAIT, || !collector.named("signed_out").is_empty()).expect("the sign-out sent"); + let identify = collector.named("$identify"); + assert_eq!(identify.len(), 1); + let account = identify[0]["distinct_id"].as_str().unwrap_or_default().to_owned(); + assert!(account.starts_with("acct_"), "{account}"); + assert_eq!(collector.named("signed_in")[0]["distinct_id"], account.as_str()); + assert_eq!(collector.named("signed_in")[0]["properties"]["method"], "interactive"); + assert_eq!(collector.named("signed_out")[0]["distinct_id"], account.as_str()); + drop(window); + } +} diff --git a/linux/crates/app/src/ui/preferences_dialog/account_view.rs b/linux/crates/app/src/ui/preferences_dialog/account_view.rs new file mode 100644 index 0000000000..0874332970 --- /dev/null +++ b/linux/crates/app/src/ui/preferences_dialog/account_view.rs @@ -0,0 +1,1104 @@ +use std::cmp::Reverse; + +use chrono::{DateTime, TimeDelta, Utc}; +use tablepro_account::{ + AccountError, AccountStatus, Device, Entitlement, EntitlementProblem, LicenseClaim, Profile, SignedIn, +}; + +use crate::i18n::{gettext, gettext_f, ngettext_f}; +use crate::ui::error_text::account_error_message; + +// clients.md, Settings > Account: an entitlement this close to its end without a refresh gets a notice. +const EXPIRING_SOON: TimeDelta = TimeDelta::days(3); +const OWNER: &str = "owner"; + +// product.md's codes, and whether TablePro for Linux has the feature yet. +const FEATURES: [(&str, bool); 10] = [ + ("icloud_sync", false), + ("encrypted_export", false), + ("env_var_references", false), + ("linked_folders", false), + ("query_insights", false), + ("result_charts", false), + ("compare_sync", false), + ("data_rewind", false), + ("team_catalog", false), + ("team_library", false), +]; + +#[derive(Default)] +pub(crate) enum Fetch { + #[default] + Idle, + Loading, + Loaded(T), + Failed(AccountError), +} + +pub(crate) struct Facts<'a> { + pub status: &'a AccountStatus, + pub profile: &'a Fetch, + pub devices: &'a Fetch>, + pub machine_id: Option<&'a str>, + pub now: DateTime, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Mode { + SignedOut, + SignedIn, + Offline, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Identity { + pub name: String, + pub email: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Notice { + pub title: String, + pub body: String, + pub action: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum NoticeAction { + ChooseDevice, + Refresh, + Open { label: String, path: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LinkRow { + pub title: String, + pub subtitle: String, + pub path: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Listing { + Loading, + Failed(String), + Rows(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Plan { + Free, + Licenses(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PlanRow { + pub title: String, + pub subtitle: String, + // The portal path of Billing…, for a license someone pays for and may manage. + pub billing: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct FeatureRow { + pub title: String, + pub subtitle: Option, + pub included: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct DeviceRow { + pub id: String, + pub name: String, + pub subtitle: String, + pub removal: Removal, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Removal { + ThisDevice, + Allowed, + KeptUntil(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Verification { + pub title: String, + pub subtitle: String, +} + +pub(crate) fn mode(status: &AccountStatus) -> Mode { + match status { + AccountStatus::SignedOut => Mode::SignedOut, + AccountStatus::SignedIn(_) => Mode::SignedIn, + AccountStatus::Offline(_) => Mode::Offline, + } +} + +pub(crate) fn identity(facts: &Facts<'_>) -> Option { + let (email, name) = match (facts.profile, facts.status.entitlement()) { + (Fetch::Loaded(profile), _) => (profile.email.clone(), profile.name.clone()), + (_, Some(entitlement)) => { + let account = &entitlement.claims().account; + (account.email.clone(), Some(account.name.clone())) + } + _ => return None, + }; + Some(match name.filter(|name| !name.trim().is_empty()) { + Some(name) => Identity { + name, + email: Some(email), + }, + None => Identity { + name: email, + email: None, + }, + }) +} + +pub(crate) fn notices(facts: &Facts<'_>) -> Vec { + let AccountStatus::SignedIn(signed_in) = facts.status else { + return offline_notices(facts); + }; + let mut notices = Vec::new(); + let current = signed_in.entitlement.as_ref().filter(|it| it.is_current(facts.now)); + match (&signed_in.problem, current) { + (Some(EntitlementProblem::DeviceLimitReached(devices)), _) => notices.push(device_limit(devices.len())), + (Some(EntitlementProblem::RefreshFailed(error)), None) => notices.push(Notice { + title: gettext("Could Not Verify Your Plan"), + body: account_error_message(error), + action: Some(NoticeAction::Refresh), + }), + (Some(EntitlementProblem::Expired), None) => notices.push(paused(signed_in)), + (None, None) if signed_in.entitlement.is_some() => notices.push(paused(signed_in)), + _ => {} + } + for license in licenses(facts) { + if let Some(notice) = license_notice(license, billing(license, facts.profile), facts.now) { + notices.push(notice); + } + } + if let Some(entitlement) = current + && let Some(notice) = expiring_soon(entitlement, facts.now) + { + notices.push(notice); + } + if !signed_in.remembered { + notices.push(Notice { + title: gettext("Sign-In Not Saved"), + body: gettext("No keyring took the sign-in, so TablePro asks for it again the next time it starts."), + action: None, + }); + } + notices +} + +pub(crate) fn plan(facts: &Facts<'_>) -> Result> { + let known = matches!(facts.profile, Fetch::Loaded(_)) || facts.status.entitlement().is_some(); + if !known { + return Err(match facts.profile { + Fetch::Failed(error) => Listing::Failed(account_error_message(error)), + Fetch::Idle | Fetch::Loading | Fetch::Loaded(_) => Listing::Loading, + }); + } + let rows: Vec = licenses(facts) + .into_iter() + .map(|license| license_row(license, billing(license, facts.profile))) + .collect(); + Ok(match rows.is_empty() { + true => Plan::Free, + false => Plan::Licenses(rows), + }) +} + +pub(crate) fn features(facts: &Facts<'_>) -> Vec { + let current = facts.status.entitlement().filter(|it| it.is_current(facts.now)); + FEATURES + .iter() + .map(|(code, on_linux)| FeatureRow { + title: feature_name(code), + subtitle: (!on_linux).then(|| gettext("In TablePro for macOS")), + included: current.is_some_and(|entitlement| entitlement.includes(code)), + }) + .collect() +} + +pub(crate) fn devices(facts: &Facts<'_>) -> Listing { + let devices = match facts.devices { + Fetch::Idle | Fetch::Loading => return Listing::Loading, + Fetch::Failed(error) => return Listing::Failed(account_error_message(error)), + Fetch::Loaded(devices) => devices, + }; + let mut sorted: Vec<&Device> = devices.iter().collect(); + sorted.sort_by_key(|device| { + ( + Some(device.machine_id.as_str()) != facts.machine_id, + Reverse(device.last_seen_at), + ) + }); + let rows = sorted + .into_iter() + .map(|device| { + let this_device = Some(device.machine_id.as_str()) == facts.machine_id; + let mut parts = Vec::new(); + if this_device { + parts.push(gettext("This computer")); + } + parts.push(system(device)); + if !this_device { + parts.push(last_active(device.last_seen_at, facts.now)); + } + DeviceRow { + id: device.id.clone(), + name: device.name.clone(), + subtitle: parts.join(" · "), + removal: match this_device { + true => Removal::ThisDevice, + false => removal(device, facts.now), + }, + } + }) + .collect(); + Listing::Rows(rows) +} + +// The devices device_limit_reached names, each with its offline_until. +pub(crate) fn limit_devices(holding: &[Device], now: DateTime) -> Vec { + holding + .iter() + .map(|device| DeviceRow { + id: device.id.clone(), + name: device.name.clone(), + subtitle: [system(device), last_active(device.last_seen_at, now)].join(" · "), + removal: removal(device, now), + }) + .collect() +} + +pub(crate) fn teams(profile: &Fetch) -> Listing { + let profile = match profile { + Fetch::Idle | Fetch::Loading => return Listing::Loading, + Fetch::Failed(error) => return Listing::Failed(account_error_message(error)), + Fetch::Loaded(profile) => profile, + }; + let rows = profile + .teams + .iter() + .map(|membership| { + let seat = match membership.has_seat { + true => gettext("Holds a seat"), + false => gettext("No seat"), + }; + let subtitle = match role_name(&membership.role) { + Some(role) => [role, seat].join(" · "), + None => seat, + }; + LinkRow { + title: membership.team.name.clone(), + subtitle, + path: format!("/teams/{}", membership.team.id), + } + }) + .collect(); + Listing::Rows(rows) +} + +pub(crate) fn verification(facts: &Facts<'_>) -> Option { + match facts.status { + AccountStatus::SignedOut => None, + AccountStatus::Offline(entitlement) => Some(Verification { + title: gettext("Offline File"), + subtitle: validity(entitlement, facts.now), + }), + AccountStatus::SignedIn(signed_in) => { + let Some(entitlement) = &signed_in.entitlement else { + return Some(Verification { + title: gettext("Not Verified Yet"), + subtitle: gettext("TablePro checks your plan with the account service once a day."), + }); + }; + let issued = DateTime::from_timestamp(entitlement.claims().iat, 0).unwrap_or(facts.now); + let mut subtitle = validity(entitlement, facts.now); + if let Some(EntitlementProblem::RefreshFailed(error)) = &signed_in.problem { + subtitle = gettext_f( + "{validity}. The last check failed: {reason}", + &[("validity", &subtitle), ("reason", &account_error_message(error))], + ); + } + Some(Verification { + title: gettext_f("Verified {when}", &[("when", &ago(issued, facts.now))]), + subtitle, + }) + } + } +} + +pub(crate) fn plan_name(code: &str) -> String { + match code { + "starter" => gettext("Starter"), + "team" => gettext("Team"), + _ => gettext("License"), + } +} + +pub(crate) fn cycle_name(code: &str) -> Option { + match code { + "monthly" => Some(gettext("Monthly")), + "yearly" => Some(gettext("Yearly")), + "lifetime" => Some(gettext("Lifetime")), + _ => None, + } +} + +pub(crate) fn role_name(code: &str) -> Option { + match code { + OWNER => Some(gettext("Owner")), + "admin" => Some(gettext("Admin")), + "member" => Some(gettext("Member")), + _ => None, + } +} + +// A status this build does not know says nothing rather than something wrong. +pub(crate) fn license_state(license: &LicenseClaim) -> Option { + let ends = license + .ends_at + .map(|at| gettext_f("Ends on {date}", &[("date", &day(at))])); + match license.status.as_str() { + "past_due" => Some(gettext("Payment problem")), + "canceling" => ends.or_else(|| Some(gettext("Ending"))), + "active" if ends.is_some() => ends, + "active" => match license.renews_at { + Some(at) => Some(gettext_f("Renews on {date}", &[("date", &day(at))])), + // The row's cycle says Lifetime already. + None if billed(license) && license.cycle == "lifetime" => None, + None => Some(gettext("Active")), + }, + _ => None, + } +} + +// As the portal: nothing bills a license an admin granted or the migration brought over, so it has no cycle. +fn billed(license: &LicenseClaim) -> bool { + matches!(license.source.as_str(), "subscription" | "order") +} + +pub(crate) fn day(at: DateTime) -> String { + glib::DateTime::from_unix_local(at.timestamp()) + .and_then(|local| local.format("%x")) + .map(String::from) + .unwrap_or_else(|_| at.format("%Y-%m-%d").to_string()) +} + +pub(crate) fn ago(when: DateTime, now: DateTime) -> String { + let seconds = (now - when).num_seconds().max(0); + let count = |n: i64| (u32::try_from(n).unwrap_or(u32::MAX), n.to_string()); + if seconds < 60 { + gettext("just now") + } else if seconds < 3_600 { + let (n, text) = count(seconds / 60); + ngettext_f("{n} minute ago", "{n} minutes ago", n, &[("n", &text)]) + } else if seconds < 86_400 { + let (n, text) = count(seconds / 3_600); + ngettext_f("{n} hour ago", "{n} hours ago", n, &[("n", &text)]) + } else if seconds < 30 * 86_400 { + let (n, text) = count(seconds / 86_400); + ngettext_f("{n} day ago", "{n} days ago", n, &[("n", &text)]) + } else { + day(when) + } +} + +fn offline_notices(facts: &Facts<'_>) -> Vec { + let AccountStatus::Offline(entitlement) = facts.status else { + return Vec::new(); + }; + if entitlement.is_current(facts.now) { + return Vec::new(); + } + vec![Notice { + title: gettext("Offline File Expired"), + body: gettext_f( + "It expired on {date}. Import a new one, or sign in.", + &[("date", &day(entitlement.expires_at()))], + ), + action: None, + }] +} + +fn device_limit(others: usize) -> Notice { + let count = u32::try_from(others).unwrap_or(u32::MAX); + Notice { + title: gettext("Device Limit Reached"), + body: ngettext_f( + "Your license is in use on {n} other device. Remove one to use the paid features on this computer. Until then, TablePro works here with the free features.", + "Your license is in use on {n} other devices. Remove one to use the paid features on this computer. Until then, TablePro works here with the free features.", + count, + &[("n", &count.to_string())], + ), + action: Some(NoticeAction::ChooseDevice), + } +} + +fn paused(signed_in: &SignedIn) -> Notice { + let body = match &signed_in.entitlement { + Some(entitlement) => gettext_f( + "The last verification of your plan ran out on {date}, so the paid features are off until TablePro reaches the account service. Connect to the internet and refresh.", + &[("date", &day(entitlement.expires_at()))], + ), + None => gettext( + "The last verification of your plan ran out, so the paid features are off until TablePro reaches the account service. Connect to the internet and refresh.", + ), + }; + Notice { + title: gettext("Paid Features Paused"), + body, + action: Some(NoticeAction::Refresh), + } +} + +fn license_notice(license: &LicenseClaim, billing: Billing, now: DateTime) -> Option { + let plan = match &license.team { + Some(team) => gettext_f( + "{plan} for {team}", + &[("plan", &plan_name(&license.plan)), ("team", &team.name)], + ), + None => plan_name(&license.plan), + }; + match license.status.as_str() { + "past_due" => { + let body = match &billing { + Billing::Opens(_) => gettext_f( + "Polar could not charge the card for {plan}. Its paid features keep working while Polar tries again. Update the payment method to keep them.", + &[("plan", &plan)], + ), + Billing::TeamOwnerOnly => gettext_f( + "Polar could not charge the card for {plan}. Its paid features keep working while Polar tries again. Only the team’s owner can update the payment method.", + &[("plan", &plan)], + ), + Billing::Unavailable => gettext_f( + "Polar could not charge the card for {plan}. Its paid features keep working while Polar tries again.", + &[("plan", &plan)], + ), + }; + Some(Notice { + title: gettext("Payment Problem"), + body, + action: billing.path().map(|path| NoticeAction::Open { + label: gettext("Update Payment Method…"), + path, + }), + }) + } + "canceling" => { + let ends = day(license.ends_at.filter(|at| *at > now)?); + let values = [("plan", plan.as_str()), ("date", ends.as_str())]; + let body = match &billing { + Billing::Opens(_) => gettext_f( + "{plan} is set to end on {date}. Resume it to keep its paid features.", + &values, + ), + Billing::TeamOwnerOnly => gettext_f( + "{plan} is set to end on {date}. Only the team’s owner can resume it.", + &values, + ), + Billing::Unavailable => gettext_f("{plan} is set to end on {date}.", &values), + }; + Some(Notice { + title: gettext("Plan Ending"), + body, + action: billing.path().map(|path| NoticeAction::Open { + label: gettext("Billing…"), + path, + }), + }) + } + _ => None, + } +} + +// A license that ends inside the window has its own notice; this one is for a refresh that never came. +fn expiring_soon(entitlement: &Entitlement, now: DateTime) -> Option { + let expires = entitlement.expires_at(); + if expires - now > EXPIRING_SOON { + return None; + } + let license_ends = entitlement + .claims() + .licenses + .iter() + .filter_map(|license| license.ends_at) + .any(|ends| ends <= expires + TimeDelta::days(1)); + if license_ends { + return None; + } + let issued = DateTime::from_timestamp(entitlement.claims().iat, 0).unwrap_or(now); + Some(Notice { + title: gettext("Plan Not Verified Lately"), + body: gettext_f( + "TablePro last verified your plan {when}. The paid features stop on {date} unless it reaches the account service again.", + &[("when", &ago(issued, now)), ("date", &day(expires))], + ), + action: Some(NoticeAction::Refresh), + }) +} + +fn licenses<'a>(facts: &'a Facts<'_>) -> Vec<&'a LicenseClaim> { + match (facts.profile, facts.status.entitlement()) { + (Fetch::Loaded(profile), _) => profile.licenses.iter().collect(), + (_, Some(entitlement)) => entitlement.claims().licenses.iter().collect(), + _ => Vec::new(), + } +} + +fn license_row(license: &LicenseClaim, billing: Billing) -> PlanRow { + let owner = license + .team + .as_ref() + .map_or_else(|| gettext("Personal"), |team| team.name.clone()); + let cycle = cycle_name(&license.cycle).filter(|_| billed(license)); + let parts: Vec = [cycle, Some(owner), license_state(license)] + .into_iter() + .flatten() + .collect(); + PlanRow { + title: plan_name(&license.plan), + subtitle: parts.join(" · "), + billing: billing.path(), + } +} + +// The portal lets only a team's owner into its billing, and only the profile names the role. +enum Billing { + Opens(String), + TeamOwnerOnly, + // Not billed, or the role is not known yet. + Unavailable, +} + +impl Billing { + fn path(self) -> Option { + match self { + Self::Opens(path) => Some(path), + Self::TeamOwnerOnly | Self::Unavailable => None, + } + } +} + +fn billing(license: &LicenseClaim, profile: &Fetch) -> Billing { + if !billed(license) { + return Billing::Unavailable; + } + let Some(team) = &license.team else { + return Billing::Opens("/billing".to_owned()); + }; + let Fetch::Loaded(profile) = profile else { + return Billing::Unavailable; + }; + let owner = profile + .teams + .iter() + .any(|membership| membership.team.id == team.id && membership.role == OWNER); + match owner { + true => Billing::Opens(format!("/teams/{}/billing", team.id)), + false => Billing::TeamOwnerOnly, + } +} + +fn removal(device: &Device, now: DateTime) -> Removal { + match device.offline_until.filter(|_| device.offline_file_active(now)) { + Some(until) => Removal::KeptUntil(gettext_f("Offline file until {date}", &[("date", &day(until))])), + None => Removal::Allowed, + } +} + +fn system(device: &Device) -> String { + match device.platform.as_str() { + "macos" => gettext_f("macOS {version}", &[("version", &device.os_version)]), + _ => device.os_version.clone(), + } +} + +fn last_active(seen: DateTime, now: DateTime) -> String { + gettext_f("Last active {when}", &[("when", &ago(seen, now))]) +} + +fn validity(entitlement: &Entitlement, now: DateTime) -> String { + let date = day(entitlement.expires_at()); + match entitlement.is_current(now) { + true => gettext_f("Valid until {date}", &[("date", &date)]), + false => gettext_f("Expired on {date}", &[("date", &date)]), + } +} + +fn feature_name(code: &str) -> String { + match code { + "icloud_sync" => gettext("iCloud Sync"), + "encrypted_export" => gettext("Encrypted Export"), + "env_var_references" => gettext("Environment Variable References"), + "linked_folders" => gettext("Linked Folders"), + "query_insights" => gettext("Query Insights"), + "result_charts" => gettext("Result Charts"), + "compare_sync" => gettext("Compare and Sync"), + "data_rewind" => gettext("Data Rewind"), + "team_catalog" => gettext("Team Catalog"), + "team_library" => gettext("Team Library"), + other => other.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use tablepro_account::{EntitlementClaims, Membership, SignedIn, TeamClaim, fakes}; + + use super::*; + + const MACHINE: &str = fakes::MACHINE; + + fn license(plan: &str, cycle: &str, status: &str, team: Option<&str>) -> LicenseClaim { + LicenseClaim { + id: "lic_01".to_owned(), + plan: plan.to_owned(), + cycle: cycle.to_owned(), + source: "subscription".to_owned(), + status: status.to_owned(), + renews_at: None, + ends_at: None, + team: team.map(|name| TeamClaim { + id: "team_01".to_owned(), + name: name.to_owned(), + }), + } + } + + fn entitlement(issued: DateTime, lasts: TimeDelta, licenses: Vec) -> Entitlement { + let mut claims: EntitlementClaims = fakes::claims(issued, lasts, &["env_var_references", "data_rewind"]); + claims.licenses = licenses; + fakes::entitlement(&claims).expect("an entitlement") + } + + fn signed_in(entitlement: Option, problem: Option) -> AccountStatus { + AccountStatus::SignedIn(Box::new(SignedIn { + entitlement, + problem, + remembered: true, + })) + } + + fn device(id: &str, machine_id: &str, seen: DateTime) -> Device { + Device { + id: id.to_owned(), + machine_id: machine_id.to_owned(), + platform: "linux".to_owned(), + name: format!("Device {id}"), + app_version: "0.1.0".to_owned(), + os_version: "Ubuntu 26.04".to_owned(), + last_seen_at: seen, + licensed_at: Some(seen), + offline_until: None, + } + } + + fn facts<'a>( + status: &'a AccountStatus, + profile: &'a Fetch, + devices: &'a Fetch>, + now: DateTime, + ) -> Facts<'a> { + Facts { + status, + profile, + devices, + machine_id: Some(MACHINE), + now, + } + } + + #[test] + fn plans_cycles_statuses_and_roles_this_build_does_not_know_read_neutrally() { + let unknown = license("enterprise", "quarterly", "paused", None); + let mut unknown_ending = unknown.clone(); + unknown_ending.ends_at = Some(Utc::now() + TimeDelta::days(10)); + + assert_eq!(plan_name("enterprise"), "License"); + assert_eq!(cycle_name("quarterly"), None); + assert_eq!(role_name("billing_manager"), None); + assert_eq!(license_state(&unknown), None); + let row = license_row(&unknown, billing(&unknown, &Fetch::Idle)); + assert_eq!(row.title, "License"); + assert_eq!(row.subtitle, "Personal"); + assert_eq!(license_state(&unknown_ending), None); + assert_eq!( + license_row(&unknown_ending, billing(&unknown_ending, &Fetch::Idle)).subtitle, + "Personal" + ); + assert_eq!( + license_notice(&unknown, billing(&unknown, &Fetch::Idle), Utc::now()), + None + ); + } + + #[test] + fn a_license_says_its_cycle_owner_and_state() { + let now = Utc::now(); + let mut ending = license("starter", "yearly", "canceling", None); + ending.ends_at = Some(now + TimeDelta::days(10)); + let mut lifetime = license("starter", "lifetime", "active", None); + lifetime.source = "order".to_owned(); + let mut renewing = license("team", "monthly", "active", Some("Acme")); + renewing.renews_at = Some(now + TimeDelta::days(20)); + let unrenewed = license("starter", "monthly", "active", None); + let past_due = license("team", "yearly", "past_due", Some("Acme")); + let row = |license: &LicenseClaim| license_row(license, billing(license, &Fetch::Idle)); + + assert_eq!(row(&lifetime).subtitle, "Lifetime · Personal"); + assert_eq!( + row(&renewing).subtitle, + format!("Monthly · Acme · Renews on {}", day(now + TimeDelta::days(20))) + ); + assert_eq!(row(&unrenewed).subtitle, "Monthly · Personal · Active"); + assert_eq!(row(&past_due).subtitle, "Yearly · Acme · Payment problem"); + assert_eq!( + row(&ending).subtitle, + format!("Yearly · Personal · Ends on {}", day(now + TimeDelta::days(10))) + ); + } + + #[test] + fn a_license_nobody_pays_for_shows_its_plan_and_state_without_a_cycle_or_billing() { + let now = Utc::now(); + let owner = holding(Vec::new(), vec![membership("team_01", "owner")]); + let paid = |source: &str, team: Option<&str>| { + let mut paid = license("starter", "lifetime", "active", team); + paid.source = source.to_owned(); + license_row(&paid, billing(&paid, &owner)) + }; + let granted = |source: &str, ends_at: Option>| { + let mut granted = license("team", "lifetime", "active", Some("Acme")); + granted.source = source.to_owned(); + granted.ends_at = ends_at; + license_row(&granted, billing(&granted, &owner)) + }; + let ends = now + TimeDelta::days(30); + + assert_eq!(paid("subscription", None).billing.as_deref(), Some("/billing")); + assert_eq!(paid("order", None).billing.as_deref(), Some("/billing")); + assert_eq!( + paid("order", Some("Acme")).billing.as_deref(), + Some("/teams/team_01/billing") + ); + for source in ["manual", "legacy", "reseller"] { + let open = granted(source, None); + let ending = granted(source, Some(ends)); + + assert_eq!((open.title.as_str(), open.billing), ("Team", None), "{source}"); + assert_eq!(open.subtitle, "Acme · Active", "{source}"); + assert_eq!(ending.subtitle, format!("Acme · Ends on {}", day(ends)), "{source}"); + assert_eq!(ending.billing, None, "{source}"); + } + } + + #[test] + fn a_team_license_opens_billing_for_the_teams_owner_only() { + let team_plan = license("team", "yearly", "active", Some("Acme")); + let billing_for = |profile: &Fetch| license_row(&team_plan, billing(&team_plan, profile)).billing; + + let owner = billing_for(&holding(Vec::new(), vec![membership("team_01", "owner")])); + let admin = billing_for(&holding(Vec::new(), vec![membership("team_01", "admin")])); + let unknown = billing_for(&Fetch::Loading); + + assert_eq!(owner.as_deref(), Some("/teams/team_01/billing")); + assert_eq!(admin, None); + assert_eq!(unknown, None); + } + + fn membership(team: &str, role: &str) -> Membership { + Membership { + team: TeamClaim { + id: team.to_owned(), + name: "Acme".to_owned(), + }, + role: role.to_owned(), + has_seat: true, + } + } + + fn holding(licenses: Vec, teams: Vec) -> Fetch { + Fetch::Loaded(Profile { + id: "acct_01".to_owned(), + email: "ada@m17.example".to_owned(), + name: None, + licenses, + teams, + }) + } + + #[test] + fn a_payment_problem_and_a_cancellation_get_notices_that_open_billing() { + let now = Utc::now(); + let mut ending = license("starter", "monthly", "canceling", None); + ending.ends_at = Some(now + TimeDelta::days(5)); + let licenses = vec![license("team", "yearly", "past_due", Some("Acme")), ending]; + let status = signed_in(Some(entitlement(now, TimeDelta::days(5), licenses.clone())), None); + let profile = holding(licenses, vec![membership("team_01", "owner")]); + let devices = Fetch::Idle; + + let notices = notices(&facts(&status, &profile, &devices, now)); + + assert_eq!(notices.len(), 2, "{notices:#?}"); + assert_eq!(notices[0].title, "Payment Problem"); + assert_eq!( + notices[0].action, + Some(NoticeAction::Open { + label: "Update Payment Method…".to_owned(), + path: "/teams/team_01/billing".to_owned(), + }) + ); + assert!(notices[0].body.contains("for Team for Acme."), "{}", notices[0].body); + assert!(notices[0].body.ends_with("Update the payment method to keep them.")); + assert_eq!(notices[1].title, "Plan Ending"); + assert!( + notices[1].body.starts_with("Starter is set to end on "), + "{}", + notices[1].body + ); + assert_eq!( + notices[1].action, + Some(NoticeAction::Open { + label: "Billing…".to_owned(), + path: "/billing".to_owned(), + }) + ); + } + + #[test] + fn a_team_plan_only_its_owner_can_fix_opens_no_billing_for_an_admin_a_member_or_an_unknown_role() { + let now = Utc::now(); + let past_due = license("team", "yearly", "past_due", Some("Acme")); + let mut ending = license("team", "monthly", "canceling", Some("Acme")); + ending.ends_at = Some(now + TimeDelta::days(5)); + let devices = Fetch::Idle; + let notices_for = |licenses: Vec, membership: Option| { + let profile = membership.map_or(Fetch::Idle, |membership| holding(licenses.clone(), vec![membership])); + let status = signed_in(Some(entitlement(now, TimeDelta::days(14), licenses)), None); + notices(&facts(&status, &profile, &devices, now)) + }; + + let admin = notices_for(vec![past_due.clone()], Some(self::membership("team_01", "admin"))); + let member = notices_for(vec![ending.clone()], Some(self::membership("team_01", "member"))); + let owns_another = notices_for(vec![past_due.clone()], Some(self::membership("team_02", "owner"))); + let unknown = notices_for(vec![past_due, ending], None); + + assert_eq!(admin[0].action, None); + assert!( + admin[0] + .body + .ends_with("Only the team’s owner can update the payment method."), + "{}", + admin[0].body + ); + assert_eq!(member[0].action, None); + assert!( + member[0].body.ends_with("Only the team’s owner can resume it."), + "{}", + member[0].body + ); + assert_eq!(owns_another[0].action, None); + assert_eq!(unknown.len(), 2, "{unknown:#?}"); + assert!(unknown.iter().all(|notice| notice.action.is_none()), "{unknown:#?}"); + assert!( + unknown.iter().all(|notice| !notice.body.contains("owner")), + "{unknown:#?}" + ); + } + + #[test] + fn an_entitlement_near_its_end_without_a_refresh_gets_a_notice_and_a_license_ending_does_not() { + let now = Utc::now(); + let stale = signed_in( + Some(entitlement(now - TimeDelta::days(12), TimeDelta::days(14), Vec::new())), + None, + ); + let mut ending = license("starter", "monthly", "canceling", None); + ending.ends_at = Some(now + TimeDelta::days(2)); + let ending_soon = signed_in(Some(entitlement(now, TimeDelta::days(2), vec![ending])), None); + let (profile, devices) = (Fetch::Idle, Fetch::Idle); + + let stale_notices = notices(&facts(&stale, &profile, &devices, now)); + let ending_notices = notices(&facts(&ending_soon, &profile, &devices, now)); + + assert_eq!(stale_notices.len(), 1); + assert_eq!(stale_notices[0].title, "Plan Not Verified Lately"); + assert_eq!(stale_notices[0].action, Some(NoticeAction::Refresh)); + assert!( + ending_notices + .iter() + .all(|notice| notice.title != "Plan Not Verified Lately") + ); + } + + #[test] + fn the_device_limit_and_a_failed_refresh_say_so_and_a_kept_entitlement_keeps_quiet() { + let now = Utc::now(); + let others = vec![ + device("dev_a", "a", now), + device("dev_b", "b", now), + device("dev_c", "c", now), + ]; + let limited = signed_in(None, Some(EntitlementProblem::DeviceLimitReached(others))); + let offline = AccountError::Unreachable("refused".to_owned()); + let failed = signed_in(None, Some(EntitlementProblem::RefreshFailed(offline.clone()))); + let kept = signed_in( + Some(entitlement(now, TimeDelta::days(14), Vec::new())), + Some(EntitlementProblem::RefreshFailed(offline)), + ); + let (profile, devices) = (Fetch::Idle, Fetch::Idle); + + let limited = notices(&facts(&limited, &profile, &devices, now)); + let failed = notices(&facts(&failed, &profile, &devices, now)); + let kept_notices = notices(&facts(&kept, &profile, &devices, now)); + let kept_verification = verification(&facts(&kept, &profile, &devices, now)).expect("a verification"); + + assert_eq!(limited[0].title, "Device Limit Reached"); + assert!(limited[0].body.contains("on 3 other devices"), "{}", limited[0].body); + assert_eq!(limited[0].action, Some(NoticeAction::ChooseDevice)); + assert_eq!(failed[0].title, "Could Not Verify Your Plan"); + assert!(kept_notices.is_empty(), "{kept_notices:#?}"); + assert_eq!(kept_verification.title, "Verified just now"); + assert!(kept_verification.subtitle.contains("The last check failed")); + } + + #[test] + fn this_device_comes_first_and_a_device_with_an_offline_file_cannot_be_removed() { + let now = Utc::now(); + let mut held = device("dev_held", "held", now - TimeDelta::days(3)); + held.offline_until = Some(now + TimeDelta::days(200)); + let listed = vec![ + device("dev_old", "old", now - TimeDelta::days(9)), + held, + device("dev_here", MACHINE, now), + ]; + let status = signed_in(None, None); + let (profile, devices) = (Fetch::Idle, Fetch::Loaded(listed)); + + let Listing::Rows(rows) = self::devices(&facts(&status, &profile, &devices, now)) else { + panic!("rows"); + }; + + let ids: Vec<&str> = rows.iter().map(|row| row.id.as_str()).collect(); + assert_eq!(ids, ["dev_here", "dev_held", "dev_old"]); + assert_eq!(rows[0].removal, Removal::ThisDevice); + assert!(rows[0].subtitle.starts_with("This computer · ")); + assert!(matches!(&rows[1].removal, Removal::KeptUntil(text) if text.starts_with("Offline file until "))); + assert_eq!(rows[2].removal, Removal::Allowed); + assert!( + rows[2].subtitle.ends_with("Last active 9 days ago"), + "{}", + rows[2].subtitle + ); + } + + #[test] + fn the_limit_dialog_keeps_a_holder_whose_offline_file_is_valid() { + let now = Utc::now(); + let mut holders = vec![ + device("dev_a", "a", now), + device("dev_b", "b", now), + device("dev_c", "c", now), + ]; + holders[1].offline_until = Some(now + TimeDelta::days(30)); + holders[2].offline_until = Some(now - TimeDelta::days(1)); + + let rows = limit_devices(&holders, now); + + assert_eq!(rows[0].removal, Removal::Allowed); + assert_eq!( + rows[1].removal, + Removal::KeptUntil(format!("Offline file until {}", day(now + TimeDelta::days(30)))) + ); + assert_eq!(rows[2].removal, Removal::Allowed); + } + + #[test] + fn teams_name_the_role_and_the_seat() { + let profile = Fetch::Loaded(Profile { + id: "acct_01".to_owned(), + email: "ada@m17.example".to_owned(), + name: None, + licenses: Vec::new(), + teams: vec![ + Membership { + team: TeamClaim { + id: "team_01".to_owned(), + name: "Acme".to_owned(), + }, + role: "member".to_owned(), + has_seat: true, + }, + Membership { + team: TeamClaim { + id: "team_02".to_owned(), + name: "Globex".to_owned(), + }, + role: "auditor".to_owned(), + has_seat: false, + }, + ], + }); + + let Listing::Rows(rows) = teams(&profile) else { + panic!("rows"); + }; + + assert_eq!(rows[0].subtitle, "Member · Holds a seat"); + assert_eq!(rows[1].subtitle, "No seat"); + assert_eq!(rows[1].path, "/teams/team_02"); + } + + #[test] + fn a_free_account_is_free_once_known_and_loading_before() { + let now = Utc::now(); + let status = signed_in(None, None); + let loading = Fetch::Loading; + let free = Fetch::Loaded(Profile { + id: "acct_01".to_owned(), + email: "ada@m17.example".to_owned(), + name: Some("Ada".to_owned()), + licenses: Vec::new(), + teams: Vec::new(), + }); + let devices = Fetch::Idle; + + assert_eq!(plan(&facts(&status, &loading, &devices, now)), Err(Listing::Loading)); + assert_eq!(plan(&facts(&status, &free, &devices, now)), Ok(Plan::Free)); + assert_eq!( + identity(&facts(&status, &free, &devices, now)), + Some(Identity { + name: "Ada".to_owned(), + email: Some("ada@m17.example".to_owned()), + }) + ); + } + + #[test] + fn features_are_included_by_the_current_entitlement_only() { + let now = Utc::now(); + let current = signed_in(Some(entitlement(now, TimeDelta::days(14), Vec::new())), None); + let lapsed = signed_in( + Some(entitlement(now - TimeDelta::days(20), TimeDelta::days(14), Vec::new())), + None, + ); + let (profile, devices) = (Fetch::Idle, Fetch::Idle); + + let included = |status: &AccountStatus| -> Vec { + features(&facts(status, &profile, &devices, now)) + .into_iter() + .filter(|row| row.included) + .map(|row| row.title) + .collect() + }; + + assert_eq!(included(¤t), ["Environment Variable References", "Data Rewind"]); + assert!(included(&lapsed).is_empty()); + assert!(included(&AccountStatus::SignedOut).is_empty()); + } +} diff --git a/linux/crates/app/src/ui/preferences_dialog/device_limit_dialog.rs b/linux/crates/app/src/ui/preferences_dialog/device_limit_dialog.rs new file mode 100644 index 0000000000..6d9a3d5bb9 --- /dev/null +++ b/linux/crates/app/src/ui/preferences_dialog/device_limit_dialog.rs @@ -0,0 +1,237 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use chrono::Utc; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_account::{AccountError, AccountStatus, Device, EntitlementProblem}; +use tablepro_session::runtime::Tasks; + +use super::account_view::{self, DeviceRow, Removal}; +use crate::i18n::{gettext, gettext_f}; +use crate::services::account_service::AccountHandle; +use crate::ui::error_text::account_error_message; + +const CANCEL: &str = "cancel"; +const REMOVE: &str = "remove"; + +// The device's name, and why the plan is not verified yet when the check after the removal failed. +type Removed = dyn Fn(&str, Option<&AccountError>); + +// The license's holders, from device_limit_reached; one is removed in place to move the license here. +pub(crate) struct DeviceLimitDialog { + pub(crate) dialog: adw::Dialog, + group: adw::PreferencesGroup, + banner: adw::Banner, + rows: RefCell>, + holding: RefCell>, + removing: RefCell>, + handle: AccountHandle, + tasks: Tasks, + removed: Box, + following: RefCell>>, + closed: Cell, +} + +impl DeviceLimitDialog { + pub(crate) fn new( + holding: Vec, + handle: AccountHandle, + tasks: Tasks, + removed: impl Fn(&str, Option<&AccountError>) + 'static, + ) -> Rc { + let banner = adw::Banner::builder().use_markup(false).revealed(false).build(); + let group = adw::PreferencesGroup::builder() + .title(gettext("Devices Using the License")) + .description(gettext( + "Paid features work on 3 devices at a time. Remove one to use them on this computer. Until then, TablePro works here with the free features.", + )) + .build(); + let page = adw::PreferencesPage::new(); + page.add(&group); + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&adw::HeaderBar::new()); + toolbar.add_top_bar(&banner); + toolbar.set_content(Some(&page)); + let dialog = adw::Dialog::builder() + .title(gettext("Device Limit Reached")) + .content_width(520) + .content_height(460) + .child(&toolbar) + .build(); + let this = Rc::new(Self { + dialog, + group, + banner, + rows: RefCell::new(Vec::new()), + holding: RefCell::new(holding), + removing: RefCell::new(None), + handle, + tasks, + removed: Box::new(removed), + following: RefCell::new(None), + closed: Cell::new(false), + }); + this.render(); + this.follow(); + let closing = Rc::downgrade(&this); + this.dialog.connect_closed(move |_| { + let Some(this) = closing.upgrade() else { + return; + }; + this.closed.set(true); + if let Some(following) = this.following.take() { + following.abort(); + } + }); + this + } + + #[cfg(test)] + pub(crate) fn rows(&self) -> Vec { + self.rows.borrow().clone() + } + + #[cfg(test)] + pub(crate) fn error(&self) -> Option { + self.banner.is_revealed().then(|| self.banner.title().to_string()) + } + + fn follow(self: &Rc) { + let mut status = self.handle.status.clone(); + let this = Rc::downgrade(self); + let following = glib::spawn_future_local(async move { + while status.changed().await.is_ok() { + let holding = status.borrow_and_update().device_limit().map(<[Device]>::to_vec); + let Some(this) = this.upgrade() else { + return; + }; + match holding { + Some(devices) => { + this.holding.replace(devices); + this.render(); + } + None => { + this.close(); + return; + } + } + } + }); + self.following.replace(Some(following)); + } + + fn render(self: &Rc) { + for row in self.rows.take() { + self.group.remove(&row); + } + let rows = account_view::limit_devices(&self.holding.borrow(), Utc::now()); + let removing = self.removing.borrow().clone(); + let mut built = Vec::new(); + for device in rows { + let row = self.row(&device, removing.as_deref()); + self.group.add(&row); + built.push(row); + } + self.rows.replace(built); + } + + fn row(self: &Rc, device: &DeviceRow, removing: Option<&str>) -> adw::ActionRow { + let row = super::account_page::plain_row(&device.name, Some(&device.subtitle)); + match &device.removal { + Removal::KeptUntil(until) => { + let label = gtk4::Label::builder().label(until).valign(gtk4::Align::Center).build(); + label.add_css_class("dim-label"); + row.add_suffix(&label); + } + Removal::Allowed | Removal::ThisDevice if removing == Some(device.id.as_str()) => { + row.add_suffix(&adw::Spinner::new()); + } + Removal::Allowed | Removal::ThisDevice => { + let button = gtk4::Button::builder() + .label(gettext("Remove")) + .valign(gtk4::Align::Center) + .sensitive(removing.is_none()) + .build(); + button.update_property(&[gtk4::accessible::Property::Label(&gettext_f( + "Remove {name}", + &[("name", &device.name)], + ))]); + let this = Rc::downgrade(self); + let (id, name) = (device.id.clone(), device.name.clone()); + button.connect_clicked(move |_| { + if let Some(this) = this.upgrade() { + this.confirm(id.clone(), name.clone()); + } + }); + row.add_suffix(&button); + } + } + row + } + + // A removal and the status it changes both end the dialog, and a second close is a GLib critical. + fn close(&self) { + if !self.closed.replace(true) { + self.dialog.close(); + } + } + + fn confirm(self: &Rc, id: String, name: String) { + let alert = adw::AlertDialog::new( + Some(&gettext_f("Remove {name}?", &[("name", &name)])), + Some(&gettext( + "TablePro on it signs out the next time it checks in, and this computer takes its license slot.", + )), + ); + alert.add_response(CANCEL, &gettext("_Cancel")); + alert.add_response(REMOVE, &gettext("_Remove")); + alert.set_response_appearance(REMOVE, adw::ResponseAppearance::Destructive); + alert.set_default_response(Some(CANCEL)); + alert.set_close_response(CANCEL); + let this = self.clone(); + glib::spawn_future_local(async move { + let parent: gtk4::Widget = this.dialog.clone().upcast(); + if crate::workspace::respond(&alert, &parent).await == REMOVE { + this.remove(id, name).await; + } + }); + } + + async fn remove(self: &Rc, id: String, name: String) { + let Some(account) = self.handle.account() else { + return; + }; + self.banner.set_revealed(false); + self.removing.replace(Some(id.clone())); + self.render(); + let removing = self.tasks.spawn_task(async move { account.remove_device(&id).await }); + let removed = removing.await; + self.removing.replace(None); + match removed { + Ok(Ok(())) => { + (self.removed)(&name, None); + self.close(); + } + // The device is gone and only the check after it failed, which the page goes on showing. + Ok(Err(error)) if refresh_failed(&self.handle.status.borrow()) => { + (self.removed)(&name, Some(&error)); + self.close(); + } + Ok(Err(error)) => { + self.banner.set_title(&account_error_message(&error)); + self.banner.set_revealed(true); + self.render(); + } + Err(error) => { + tracing::warn!(%error, "the device removal did not finish"); + self.render(); + } + } + } +} + +fn refresh_failed(status: &AccountStatus) -> bool { + matches!(status, AccountStatus::SignedIn(held) if matches!(held.problem, Some(EntitlementProblem::RefreshFailed(_)))) +} diff --git a/linux/crates/app/src/ui/preferences_dialog/general_page.rs b/linux/crates/app/src/ui/preferences_dialog/general_page.rs index a4f3ccc3e1..299e3f0cfc 100644 --- a/linux/crates/app/src/ui/preferences_dialog/general_page.rs +++ b/linux/crates/app/src/ui/preferences_dialog/general_page.rs @@ -27,6 +27,10 @@ mod imp { pub data_browser_group: TemplateChild, #[template_child] pub history_group: TemplateChild, + #[template_child] + pub privacy_group: TemplateChild, + #[template_child] + pub share_usage_data_row: TemplateChild, } #[glib::object_subclass] @@ -65,6 +69,7 @@ impl Default for GeneralPage { } impl GeneralPage { + // A build without the analytics secret sends nothing, so it shows no switch that would stop it. pub(super) fn bind( &self, settings: &Rc, @@ -72,12 +77,18 @@ impl GeneralPage { history: crate::services::history_service::HistoryService, tasks: &Tasks, revealer: Rc, + can_share_usage_data: bool, ) { let imp = self.imp(); settings .gio() .bind(keys::RESTORE_SESSION, &imp.restore_session_row.get(), "active") .build(); + imp.privacy_group.set_visible(can_share_usage_data); + settings + .gio() + .bind(keys::SHARE_USAGE_DATA, &imp.share_usage_data_row.get(), "active") + .build(); imp.data_browser_group.bind(settings); imp.history_group.bind(settings, storage, history, tasks, revealer); } @@ -101,6 +112,16 @@ mod tests { storage: &crate::storage::SharedStorage, root: &tempfile::TempDir, tasks: &Tasks, + ) -> GeneralPage { + page_with_usage_data(settings, storage, root, tasks, false) + } + + fn page_with_usage_data( + settings: &MemorySettings, + storage: &crate::storage::SharedStorage, + root: &tempfile::TempDir, + tasks: &Tasks, + can_share_usage_data: bool, ) -> GeneralPage { crate::register_test_resources(); let page = GeneralPage::default(); @@ -110,6 +131,7 @@ mod tests { super::super::test_history(root, tasks), tasks, Rc::new(RecordingRevealer::succeeding()), + can_share_usage_data, ); page } @@ -119,9 +141,46 @@ mod tests { crate::test_support::descendants(page) .into_iter() .filter_map(|widget| widget.downcast::().ok()) + .filter(WidgetExt::is_visible) .collect() } + #[gtk4::test] + fn usage_data_is_on_by_default_and_the_switch_turns_it_off() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let root = tempfile::tempdir().expect("tempdir"); + let page = page_with_usage_data(&settings, &storage, &root, &runtime.tasks(), true); + let row = page.imp().share_usage_data_row.get(); + let titles: Vec = groups(&page).iter().map(|group| group.title().to_string()).collect(); + + let by_default = (row.is_active(), settings.get().share_usage_data()); + row.set_active(false); + + assert_eq!(titles.last().map(String::as_str), Some("Privacy")); + assert_eq!(by_default, (true, true)); + assert!(!settings.get().share_usage_data()); + assert_eq!(row.title(), "Share Usage Data"); + assert_eq!( + row.subtitle().as_deref(), + Some("Help improve TablePro with app activity and system information") + ); + } + + #[gtk4::test] + fn a_build_without_the_secret_shows_no_privacy_switch() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let root = tempfile::tempdir().expect("tempdir"); + + let page = bound_page(&settings, &storage, &root, &runtime.tasks()); + + let titles: Vec = groups(&page).iter().map(|group| group.title().to_string()).collect(); + assert_eq!(titles, ["Startup", "Data Browser", "Query History"]); + } + #[gtk4::test] fn general_page_contains_the_data_browser_and_history_groups() { let settings = MemorySettings::new(); diff --git a/linux/crates/app/src/ui/preferences_dialog/imp.rs b/linux/crates/app/src/ui/preferences_dialog/imp.rs index 9e2f5f2958..a726595628 100644 --- a/linux/crates/app/src/ui/preferences_dialog/imp.rs +++ b/linux/crates/app/src/ui/preferences_dialog/imp.rs @@ -8,6 +8,7 @@ use libadwaita as adw; use libadwaita::subclass::prelude::*; use tablepro_linux_platform::AppSettings; +use super::account_page::AccountPage; use super::ai_page::AiPage; use super::editor_page::EditorPage; use super::general_page::GeneralPage; @@ -24,6 +25,8 @@ pub struct PreferencesDialog { pub mcp_page: TemplateChild, #[template_child] pub ai_page: TemplateChild, + #[template_child] + pub account_page: TemplateChild, pub settings: RefCell>>, } @@ -38,6 +41,7 @@ impl ObjectSubclass for PreferencesDialog { EditorPage::ensure_type(); McpPage::ensure_type(); AiPage::ensure_type(); + AccountPage::ensure_type(); klass.bind_template(); } diff --git a/linux/crates/app/src/ui/preferences_dialog/mod.rs b/linux/crates/app/src/ui/preferences_dialog/mod.rs index 3bb855f421..567b5f661b 100644 --- a/linux/crates/app/src/ui/preferences_dialog/mod.rs +++ b/linux/crates/app/src/ui/preferences_dialog/mod.rs @@ -1,5 +1,8 @@ +mod account_page; +pub(crate) mod account_view; mod ai_page; mod data_browser_group; +mod device_limit_dialog; mod editor_page; mod general_page; mod history_group; @@ -15,6 +18,7 @@ use libadwaita::prelude::*; use tablepro_linux_platform::AppSettings; use tablepro_session::runtime::Tasks; +use crate::services::account_service::AccountHandle; use crate::services::ai_service::AiService; use crate::services::history_service::HistoryService; use crate::services::mcp_service::McpService; @@ -33,8 +37,12 @@ glib::wrapper! { } impl PreferencesDialog { - /// Every row binds straight to GSettings, so a change reaches any - /// other view through `changed::` without a save step. + // Every row binds straight to GSettings, so a change reaches any other view through + // `changed::` without a save step. + #[expect( + clippy::too_many_arguments, + reason = "one per page's service, as the pages take them" + )] pub(crate) fn new( settings: &Rc, storage: &crate::storage::SharedStorage, @@ -43,12 +51,20 @@ impl PreferencesDialog { revealer: Rc, mcp: Option, ai: AiService, + account: AccountHandle, + telemetry: crate::services::telemetry::Telemetry, ) -> Self { let dialog: Self = glib::Object::new(); let imp = dialog.imp(); imp.settings.replace(Some(settings.clone())); - imp.general_page - .bind(settings, storage, history, tasks, revealer.clone()); + imp.general_page.bind( + settings, + storage, + history, + tasks, + revealer.clone(), + telemetry.has_project_key(), + ); imp.editor_page.bind( settings, editor_page::SnippetsFolder { @@ -59,6 +75,13 @@ impl PreferencesDialog { ); imp.mcp_page.bind(settings, mcp); imp.ai_page.bind(ai); + imp.account_page.bind(account, tasks.clone(), telemetry.clone()); + let shown_in = dialog.downgrade(); + imp.account_page.connect_toast(move |_, toast| { + if let Some(dialog) = shown_in.upgrade() { + dialog.add_toast(toast.clone()); + } + }); let shown_in = dialog.downgrade(); imp.general_page.history_group().connect_toast(move |_, toast| { if let Some(dialog) = shown_in.upgrade() { @@ -136,19 +159,22 @@ mod tests { Rc::new(RecordingRevealer::succeeding()), None, AiService::new(settings.get().clone(), tasks.clone(), None), + crate::services::account_service::AccountService::signed_out().handle(), + crate::services::telemetry::Telemetry::default(), ) } /// The dialog's pages, in the order it shows them. They are not in /// its widget tree until it is presented, so they come from the /// template. - fn pages(dialog: &PreferencesDialog) -> [adw::PreferencesPage; 4] { + fn pages(dialog: &PreferencesDialog) -> [adw::PreferencesPage; 5] { let imp = dialog.imp(); [ imp.general_page.get().upcast(), imp.editor_page.get().upcast(), imp.ai_page.get().upcast(), imp.mcp_page.get().upcast(), + imp.account_page.get().upcast(), ] } @@ -164,12 +190,13 @@ mod tests { test_history(&root, &runtime.tasks()), &runtime.tasks(), ); - let [general, editor, ai, mcp] = pages(&dialog); + let [general, editor, ai, mcp, account] = pages(&dialog); assert_eq!(general.title(), "General"); assert_eq!(editor.title(), "Editor"); assert_eq!(ai.title(), "AI"); assert_eq!(mcp.title(), "MCP"); + assert_eq!(account.title(), "Account"); assert_eq!(dialog.visible_page().as_ref(), Some(&general)); dialog.set_visible_page(&editor); assert_eq!( diff --git a/linux/crates/app/src/ui/session_window/launch_notice.rs b/linux/crates/app/src/ui/session_window/launch_notice.rs index 1ea3cfcbf9..be8f12cfa5 100644 --- a/linux/crates/app/src/ui/session_window/launch_notice.rs +++ b/linux/crates/app/src/ui/session_window/launch_notice.rs @@ -6,13 +6,15 @@ use gtk4::{gio, glib}; use libadwaita as adw; use super::SessionWindow; -use crate::i18n::{gettext, gettext_f}; +use crate::i18n::{gettext, gettext_f, ngettext_f}; use crate::ui::file_reveal::reveal_failure_toast; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum LaunchNotice { Crash(PathBuf), Update(String), + // The days of use, from the 30th day without a license. + LicenseAsk(u32), } impl LaunchNotice { @@ -95,6 +97,17 @@ impl SessionWindow { .action_name("app.about") .use_markup(false) .build(), + LaunchNotice::LicenseAsk(days) => adw::Toast::builder() + .title(ngettext_f( + "You’ve used TablePro on {n} day. If it’s part of your work, please buy a license.", + "You’ve used TablePro on {n} days. If it’s part of your work, please buy a license.", + *days, + &[("n", &days.to_string())], + )) + .button_label(gettext("Buy a License")) + .action_name("app.buy-license") + .use_markup(false) + .build(), }; let window = self.downgrade(); let notice = notice.clone(); diff --git a/linux/crates/app/tests/startup.rs b/linux/crates/app/tests/startup.rs index 97fc0dd72c..e9b19b9d3c 100644 --- a/linux/crates/app/tests/startup.rs +++ b/linux/crates/app/tests/startup.rs @@ -24,6 +24,9 @@ fn main() -> ExitCode { empty: &empty, runtime: &runtime, }; + if the_binary_has_no_posthog_key().is_err() { + return ExitCode::FAILURE; + } if help_lists_the_gapplication_options(&schemas, &sandbox).is_err() { return ExitCode::FAILURE; } @@ -148,6 +151,15 @@ fn combined(output: &Output) -> String { ) } +// The binary is built with the library this test links, so one key for both. +fn the_binary_has_no_posthog_key() -> Result<(), ()> { + if tablepro_app::config::POSTHOG_KEY.is_empty() { + return Ok(()); + } + eprintln!("the tests have a PostHog key; build them with TABLEPRO_POSTHOG_KEY empty, as meson test does"); + Err(()) +} + /// `--help-gapplication` appears only once GApplication has parsed the /// command line, which happens after `run()` registered the embedded /// GResource. A missing or malformed gresource aborts before that. diff --git a/linux/crates/platform/src/lib.rs b/linux/crates/platform/src/lib.rs index 9a5b6b0801..0a70b09bc1 100644 --- a/linux/crates/platform/src/lib.rs +++ b/linux/crates/platform/src/lib.rs @@ -1,7 +1,9 @@ +mod machine_id; mod paths; mod secrets; pub mod settings; +pub use machine_id::{MACHINE_ID_FILES, MachineIdError, read_machine_id}; pub use paths::{foreign_app_dirs, storage_paths}; pub use secrets::SecretStore; pub use settings::{AppSettings, EditorFont, SettingsError, WindowGeometry}; diff --git a/linux/crates/platform/src/machine_id.rs b/linux/crates/platform/src/machine_id.rs new file mode 100644 index 0000000000..61fa60333b --- /dev/null +++ b/linux/crates/platform/src/machine_id.rs @@ -0,0 +1,58 @@ +use std::path::{Path, PathBuf}; + +// Flatpak binds the host's first file into every sandbox at the first path, or else its second, D-Bus's older +// copy, so the host build reads them in the same order and both builds name one machine. +pub const MACHINE_ID_FILES: [&str; 2] = ["/etc/machine-id", "/var/lib/dbus/machine-id"]; + +#[derive(Debug, thiserror::Error)] +#[error("cannot read {}: {source}", path.display())] +pub struct MachineIdError { + pub path: PathBuf, + #[source] + pub source: std::io::Error, +} + +// The machine ID's text, never sent as it is: the account hashes it first (machine-id(5)). The first file's error +// is the one to fix. +pub fn read_machine_id(paths: &[&Path]) -> Result { + let mut first = None; + for path in paths { + match std::fs::read_to_string(path) { + Ok(text) => return Ok(text), + Err(source) => { + first.get_or_insert(MachineIdError { + path: path.to_path_buf(), + source, + }); + } + } + } + Err(first.unwrap_or_else(|| MachineIdError { + path: PathBuf::new(), + source: std::io::Error::from(std::io::ErrorKind::NotFound), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_d_bus_copy_stands_in_as_flatpak_binds_it() { + let dir = tempfile::tempdir().expect("a temporary directory"); + let (etc, dbus) = (dir.path().join("etc-machine-id"), dir.path().join("dbus-machine-id")); + std::fs::write(&dbus, "4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f20\n").expect("the D-Bus file"); + + let from_dbus = read_machine_id(&[&etc, &dbus]).expect("a machine ID"); + std::fs::write(&etc, "4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f21\n").expect("the systemd file"); + let from_etc = read_machine_id(&[&etc, &dbus]).expect("a machine ID"); + let neither = read_machine_id(&[&dir.path().join("absent"), &dir.path().join("also-absent")]); + + assert_eq!(from_dbus, "4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f20\n"); + assert_eq!(from_etc, "4c4f5e2a0b6d4f3c9e8a7b6c5d4e3f21\n"); + assert!( + matches!(&neither, Err(MachineIdError { path, .. }) if path.ends_with("absent")), + "{neither:?}" + ); + } +} diff --git a/linux/crates/platform/src/secrets/secret_attributes.rs b/linux/crates/platform/src/secrets/secret_attributes.rs index afa0f3b61a..42193f232c 100644 --- a/linux/crates/platform/src/secrets/secret_attributes.rs +++ b/linux/crates/platform/src/secrets/secret_attributes.rs @@ -39,6 +39,14 @@ pub(super) fn for_ai_key(schema: &str, provider: &str) -> HashMap<&'static str, attributes } +// Like the MCP token it belongs to no connection. +pub(super) fn for_account_token(schema: &str) -> HashMap<&'static str, String> { + let mut attributes = HashMap::new(); + attributes.insert("xdg:schema", schema.to_owned()); + attributes.insert("kind", "account_refresh_token".to_owned()); + attributes +} + #[cfg(test)] mod tests { use super::*; @@ -109,6 +117,19 @@ mod tests { } } + #[test] + fn the_account_token_matches_no_connection_and_no_other_item() { + let token = for_account_token(SCHEMA); + + assert!(!token.contains_key("connection-id")); + assert_eq!(token.get("xdg:schema").map(String::as_str), Some(SCHEMA)); + assert_ne!(token.get("kind"), for_mcp_token(SCHEMA).get("kind")); + assert_ne!(token.get("kind"), for_ai_key(SCHEMA, "anthropic").get("kind")); + for kind in SecretKind::ALL { + assert_ne!(token.get("kind"), for_secret(SCHEMA, Uuid::new_v4(), kind).get("kind")); + } + } + #[test] fn the_uuid_is_canonical_lowercase_hyphenated() { let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").expect("a valid uuid"); diff --git a/linux/crates/platform/src/secrets/secret_store.rs b/linux/crates/platform/src/secrets/secret_store.rs index 9e027f9aaf..afe7d84b5d 100644 --- a/linux/crates/platform/src/secrets/secret_store.rs +++ b/linux/crates/platform/src/secrets/secret_store.rs @@ -82,6 +82,28 @@ impl SecretStore { .await } + pub async fn load_account_token(&self) -> Result, SecretError> { + self.load_text(&secret_attributes::for_account_token(&self.schema)) + .await + } + + // Replaces the item: the server rotates the token on every refresh. + pub async fn store_account_token(&self, token: &SecretString, label: &str) -> Result<(), SecretError> { + let attributes = secret_attributes::for_account_token(&self.schema); + self.with_keyring(async |keyring| { + keyring + .create_item(label, &attributes, oo7::Secret::text(token.expose_secret()), true) + .await + }) + .await + } + + pub async fn delete_account_token(&self) -> Result<(), SecretError> { + let attributes = secret_attributes::for_account_token(&self.schema); + self.with_keyring(async |keyring| keyring.delete(&attributes).await) + .await + } + /// The text of the first item matching `attributes`. async fn load_text(&self, attributes: &HashMap<&'static str, String>) -> Result, SecretError> { let secret = self diff --git a/linux/crates/platform/src/settings/keys.rs b/linux/crates/platform/src/settings/keys.rs index c2289b1106..7e3cd2fc79 100644 --- a/linux/crates/platform/src/settings/keys.rs +++ b/linux/crates/platform/src/settings/keys.rs @@ -11,6 +11,10 @@ pub const VIM_KEYBINDINGS: &str = "vim-keybindings"; pub const CONNECT_DIALOG_DRIVER: &str = "connect-dialog-driver"; pub const RESTORE_SESSION: &str = "restore-session"; pub const LAST_SEEN_VERSION: &str = "last-seen-version"; +pub const SHARE_USAGE_DATA: &str = "share-usage-data"; +pub const USAGE_DAYS: &str = "usage-days"; +pub const LAST_USAGE_DAY: &str = "last-usage-day"; +pub const LICENSE_ASK_DAY: &str = "license-ask-day"; pub const MCP_SERVER_ENABLED: &str = "mcp-server-enabled"; pub const MCP_SERVER_PORT: &str = "mcp-server-port"; pub const AI_PROVIDER: &str = "ai-provider"; diff --git a/linux/crates/platform/src/settings/mod.rs b/linux/crates/platform/src/settings/mod.rs index 77eafb0f3f..45998d1b79 100644 --- a/linux/crates/platform/src/settings/mod.rs +++ b/linux/crates/platform/src/settings/mod.rs @@ -181,6 +181,40 @@ impl AppSettings { self.set_string(keys::LAST_SEEN_VERSION, version) } + pub fn share_usage_data(&self) -> bool { + self.settings.boolean(keys::SHARE_USAGE_DATA) + } + + pub fn set_share_usage_data(&self, share: bool) -> Result<(), SettingsError> { + self.set_boolean(keys::SHARE_USAGE_DATA, share) + } + + pub fn usage_days(&self) -> u32 { + self.settings.uint(keys::USAGE_DAYS) + } + + pub fn last_usage_day(&self) -> String { + self.settings.string(keys::LAST_USAGE_DAY).into() + } + + // Counts `day` once, however often it is recorded. + pub fn record_usage_day(&self, day: &str) -> Result<(), SettingsError> { + if self.last_usage_day() == day { + return Ok(()); + } + self.set_string(keys::LAST_USAGE_DAY, day)?; + self.set_uint(keys::USAGE_DAYS, self.usage_days().saturating_add(1)) + } + + pub fn license_ask_day(&self) -> String { + self.settings.string(keys::LICENSE_ASK_DAY).into() + } + + // Recorded as it shows, so quitting while it shows still counts as an ask. + pub fn set_license_asked(&self, day: &str) -> Result<(), SettingsError> { + self.set_string(keys::LICENSE_ASK_DAY, day) + } + /// Whether the MCP server runs. Off until the user turns it on. pub fn mcp_server_enabled(&self) -> bool { self.settings.boolean(keys::MCP_SERVER_ENABLED) diff --git a/linux/crates/platform/tests/secret_service.rs b/linux/crates/platform/tests/secret_service.rs index eb3e2525fb..dec5bf2fd0 100644 --- a/linux/crates/platform/tests/secret_service.rs +++ b/linux/crates/platform/tests/secret_service.rs @@ -135,3 +135,30 @@ async fn each_ai_key_is_its_own_item_and_outlives_a_connection_delete() -> TestR store.delete_ai_key("anthropic").await?; Ok(()) } + +#[tokio::test] +#[ignore = "requires a Secret Service"] +async fn the_account_token_is_rotated_in_place_and_outlives_a_connection_delete() -> TestResult { + let store = store(); + let id = Uuid::new_v4(); + store + .store(id, SecretKind::DatabasePassword, &SecretString::from("pw"), "l") + .await?; + + let before = store.load_account_token().await?; + store + .store_account_token(&SecretString::from("rt_first"), "sign-in") + .await?; + store + .store_account_token(&SecretString::from("rt_second"), "sign-in") + .await?; + store.delete_connection(id).await?; + let rotated = store.load_account_token().await?; + store.delete_account_token().await?; + let after = store.load_account_token().await?; + + assert!(before.is_none()); + assert_eq!(rotated.as_ref().map(|token| token.expose_secret()), Some("rt_second")); + assert!(after.is_none()); + Ok(()) +} diff --git a/linux/crates/platform/tests/settings.rs b/linux/crates/platform/tests/settings.rs index 4e0663d481..5e50a60ee0 100644 --- a/linux/crates/platform/tests/settings.rs +++ b/linux/crates/platform/tests/settings.rs @@ -55,6 +55,37 @@ fn defaults_match_schema() -> TestResult { assert!(!fixture.settings.vim_keybindings()); assert!(!fixture.settings.mcp_server_enabled()); assert_eq!(fixture.settings.mcp_server_port(), 23508); + assert_eq!(fixture.settings.usage_days(), 0); + assert!(fixture.settings.share_usage_data()); + assert_eq!(fixture.settings.license_ask_day(), ""); + Ok(()) +} + +#[test] +fn usage_data_turns_off_and_on_again() -> TestResult { + let fixture = Fixture::new()?; + + fixture.settings.set_share_usage_data(false)?; + let off = fixture.settings.share_usage_data(); + fixture.settings.set_share_usage_data(true)?; + + assert!(!off); + assert!(fixture.settings.share_usage_data()); + Ok(()) +} + +#[test] +fn a_day_of_use_counts_once() -> TestResult { + let fixture = Fixture::new()?; + + fixture.settings.record_usage_day("2026-11-05")?; + fixture.settings.record_usage_day("2026-11-05")?; + fixture.settings.record_usage_day("2026-11-06")?; + fixture.settings.set_license_asked("2026-11-06")?; + + assert_eq!(fixture.settings.usage_days(), 2); + assert_eq!(fixture.settings.last_usage_day(), "2026-11-06"); + assert_eq!(fixture.settings.license_ask_day(), "2026-11-06"); Ok(()) } diff --git a/linux/data/app.tablepro.TablePro.gschema.xml.in b/linux/data/app.tablepro.TablePro.gschema.xml.in index b2459fbb0d..5e82ecfe29 100644 --- a/linux/data/app.tablepro.TablePro.gschema.xml.in +++ b/linux/data/app.tablepro.TablePro.gschema.xml.in @@ -127,6 +127,32 @@ The newest TablePro release that has run, so the first launch of a later one can point to its release notes. Empty before the first launch. + + true + Share usage data + Whether TablePro sends usage events, such as an app launch, a connection attempt or a sign-in, to PostHog. On unless you turn it off; a build made without a PostHog key sends none. + + + + + 0 + Days of use + How many distinct local days TablePro was in use, which decides when it first asks about a license. It never leaves this computer. + + + + '' + Last day of use + The last local day counted in usage-days, as YYYY-MM-DD. Empty before the first launch. + + + + '' + Day of the last license ask + The local day TablePro last asked whether you would buy a license, as YYYY-MM-DD. Empty when it never asked. + + + false Run the MCP server diff --git a/linux/data/app.tablepro.TablePro.metainfo.xml.in.in b/linux/data/app.tablepro.TablePro.metainfo.xml.in.in index a87ec6ef8a..958c888318 100644 --- a/linux/data/app.tablepro.TablePro.metainfo.xml.in.in +++ b/linux/data/app.tablepro.TablePro.metainfo.xml.in.in @@ -55,7 +55,37 @@ Connect through an SSH tunnel - + + + TablePro account + +

+ Signed in to a TablePro account, TablePro reaches account.tablepro.app and api.tablepro.app to keep + the sign-in, to register this computer as a device with its host name, the TablePro and system + versions and a machine ID hashed from /etc/machine-id, to check the plan at launch and once a day, + and to list teams and devices while Preferences shows the account. +

+
+
+ + Usage data + +

+ Unless you turn it off in Preferences > General > Privacy, a daily launch with the kinds and number + of open connections, the first connection and query, sign-in and sign-out, and upgrade clicks go to + PostHog at eu.i.posthog.com, with a random installation ID, or the account's ID with the TablePro and + system versions, processor type and language once signed in. Never a host name, a password, a query + or a row. +

+
+
+
+ + + moderate + mild + keyboard pointing diff --git a/linux/data/licenses/THIRD-PARTY-NOTICE.md b/linux/data/licenses/THIRD-PARTY-NOTICE.md index 48a3a415bf..da91cefa2c 100644 --- a/linux/data/licenses/THIRD-PARTY-NOTICE.md +++ b/linux/data/licenses/THIRD-PARTY-NOTICE.md @@ -23,6 +23,193 @@ TablePro is licensed under the GNU Affero General Public License, version 3 or l Used by: +- sync_wrapper 1.0.2 + +~~~~text + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + +~~~~ + +### Apache License 2.0 + +Used by: + - flagset 0.4.7 ~~~~text @@ -2788,6 +2975,7 @@ insights. Used by: +- untrusted 0.7.1 - untrusted 0.9.0 ~~~~text @@ -4132,6 +4320,7 @@ Used by: - cfg-if 1.0.4 - futures-timer 3.0.4 +- oauth2 5.0.0 - openssl-probe 0.2.1 - socket2 0.6.5 @@ -5020,6 +5209,41 @@ DEALINGS IN THE SOFTWARE. Used by: +- serde_urlencoded 0.7.1 + +~~~~text +Copyright (c) 2016 Anthony Ramine + +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. + +~~~~ + +### MIT License + +Used by: + - hyper-rustls 0.27.9 - rustls-native-certs 0.8.4 - rustls 0.23.46 @@ -5288,6 +5512,36 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- reqwest 0.13.5 + +~~~~text +Copyright (c) 2016-2026 Sean McArthur + +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. + + ~~~~ ### MIT License @@ -5408,17 +5662,52 @@ DEALINGS IN THE SOFTWARE. Used by: -- memoffset 0.9.1 +- fnv 1.0.7 ~~~~text -Copyright (c) 2017 Gilad Naaman - -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: +Copyright (c) 2017 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. + +~~~~ + +### MIT License + +Used by: + +- memoffset 0.9.1 + +~~~~text +Copyright (c) 2017 Gilad Naaman + +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. @@ -6053,6 +6342,41 @@ DEALINGS IN THE SOFTWARE. Used by: +- signature 2.2.0 + +~~~~text +Copyright (c) 2018-2023 RustCrypto Developers + +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. + +~~~~ + +### MIT License + +Used by: + - try-lock 0.2.5 ~~~~text @@ -6605,7 +6929,9 @@ DEALINGS IN THE SOFTWARE. Used by: +- tower-layer 0.3.3 - tower-service 0.3.3 +- tower 0.5.3 ~~~~text Copyright (c) 2019 Tower Contributors @@ -6640,6 +6966,41 @@ DEALINGS IN THE SOFTWARE. Used by: +- tower-http 0.6.11 + +~~~~text +Copyright (c) 2019-2021 Tower 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. + +~~~~ + +### MIT License + +Used by: + - chacha20 0.9.1 ~~~~text @@ -7972,8 +8333,11 @@ DEALINGS IN THE SOFTWARE. Used by: - rand 0.10.2 +- rand 0.8.8 - rand 0.9.5 +- rand_chacha 0.3.1 - rand_chacha 0.9.0 +- rand_core 0.6.4 - rand_core 0.9.5 ~~~~text @@ -8398,8 +8762,11 @@ SOFTWARE. Used by: +- darling 0.20.11 - darling 0.24.1 +- darling_core 0.20.11 - darling_core 0.24.1 +- darling_macro 0.20.11 - darling_macro 0.24.1 ~~~~text @@ -8951,6 +9318,67 @@ SOFTWARE. Used by: +- regress 0.12.0 + +~~~~text +MIT License + +Copyright (c) 2020 ridiculous_fish + +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. + +~~~~ + +### MIT License + +Used by: + +- posthog-rs 0.27.1 + +~~~~text +MIT License + +Copyright (c) 2020-2021 OpenQuery Inc. + +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. +~~~~ + +### MIT License + +Used by: + - arrow-array 60.1.0 ~~~~text @@ -9631,11 +10059,14 @@ Used by: - serde_derive_internals 0.29.1 - serde_derive_internals 0.30.0 - serde_json 1.0.151 +- serde_path_to_error 0.1.20 - serde_repr 0.1.21 - sqlformat 0.5.0 - syn 2.0.119 - syn 3.0.5 +- thiserror-impl 1.0.69 - thiserror-impl 2.0.20 +- thiserror 1.0.69 - thiserror 2.0.20 - typed-builder-macro 0.22.0 - typed-builder 0.22.0 @@ -10087,6 +10518,7 @@ THE SOFTWARE. Used by: - nix 0.30.1 +- nix 0.31.3 ~~~~text The MIT License (MIT) @@ -10239,6 +10671,37 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- jsonwebtoken 11.1.0 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2015 Vincent Prouillet + +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. + ~~~~ ### MIT License @@ -10433,6 +10896,39 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- derive_builder 0.20.2 +- derive_builder_core 0.20.2 +- derive_builder_macro 0.20.2 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2016 rust-derive-builder 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. + ~~~~ ### MIT License @@ -10474,12 +10970,12 @@ THE SOFTWARE. Used by: -- roxmltree 0.20.0 +- os_info 3.15.0 ~~~~text The MIT License (MIT) -Copyright (c) 2018 Yevhenii Reizner +Copyright (c) 2017 Stanislav Tkach Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -10505,12 +11001,12 @@ SOFTWARE. Used by: -- bstr 1.13.1 +- roxmltree 0.20.0 ~~~~text The MIT License (MIT) -Copyright (c) 2018-2019 Andrew Gallant +Copyright (c) 2018 Yevhenii Reizner Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -10519,16 +11015,16 @@ 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 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. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ~~~~ @@ -10536,29 +11032,93 @@ THE SOFTWARE. Used by: -- deadpool-postgres 0.14.2 -- deadpool-runtime 0.3.1 -- deadpool 0.13.1 +- async-compression 0.4.52 +- compression-codecs 0.4.47 +- compression-core 0.4.34 ~~~~text The MIT License (MIT) -Copyright (c) 2019 Michael P. Jung +Copyright (c) 2018 the rustasync developers -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: +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 +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. + +~~~~ + +### MIT License + +Used by: + +- bstr 1.13.1 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2018-2019 Andrew Gallant + +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. + +~~~~ + +### MIT License + +Used by: + +- deadpool-postgres 0.14.2 +- deadpool-runtime 0.3.1 +- deadpool 0.13.1 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2019 Michael P. Jung + +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. @@ -11550,6 +12110,389 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice ~~~~ +### Mozilla Public License 2.0 + +Used by: + +- progenitor-client 0.15.1 + +~~~~text +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. + +~~~~ + ### Unicode License v3 Used by: diff --git a/linux/data/licenses/third-party.json b/linux/data/licenses/third-party.json index cd303ef6c5..62c678807d 100644 --- a/linux/data/licenses/third-party.json +++ b/linux/data/licenses/third-party.json @@ -1,5 +1,13 @@ { "licenses": [ + { + "id": "Apache-2.0", + "name": "Apache License 2.0", + "text": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n", + "used_by": [ + { "crate": { "name": "sync_wrapper", "version": "1.0.2" } } + ] + }, { "id": "Apache-2.0", "name": "Apache License 2.0", @@ -161,6 +169,7 @@ "name": "ISC License", "text": "// Copyright 2015-2016 Brian Smith.\n//\n// Permission to use, copy, modify, and/or distribute this software for any\n// purpose with or without fee is hereby granted, provided that the above\n// copyright notice and this permission notice appear in all copies.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHORS DISCLAIM ALL WARRANTIES\n// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\n// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR\n// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\n// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\n// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF\n// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n", "used_by": [ + { "crate": { "name": "untrusted", "version": "0.7.1" } }, { "crate": { "name": "untrusted", "version": "0.9.0" } } ] }, @@ -302,6 +311,7 @@ "used_by": [ { "crate": { "name": "cfg-if", "version": "1.0.4" } }, { "crate": { "name": "futures-timer", "version": "3.0.4" } }, + { "crate": { "name": "oauth2", "version": "5.0.0" } }, { "crate": { "name": "openssl-probe", "version": "0.2.1" } }, { "crate": { "name": "socket2", "version": "0.6.5" } } ] @@ -544,6 +554,14 @@ { "crate": { "name": "hashbrown", "version": "0.17.1" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016 Anthony Ramine\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "serde_urlencoded", "version": "0.7.1" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -614,6 +632,14 @@ { "crate": { "name": "cipher", "version": "0.4.4" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016-2026 Sean McArthur\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n\n", + "used_by": [ + { "crate": { "name": "reqwest", "version": "0.13.5" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -643,6 +669,14 @@ { "crate": { "name": "pbkdf2", "version": "0.13.0" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 Contributors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "fnv", "version": "1.0.7" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -798,6 +832,14 @@ { "crate": { "name": "cbc", "version": "0.1.2" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2023 RustCrypto Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "signature", "version": "2.2.0" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -936,7 +978,17 @@ "name": "MIT License", "text": "Copyright (c) 2019 Tower Contributors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", "used_by": [ - { "crate": { "name": "tower-service", "version": "0.3.3" } } + { "crate": { "name": "tower-layer", "version": "0.3.3" } }, + { "crate": { "name": "tower-service", "version": "0.3.3" } }, + { "crate": { "name": "tower", "version": "0.5.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019-2021 Tower Contributors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tower-http", "version": "0.6.11" } } ] }, { @@ -1285,8 +1337,11 @@ "text": "Copyright 2018 Developers of the Rand project\nCopyright (c) 2014 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", "used_by": [ { "crate": { "name": "rand", "version": "0.10.2" } }, + { "crate": { "name": "rand", "version": "0.8.8" } }, { "crate": { "name": "rand", "version": "0.9.5" } }, + { "crate": { "name": "rand_chacha", "version": "0.3.1" } }, { "crate": { "name": "rand_chacha", "version": "0.9.0" } }, + { "crate": { "name": "rand_core", "version": "0.6.4" } }, { "crate": { "name": "rand_core", "version": "0.9.5" } } ] }, @@ -1420,8 +1475,11 @@ "name": "MIT License", "text": "MIT License\n\nCopyright (c) 2017 Ted Driggs\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", "used_by": [ + { "crate": { "name": "darling", "version": "0.20.11" } }, { "crate": { "name": "darling", "version": "0.24.1" } }, + { "crate": { "name": "darling_core", "version": "0.20.11" } }, { "crate": { "name": "darling_core", "version": "0.24.1" } }, + { "crate": { "name": "darling_macro", "version": "0.20.11" } }, { "crate": { "name": "darling_macro", "version": "0.24.1" } } ] }, @@ -1564,6 +1622,22 @@ { "crate": { "name": "funty", "version": "2.0.0" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2020 ridiculous_fish\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "regress", "version": "0.12.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2020-2021 OpenQuery Inc.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.", + "used_by": [ + { "crate": { "name": "posthog-rs", "version": "0.27.1" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -1787,11 +1861,14 @@ { "crate": { "name": "serde_derive_internals", "version": "0.29.1" } }, { "crate": { "name": "serde_derive_internals", "version": "0.30.0" } }, { "crate": { "name": "serde_json", "version": "1.0.151" } }, + { "crate": { "name": "serde_path_to_error", "version": "0.1.20" } }, { "crate": { "name": "serde_repr", "version": "0.1.21" } }, { "crate": { "name": "sqlformat", "version": "0.5.0" } }, { "crate": { "name": "syn", "version": "2.0.119" } }, { "crate": { "name": "syn", "version": "3.0.5" } }, + { "crate": { "name": "thiserror-impl", "version": "1.0.69" } }, { "crate": { "name": "thiserror-impl", "version": "2.0.20" } }, + { "crate": { "name": "thiserror", "version": "1.0.69" } }, { "crate": { "name": "thiserror", "version": "2.0.20" } }, { "crate": { "name": "typed-builder-macro", "version": "0.22.0" } }, { "crate": { "name": "typed-builder", "version": "0.22.0" } }, @@ -1945,7 +2022,8 @@ "name": "MIT License", "text": "The MIT License (MIT)\n\nCopyright (c) 2015 Carl Lerche + nix-rust Authors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", "used_by": [ - { "crate": { "name": "nix", "version": "0.30.1" } } + { "crate": { "name": "nix", "version": "0.30.1" } }, + { "crate": { "name": "nix", "version": "0.31.3" } } ] }, { @@ -1980,6 +2058,14 @@ { "crate": { "name": "rustc_version_runtime", "version": "0.3.0" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2015 Vincent Prouillet\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "jsonwebtoken", "version": "11.1.0" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -2032,6 +2118,16 @@ { "crate": { "name": "tokio-postgres", "version": "0.7.18" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2016 rust-derive-builder contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "derive_builder", "version": "0.20.2" } }, + { "crate": { "name": "derive_builder_core", "version": "0.20.2" } }, + { "crate": { "name": "derive_builder_macro", "version": "0.20.2" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -2040,6 +2136,14 @@ { "crate": { "name": "locale_config", "version": "0.3.0" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2017 Stanislav Tkach\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "os_info", "version": "3.15.0" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -2048,6 +2152,16 @@ { "crate": { "name": "roxmltree", "version": "0.20.0" } } ] }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2018 the rustasync developers\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "async-compression", "version": "0.4.52" } }, + { "crate": { "name": "compression-codecs", "version": "0.4.47" } }, + { "crate": { "name": "compression-core", "version": "0.4.34" } } + ] + }, { "id": "MIT", "name": "MIT License", @@ -2172,6 +2286,14 @@ { "crate": { "name": "gufo-exif", "version": "0.4.0" } } ] }, + { + "id": "MPL-2.0", + "name": "Mozilla Public License 2.0", + "text": "Mozilla Public License Version 2.0\n==================================\n\n1. Definitions\n--------------\n\n1.1. \"Contributor\"\n means each individual or legal entity that creates, contributes to\n the creation of, or owns Covered Software.\n\n1.2. \"Contributor Version\"\n means the combination of the Contributions of others (if any) used\n by a Contributor and that particular Contributor's Contribution.\n\n1.3. \"Contribution\"\n means Covered Software of a particular Contributor.\n\n1.4. \"Covered Software\"\n means Source Code Form to which the initial Contributor has attached\n the notice in Exhibit A, the Executable Form of such Source Code\n Form, and Modifications of such Source Code Form, in each case\n including portions thereof.\n\n1.5. \"Incompatible With Secondary Licenses\"\n means\n\n (a) that the initial Contributor has attached the notice described\n in Exhibit B to the Covered Software; or\n\n (b) that the Covered Software was made available under the terms of\n version 1.1 or earlier of the License, but not also under the\n terms of a Secondary License.\n\n1.6. \"Executable Form\"\n means any form of the work other than Source Code Form.\n\n1.7. \"Larger Work\"\n means a work that combines Covered Software with other material, in \n a separate file or files, that is not Covered Software.\n\n1.8. \"License\"\n means this document.\n\n1.9. \"Licensable\"\n means having the right to grant, to the maximum extent possible,\n whether at the time of the initial grant or subsequently, any and\n all of the rights conveyed by this License.\n\n1.10. \"Modifications\"\n means any of the following:\n\n (a) any file in Source Code Form that results from an addition to,\n deletion from, or modification of the contents of Covered\n Software; or\n\n (b) any new file in Source Code Form that contains any Covered\n Software.\n\n1.11. \"Patent Claims\" of a Contributor\n means any patent claim(s), including without limitation, method,\n process, and apparatus claims, in any patent Licensable by such\n Contributor that would be infringed, but for the grant of the\n License, by the making, using, selling, offering for sale, having\n made, import, or transfer of either its Contributions or its\n Contributor Version.\n\n1.12. \"Secondary License\"\n means either the GNU General Public License, Version 2.0, the GNU\n Lesser General Public License, Version 2.1, the GNU Affero General\n Public License, Version 3.0, or any later versions of those\n licenses.\n\n1.13. \"Source Code Form\"\n means the form of the work preferred for making modifications.\n\n1.14. \"You\" (or \"Your\")\n means an individual or a legal entity exercising rights under this\n License. For legal entities, \"You\" includes any entity that\n controls, is controlled by, or is under common control with You. For\n purposes of this definition, \"control\" means (a) the power, direct\n or indirect, to cause the direction or management of such entity,\n whether by contract or otherwise, or (b) ownership of more than\n fifty percent (50%) of the outstanding shares or beneficial\n ownership of such entity.\n\n2. License Grants and Conditions\n--------------------------------\n\n2.1. Grants\n\nEach Contributor hereby grants You a world-wide, royalty-free,\nnon-exclusive license:\n\n(a) under intellectual property rights (other than patent or trademark)\n Licensable by such Contributor to use, reproduce, make available,\n modify, display, perform, distribute, and otherwise exploit its\n Contributions, either on an unmodified basis, with Modifications, or\n as part of a Larger Work; and\n\n(b) under Patent Claims of such Contributor to make, use, sell, offer\n for sale, have made, import, and otherwise transfer either its\n Contributions or its Contributor Version.\n\n2.2. Effective Date\n\nThe licenses granted in Section 2.1 with respect to any Contribution\nbecome effective for each Contribution on the date the Contributor first\ndistributes such Contribution.\n\n2.3. Limitations on Grant Scope\n\nThe licenses granted in this Section 2 are the only rights granted under\nthis License. No additional rights or licenses will be implied from the\ndistribution or licensing of Covered Software under this License.\nNotwithstanding Section 2.1(b) above, no patent license is granted by a\nContributor:\n\n(a) for any code that a Contributor has removed from Covered Software;\n or\n\n(b) for infringements caused by: (i) Your and any other third party's\n modifications of Covered Software, or (ii) the combination of its\n Contributions with other software (except as part of its Contributor\n Version); or\n\n(c) under Patent Claims infringed by Covered Software in the absence of\n its Contributions.\n\nThis License does not grant any rights in the trademarks, service marks,\nor logos of any Contributor (except as may be necessary to comply with\nthe notice requirements in Section 3.4).\n\n2.4. Subsequent Licenses\n\nNo Contributor makes additional grants as a result of Your choice to\ndistribute the Covered Software under a subsequent version of this\nLicense (see Section 10.2) or under the terms of a Secondary License (if\npermitted under the terms of Section 3.3).\n\n2.5. Representation\n\nEach Contributor represents that the Contributor believes its\nContributions are its original creation(s) or it has sufficient rights\nto grant the rights to its Contributions conveyed by this License.\n\n2.6. Fair Use\n\nThis License is not intended to limit any rights You have under\napplicable copyright doctrines of fair use, fair dealing, or other\nequivalents.\n\n2.7. Conditions\n\nSections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted\nin Section 2.1.\n\n3. Responsibilities\n-------------------\n\n3.1. Distribution of Source Form\n\nAll distribution of Covered Software in Source Code Form, including any\nModifications that You create or to which You contribute, must be under\nthe terms of this License. You must inform recipients that the Source\nCode Form of the Covered Software is governed by the terms of this\nLicense, and how they can obtain a copy of this License. You may not\nattempt to alter or restrict the recipients' rights in the Source Code\nForm.\n\n3.2. Distribution of Executable Form\n\nIf You distribute Covered Software in Executable Form then:\n\n(a) such Covered Software must also be made available in Source Code\n Form, as described in Section 3.1, and You must inform recipients of\n the Executable Form how they can obtain a copy of such Source Code\n Form by reasonable means in a timely manner, at a charge no more\n than the cost of distribution to the recipient; and\n\n(b) You may distribute such Executable Form under the terms of this\n License, or sublicense it under different terms, provided that the\n license for the Executable Form does not attempt to limit or alter\n the recipients' rights in the Source Code Form under this License.\n\n3.3. Distribution of a Larger Work\n\nYou may create and distribute a Larger Work under terms of Your choice,\nprovided that You also comply with the requirements of this License for\nthe Covered Software. If the Larger Work is a combination of Covered\nSoftware with a work governed by one or more Secondary Licenses, and the\nCovered Software is not Incompatible With Secondary Licenses, this\nLicense permits You to additionally distribute such Covered Software\nunder the terms of such Secondary License(s), so that the recipient of\nthe Larger Work may, at their option, further distribute the Covered\nSoftware under the terms of either this License or such Secondary\nLicense(s).\n\n3.4. Notices\n\nYou may not remove or alter the substance of any license notices\n(including copyright notices, patent notices, disclaimers of warranty,\nor limitations of liability) contained within the Source Code Form of\nthe Covered Software, except that You may alter any license notices to\nthe extent required to remedy known factual inaccuracies.\n\n3.5. Application of Additional Terms\n\nYou may choose to offer, and to charge a fee for, warranty, support,\nindemnity or liability obligations to one or more recipients of Covered\nSoftware. However, You may do so only on Your own behalf, and not on\nbehalf of any Contributor. You must make it absolutely clear that any\nsuch warranty, support, indemnity, or liability obligation is offered by\nYou alone, and You hereby agree to indemnify every Contributor for any\nliability incurred by such Contributor as a result of warranty, support,\nindemnity or liability terms You offer. You may include additional\ndisclaimers of warranty and limitations of liability specific to any\njurisdiction.\n\n4. Inability to Comply Due to Statute or Regulation\n---------------------------------------------------\n\nIf it is impossible for You to comply with any of the terms of this\nLicense with respect to some or all of the Covered Software due to\nstatute, judicial order, or regulation then You must: (a) comply with\nthe terms of this License to the maximum extent possible; and (b)\ndescribe the limitations and the code they affect. Such description must\nbe placed in a text file included with all distributions of the Covered\nSoftware under this License. Except to the extent prohibited by statute\nor regulation, such description must be sufficiently detailed for a\nrecipient of ordinary skill to be able to understand it.\n\n5. Termination\n--------------\n\n5.1. The rights granted under this License will terminate automatically\nif You fail to comply with any of its terms. However, if You become\ncompliant, then the rights granted under this License from a particular\nContributor are reinstated (a) provisionally, unless and until such\nContributor explicitly and finally terminates Your grants, and (b) on an\nongoing basis, if such Contributor fails to notify You of the\nnon-compliance by some reasonable means prior to 60 days after You have\ncome back into compliance. Moreover, Your grants from a particular\nContributor are reinstated on an ongoing basis if such Contributor\nnotifies You of the non-compliance by some reasonable means, this is the\nfirst time You have received notice of non-compliance with this License\nfrom such Contributor, and You become compliant prior to 30 days after\nYour receipt of the notice.\n\n5.2. If You initiate litigation against any entity by asserting a patent\ninfringement claim (excluding declaratory judgment actions,\ncounter-claims, and cross-claims) alleging that a Contributor Version\ndirectly or indirectly infringes any patent, then the rights granted to\nYou by any and all Contributors for the Covered Software under Section\n2.1 of this License shall terminate.\n\n5.3. In the event of termination under Sections 5.1 or 5.2 above, all\nend user license agreements (excluding distributors and resellers) which\nhave been validly granted by You or Your distributors under this License\nprior to termination shall survive termination.\n\n************************************************************************\n* *\n* 6. Disclaimer of Warranty *\n* ------------------------- *\n* *\n* Covered Software is provided under this License on an \"as is\" *\n* basis, without warranty of any kind, either expressed, implied, or *\n* statutory, including, without limitation, warranties that the *\n* Covered Software is free of defects, merchantable, fit for a *\n* particular purpose or non-infringing. The entire risk as to the *\n* quality and performance of the Covered Software is with You. *\n* Should any Covered Software prove defective in any respect, You *\n* (not any Contributor) assume the cost of any necessary servicing, *\n* repair, or correction. This disclaimer of warranty constitutes an *\n* essential part of this License. No use of any Covered Software is *\n* authorized under this License except under this disclaimer. *\n* *\n************************************************************************\n\n************************************************************************\n* *\n* 7. Limitation of Liability *\n* -------------------------- *\n* *\n* Under no circumstances and under no legal theory, whether tort *\n* (including negligence), contract, or otherwise, shall any *\n* Contributor, or anyone who distributes Covered Software as *\n* permitted above, be liable to You for any direct, indirect, *\n* special, incidental, or consequential damages of any character *\n* including, without limitation, damages for lost profits, loss of *\n* goodwill, work stoppage, computer failure or malfunction, or any *\n* and all other commercial damages or losses, even if such party *\n* shall have been informed of the possibility of such damages. This *\n* limitation of liability shall not apply to liability for death or *\n* personal injury resulting from such party's negligence to the *\n* extent applicable law prohibits such limitation. Some *\n* jurisdictions do not allow the exclusion or limitation of *\n* incidental or consequential damages, so this exclusion and *\n* limitation may not apply to You. *\n* *\n************************************************************************\n\n8. Litigation\n-------------\n\nAny litigation relating to this License may be brought only in the\ncourts of a jurisdiction where the defendant maintains its principal\nplace of business and such litigation shall be governed by laws of that\njurisdiction, without reference to its conflict-of-law provisions.\nNothing in this Section shall prevent a party's ability to bring\ncross-claims or counter-claims.\n\n9. Miscellaneous\n----------------\n\nThis License represents the complete agreement concerning the subject\nmatter hereof. If any provision of this License is held to be\nunenforceable, such provision shall be reformed only to the extent\nnecessary to make it enforceable. Any law or regulation which provides\nthat the language of a contract shall be construed against the drafter\nshall not be used to construe this License against a Contributor.\n\n10. Versions of the License\n---------------------------\n\n10.1. New Versions\n\nMozilla Foundation is the license steward. Except as provided in Section\n10.3, no one other than the license steward has the right to modify or\npublish new versions of this License. Each version will be given a\ndistinguishing version number.\n\n10.2. Effect of New Versions\n\nYou may distribute the Covered Software under the terms of the version\nof the License under which You originally received the Covered Software,\nor under the terms of any subsequent version published by the license\nsteward.\n\n10.3. Modified Versions\n\nIf you create software not governed by this License, and you want to\ncreate a new license for such software, you may create and use a\nmodified version of this License if you rename the license and remove\nany references to the name of the license steward (except to note that\nsuch modified license differs from this License).\n\n10.4. Distributing Source Code Form that is Incompatible With Secondary\nLicenses\n\nIf You choose to distribute Source Code Form that is Incompatible With\nSecondary Licenses under the terms of this version of the License, the\nnotice described in Exhibit B of this License must be attached.\n\nExhibit A - Source Code Form License Notice\n-------------------------------------------\n\n This Source Code Form is subject to the terms of the Mozilla Public\n License, v. 2.0. If a copy of the MPL was not distributed with this\n file, You can obtain one at https://mozilla.org/MPL/2.0/.\n\nIf it is not possible or desirable to put the notice in a particular\nfile, then You may include the notice in a location (such as a LICENSE\nfile in a relevant directory) where a recipient would be likely to look\nfor such a notice.\n\nYou may add additional accurate notices of copyright ownership.\n\nExhibit B - \"Incompatible With Secondary Licenses\" Notice\n---------------------------------------------------------\n\n This Source Code Form is \"Incompatible With Secondary Licenses\", as\n defined by the Mozilla Public License, v. 2.0.\n", + "used_by": [ + { "crate": { "name": "progenitor-client", "version": "0.15.1" } } + ] + }, { "id": "Unicode-3.0", "name": "Unicode License v3", @@ -2512,6 +2634,7 @@ { "name": "ashpd", "version": "0.13.13", "license": "MIT" }, { "name": "async-broadcast", "version": "0.7.2", "license": "MIT OR Apache-2.0" }, { "name": "async-channel", "version": "2.5.0", "license": "Apache-2.0 OR MIT" }, + { "name": "async-compression", "version": "0.4.52", "license": "MIT OR Apache-2.0" }, { "name": "async-lock", "version": "3.4.2", "license": "Apache-2.0 OR MIT" }, { "name": "async-recursion", "version": "1.1.1", "license": "MIT OR Apache-2.0" }, { "name": "async-task", "version": "4.7.1", "license": "Apache-2.0 OR MIT" }, @@ -2554,6 +2677,8 @@ { "name": "cmov", "version": "0.5.4", "license": "Apache-2.0 OR MIT" }, { "name": "combine", "version": "4.6.8", "license": "MIT" }, { "name": "comfy-table", "version": "8.0.1", "license": "MIT" }, + { "name": "compression-codecs", "version": "0.4.47", "license": "MIT OR Apache-2.0" }, + { "name": "compression-core", "version": "0.4.34", "license": "MIT OR Apache-2.0" }, { "name": "concurrent-queue", "version": "2.5.0", "license": "Apache-2.0 OR MIT" }, { "name": "connection-string", "version": "0.2.0", "license": "MIT OR Apache-2.0" }, { "name": "const-oid", "version": "0.9.6", "license": "Apache-2.0 OR MIT" }, @@ -2583,8 +2708,11 @@ { "name": "csv", "version": "1.4.0", "license": "Unlicense OR MIT" }, { "name": "csv-core", "version": "0.1.13", "license": "Unlicense OR MIT" }, { "name": "ctutils", "version": "0.4.8", "license": "Apache-2.0 OR MIT" }, + { "name": "darling", "version": "0.20.11", "license": "MIT" }, { "name": "darling", "version": "0.24.1", "license": "MIT" }, + { "name": "darling_core", "version": "0.20.11", "license": "MIT" }, { "name": "darling_core", "version": "0.24.1", "license": "MIT" }, + { "name": "darling_macro", "version": "0.20.11", "license": "MIT" }, { "name": "darling_macro", "version": "0.24.1", "license": "MIT" }, { "name": "dashmap", "version": "6.2.1", "license": "MIT" }, { "name": "data-encoding", "version": "2.11.1", "license": "MIT" }, @@ -2597,6 +2725,9 @@ { "name": "deranged", "version": "0.5.8", "license": "MIT OR Apache-2.0" }, { "name": "derive-syn-parse", "version": "0.2.0", "license": "MIT OR Apache-2.0" }, { "name": "derive-where", "version": "1.7.0", "license": "MIT OR Apache-2.0" }, + { "name": "derive_builder", "version": "0.20.2", "license": "MIT OR Apache-2.0" }, + { "name": "derive_builder_core", "version": "0.20.2", "license": "MIT OR Apache-2.0" }, + { "name": "derive_builder_macro", "version": "0.20.2", "license": "MIT OR Apache-2.0" }, { "name": "derive_more", "version": "2.1.1", "license": "MIT" }, { "name": "derive_more-impl", "version": "2.1.1", "license": "MIT" }, { "name": "digest", "version": "0.10.7", "license": "MIT OR Apache-2.0" }, @@ -2627,6 +2758,7 @@ { "name": "flagset", "version": "0.4.7", "license": "Apache-2.0" }, { "name": "flate2", "version": "1.1.10", "license": "MIT OR Apache-2.0" }, { "name": "flume", "version": "0.12.0", "license": "Apache-2.0 OR MIT" }, + { "name": "fnv", "version": "1.0.7", "license": "Apache-2.0 OR MIT" }, { "name": "foldhash", "version": "0.1.5", "license": "Zlib" }, { "name": "foldhash", "version": "0.2.0", "license": "Zlib" }, { "name": "foreign-types", "version": "0.5.0", "license": "MIT OR Apache-2.0" }, @@ -2722,6 +2854,7 @@ { "name": "itoa", "version": "1.0.18", "license": "MIT OR Apache-2.0" }, { "name": "jiff", "version": "0.2.37", "license": "Unlicense OR MIT" }, { "name": "jiff-core", "version": "0.1.1", "license": "Unlicense OR MIT" }, + { "name": "jsonwebtoken", "version": "11.1.0", "license": "MIT" }, { "name": "lazy_static", "version": "1.5.0", "license": "MIT OR Apache-2.0" }, { "name": "lcms2", "version": "6.2.0", "license": "MIT" }, { "name": "lcms2-sys", "version": "4.0.7", "license": "MIT" }, @@ -2769,6 +2902,7 @@ { "name": "multiversion-macros", "version": "0.9.0", "license": "MIT OR Apache-2.0" }, { "name": "multiversion_no_op", "version": "1.0.0", "license": "Apache-2.0 OR MIT" }, { "name": "nix", "version": "0.30.1", "license": "MIT" }, + { "name": "nix", "version": "0.31.3", "license": "MIT" }, { "name": "nu-ansi-term", "version": "0.50.3", "license": "MIT" }, { "name": "num", "version": "0.4.3", "license": "MIT OR Apache-2.0" }, { "name": "num-bigint", "version": "0.4.8", "license": "MIT OR Apache-2.0" }, @@ -2780,10 +2914,12 @@ { "name": "num-iter", "version": "0.1.46", "license": "MIT OR Apache-2.0" }, { "name": "num-rational", "version": "0.4.2", "license": "MIT OR Apache-2.0" }, { "name": "num-traits", "version": "0.2.19", "license": "MIT OR Apache-2.0" }, + { "name": "oauth2", "version": "5.0.0", "license": "MIT OR Apache-2.0" }, { "name": "once_cell", "version": "1.21.4", "license": "MIT OR Apache-2.0" }, { "name": "oo7", "version": "0.6.0", "license": "MIT" }, { "name": "openssl-probe", "version": "0.2.1", "license": "MIT OR Apache-2.0" }, { "name": "ordered-stream", "version": "0.2.0", "license": "MIT OR Apache-2.0" }, + { "name": "os_info", "version": "3.15.0", "license": "MIT" }, { "name": "pango", "version": "0.22.9", "license": "MIT" }, { "name": "pango-sys", "version": "0.22.9", "license": "MIT" }, { "name": "parking", "version": "2.2.1", "license": "Apache-2.0 OR MIT" }, @@ -2805,6 +2941,7 @@ { "name": "portable-atomic", "version": "1.15.0", "license": "Apache-2.0 OR MIT" }, { "name": "postgres-protocol", "version": "0.6.12", "license": "MIT OR Apache-2.0" }, { "name": "postgres-types", "version": "0.2.14", "license": "MIT OR Apache-2.0" }, + { "name": "posthog-rs", "version": "0.27.1", "license": "MIT" }, { "name": "potential_utf", "version": "0.1.6", "license": "Unicode-3.0" }, { "name": "powerfmt", "version": "0.2.0", "license": "MIT OR Apache-2.0" }, { "name": "ppv-lite86", "version": "0.2.21", "license": "MIT OR Apache-2.0" }, @@ -2812,13 +2949,17 @@ { "name": "pretty-hex", "version": "0.4.2", "license": "MIT" }, { "name": "proc-macro-crate", "version": "3.5.0", "license": "MIT OR Apache-2.0" }, { "name": "proc-macro2", "version": "1.0.107", "license": "MIT OR Apache-2.0" }, + { "name": "progenitor-client", "version": "0.15.1", "license": "MPL-2.0" }, { "name": "pulldown-cmark", "version": "0.13.6", "license": "MIT" }, { "name": "quick-xml", "version": "0.42.0", "license": "MIT" }, { "name": "quote", "version": "1.0.47", "license": "MIT OR Apache-2.0" }, { "name": "radium", "version": "0.7.0", "license": "MIT" }, + { "name": "rand", "version": "0.8.8", "license": "MIT OR Apache-2.0" }, { "name": "rand", "version": "0.9.5", "license": "MIT OR Apache-2.0" }, { "name": "rand", "version": "0.10.2", "license": "MIT OR Apache-2.0" }, + { "name": "rand_chacha", "version": "0.3.1", "license": "MIT OR Apache-2.0" }, { "name": "rand_chacha", "version": "0.9.0", "license": "MIT OR Apache-2.0" }, + { "name": "rand_core", "version": "0.6.4", "license": "MIT OR Apache-2.0" }, { "name": "rand_core", "version": "0.9.5", "license": "MIT OR Apache-2.0" }, { "name": "rand_core", "version": "0.10.1", "license": "MIT OR Apache-2.0" }, { "name": "rand_pcg", "version": "0.9.0", "license": "MIT OR Apache-2.0" }, @@ -2830,6 +2971,8 @@ { "name": "regex", "version": "1.13.1", "license": "MIT OR Apache-2.0" }, { "name": "regex-automata", "version": "0.4.18", "license": "MIT OR Apache-2.0" }, { "name": "regex-syntax", "version": "0.8.11", "license": "MIT OR Apache-2.0" }, + { "name": "regress", "version": "0.12.0", "license": "MIT OR Apache-2.0" }, + { "name": "reqwest", "version": "0.13.5", "license": "MIT OR Apache-2.0" }, { "name": "resolv-conf", "version": "0.7.6", "license": "MIT OR Apache-2.0" }, { "name": "rmcp", "version": "3.6.0", "license": "Apache-2.0 AND MIT" }, { "name": "rmp", "version": "0.8.15", "license": "MIT" }, @@ -2864,7 +3007,9 @@ { "name": "serde_derive_internals", "version": "0.29.1", "license": "MIT OR Apache-2.0" }, { "name": "serde_derive_internals", "version": "0.30.0", "license": "MIT OR Apache-2.0" }, { "name": "serde_json", "version": "1.0.151", "license": "MIT OR Apache-2.0" }, + { "name": "serde_path_to_error", "version": "0.1.20", "license": "MIT OR Apache-2.0" }, { "name": "serde_repr", "version": "0.1.21", "license": "MIT OR Apache-2.0" }, + { "name": "serde_urlencoded", "version": "0.7.1", "license": "MIT OR Apache-2.0" }, { "name": "serde_with", "version": "3.23.0", "license": "MIT OR Apache-2.0" }, { "name": "serde_with_macros", "version": "3.23.0", "license": "MIT OR Apache-2.0" }, { "name": "sha1", "version": "0.11.0", "license": "MIT OR Apache-2.0" }, @@ -2872,6 +3017,7 @@ { "name": "sha2", "version": "0.11.0", "license": "MIT OR Apache-2.0" }, { "name": "sharded-slab", "version": "0.1.7", "license": "MIT" }, { "name": "signal-hook-registry", "version": "1.4.8", "license": "MIT OR Apache-2.0" }, + { "name": "signature", "version": "2.2.0", "license": "Apache-2.0 OR MIT" }, { "name": "signature", "version": "3.0.0", "license": "Apache-2.0 OR MIT" }, { "name": "simd-adler32", "version": "0.3.10", "license": "MIT" }, { "name": "simdutf8", "version": "0.1.5", "license": "MIT OR Apache-2.0" }, @@ -2903,12 +3049,15 @@ { "name": "subtle", "version": "2.6.1", "license": "BSD-3-Clause" }, { "name": "syn", "version": "2.0.119", "license": "MIT OR Apache-2.0" }, { "name": "syn", "version": "3.0.5", "license": "MIT OR Apache-2.0" }, + { "name": "sync_wrapper", "version": "1.0.2", "license": "Apache-2.0" }, { "name": "synstructure", "version": "0.13.2", "license": "MIT" }, { "name": "tagptr", "version": "0.2.0", "license": "MIT OR Apache-2.0" }, { "name": "take_mut", "version": "0.2.2", "license": "MIT" }, { "name": "tap", "version": "1.0.1", "license": "MIT" }, { "name": "tempfile", "version": "3.27.0", "license": "MIT OR Apache-2.0" }, + { "name": "thiserror", "version": "1.0.69", "license": "MIT OR Apache-2.0" }, { "name": "thiserror", "version": "2.0.20", "license": "MIT OR Apache-2.0" }, + { "name": "thiserror-impl", "version": "1.0.69", "license": "MIT OR Apache-2.0" }, { "name": "thiserror-impl", "version": "2.0.20", "license": "MIT OR Apache-2.0" }, { "name": "thread_local", "version": "1.1.10", "license": "MIT OR Apache-2.0" }, { "name": "tiberius", "version": "0.13.0", "license": "MIT OR Apache-2.0" }, @@ -2929,6 +3078,9 @@ { "name": "toml_datetime", "version": "1.1.1+spec-1.1.0", "license": "MIT OR Apache-2.0" }, { "name": "toml_edit", "version": "0.25.15+spec-1.1.0", "license": "MIT OR Apache-2.0" }, { "name": "toml_parser", "version": "1.1.3+spec-1.1.0", "license": "MIT OR Apache-2.0" }, + { "name": "tower", "version": "0.5.3", "license": "MIT" }, + { "name": "tower-http", "version": "0.6.11", "license": "MIT" }, + { "name": "tower-layer", "version": "0.3.3", "license": "MIT" }, { "name": "tower-service", "version": "0.3.3", "license": "MIT" }, { "name": "tracing", "version": "0.1.44", "license": "MIT" }, { "name": "tracing-attributes", "version": "0.1.31", "license": "MIT" }, @@ -2951,6 +3103,7 @@ { "name": "unicode-width", "version": "0.2.2", "license": "MIT OR Apache-2.0" }, { "name": "unicode-xid", "version": "0.2.6", "license": "MIT OR Apache-2.0" }, { "name": "unicode_categories", "version": "0.1.1", "license": "MIT OR Apache-2.0" }, + { "name": "untrusted", "version": "0.7.1", "license": "ISC" }, { "name": "untrusted", "version": "0.9.0", "license": "ISC" }, { "name": "url", "version": "2.5.8", "license": "MIT OR Apache-2.0" }, { "name": "utf8_iter", "version": "1.0.4", "license": "Apache-2.0 OR MIT" }, diff --git a/linux/data/resources/resources.gresource.xml b/linux/data/resources/resources.gresource.xml index 4d2ba79ed3..35964baac2 100644 --- a/linux/data/resources/resources.gresource.xml +++ b/linux/data/resources/resources.gresource.xml @@ -28,6 +28,7 @@ ui/pager-bar.ui ui/pending-changes-bar.ui ui/picker-page.ui + ui/preferences-account-page.ui ui/preferences-data-browser-group.ui ui/preferences-dialog.ui ui/preferences-editor-page.ui diff --git a/linux/data/resources/ui/preferences-account-page.ui b/linux/data/resources/ui/preferences-account-page.ui new file mode 100644 index 0000000000..a9021f1a17 --- /dev/null +++ b/linux/data/resources/ui/preferences-account-page.ui @@ -0,0 +1,52 @@ + + + + + + diff --git a/linux/data/resources/ui/preferences-dialog.ui b/linux/data/resources/ui/preferences-dialog.ui index 854a0048e6..a3a3429e32 100644 --- a/linux/data/resources/ui/preferences-dialog.ui +++ b/linux/data/resources/ui/preferences-dialog.ui @@ -17,5 +17,8 @@ + + + diff --git a/linux/data/resources/ui/preferences-general-page.ui b/linux/data/resources/ui/preferences-general-page.ui index 8b550547f9..116c7f3fe4 100644 --- a/linux/data/resources/ui/preferences-general-page.ui +++ b/linux/data/resources/ui/preferences-general-page.ui @@ -22,5 +22,17 @@ + + + False + Privacy + + + Share Usage Data + Help improve TablePro with app activity and system information + + + + diff --git a/linux/docs/decisions/0005-supply-chain-policy.md b/linux/docs/decisions/0005-supply-chain-policy.md index 0e9c2252f7..cb3bea9ba7 100644 --- a/linux/docs/decisions/0005-supply-chain-policy.md +++ b/linux/docs/decisions/0005-supply-chain-policy.md @@ -62,3 +62,13 @@ sqlx takes MySQL CA certificates only as PEM bytes and has no hook for a rustls `tablepro-ai` writes the request bodies and reads the streamed events of two protocols by hand, over hyper: Anthropic's Messages API (`anthropic.rs`) and OpenAI's Chat Completions (`openai.rs`), which Ollama and other self-hosted servers also speak. Anthropic publishes no Rust SDK. The community crates for OpenAI's protocol go through reqwest, a second HTTP client beside the hyper one the ClickHouse driver uses, with a TLS setup of its own, where every connection the app makes takes its rustls configuration from `tablepro-net` ([ADR 0009](0009-crypto-provider-and-sources.md)). The server-sent events are parsed by `sse-stream`, which rmcp already brings; `tests/providers.rs` runs both protocols against a local server that answers the way each provider does. **Watch list**: an official Anthropic Rust SDK, or an OpenAI-protocol crate, that takes the caller's hyper client or rustls `ClientConfig`. + +### TablePro account glue (`tablepro-account`) + +`oauth2`, progenitor's generated client and `jsonwebtoken` do the protocol work ([core ADR 0016](../../../core/docs/decisions/0016-tablepro-accounts.md)). Three pieces around them are written here: + +1. **Loopback redirect listener** (`loopback.rs`): oauth2 5.0.0 builds the authorization URL and exchanges the code, and leaves receiving the redirect to the app. The listener is hyper, which the MCP server already brings, and answers every request until one carries the sign-in's `state` (RFC 8252 section 7.3). `the_listener_keeps_waiting_past_other_requests_for_its_state` runs it over real sockets. +2. **Token revocation** (`oauth.rs`): oauth2 5.0.0's `revoke_token` refuses a revocation URL that is not https (`ConfigurationError::InsecureUrl`), and the local tablepro-cloud the stack tests run against serves http. It is one RFC 7009 form POST. +3. **Entitlement checks around jsonwebtoken** (`entitlement.rs`): jsonwebtoken cannot parse a header whose `alg` it does not know, such as `none`, so `alg`, `typ` and `kid` are read from the header first; and it reads `exp` and `nbf` against the system clock, so `mid`, `nbf` and `exp` are checked against the caller's time after it verified the signature and `aud`. tablepro-cloud's shared vectors cover every check. + +**Watch list**: oauth2 accepting an http revocation URL on a loopback or test host; jsonwebtoken validating against a caller's time. Each one deletes its piece. diff --git a/linux/docs/decisions/0038-account-page-and-license-ask.md b/linux/docs/decisions/0038-account-page-and-license-ask.md new file mode 100644 index 0000000000..2a758f057b --- /dev/null +++ b/linux/docs/decisions/0038-account-page-and-license-ask.md @@ -0,0 +1,41 @@ +# 0038: Preferences > Account, the device limit dialog, offline files and the license ask + +- **Status**: Accepted +- **Date**: 2026-11-05 + +## Context + +The maintainer decided on 2026-11-05 that TablePro for Linux is licensed as the Mac is: the same plans, accounts and devices, built now, while the paid features come to Linux later. Linux had no licensing before, so it gets the `/v2` flow only (tablepro-cloud's `docs/design/clients.md`, Linux). The account itself is the core's `tablepro-account` ([core ADR 0016](../../../core/docs/decisions/0016-tablepro-accounts.md)). tablepro-cloud's `docs/design/clients.md` asks for a Preferences > Account page with the sections of the macOS Settings > Account: Account, Plan, Features, Devices, Teams and Offline, with notices for a payment problem, a cancellation and an entitlement close to its end. Over the device limit, a dialog lists the 3 devices holding the license and removes one in place, except a device whose offline file is still valid. A machine that never reaches the internet imports an offline file instead of signing in. The Mac also asks, on the 30th day of use without a license, whether you would buy one (#3922). + +## Decision + +An `AdwPreferencesPage` in the Preferences dialog shows the account, built from the entitlement and, while the page is shown, from `GET /v2/me` and `GET /v2/devices`. An imported offline file is an entitlement like any other, kept in `account.json`, and works signed out. The license ask works as on macOS, as a toast. + +## How it works + +- **Where each part comes from.** The plan, the features and the verification time come from the entitlement, so they show offline. The teams, their roles and seats, and the devices come from `/v2/me` and `/v2/devices`, fetched when the page is shown and after each change; until the profile answers, the plan comes from the entitlement's licenses. `tablepro-account` turns the generated types into `Profile` and `Device`, whose plan, cycle, status, role and platform are strings, so one this build does not know shows as `License` or not at all instead of failing the page. +- **Features.** The page lists the ten feature codes, each included or not by the current entitlement. TablePro for Linux has none of the ten yet, so each row says it is in TablePro for macOS, and nothing is gated. Signed out, the group ends with Buy a License, which opens the pricing page, and every state with Learn More About Licensing, as the Mac's Features footer does. +- **Sign-in.** Sign In runs `Account::sign_in`, which opens the browser through `gtk::UriLauncher`; the page shows the wait and a Cancel that drops it. `device_limit_reached` at the end of a sign-in opens the dialog; at a background refresh it is a notice in the Plan section with the same dialog behind a button. +- **Plan rows.** One per license: the plan, and for a subscription or order license its cycle and a Billing... button, as the portal does; nothing bills a license an admin granted or the migration brought over. The status is Renews on (the license's `renews_at`), Active, Ends on, Payment problem, or for a lifetime purchase the cycle alone; a status this build does not know shows none. +- **The device limit dialog** lists the devices `device_limit_reached` names, each with its `offline_until`. Remove asks first, then `Account::remove_device` deletes the device and refreshes the entitlement, which the freed slot lets through. A device whose file is valid shows until when, and the server's `offline_file_active` is worded too. +- **Errors.** Every `AccountError` has a sentence of its own (`crates/app/src/ui/error_text/account.rs`), picked by the variant and, for `Refused`, by the problem code. +- **Offline files.** Import Offline File reads a `tablepro-offline-*.jwt` and verifies it as an online entitlement. Signed in, a file of the same account replaces the entitlement until the next refresh, and one of another account is refused. Signed out, `account.json` keeps it with `offline_only`, the status is `AccountStatus::Offline`, and the keyring is never opened for it. The page shows the machine ID to make the file for. Sign-out removes the file with the rest of `account.json`; signed out, a file stays until another replaces it or a sign-in replaces it with the account's entitlement, since a Remove of its own is not built yet. +- **Portal links** go to the account service the build signs in to: `/`, `/billing`, `/devices`, `/teams/` and `/teams//billing`. The portal opens a team's billing to its owner only, so a team license's Billing... and its payment and ending notices link there only when `/v2/me` names the user its owner. +- **The license ask.** GSettings counts the distinct local days TablePro is in use (`usage-days`, `last-usage-day`), at launch and whenever one of its windows takes the focus; the count never leaves the computer. From the 30th day, with no license, a toast on the window in use says how many days and asks for a license, with Buy a License, which opens the pricing page and sends `upgrade_clicked` with `app-ask`; each ask sends `support_ask_shown` ([ADR 0039](0039-usage-data.md)). It comes back 90 days later, and never once a license is held: an entitlement that names a license, even one that has lapsed. It waits out the first five minutes after launch, any tab that is loading, which a running query is, and any dialog already on the window, and shows only on a connection or picker window. The day it showed is recorded as it shows (`license-ask-day`), so quitting while it shows still counts. +- **A toast, where macOS has a sheet.** The GNOME HIG says of dialogs: "Never pop up a dialog window unexpectedly. They should only ever be displayed in immediate response to a deliberate user action." A toast takes one button, so I Use It Personally, which quiets the macOS ask for a year, has no place; a toast that goes on its own asks less than a sheet that waits for an answer. +- **A local stack.** A development build takes `-Ddev_account_url`, `-Ddev_api_url` and `-Ddev_entitlement_keys` ([testing.md](../testing.md#the-app-against-a-local-stack)). `meson setup` refuses them in any other profile, and the app ignores them in a release build. Half a pair, or a key that does not parse, keeps the account from starting rather than falling back to production. + +## Consequences + +- The app reaches the account service only once someone signs in, or has before. +- No Use License Key: Linux has no key from an older build to exchange (clients.md, Linux). +- Teams and devices need the network; offline the page shows the plan and features from the entitlement and says the rest could not load. +- A paid feature that comes to Linux drops its "In TablePro for macOS" line and asks `AppServices::feature_access` with its code. + +## Alternatives considered + +- **Everything from the entitlement.** It lists no team without a seat, no role and no device. +- **The dialog on every refresh that hits the limit.** A dialog nobody asked for over the user's work; the notice offers it instead. +- **The offline file in a file of its own.** One file keeps one rule: what `account.json` holds is what the gate reads at launch. +- **The usage count in the state directory.** GSettings already keeps `last-seen-version`, the one other record of this kind, and resets with the rest of the app's settings. +- **The macOS sheet as an `AdwAlertDialog`.** The HIG rule above; a banner is for a persistent state, which an ask is not. diff --git a/linux/docs/decisions/0039-usage-data.md b/linux/docs/decisions/0039-usage-data.md new file mode 100644 index 0000000000..afb65d8cd1 --- /dev/null +++ b/linux/docs/decisions/0039-usage-data.md @@ -0,0 +1,35 @@ +# 0039: Usage data through posthog-rs, on by default as on macOS + +- **Status**: Accepted +- **Date**: 2026-11-05 + +## Context + +tablepro-cloud's `docs/design/clients.md` has the apps send usage events to PostHog directly, identify with the account's ID after a sign-in, reset on a sign-out, and keep a setting that also stops the identify. Linux follows its macOS Telemetry list, except `feature_gate_shown`, and macOS's `Telemetry.swift` is the reference for what it leaves open. It names posthog-rs 0.27 for Linux, with `flush()` before exit. On 2026-11-05 the maintainer decided that every TablePro app shares usage data by default, Linux included, as macOS 0.86.0 does, with no question at launch and a switch to turn it off. TablePro for Linux sent nothing of the kind before. + +## Decision + +`crates/app/src/services/telemetry.rs` sends the events through posthog-rs 0.27.0's blocking client, to the project key Meson passes as `-Dposthog_key`. Without a key, or while Share Usage Data is off, no client is made, so nothing is sent. The GSettings key `share-usage-data` is on by default. Preferences > General > Privacy, shown only in a build with a key, has the Share Usage Data switch, "Help improve TablePro with app activity and system information"; turning it off stops the events at once. + +## How it works + +- **Client.** posthog-rs without its default features: no async client, no error tracking. Its worker is a thread of its own with reqwest's blocking client, built off the GTK thread the first time Share Usage Data is on; what is captured before it exists waits for it. Each event is sent as it is captured (`flush_at` 1) and tried once, so one that fails is lost. The worker sends one request at a time, and a request times out after 5 seconds, which bounds the `flush()` the application's shutdown runs. An event captured while an earlier request waits for PostHog waits behind it; each event carries the turn of Share Usage Data it was captured in, and a `before_send` hook drops it as it leaves unless that turn still holds, so the switch going off meanwhile drops it even if it comes back on. Once the switch is off, only the request already on the wire can still reach PostHog. The host is PostHog's EU ingestion host. +- **Identity.** `telemetry.json` in the state directory keeps a random anonymous ID, the account PostHog knows the install as, the day `app_opened` last went out and the events sent once. While Share Usage Data is on, the person follows the account: `$identify` once a run while signed in, with the account's TypeID and `$anon_distinct_id`, the account from the entitlement's `sub` or, signed in without an entitlement, from `/v2/me`; and a reset, a new anonymous ID, once the account is gone. Before the account has restored at launch, neither happens. An anonymous event carries `$process_person_profile` false, as posthog-ios's `identifiedOnly` does on macOS. +- **Person properties**, in `$set` on `$identify`, with the keys macOS sets: `app_version`, `os_version` (the host's `PRETTY_NAME`), `architecture` (`arm64` or `x86_64` as macOS names them, so Rust's `aarch64` goes as `arm64`) and `locale`. Linux has no language setting, so `locale` is the interface's `LC_MESSAGES` locale without its codeset, such as `vi_VN`. +- **Events**: `app_opened` once per local day, 10 seconds after launch so restored sessions count and then each hour, with `database_types` and `connection_count` of the open connections; `connection_attempted` and `connection_succeeded` once per install with `database_type`, from a wrapper around the app's `Connector`; `first_query_executed` once per install with `database_type`, from the first editor run that succeeded, which the query history service sees whether or not the history is on; `upgrade_clicked` with `source` `app-settings`, the source macOS's Settings > Account sends, from Preferences > Account, and `app-ask` from the license ask's Buy a License; `support_ask_shown` with no properties each time the license ask shows, and `support_ask_answered` with `answer` `buy` when Buy a License is pressed, since a toast that goes on its own is no answer; `signed_in` with `method` `interactive`; `signed_out` with none, for the user's own sign-out only. A removed device or a refused refresh token only resets, so nothing goes out as an account that may be deleted. Between the user's Sign Out and the status it ends in, an answer that still names the account, from `/v2/me` or a scheduled refresh, identifies nobody. `feature_gate_shown` waits for a gated feature on Linux. +- **Database types.** `database_type` and `database_types` carry the names macOS's `DatabaseType` uses, such as `PostgreSQL`, `MySQL`, `SQLite` and `SQL Server`, not the driver IDs, so a breakdown by type reads the same from either app. MariaDB goes through the MySQL driver and reports `MySQL`; ScyllaDB goes through the Cassandra driver and reports `Cassandra`. A driver without a name there reports its ID. +- **The switch** stops every event, the `$identify` and the reset at once, and drops what waits for the client. A sign-out made while it is off resets when it is turned on again. As on macOS, the first connection or query made while it is off is still the one counted, and is never sent. +- **Besides the events**, posthog-rs adds `$lib`, `$lib_version` and its three parts, and `$os` and `$os_version` from `os_info` to each event, with a UUID and a timestamp; each request carries the project key, a `sent_at` time and the `posthog-rs/0.27.0` User-Agent. PostHog sees the address the request comes from, which it uses for GeoIP. posthog-rs asks for no feature flags and makes no request of its own. + +## Consequences + +- A build without `-Dposthog_key`, which is every build so far, sends nothing and shows no Privacy group, since its switch would do nothing. +- A build with a key, which the PPA and COPR packages are, sends the events above from its first launch, and stops when the switch goes off. +- posthog-rs brings reqwest's blocking and gzip features, `derive_builder` and `os_info` into the workspace. + +## Alternatives considered + +- **The async client.** It needs a runtime to build and drop, and the GTK thread has none; capture is a queue push either way. +- **The hashed machine ID as the anonymous ID.** A reset has to give a new ID, and the machine ID never changes. +- **Off until the user opts in**, from a launch toast whose Choose… opened a Share or Don't Share question with no default, and the first form of this change. The maintainer chose on by default for every app on 2026-11-05, so Linux behaves as macOS does. +- **The question as a first-launch dialog.** The HIG: "Never pop up a dialog window unexpectedly." diff --git a/linux/docs/decisions/README.md b/linux/docs/decisions/README.md index c9c8aa36c1..38cce430ae 100644 --- a/linux/docs/decisions/README.md +++ b/linux/docs/decisions/README.md @@ -84,6 +84,8 @@ A fact the repository keeps elsewhere, such as a workflow trigger, a branch, a v | [0034](0034-dropping-a-database-the-window-holds.md) | Dropping a database the window holds open | Accepted | Drop is never offered for a session's own database; a sibling is closed after its prompts before the connection drops its database; a session whose database is gone parks. | | [0036](0036-editing-query-results.md) | A query tab edits the one result of its run, through the table tab's save | Accepted | One tracker per query tab under its tab id, held by the run's one editable result; saves go through the window's connection with Confirm Writes, history and row guards, and the statement runs again after a save. | | [0037](0037-mysql-ca-bundle-removal-condition.md) | When the MySQL CA bundle goes | Accepted | sqlx #4044/#4045 add rustls-platform-verifier features, not a caller's ClientConfig; the bundle goes once sqlx's own TLS passes system-trust, extra-CA and name tests against MySQL. | +| [0038](0038-account-page-and-license-ask.md) | Preferences > Account, the device limit dialog, offline files and the license ask | Accepted | The page shows the plan and features from the entitlement and the teams and devices from `/v2`; over the limit a dialog removes a device in place; an offline file works signed out; from the 30th day of use without a license a toast asks, then every 90 days. | +| [0039](0039-usage-data.md) | Usage data through posthog-rs, on by default as on macOS | Accepted | posthog-rs's blocking client sends the clients.md events to the key `-Dposthog_key` gives; on by default, off from Preferences > General > Privacy; no key, no client. | | [0040](0040-copyright-file-from-the-source-tarballs.md) | The copyright file describes the source tarballs | Accepted | `debian/copyright` covers every crate `cargo vendor` ships and the code crates carry from other projects, from `cargo metadata` and each crate's license files; the binary inventory keeps the About dialog, the notices and the RPM `License` tag. | | [0041](0041-beta-repositories-and-release-packages.md) | Beta repositories, a Flatpak repository of our own, and packages on every GitHub release | Accepted | A beta tag goes to a beta PPA, COPR project and Flatpak repository that testers add beside the stable ones; a signed Flatpak repository on R2 serves every other distribution with updates; every GitHub release carries the tested .deb and .rpm and a Flatpak bundle that updates from that repository, with SHA256SUMS. | diff --git a/linux/docs/releasing.md b/linux/docs/releasing.md index 36cac557c5..a8b1df0385 100644 --- a/linux/docs/releasing.md +++ b/linux/docs/releasing.md @@ -52,7 +52,7 @@ A repository admin does these once. The workflow's header names the same secrets 5. **GitHub**: the `linux-release` environment takes a required reviewer and deploys from `linux-v*` tags and the `main` branch only. The branch is for a [packaging fix](#a-packaging-fix). - Secrets in it: `LINUX_PPA_SIGNING_KEY`, the file the first step exported; `LINUX_COPR_WEBHOOK`, the URL of `@tablepro/tablepro`; `LINUX_COPR_WEBHOOK_BETA`, the URL of `@tablepro/tablepro-beta`; `LINUX_FLATPAK_GPG_KEY`, the Flatpak key's file; `LINUX_FLATPAK_R2_ACCESS_KEY_ID` and `LINUX_FLATPAK_R2_SECRET_ACCESS_KEY`, the R2 token's. Then delete the exported key files. - - Repository variables: `LINUX_PPA` set to `tablepro/tablepro`, `LINUX_PPA_BETA` set to `tablepro/tablepro-beta`, `LINUX_FLATPAK_R2_ENDPOINT` set to the S3 endpoint. `LINUX_BRANCH` stays unset unless a release comes from a [maintenance branch](#a-fix-release). + - Repository variables: `LINUX_PPA` set to `tablepro/tablepro`, `LINUX_PPA_BETA` set to `tablepro/tablepro-beta`, `LINUX_FLATPAK_R2_ENDPOINT` set to the S3 endpoint, `POSTHOG_PROJECT_KEY` set to the project API key (`phc_...`) of the one PostHog project, which the macOS release builds with too. It is public by design, so a variable, not a secret; the .deb and .rpm send usage data to it unless the user turns it off ([ADR 0039](decisions/0039-usage-data.md)), and a release fails without it. `LINUX_BRANCH` stays unset unless a release comes from a [maintenance branch](#a-fix-release). ```sh gh secret set LINUX_PPA_SIGNING_KEY --env linux-release --repo TableProApp/TablePro < linux-ppa-signing-key.asc @@ -64,6 +64,7 @@ A repository admin does these once. The workflow's header names the same secrets gh variable set LINUX_PPA --body tablepro/tablepro --repo TableProApp/TablePro gh variable set LINUX_PPA_BETA --body tablepro/tablepro-beta --repo TableProApp/TablePro gh variable set LINUX_FLATPAK_R2_ENDPOINT --body 'https://.r2.cloudflarestorage.com' --repo TableProApp/TablePro + gh variable set POSTHOG_PROJECT_KEY --repo TableProApp/TablePro ``` Until these exist, a tag still builds and checks everything, and each publishing step that lacks its secret or variable fails and publishes nothing. @@ -117,7 +118,7 @@ A fix to `build-aux/debian` or `build-aux/rpm` that needs no new version, such a gh workflow run release-linux.yml --ref main -f tag=linux-v0.1.0 -f revision=2 ``` -It downloads that release's two tarballs, checks them against its `SHA256SUMS`, builds the source package with the packaging from `main` as `0.1.0-0ppa2~YY.MM`, builds and tests the .deb on both architectures, and uploads it to the stable PPA once a reviewer approves. Launchpad accepts the release's own orig tarballs again, and would refuse new ones whose bytes differ. Count the revision up for each further upload of the same version. The release's GitHub assets stay as they were. +It downloads that release's two tarballs, checks them against its `SHA256SUMS`, builds the source package with the packaging from `main` as `0.1.0-0ppa2~YY.MM`, builds and tests the .deb on both architectures, and uploads it to the stable PPA once a reviewer approves. Launchpad accepts the release's own orig tarballs again, and would refuse new ones whose bytes differ. The vendor tarball brings the release's PostHog key with it. Count the revision up for each further upload of the same version. The release's GitHub assets stay as they were. **COPR.** COPR builds the spec at the tag, so a fix to it goes in by hand. Raise `Release` in `build-aux/rpm/tablepro.spec` on `main` with a `%changelog` entry, then, on Fedora with `copr-cli` signed in, build the source RPM from that spec and submit it: diff --git a/linux/docs/storage.md b/linux/docs/storage.md index 12d8b6c266..b02c7aa578 100644 --- a/linux/docs/storage.md +++ b/linux/docs/storage.md @@ -6,6 +6,9 @@ The app's data goes to the store its shape calls for. The core's `storage` crate |---|---|---| | Saved connections | JSON file | `ConnectionStore` | | Database passwords, SSH passwords and key passphrases | `oo7`: the host's Secret Service, or the Secret portal inside Flatpak | `SecretStore`, read at each connect by `KeyringCredentials` | +| The TablePro account's refresh token | the same keyring, or memory alone when none answers | `SecretStore` through the app's `KeyringRefreshToken`, a `tablepro_account::RefreshTokenVault` | +| The account's device ID and last entitlement, or an imported offline file | JSON file | `tablepro-account` ([core ADR 0016](../../core/docs/decisions/0016-tablepro-accounts.md), [ADR 0038](decisions/0038-account-page-and-license-ask.md)) | +| The usage data's anonymous ID and what it sent once | JSON file | `Telemetry` in the app crate ([ADR 0039](decisions/0039-usage-data.md)) | | Preferences and window geometry | `gio::Settings` | `AppSettings` | | Query history and saved queries | SQLite, one database | `QueryHistory`, `SavedQueries` | | Editor tab text | one file per tab | `DraftStore` | @@ -63,6 +66,7 @@ Notes: - Schema name: the application id plus `.Password`, so `app.tablepro.TablePro.Password` for an installed build and `app.tablepro.TablePro.Devel.Password` for a development one. Neither reads the other's secrets. Attributes: `xdg:schema`, `connection-id` and `kind` (`db_password`, `ssh_password`, `ssh_passphrase`). - The MCP server's access token is one item of its own, with `xdg:schema` and `kind` `mcp_token` and no `connection-id`, so deleting a connection never matches it. It is made on the server's first start and replaced from Preferences. - Each AI provider's API key is one item, with `xdg:schema`, `kind` `ai_api_key` and `provider` (`anthropic`, `openai` or `openai-compatible`) and no `connection-id`. Preferences > AI stores and removes it; the assistant reads it before each request. +- The TablePro account's refresh token is one item, with `xdg:schema` and `kind` `account_refresh_token` and no `connection-id`. The server rotates it on every refresh, and each one replaces the item. Sign-out deletes it. When no keyring takes it, it lives in memory until the app quits and the next launch starts signed out; the item the keyring held before is deleted then, since the server refuses a rotated token. - Labels name the kind and the target, for example `TablePro database password for app@db.example.com:5432/shop`, so the item is identifiable in Seahorse on a host install. Under Flatpak the items sit in the app's own file keyring. - The store unlocks the keyring and the item before reading. A locked keyring otherwise looks exactly like a connection with no saved password. - Failures are typed (`SecretError`): a dismissed prompt, a locked keyring, a missing service, a missing portal and a bad encoding each get their own message and their own next step. @@ -138,6 +142,8 @@ nothing reaches outside it. | `drafts_dir()` | `$XDG_DATA_HOME//drafts` | | `snippets_dir()` | `$XDG_DATA_HOME//snippets`: the user's SQL editor snippets, made when Preferences opens it ([README](../README.md#snippets)) | | `crashes_dir()` | `$XDG_STATE_HOME//crashes`: crash reports ([error-handling.md](error-handling.md#crashes)) | +| `telemetry_file()` | `$XDG_STATE_HOME//telemetry.json`: the usage data's random `anonymous_id`, the account PostHog knows the install as (`identified`), the day `app_opened` last went out (`opened_on`) and the events sent once per install (`sent_once`). Written only by a build with a PostHog key, once the user shares usage data | +| `account_file()` | `$XDG_STATE_HOME//account.json`, version 1: the `device_id` this machine has on the TablePro account, the last `entitlement` token, which is signed and verified again on read, and the `account_id` it names, kept once the token expires so an offline file of another account is still refused. `offline_only` marks an offline file imported with no sign-in: the launch then never opens the keyring, and a file that no longer verifies is removed. A file of another version reads as empty, and the device is registered again. Sign-out removes it. Without it, a launch never opens the keyring for the account | `` is `tablepro` for an installed build and `tablepro-devel` for a development one, so `cargo run` never writes over an installed build's @@ -316,6 +322,10 @@ The schema id is the application id and the path follows it: `app.tablepro.Table | `confirm-bulk-delete` | `b` | `true` | | | `restore-session` | `b` | `true` | | | `last-seen-version` | `s` | empty | | +| `share-usage-data` | `b` | `true` | | +| `usage-days` | `u` | `0` | | +| `last-usage-day` | `s` | empty | `YYYY-MM-DD` | +| `license-ask-day` | `s` | empty | `YYYY-MM-DD` | | `mcp-server-enabled` | `b` | `false` | | | `mcp-server-port` | `u` | `23508` | 1024 to 65535 | | `ai-provider` | enum | `none` | `none`, `anthropic`, `openai`, `openai-compatible` | diff --git a/linux/docs/testing.md b/linux/docs/testing.md index 60be0b8da2..429e995687 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -9,6 +9,7 @@ Three layers, three tools. Each crate's test policy follows from its position in | `net` | TLS setup | Unit tests, a TLS handshake test against a local server | Yes | | `ssh` | OpenSSH transport | Unit tests, `testcontainers-rs` tests against an OpenSSH server | Yes | | `test-fixtures` | Containers for the other crates' tests | `testcontainers-rs` tests of the fixtures themselves | Yes | +| `account` | Sign-in, tokens, device, entitlement, gate | Unit tests, among them tablepro-cloud's entitlement vectors and the loopback listener over real sockets, plus tests against a local tablepro-cloud through a headless browser | Yes, the unit tests | | `session` | Sessions, connect, health | Unit tests over the fakes in `tablepro-test-support` and `tablepro_session::fakes`, plus `testcontainers-rs` tests | Yes | | `drivers/` | Real engines | Unit tests, the conformance suites, `testcontainers-rs` integration tests | Yes | | `app` | GTK 4 widgets | `#[gtk4::test]` widget tests; pure logic is unit-tested | Yes | @@ -287,6 +288,7 @@ Three reasons are allowed: - `#[ignore = "requires docker"]` for testcontainers suites. - `#[ignore = "requires a Secret Service"]` for tests that talk to a keyring. - `#[ignore = "requires a SQL Server on this Windows machine"]` for the SQL Server driver's integrated login through SSPI (`tests/integration/windows_server.rs`). SQL Server installs on x64 Windows only, so `core.yml`'s `sql-server-windows` job runs them on `windows-2025`: `tests/windows/sql-server.ps1 -Install` sets up SQL Server 2022 Express with a self-signed certificate, the `sspi` tests sign in over TDS 7.4 and strict TLS, `-ForceStrict` turns on Force Strict Encryption, and the `force_strict` test checks that a TDS 7.4 login is refused and a strict one gets in. The runner is in no domain, so SSPI signs in with NTLM. +- `#[ignore = "requires a tablepro-cloud stack"]` for the account's tests against a running backend ([below](#tablepro-cloud-stack)). The workspace denies `clippy::ignore_without_reason`, so a bare `#[ignore]` fails the build. @@ -314,6 +316,64 @@ meson and distro builds use cargo test, which works too: cargo test -p tablepro-driver-postgres -- --ignored --test-threads=1 ``` +### Files from tablepro-cloud + +`core/crates/account` keeps copies of two tablepro-cloud files: `openapi/v2.openapi-3.0.json`, the `/v2` document `build.rs` generates the client from, and `tests/data/entitlement_vectors.json`, the vectors the entitlement checks run against. tablepro-cloud is private and CI cannot reach it, so they are copies, never links. From `linux/`, refresh both from a tablepro-cloud checkout: + +```bash +build-aux/update-cloud-api.sh ~/src/tablepro-cloud # without an argument, the checkout beside this repository +``` + +It prints the tablepro-cloud commit, for the commit message. Then build and run `tablepro-account`'s tests, and the stack tests below against a stack of that commit. + +### tablepro-cloud stack + +`core/crates/account/tests/cloud_stack.rs` runs the account against a running tablepro-cloud, such as the stack its `web/e2e` suite starts, through the real OAuth flow. Its browser steps, `core/crates/account/tests/browser/portal.cjs`, are Puppeteer driving a headless Chrome: it signs in to the portal with the code the stack's fake Resend keeps, then does one step: + +- `approve`: gives the account a Starter license the way the e2e seed does when asked, and licensed devices of its own with `TABLEPRO_CLOUD_SEED_DEVICES`, the first with an offline file valid 30 days; then approves the app's sign-in on the confirmation screen. The redirect reaches the account's loopback listener as from a desktop browser, or, with `TABLEPRO_CLOUD_CALLBACK_FILE`, is written to that file for the test to send itself. +- `remove-device`: removes the machine from the portal's Devices page. +- `offline-file`: makes an offline file for `TABLEPRO_CLOUD_MACHINE_ID` through the portal's API, as its Devices page does, into `TABLEPRO_CLOUD_OUTPUT`. + +The cases cover a licensed sign-in with a restart, a refresh and a sign-out whose token the server then refuses; a free account without a keyring; a fourth machine told `device_limit_reached` with the three that hold the license; a machine over the limit that removes a holder in place and gets the license, while the holder with an offline file, which `device_limit_reached` names with its `offline_until`, is refused with `offline_file_active`; a second sign-in, which revokes the first one's token; a device removed in the portal, which signs the account out at its next refresh; and the profile and devices of an account, with an offline file the portal made unlocking a machine that never signed in, without opening its keyring. + +They read the stack from the environment: + +| Variable | Value | +|---|---| +| `TABLEPRO_CLOUD_ACCOUNT_URL`, `TABLEPRO_CLOUD_API_URL` | The issuer and the `/v2` base, such as `http://account.localhost:5284` and `http://api.localhost:5284/v2` | +| `TABLEPRO_CLOUD_ENTITLEMENT_KEY` | The `x` of the stack's entitlement signing key, base64url | +| `TABLEPRO_CLOUD_EMAIL_DOMAIN` | The domain of the accounts the tests make, so runs never share one | +| `TABLEPRO_CLOUD_MAIL_URL` | The fake Resend that keeps the sign-in emails | +| `TABLEPRO_CLOUD_DATABASE_URL` | The stack's database, for the license grant and the seeded devices | +| `TABLEPRO_CLOUD_CHROME` | A Chrome or chrome-headless-shell binary | +| `NODE_PATH` | Where `puppeteer` and `pg` are installed | + +```bash +cargo test -p tablepro-account --test cloud_stack -- --ignored --test-threads=1 +``` + +The app has one case of its own, in `crates/app/src/ui/preferences_dialog/account_page.rs`: Sign In on Preferences > Account, through the page's own code, against the same stack, until the page shows the plan and this computer, then Sign Out. It needs the gtk suite's variables and a display, and the browser step hands back the redirect, so Chrome and the app need not share a loopback: + +```bash +GSK_RENDERER=cairo GTK_A11Y=test GDK_BACKEND=x11 xvfb-run -a dbus-run-session -- \ + meson devenv -C _build cargo test -p tablepro --lib -- --ignored the_page_signs_in +``` + +No CI job runs them. They never point at a production host. + +### The app against a local stack + +A development build signs in to a local tablepro-cloud when Meson is given its addresses and the public key it signs entitlements with: + +```bash +meson setup _build -Dprofile=development \ + -Ddev_account_url=http://account.localhost:5284 \ + -Ddev_api_url=http://api.localhost:5284/v2 \ + -Ddev_entitlement_keys="$(openssl pkey -in web/e2e/.stack/keys/entitlement -pubout -outform DER | tail -c 32 | base64 | tr '+/' '-_' | tr -d '=')" +``` + +The key comes from the tablepro-cloud checkout, with an OpenSSL that knows Ed25519. Portal links then open the local account pages. `meson setup` refuses these options outside the development profile, and a release build signs in to `account.tablepro.app` whatever it was built with ([ADR 0038](decisions/0038-account-page-and-license-ask.md)). An address without the other, or a key that does not parse, keeps the account from starting, which Preferences > Account says. + ### Server releases A fixture starts its engine's default release unless the engine's variable names another: diff --git a/linux/docs/troubleshooting.md b/linux/docs/troubleshooting.md index d0872d7202..fd9c521eae 100644 --- a/linux/docs/troubleshooting.md +++ b/linux/docs/troubleshooting.md @@ -28,6 +28,21 @@ The keyring is locked. The desktop asks to unlock it when TablePro reads a passw Saved passwords are listed in Seahorse as `TablePro database password for …`, `TablePro SSH password for …` and `TablePro SSH key passphrase for …`. +## TablePro account + +Preferences > Account signs in to a TablePro account, which lists your plan, devices and teams. The paid features it names are in TablePro for macOS for now; everything in TablePro for Linux works without an account. + +- **Sign In opens no browser**: TablePro asks the desktop to open the sign-in page in the default browser, through the OpenURI portal inside Flatpak. Check that one is set, under Settings > Default Apps on GNOME. +- **The browser says the sign-in finished, but TablePro still waits**: the page sends the browser back to `http://127.0.0.1` on a port TablePro picked for this sign-in. A browser that cannot reach this computer's loopback address, such as one in a container or on another machine, or a proxy setting that sends `127.0.0.1` elsewhere, never brings it back. Cancel, fix the browser, and sign in again. TablePro stops waiting after 10 minutes. +- **Signed out at every launch**: no keyring took the sign-in, so it lasts until TablePro quits; the Account page says "Sign-In Not Saved". Set up a Secret Service as for [passwords](#tablepro-asks-for-the-password-at-every-connect). The item is `TablePro account sign-in` in Seahorse. +- **Accounts Unavailable**: TablePro could not read `/etc/machine-id`, or `/var/lib/dbus/machine-id` in its place, which it hashes into the ID this computer has on your account. `systemd-machine-id-setup` makes one on a system that has none. +- **Your plan is in use on 3 other devices**: remove one from the list Preferences > Account offers, or under Devices in your account. +- **A computer with no internet**: make an offline file for the Machine ID the Account page shows, in your account, then use Import Offline File. + +## Usage data + +The PPA and COPR packages share usage data by default, as TablePro for macOS does; a build from source sends none. What it sends, to whom and when: [README](../README.md#privacy). To stop it, turn off **Preferences > General > Privacy > Share Usage Data**, or run `gsettings set app.tablepro.TablePro share-usage-data false`; events still waiting are dropped. The anonymous ID PostHog knows this computer by is in `~/.local/state/tablepro/telemetry.json`; removing that file gives a new one. + ## SSH tunnels TablePro runs the system `ssh` and reads `~/.ssh/config`, `~/.ssh/known_hosts` and the SSH agent the same way a terminal does. When a tunnel fails, try the same hop in a terminal first: diff --git a/linux/meson.build b/linux/meson.build index 02fa04de3a..582a8307ef 100644 --- a/linux/meson.build +++ b/linux/meson.build @@ -68,6 +68,18 @@ cargo_env.set('TABLEPRO_PROFILE', profile) cargo_env.set('TABLEPRO_LOCALEDIR', localedir) cargo_env.set('TABLEPRO_LIBEXECDIR', libexecdir) +# A release build always talks to tablepro.app (docs/testing.md, The app against a local stack). +dev_cloud = ['dev_account_url', 'dev_api_url', 'dev_entitlement_keys'] +foreach name : dev_cloud + if get_option(name) != '' and profile != 'development' + error(name + ' is for -Dprofile=development only') + endif +endforeach +cargo_env.set('TABLEPRO_DEV_ACCOUNT_URL', get_option('dev_account_url')) +cargo_env.set('TABLEPRO_DEV_API_URL', get_option('dev_api_url')) +cargo_env.set('TABLEPRO_DEV_ENTITLEMENT_KEYS', get_option('dev_entitlement_keys')) +cargo_env.set('TABLEPRO_POSTHOG_KEY', get_option('posthog_key')) + subdir('data') subdir('po') subdir('crates/app') @@ -91,4 +103,6 @@ summary({ 'Version': version, 'Kerberos': get_option('kerberos'), 'Flatpak': get_option('flatpak'), + 'Account service': get_option('dev_account_url') == '' ? 'production' : get_option('dev_account_url'), + 'Usage data': get_option('posthog_key') == '' ? 'off' : 'PostHog, on by default', }) diff --git a/linux/meson.options b/linux/meson.options index 31cdf21770..f0a482f972 100644 --- a/linux/meson.options +++ b/linux/meson.options @@ -19,3 +19,31 @@ option( value: false, description: 'Install the files a Flatpak build needs, such as the Kerberos configuration.', ) + +option( + 'dev_account_url', + type: 'string', + value: '', + description: 'Development profile only: the TablePro account service to sign in to, such as http://account.localhost:5284. Empty means production.', +) + +option( + 'dev_api_url', + type: 'string', + value: '', + description: 'Development profile only: its /v2 base, such as http://api.localhost:5284/v2.', +) + +option( + 'dev_entitlement_keys', + type: 'string', + value: '', + description: 'Development profile only: the base64url x of each Ed25519 key that service signs entitlements with, comma separated.', +) + +option( + 'posthog_key', + type: 'string', + value: '', + description: 'The PostHog project key usage data goes to unless the user turns it off. Empty, the app sends none.', +) diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 56ae2800b0..9e945d1dc8 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -3,12 +3,14 @@ crates/app/src/application/accelerator.rs crates/app/src/application/accelerator_scope.rs crates/app/src/application/accelerators.rs crates/app/src/application/app_actions.rs +crates/app/src/application/app_activity.rs crates/app/src/application/app_services.rs crates/app/src/application/app_window_host.rs crates/app/src/application/debug_info.rs crates/app/src/application/imp.rs crates/app/src/application/launch_notice.rs crates/app/src/application/legal_section.rs +crates/app/src/application/license_ask_prompt.rs crates/app/src/application/mcp_bridge.rs crates/app/src/application/metainfo_release.rs crates/app/src/application/mod.rs @@ -56,6 +58,7 @@ crates/app/src/runtime/request_ticket.rs crates/app/src/runtime/resume_detector.rs crates/app/src/runtime/stamped.rs crates/app/src/runtime/system_clocks.rs +crates/app/src/services/account_service.rs crates/app/src/services/active_session.rs crates/app/src/services/ai_service.rs crates/app/src/services/cell_overlay.rs @@ -100,6 +103,7 @@ crates/app/src/services/history_preferences.rs crates/app/src/services/history_service.rs crates/app/src/services/import_file.rs crates/app/src/services/library_outline.rs +crates/app/src/services/license_ask.rs crates/app/src/services/mcp_service.rs crates/app/src/services/mcp_status.rs crates/app/src/services/mod.rs @@ -123,6 +127,8 @@ crates/app/src/services/structure_tracker_handle.rs crates/app/src/services/structure_tracker_registry.rs crates/app/src/services/tab_registry.rs crates/app/src/services/tab_tracker_handle.rs +crates/app/src/services/telemetry.rs +crates/app/src/services/telemetry_connector.rs crates/app/src/services/touch.rs crates/app/src/services/tracker_delta.rs crates/app/src/services/tracker_edit.rs @@ -130,8 +136,10 @@ crates/app/src/services/undo_op.rs crates/app/src/storage.rs crates/app/src/test_support/access_keys.rs crates/app/src/test_support/accessible_label.rs +crates/app/src/test_support/account_fakes.rs crates/app/src/test_support/alert_response.rs crates/app/src/test_support/application_end.rs +crates/app/src/test_support/cloud_stub.rs crates/app/src/test_support/connection_groups.rs crates/app/src/test_support/fake_clocks.rs crates/app/src/test_support/fake_reachability.rs @@ -145,6 +153,7 @@ crates/app/src/test_support/main_loop.rs crates/app/src/test_support/memory_settings.rs crates/app/src/test_support/mod.rs crates/app/src/test_support/owned_window.rs +crates/app/src/test_support/posthog_collector.rs crates/app/src/test_support/shortcut_keys.rs crates/app/src/test_support/signal_log.rs crates/app/src/test_support/stall_probe.rs @@ -369,6 +378,7 @@ crates/app/src/ui/editor/tests.rs crates/app/src/ui/editor/transaction_dirty.rs crates/app/src/ui/editor/vim_mode.rs crates/app/src/ui/editor_font_provider.rs +crates/app/src/ui/error_text/account.rs crates/app/src/ui/error_text/ddl.rs crates/app/src/ui/error_text/driver.rs crates/app/src/ui/error_text/edit.rs @@ -470,8 +480,11 @@ crates/app/src/ui/picker/picker_actions.rs crates/app/src/ui/picker/rename_group_dialog.rs crates/app/src/ui/picker/tests.rs crates/app/src/ui/plain_toast.rs +crates/app/src/ui/preferences_dialog/account_page.rs +crates/app/src/ui/preferences_dialog/account_view.rs crates/app/src/ui/preferences_dialog/ai_page.rs crates/app/src/ui/preferences_dialog/data_browser_group.rs +crates/app/src/ui/preferences_dialog/device_limit_dialog.rs crates/app/src/ui/preferences_dialog/editor_page.rs crates/app/src/ui/preferences_dialog/general_page.rs crates/app/src/ui/preferences_dialog/history_group.rs @@ -743,6 +756,7 @@ data/resources/ui/import-from-app-dialog.ui data/resources/ui/pager-bar.ui data/resources/ui/pending-changes-bar.ui data/resources/ui/picker-page.ui +data/resources/ui/preferences-account-page.ui data/resources/ui/preferences-ai-page.ui data/resources/ui/preferences-data-browser-group.ui data/resources/ui/preferences-dialog.ui