diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..9741f11 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[alias] +xtask = "run --quiet --package xtask --" diff --git a/.github/workflows/api-spec-sync.yml b/.github/workflows/api-spec-sync.yml new file mode 100644 index 0000000..71eaa12 --- /dev/null +++ b/.github/workflows/api-spec-sync.yml @@ -0,0 +1,89 @@ +# Keeps the vendored Platform API spec (vendor/openapi.yaml) current with +# the API repo. (The help center's API reference is rebuilt from the live +# spec by the specs repo itself.) +# +# Runs every 6 hours, on demand, and on a `repository_dispatch` of type +# `api-spec-updated`. If openapi.yaml changed, it diffs old vs new with +# oasdiff and opens (or updates) a pull request with the changelog, +# labelled `breaking-change` when oasdiff finds one, so the app code gets +# reviewed against the change. +name: API spec sync + +on: + schedule: + - cron: "23 */6 * * *" + workflow_dispatch: + repository_dispatch: + types: [api-spec-updated] + +permissions: + contents: write + pull-requests: write + +concurrency: + group: api-spec-sync + cancel-in-progress: false + +env: + SPEC_URL: https://raw.githubusercontent.com/CompositeCode/substratalapps.com/main/docs/openapi.yaml + OASDIFF_VERSION: 1.34.1 + +jobs: + sync: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + + - name: Fetch the API's current spec + id: fetch + run: | + curl -fsSL "$SPEC_URL" -o _new.yaml + if cmp -s vendor/openapi.yaml _new.yaml; then + echo "changed=false" >> "$GITHUB_OUTPUT" + echo "openapi.yaml unchanged." + else + echo "changed=true" >> "$GITHUB_OUTPUT" + echo "version=$(python3 -c 'import yaml;print(yaml.safe_load(open("_new.yaml"))["info"]["version"])')" >> "$GITHUB_OUTPUT" + fi + + - name: Diff with oasdiff + if: steps.fetch.outputs.changed == 'true' + id: diff + run: | + curl -fsSL "https://github.com/oasdiff/oasdiff/releases/download/v${OASDIFF_VERSION}/oasdiff_${OASDIFF_VERSION}_linux_amd64.tar.gz" | tar -xz oasdiff + ./oasdiff changelog vendor/openapi.yaml _new.yaml --format markdown > _changelog.md || true + if ./oasdiff breaking vendor/openapi.yaml _new.yaml --fail-on ERR > _breaking.txt; then + echo "breaking=false" >> "$GITHUB_OUTPUT" + else + echo "breaking=true" >> "$GITHUB_OUTPUT" + fi + cat _changelog.md + + - name: Update the vendored spec + if: steps.fetch.outputs.changed == 'true' + id: notes + run: | + mv _new.yaml vendor/openapi.yaml + { echo 'changelog<> "$GITHUB_OUTPUT" + rm -f oasdiff _changelog.md _breaking.txt + + - uses: peter-evans/create-pull-request@v8 + if: steps.fetch.outputs.changed == 'true' + with: + # A PAT (SYNC_TOKEN) lets the PR trigger CI; GITHUB_TOKEN PRs don't. + token: ${{ secrets.SYNC_TOKEN || github.token }} + branch: automation/api-spec-sync + delete-branch: true + commit-message: "Sync Platform API spec to ${{ steps.fetch.outputs.version }}" + title: "Sync Platform API spec to ${{ steps.fetch.outputs.version }}" + labels: | + api-spec + ${{ steps.diff.outputs.breaking == 'true' && 'breaking-change' || '' }} + body: | + The Platform API's `openapi.yaml` changed upstream. This updates `vendor/openapi.yaml`. + + Breaking changes found by oasdiff: **${{ steps.diff.outputs.breaking }}** + + ${{ steps.notes.outputs.changelog }} + + Before merging, check the app against the change: hand-written models in `crates/substratal-core/src/models.rs`, and whether `Substratal-Version` (`crates/substratal-core/src/version.rs`) needs to move. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0960085 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +# Build, lint and test the app; check that spec-derived files and the +# Linux help pages match their sources. +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + rust: + name: Build and test (Ubuntu 24.04, GTK 4.14, libadwaita 1.5) + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - name: Install GTK, libadwaita and Blueprint + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libgtk-4-dev libadwaita-1-dev blueprint-compiler libdbus-1-dev \ + desktop-file-utils appstream + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + - run: cargo fmt --all --check + - run: cargo clippy --workspace --all-targets -- -D warnings + - run: cargo test --workspace + - name: Desktop and AppStream files are valid + run: | + desktop-file-validate data/com.substratalapps.Hub.desktop + appstreamcli validate --no-net data/com.substratalapps.Hub.metainfo.xml + + specs: + name: Spec-derived files match the specs package + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: CompositeCode/substratalapps-applications-specs + path: _specs + - name: Check (warns only; the Specs sync workflow opens the update PR) + run: python3 scripts/sync-specs.py --specs _specs --check || echo "::warning::Specs package has changed since the last sync; see the Specs sync workflow." + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Linux help pages generate (warns when the help center needs an update) + run: | + if [ -d _specs/help/guides ]; then + cargo xtask gen-help --out _specs/help --check || echo "::warning::The Linux pages in the help center differ from this code; the Docs dispatch workflow updates them after merge." + fi + diff --git a/.github/workflows/docs-dispatch.yml b/.github/workflows/docs-dispatch.yml new file mode 100644 index 0000000..472fe75 --- /dev/null +++ b/.github/workflows/docs-dispatch.yml @@ -0,0 +1,79 @@ +# Keeps the Linux app's pages in the Hub help center current. +# +# The help center lives in the specs repo +# (CompositeCode/substratalapps-applications-specs, help/) and is the one +# documentation site for every Hub app. On every push to main that changes +# the app, this: +# 1. regenerates the pages that come from this app's code +# (`cargo xtask gen-help`: Linux keyboard shortcuts, substratalctl), +# 2. opens or updates a pull request in the specs repo when they changed, +# 3. sends `app-docs-updated` so the help center rebuilds. +# +# Needs the secret SPECS_DOCS_TOKEN: a fine-grained token with Contents and +# Pull requests read/write on the specs repo (see help/README.md there). +# Without it, the run regenerates and reports, and skips steps 2 and 3. +name: Docs dispatch + +on: + push: + branches: [main] + paths: ["crates/**", ".github/workflows/docs-dispatch.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: docs-dispatch + cancel-in-progress: false + +env: + SPECS_REPO: CompositeCode/substratalapps-applications-specs + HAS_TOKEN: ${{ secrets.SPECS_DOCS_TOKEN != '' }} + +jobs: + help: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - name: Check out the help center + uses: actions/checkout@v7 + with: + repository: ${{ env.SPECS_REPO }} + path: _specs + token: ${{ secrets.SPECS_DOCS_TOKEN || github.token }} + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Regenerate the Linux help pages + id: gen + run: | + if [ ! -d _specs/help/guides ]; then + echo "::notice::The specs repo's default branch has no help/ yet; nothing to update." + echo "ready=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + cargo xtask gen-help --out _specs/help + echo "ready=true" >> "$GITHUB_OUTPUT" + git -C _specs status --short + - name: Open a pull request in the specs repo + if: steps.gen.outputs.ready == 'true' && env.HAS_TOKEN == 'true' + uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.SPECS_DOCS_TOKEN }} + path: _specs + branch: automation/linux-help + delete-branch: true + commit-message: "Update the Linux help pages from substratalapps-linux@${{ github.sha }}" + title: "Update the Linux help pages" + body: | + Regenerated by `cargo xtask gen-help` in CompositeCode/substratalapps-linux at ${{ github.sha }}: the Linux keyboard shortcuts and the `substratalctl` reference. + - name: Tell the help center to rebuild + if: env.HAS_TOKEN == 'true' + env: + GH_TOKEN: ${{ secrets.SPECS_DOCS_TOKEN }} + run: | + gh api "repos/$SPECS_REPO/dispatches" -f event_type=app-docs-updated \ + -F 'client_payload[app]=linux' -F "client_payload[sha]=$GITHUB_SHA" + - name: No token + if: env.HAS_TOKEN != 'true' + run: echo "::warning::SPECS_DOCS_TOKEN isn't set, so no PR or dispatch was sent to $SPECS_REPO." diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml new file mode 100644 index 0000000..9ea56c2 --- /dev/null +++ b/.github/workflows/flatpak.yml @@ -0,0 +1,37 @@ +# Builds the Flatpak bundle (GNOME 50 runtime) for x86_64 and aarch64. +# Download it from the run's artifacts and install with +# flatpak install --user substratal-hub.flatpak +name: Flatpak + +on: + push: + branches: [main] + paths: ["crates/**", "data/**", "build-aux/flatpak/**", "Cargo.lock"] + pull_request: + paths: ["build-aux/flatpak/**", "data/**", "Cargo.lock", ".github/workflows/flatpak.yml"] + workflow_dispatch: + +permissions: + contents: read + +jobs: + flatpak: + strategy: + matrix: + include: + - arch: x86_64 + runner: ubuntu-24.04 + - arch: aarch64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + container: + image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-50 + options: --privileged + steps: + - uses: actions/checkout@v7 + - uses: flatpak/flatpak-github-actions/flatpak-builder@v6 + with: + bundle: substratal-hub-${{ matrix.arch }}.flatpak + manifest-path: build-aux/flatpak/com.substratalapps.Hub.json + arch: ${{ matrix.arch }} + cache-key: flatpak-builder-${{ github.sha }} diff --git a/.github/workflows/specs-sync.yml b/.github/workflows/specs-sync.yml new file mode 100644 index 0000000..b207e9c --- /dev/null +++ b/.github/workflows/specs-sync.yml @@ -0,0 +1,47 @@ +# Keeps the spec-derived files (screen registry, themes, icons, status +# semantics) current with the client specs package. Daily and on demand; opens a pull request when anything +# changed. +name: Specs sync + +on: + schedule: + - cron: "41 5 * * *" + workflow_dispatch: + repository_dispatch: + types: [client-specs-updated] + +permissions: + contents: write + pull-requests: write + +concurrency: + group: specs-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - uses: actions/checkout@v7 + with: + repository: CompositeCode/substratalapps-applications-specs + path: _specs + - name: Sync spec-derived files + id: sync + run: | + python3 scripts/sync-specs.py --specs _specs + echo "commit=$(git -C _specs rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + rm -rf _specs + - uses: peter-evans/create-pull-request@v8 + with: + token: ${{ secrets.SYNC_TOKEN || github.token }} + branch: automation/specs-sync + delete-branch: true + commit-message: "Sync client specs (${{ steps.sync.outputs.commit }})" + title: "Sync client specs (${{ steps.sync.outputs.commit }})" + labels: specs + body: | + The client specs package changed upstream (`CompositeCode/substratalapps-applications-specs@${{ steps.sync.outputs.commit }}`). This regenerates `data/spec/` and the bundled themes and icons. + + New or changed screens appear in the app as spec scaffolds automatically; check `cargo test` (route and navigation tests) and any hand-built screens they affect. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6ecad1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/target +/_site +/_specs +/_archive +.DS_Store +/build-aux/flatpak/.flatpak-builder +/build-aux/flatpak/build-dir +/_screens diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4f8592d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3073 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "ashpd" +version = "0.13.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8421aaa9644a5faf26735f258b669b15f063313ef8f8e2bdb28912a1a6f111" +dependencies = [ + "enumflags2", + "futures-util", + "getrandom 0.4.3", + "serde", + "tokio", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cairo-rs" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df683f1d30b457964673a5541a4603208ef95ab6873d2a55871895cf310f3b56" +dependencies = [ + "bitflags", + "cairo-sys-rs", + "glib", + "libc", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee548131103ad8f698c6725669647b9c757b3b66992dd6a026037596417bee21" +dependencies = [ + "glib-sys", + "libc", + "system-deps 9.0.1", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-expr" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ba9e9ec16c447027685b1f897b720e18e9a8afd00bd7332c483537e38086c9f" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap-markdown" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2a2617956a06d4885b490697b5307ebb09fec10b088afc18c81762d848c2339" +dependencies = [ + "clap", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-macro", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f420376dbee041b2db374ce4573892a36222bb3f6c0c43e24f0d67eae9b646" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d5209294ba3775f9b650bf78bfadeba4332089f2329b3e2f6ce0a4957a45bff" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps 9.0.1", +] + +[[package]] +name = "gdk4" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e77c579bac9eff8b84c0625d5f533da6d69988c063062fe92c2e955ede8f28eb" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk4-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk4-sys" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eff6238a30c1f198fb3c8d3d445fa7faf211d11d9075af0772e6e0ca32ae001" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps 9.0.1", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gio" +version = "0.22.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4620fbafdf19dba814473e19f18e75290cb3338662f6ae35f0402affd3b2b699" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "pin-project-lite", + "smallvec", +] + +[[package]] +name = "gio-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28739f914c15b87a9856000bed9cbd5b3a8afbca5e982d9a299e1601422cb5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps 9.0.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "glib" +version = "0.22.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4cf9e5bd8713f1990a92c422998826e0fb34824277b46bc59757373483c6694" +dependencies = [ + "bitflags", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "smallvec", +] + +[[package]] +name = "glib-build-tools" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9871f38b67853c358b8190f77b9f878eb27d933a950f1045b244c4559a9f5f0" + +[[package]] +name = "glib-macros" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9597c68fa7bdf4154a3079642cd21c4dccac0b0bdd2897d82861523bf91e7b9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "glib-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99b38907e67e40dec9b60f858bcada952598719cb512a13eb13d6cdcd16f8295" +dependencies = [ + "libc", + "system-deps 9.0.1", +] + +[[package]] +name = "gobject-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07595c9ba696bd9819cb6a8df39f08078f3dd679508fe052dd558492c2053834" +dependencies = [ + "glib-sys", + "libc", + "system-deps 9.0.1", +] + +[[package]] +name = "graphene-rs" +version = "0.22.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb856b9c558971c3f13ab692358926da710b046932a4e087aedcc35b040d7dff" +dependencies = [ + "glib", + "graphene-sys", +] + +[[package]] +name = "graphene-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97842c7543828f98aa5583aa54fcda28aa84fa2b0f8c556142177f7bbb638057" +dependencies = [ + "glib-sys", + "libc", + "system-deps 9.0.1", +] + +[[package]] +name = "gsk4" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eee23d7425c5281ec5a2f40f7b69384aac66c15861d990d248e9d9c9368be464" +dependencies = [ + "cairo-rs", + "gdk4", + "glib", + "graphene-rs", + "gsk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gsk4-sys" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27900c92e47df410bbe43b51818104051e3409da43fb88547a41c0bafddfd39c" +dependencies = [ + "cairo-sys-rs", + "gdk4-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "libc", + "pango-sys", + "system-deps 9.0.1", +] + +[[package]] +name = "gtk4" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0f8226c94dd0b65ba2e8fcd8ff402d380e890f6e5a6488e652a0158e827b9bf" +dependencies = [ + "cairo-rs", + "field-offset", + "futures-channel", + "gdk-pixbuf", + "gdk4", + "gio", + "glib", + "graphene-rs", + "gsk4", + "gtk4-macros", + "gtk4-sys", + "libc", + "pango", +] + +[[package]] +name = "gtk4-macros" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb906c98f0b4cc9002594e337e83f40492aa6dca22c97242f13be5b046c5f338" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "gtk4-sys" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd14af0137f269bdecee73404157d2a852f9651af7d68bd1023d3b8b914a14c3" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "graphene-sys", + "gsk4-sys", + "libc", + "pango-sys", + "system-deps 9.0.1", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libadwaita" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85b9900e67182a4b5b1f157b448d94f0715c8b9770cce21cf000801917f53bfa" +dependencies = [ + "gdk4", + "gio", + "glib", + "gtk4", + "libadwaita-sys", + "pango", +] + +[[package]] +name = "libadwaita-sys" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3c27642b389852aa99341bd4a4c19ec6f8a2b63ebdd7f5ba1952198079ccd" +dependencies = [ + "gdk4-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk4-sys", + "libc", + "pango-sys", + "system-deps 7.0.8", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f9a86e097b0d187ad0e65667c2f58b9254671e86e7dbb78036b16692eae099" +dependencies = [ + "libm", + "num-integer", + "num-iter", + "num-traits", + "once_cell", + "rand 0.9.5", + "serde", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "oo7" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f2bfed90f1618b4b48dcad9307f25e14ae894e2949642c87c351601d62cebd" +dependencies = [ + "aes", + "ashpd", + "cbc", + "cipher", + "digest", + "endi", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hmac", + "md-5", + "num", + "num-bigint-dig", + "pbkdf2", + "serde", + "serde_bytes", + "sha2", + "subtle", + "tokio", + "zbus", + "zbus_macros", + "zeroize", + "zvariant", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "pango" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b2022dbbd82e1c42bd950a6f1df9b98c796e725dce5ec275e03cbf9772e4efa" +dependencies = [ + "gio", + "glib", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca02d64761b74d56cdd4b437db6d73dc95e6c0d40f27e1c707952c0f09052948" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps 9.0.1", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "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", + "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.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[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 = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "substratal-core" +version = "0.1.0" +dependencies = [ + "httpdate", + "oo7", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "url", + "uuid", +] + +[[package]] +name = "substratal-hub" +version = "0.1.0" +dependencies = [ + "async-channel", + "glib-build-tools", + "gtk4", + "libadwaita", + "serde", + "serde_json", + "substratal-core", + "tokio", +] + +[[package]] +name = "substratalctl" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "serde_json", + "substratal-core", + "tokio", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "system-deps" +version = "9.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db551ad38aac61d5e0343e0eeeb0163be3115c4e23b6a025959570ee0c03c67d" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xtask" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "clap-markdown", + "substratal-core", + "substratalctl", +] + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" +dependencies = [ + "async-broadcast", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +dependencies = [ + "endi", + "enumflags2", + "serde", + "serde_bytes", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.119", + "winnow 0.7.15", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..779c2e4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[workspace] +resolver = "3" +members = ["crates/substratal-core", "crates/substratal-hub", "crates/substratalctl", "crates/xtask"] +default-members = ["crates/substratal-core", "crates/substratal-hub", "crates/substratalctl"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT" +repository = "https://github.com/CompositeCode/substratalapps-linux" +rust-version = "1.85" + +[workspace.dependencies] +substratal-core = { path = "crates/substratal-core", default-features = false } +substratalctl = { path = "crates/substratalctl", default-features = false } +anyhow = "1" +async-channel = "2" +clap = { version = "4", features = ["derive", "env"] } +httpdate = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +thiserror = "2" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } +url = "2" +uuid = { version = "1", features = ["v4"] } + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..21f3985 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Substratal Hub for Linux + +The Substratal Hub for Linux (GNOME): a GTK 4 + libadwaita client of the [Substratal Apps Platform API](https://compositecode.github.io/substratalapps.com/), and the management app for it. Built to the client specs in [substratalapps-applications-specs](https://github.com/CompositeCode/substratalapps-applications-specs) (`linux/`). + +**Help:** the Hub help center at https://compositecode.github.io/substratalapps-applications-specs/help/, the one documentation site for every Hub app. It's built from `help/` in the [specs repo](https://github.com/CompositeCode/substratalapps-applications-specs). The Linux pages are [Substratal on Linux](https://compositecode.github.io/substratalapps-applications-specs/help/guides/linux.html), [Keyboard shortcuts on Linux](https://compositecode.github.io/substratalapps-applications-specs/help/guides/linux-keyboard-shortcuts.html) and [substratalctl](https://compositecode.github.io/substratalapps-applications-specs/help/developers/substratalctl.html). The app's Help button and F1 open `help/screens/.html` for the current screen. + +> **API versioning.** The API version is the `Substratal-Version: 1` request header. URLs have no `/v1` (`https://api.substratalapps.com/users`), and `/v1/...` returns `404`. The version is set in one place, `crates/substratal-core/src/version.rs`, and sent on every request by the client middleware. + +## Layout + +| Path | What | +|---|---| +| `crates/substratal-core` | GTK-free core: API client (versioning, auth and single-flight refresh, idempotency, ETags, retries, error decoding), Secret Service storage, Capabilities, the screen registry, navigation, shortcuts | +| `crates/substratal-hub` | The GTK 4 / libadwaita app: window shell, sidebar, router and `substratal://` links, sign-in (Blueprint), themes, the spec-driven screen scaffold, and the hand-built screens | +| `crates/substratalctl` | Command-line companion on the same core | +| `crates/xtask` | `cargo xtask gen-help --out /help`: generates the help center's Linux pages (shortcuts, CLI reference) from this code | +| `data/spec/` | Generated from the specs package by `scripts/sync-specs.py`. Never edit by hand. | +| `vendor/openapi.yaml` | The Platform API spec this build targets, kept current by the API spec sync workflow | +| `build-aux/` | Flatpak manifest, Linux dev container | +| `planning/` | Plans, the API-gap list and the prerequisites check | + +## Build and run + +On Linux with GTK 4.14+, libadwaita 1.5+ and `blueprint-compiler`: + +```sh +cargo run -p substratal-hub # the app (production API) +SUBSTRATAL_API=local cargo run -p substratal-hub # against the API's devserver on :8080 +SUBSTRATAL_PREVIEW=superadmin cargo run -p substratal-hub # layout preview as a persona, no sign-in +cargo run -p substratalctl -- health +``` + +On macOS, or anywhere without GTK, use the container: + +```sh +scripts/dev.sh cargo test --workspace +scripts/dev.sh scripts/screenshot.sh _screens / /admin/users /settings # headless screenshots +``` + +## Checks (what CI runs) + +```sh +cargo fmt --all --check +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +python3 scripts/sync-specs.py --check # spec-derived files match the specs package +``` + +## Automation + +| Workflow | When | Does | +|---|---|---| +| `ci.yml` | PRs, pushes to `main` | Build, lint, test, spec and help-page checks, desktop/AppStream validation | +| `docs-dispatch.yml` | Pushes to `main` touching the app | Regenerates the Linux help pages, opens a PR in the specs repo, and sends `app-docs-updated` so the help center rebuilds (needs `SPECS_DOCS_TOKEN`) | +| `api-spec-sync.yml` | Every 6 h, on demand, `repository_dispatch: api-spec-updated` | Pulls the API's `openapi.yaml`, diffs it with oasdiff, opens a PR (labelled when breaking) | +| `specs-sync.yml` | Daily, on demand | Pulls the client specs, regenerates screens, themes and icons, opens a PR | +| `flatpak.yml` | Pushes to `main`, on demand | Builds Flatpak bundles (x86_64, aarch64) as artifacts | + +## License + +MIT diff --git a/build-aux/docker/Dockerfile.dev b/build-aux/docker/Dockerfile.dev new file mode 100644 index 0000000..64c8586 --- /dev/null +++ b/build-aux/docker/Dockerfile.dev @@ -0,0 +1,23 @@ +# Linux development and CI image for the Substratal Hub. +# Ubuntu 24.04 ships GTK 4.14 and libadwaita 1.5, the minimums in +# linux/PLATFORM-SPEC.md, so building here proves the floor works. +# +# docker build -t substratal-hub-dev -f build-aux/docker/Dockerfile.dev . +# docker run --rm -v "$PWD":/src -w /src substratal-hub-dev cargo test --workspace +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive \ + CARGO_HOME=/usr/local/cargo \ + RUSTUP_HOME=/usr/local/rustup \ + PATH=/usr/local/cargo/bin:$PATH + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential ca-certificates curl git pkg-config \ + libgtk-4-dev libadwaita-1-dev blueprint-compiler \ + libdbus-1-dev libssl-dev \ + gettext desktop-file-utils appstream libglib2.0-bin \ + librsvg2-common xvfb xauth imagemagick dbus-x11 fonts-cantarell fonts-dejavu-core adwaita-icon-theme \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --component clippy,rustfmt \ + && chmod -R a+w $CARGO_HOME $RUSTUP_HOME diff --git a/build-aux/flatpak/com.substratalapps.Hub.json b/build-aux/flatpak/com.substratalapps.Hub.json new file mode 100644 index 0000000..ceae1c1 --- /dev/null +++ b/build-aux/flatpak/com.substratalapps.Hub.json @@ -0,0 +1,70 @@ +{ + "id": "com.substratalapps.Hub", + "runtime": "org.gnome.Platform", + "runtime-version": "50", + "sdk": "org.gnome.Sdk", + "sdk-extensions": [ + "org.freedesktop.Sdk.Extension.rust-stable" + ], + "command": "substratal-hub", + "finish-args": [ + "--share=network", + "--share=ipc", + "--socket=wayland", + "--socket=fallback-x11", + "--device=dri" + ], + "build-options": { + "append-path": "/usr/lib/sdk/rust-stable/bin", + "env": { + "CARGO_HOME": "/run/build/substratal-hub/cargo", + "CARGO_NET_GIT_FETCH_WITH_CLI": "true" + }, + "build-args": [ + "--share=network" + ] + }, + "modules": [ + { + "name": "blueprint-compiler", + "buildsystem": "meson", + "cleanup": [ + "*" + ], + "sources": [ + { + "type": "git", + "url": "https://gitlab.gnome.org/GNOME/blueprint-compiler.git", + "tag": "v0.16.0" + } + ] + }, + { + "name": "substratal-hub", + "buildsystem": "simple", + "build-commands": [ + "cargo build --release --locked -p substratal-hub -p substratalctl", + "install -Dm755 target/release/substratal-hub /app/bin/substratal-hub", + "install -Dm755 target/release/substratalctl /app/bin/substratalctl", + "install -Dm644 data/com.substratalapps.Hub.desktop /app/share/applications/com.substratalapps.Hub.desktop", + "install -Dm644 data/com.substratalapps.Hub.metainfo.xml /app/share/metainfo/com.substratalapps.Hub.metainfo.xml", + "install -Dm644 data/com.substratalapps.Hub.service /app/share/dbus-1/services/com.substratalapps.Hub.service", + "cp -r data/icons/hicolor /app/share/icons/" + ], + "sources": [ + { + "type": "dir", + "path": "../..", + "skip": [ + "target", + "_site", + "_screens", + "docs", + "build-aux/flatpak/.flatpak-builder", + "build-aux/flatpak/build-dir" + ] + } + ] + } + ] +} diff --git a/crates/substratal-core/Cargo.toml b/crates/substratal-core/Cargo.toml new file mode 100644 index 0000000..d15fbd8 --- /dev/null +++ b/crates/substratal-core/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "substratal-core" +description = "Platform-independent core of the Substratal Hub for Linux: API client, session, Capabilities, screen registry and navigation." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[features] +default = ["keyring"] +# Secret Service (GNOME Keyring / KWallet, or the Secret portal inside Flatpak). +# Off in environments with no session bus, such as CI containers. +keyring = ["dep:oo7"] + +[dependencies] +httpdate.workspace = true +oo7 = { version = "0.6", optional = true, default-features = false, features = ["tokio", "native_crypto"] } +reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "http2", "charset"] } +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +url.workspace = true +uuid.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "io-util"] } +serde_json.workspace = true +url.workspace = true diff --git a/crates/substratal-core/src/api.rs b/crates/substratal-core/src/api.rs new file mode 100644 index 0000000..9585893 --- /dev/null +++ b/crates/substratal-core/src/api.rs @@ -0,0 +1,553 @@ +//! The API client layer (CLIENT-ARCHITECTURE §4). +//! +//! Every request goes through [`ApiClient::execute`], which: +//! - sends `Substratal-Version` (the one place the version is set; §4.1), +//! `User-Agent`, `Accept-Language` and the bearer token; +//! - sends `Idempotency-Key` on creates and `If-Match` on edits when given; +//! - checks the version echo and records `Deprecation` / `Sunset`; +//! - turns `400 unsupported_api_version` into [`Error::UpdateRequired`] and +//! stops all later traffic; +//! - refreshes once (single-flight) on `401` and replays the request; +//! - retries reads on network errors and `503` with jitter, never writes +//! without an idempotency key; +//! - decodes the error envelope. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; +use reqwest::{Method, StatusCode}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; +use tokio::sync::watch; +use url::Url; + +use crate::capabilities::Capabilities; +use crate::config::Environment; +use crate::error::{ApiError, Error, Result}; +use crate::models::{ + ApplicationSummary, AuthSession, Health, LoginOutcome, MfaChallenge, Page, Role, + RoleAssignment, User, +}; +use crate::secrets::SecretStore; +use crate::session::SessionStore; +use crate::version::{self, API_VERSION, VERSION_HEADER, VersionStatus}; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const READ_TIMEOUT: Duration = Duration::from_secs(30); +const READ_RETRIES: u32 = 2; + +/// One request, before it's sent. +#[derive(Clone, Debug)] +pub struct Request { + pub method: Method, + pub path: String, + pub query: Vec<(String, String)>, + pub body: Option, + pub idempotency_key: Option, + pub if_match: Option, + pub authenticated: bool, +} + +impl Request { + pub fn new(method: Method, path: impl Into) -> Self { + Request { + method, + path: path.into(), + query: Vec::new(), + body: None, + idempotency_key: None, + if_match: None, + authenticated: true, + } + } + pub fn get(path: impl Into) -> Self { + Self::new(Method::GET, path) + } + pub fn post(path: impl Into) -> Self { + Self::new(Method::POST, path) + } + pub fn patch(path: impl Into) -> Self { + Self::new(Method::PATCH, path) + } + pub fn delete(path: impl Into) -> Self { + Self::new(Method::DELETE, path) + } + pub fn query(mut self, key: &str, value: impl ToString) -> Self { + self.query.push((key.to_string(), value.to_string())); + self + } + pub fn json(mut self, body: &T) -> Self { + self.body = Some(serde_json::to_value(body).expect("serializable body")); + self + } + /// For a create form: pass the key generated when the form opened, so a + /// retry after a network error can't create twice. + pub fn idempotency_key(mut self, key: impl Into) -> Self { + self.idempotency_key = Some(key.into()); + self + } + pub fn if_match(mut self, etag: impl Into) -> Self { + self.if_match = Some(etag.into()); + self + } + pub fn anonymous(mut self) -> Self { + self.authenticated = false; + self + } + fn is_read(&self) -> bool { + self.method == Method::GET + } +} + +/// New UUID v4 for an `Idempotency-Key` (generate it when a create form opens). +pub fn new_idempotency_key() -> String { + uuid::Uuid::new_v4().to_string() +} + +/// A decoded response with the headers screens care about. +#[derive(Clone, Debug)] +pub struct Response { + pub data: T, + pub etag: Option, + pub request_id: Option, + /// `Audit-Hot-Window-Start` on audit queries. + pub audit_hot_window_start: Option, +} + +pub struct ApiClient { + http: reqwest::Client, + env: Environment, + base: Url, + session: Arc, + version: watch::Sender, +} + +impl ApiClient { + /// `client_name` goes into `User-Agent`, e.g. `Substratal-Linux/0.1.0 (Ubuntu 24.04)`. + pub fn new( + env: Environment, + secrets: SecretStore, + user_agent: &str, + accept_language: &str, + ) -> Result { + let mut defaults = HeaderMap::new(); + defaults.insert( + HeaderName::from_static("substratal-version"), + HeaderValue::from_static(API_VERSION), + ); + if let Ok(lang) = HeaderValue::from_str(accept_language) { + defaults.insert(reqwest::header::ACCEPT_LANGUAGE, lang); + } + let http = reqwest::Client::builder() + .default_headers(defaults) + .user_agent(user_agent) + .connect_timeout(CONNECT_TIMEOUT) + .read_timeout(READ_TIMEOUT) + .pool_idle_timeout(Duration::from_secs(90)) + .build() + .map_err(|e| Error::Network(e.to_string()))?; + let session = Arc::new(SessionStore::new(env.key(), secrets)); + let (version, _) = watch::channel(VersionStatus::Current); + Ok(ApiClient { + http, + base: env.base_url(), + env, + session, + version, + }) + } + + pub fn environment(&self) -> &Environment { + &self.env + } + + pub fn session(&self) -> &Arc { + &self.session + } + + /// Subscribe to API version status (drives GLB-03 Update available / required). + pub fn version_status(&self) -> watch::Receiver { + self.version.subscribe() + } + + fn url(&self, req: &Request) -> Result { + let path = req.path.trim_start_matches('/'); + let mut url = self + .base + .join(path) + .map_err(|e| Error::Decode(format!("bad path {path}: {e}")))?; + if !req.query.is_empty() { + url.query_pairs_mut() + .extend_pairs(req.query.iter().map(|(k, v)| (k.as_str(), v.as_str()))); + } + Ok(url) + } + + /// Sends a request and decodes a JSON body. + pub async fn execute(&self, req: Request) -> Result> { + let (status, headers, body) = self.execute_raw(req).await?; + let bytes = if status == StatusCode::NO_CONTENT || body.is_empty() { + b"null".as_slice() + } else { + &body + }; + let data = serde_json::from_slice(bytes).map_err(|e| Error::Decode(e.to_string()))?; + Ok(Response { + data, + etag: header(&headers, "etag"), + request_id: header(&headers, "x-request-id"), + audit_hot_window_start: header(&headers, "audit-hot-window-start"), + }) + } + + /// Sends a request; returns status, headers and raw body of a success. + pub async fn execute_raw(&self, req: Request) -> Result<(StatusCode, HeaderMap, Vec)> { + if let VersionStatus::Unsupported { supported } = &*self.version.borrow() { + return Err(Error::UpdateRequired { + supported: supported.clone(), + }); + } + let mut refreshed = false; + let mut attempt = 0; + loop { + let mut access = None; + if req.authenticated { + access = Some(self.access_token().await?); + } + match self.send_once(&req, access.as_deref()).await { + Ok((status, headers, body)) => { + self.observe_version(&headers); + if status.is_success() { + return Ok((status, headers, body)); + } + let err = ApiError::from_body( + status.as_u16(), + &body, + header(&headers, "x-request-id"), + retry_after(&headers), + ); + match (status.as_u16(), err.code.as_str()) { + (400, "unsupported_api_version") => { + let supported = err.supported_versions(); + self.version.send_replace(VersionStatus::Unsupported { + supported: supported.clone(), + }); + return Err(Error::UpdateRequired { supported }); + } + (401, _) if req.authenticated && !refreshed => { + refreshed = true; + self.refresh(access.as_deref()).await?; + continue; + } + (503, _) if req.is_read() && attempt < READ_RETRIES => { + attempt += 1; + tokio::time::sleep( + err.retry_after + .unwrap_or(backoff(attempt)) + .min(Duration::from_secs(10)), + ) + .await; + continue; + } + (409, "idempotency_key_in_flight") if attempt < READ_RETRIES => { + attempt += 1; + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } + _ => return Err(Error::from(err)), + } + } + Err(e) => { + let retryable = req.is_read() || req.idempotency_key.is_some(); + if retryable && attempt < READ_RETRIES { + attempt += 1; + tokio::time::sleep(backoff(attempt)).await; + continue; + } + return Err(e); + } + } + } + } + + async fn send_once( + &self, + req: &Request, + access: Option<&str>, + ) -> Result<(StatusCode, HeaderMap, Vec)> { + let mut builder = self.http.request(req.method.clone(), self.url(req)?); + if let Some(token) = access { + builder = builder.bearer_auth(token); + } + if let Some(key) = &req.idempotency_key { + builder = builder.header("Idempotency-Key", key); + } + if let Some(etag) = &req.if_match { + builder = builder.header("If-Match", etag); + } + if let Some(body) = &req.body { + builder = builder.json(body); + } + let resp = builder + .send() + .await + .map_err(|e| Error::Network(e.to_string()))?; + let status = resp.status(); + let headers = resp.headers().clone(); + let body = resp + .bytes() + .await + .map_err(|e| Error::Network(e.to_string()))? + .to_vec(); + Ok((status, headers, body)) + } + + /// Records `Deprecation`/`Sunset`, and logs a mismatched version echo + /// (a proxy or misconfigured environment; behavior doesn't change). + fn observe_version(&self, headers: &HeaderMap) { + if let Some(echo) = header(headers, "substratal-version") + && echo != API_VERSION + { + eprintln!( + "substratal: API answered with {VERSION_HEADER} {echo}, expected {API_VERSION}" + ); + } + let deprecation = header(headers, "deprecation"); + let sunset = header(headers, "sunset"); + if deprecation.is_some() || sunset.is_some() { + let status = VersionStatus::Deprecated { + deprecated_at: deprecation.as_deref().and_then(version::parse_deprecation), + sunset: sunset.as_deref().and_then(version::parse_sunset), + }; + self.version.send_if_modified(|current| { + if *current != status { + *current = status; + true + } else { + false + } + }); + } + } + + async fn access_token(&self) -> Result { + match self.session.current().await { + Some(t) if !t.needs_refresh(Instant::now()) => Ok(t.access_token), + Some(_) => Ok(self.refresh(None).await?.access_token), + None => Err(Error::SignedOut), + } + } + + async fn refresh(&self, seen_access: Option<&str>) -> Result { + self.session + .refresh_single_flight(seen_access, |refresh_token| async move { + let req = Request::post("auth/token/refresh") + .anonymous() + .json(&serde_json::json!({ "refresh_token": refresh_token })); + // Not `execute`: a refresh must never itself trigger a refresh. + let (status, headers, body) = self.send_once(&req, None).await?; + self.observe_version(&headers); + if status.is_success() { + serde_json::from_slice::(&body) + .map_err(|e| Error::Decode(e.to_string())) + } else { + Err(Error::from(ApiError::from_body( + status.as_u16(), + &body, + header(&headers, "x-request-id"), + retry_after(&headers), + ))) + } + }) + .await + } + + // ── Operations used by the skeleton ───────────────────────────────── + + /// `GET /health`: the GLB-03 connectivity probe (exempt from the + /// version header; sending it anyway is harmless). + pub async fn health(&self) -> Result { + Ok(self.execute(Request::get("health").anonymous()).await?.data) + } + + /// `POST /auth/login`. + pub async fn login(&self, email: &str, password: &str) -> Result { + let req = Request::post("auth/login") + .anonymous() + .json(&serde_json::json!({ "email": email, "password": password })); + let value: Value = self.execute(req).await?.data; + if value.get("mfa_required").and_then(Value::as_bool) == Some(true) { + let challenge: MfaChallenge = + serde_json::from_value(value).map_err(|e| Error::Decode(e.to_string()))?; + return Ok(LoginOutcome::MfaRequired(challenge)); + } + let session: AuthSession = + serde_json::from_value(value).map_err(|e| Error::Decode(e.to_string()))?; + self.session.establish(&session).await?; + Ok(LoginOutcome::Session(session)) + } + + /// `POST /auth/mfa/verify` with a 6-digit code or a recovery code. + pub async fn verify_mfa(&self, mfa_token: &str, code: &str) -> Result { + let code = code.trim(); + let body = if code.len() == 6 && code.chars().all(|c| c.is_ascii_digit()) { + serde_json::json!({ "mfa_token": mfa_token, "code": code }) + } else { + serde_json::json!({ "mfa_token": mfa_token, "recovery_code": code }) + }; + let session: AuthSession = self + .execute(Request::post("auth/mfa/verify").anonymous().json(&body)) + .await? + .data; + self.session.establish(&session).await?; + Ok(session) + } + + /// Restores the saved session at launch. `Ok(false)` means sign in. + pub async fn restore(&self) -> Result { + if self.session.saved_refresh_token().await?.is_none() { + return Ok(false); + } + match self.refresh(None).await { + Ok(_) => Ok(true), + Err(Error::SignedOut) => Ok(false), + Err(e) if matches!(e.status(), Some(401) | Some(403)) => Ok(false), + Err(e) => Err(e), + } + } + + /// `POST /auth/logout`, then wipe credentials whatever the result. + pub async fn logout(&self) -> Result<()> { + let result = self + .execute_raw(Request::post("auth/logout").json(&serde_json::json!({}))) + .await; + self.session.clear().await?; + match result { + Ok(_) | Err(Error::SignedOut) => Ok(()), + Err(e) if e.status() == Some(401) => Ok(()), + Err(e) => Err(e), + } + } + + /// `GET /users/me`. + pub async fn me(&self) -> Result> { + self.execute(Request::get("users/me")).await + } + + /// Computes [`Capabilities`] (CLIENT-ARCHITECTURE §5) from six calls run + /// concurrently. A `403` on the role catalog means "no admin permissions". + pub async fn capabilities(&self) -> Result<(User, Capabilities)> { + let me = self.me().await?.data; + let (assignments, roles, apps, tenants, orgs) = tokio::join!( + self.execute::>( + Request::get("users/me/roles").query("limit", 100) + ), + self.execute::>( + Request::get("roles") + .query("scope", "platform") + .query("limit", 100) + ), + self.execute::>( + Request::get("applications") + .query("owned", "true") + .query("limit", 100) + ), + self.execute::>( + Request::get("tenants") + .query("owner_user_id", &me.id) + .query("limit", 100) + ), + self.execute::>(Request::get("organizations").query("limit", 100)), + ); + let forbidden_is_empty = + |e: &Error| matches!(e.status(), Some(403) | Some(404) | Some(501)); + + let mut caps = Capabilities::member(); + caps.mfa_enabled = me.mfa_enabled; + caps.plan = Some(me.plan); + + let assigned: Vec = match assignments { + Ok(r) => r + .data + .data + .into_iter() + .filter(|a| a.application_id.is_none()) + .map(|a| a.role_id) + .collect(), + Err(e) if forbidden_is_empty(&e) => Vec::new(), + Err(e) => return Err(e), + }; + match roles { + Ok(r) => { + for role in r.data.data.into_iter().filter(|r| assigned.contains(&r.id)) { + caps.permissions.extend(role.permissions); + } + } + Err(e) if forbidden_is_empty(&e) => {} + Err(e) => return Err(e), + } + if let Ok(r) = apps { + caps.owned_apps = r.data.data.into_iter().map(|a| a.id).collect(); + } + if let Ok(r) = tenants { + caps.owned_tenants = r + .data + .data + .iter() + .filter_map(|t| t.get("id")?.as_str().map(str::to_string)) + .collect(); + } + if let Ok(r) = orgs { + caps.admin_of_orgs = r + .data + .data + .iter() + .filter(|o| o.get("my_role").and_then(Value::as_str) == Some("org_admin")) + .filter_map(|o| o.get("id")?.as_str().map(str::to_string)) + .collect(); + } + Ok((me, caps)) + } + + /// `GET /users` (USR-01). + pub async fn list_users( + &self, + q: Option<&str>, + cursor: Option<&str>, + limit: u32, + ) -> Result> { + let mut req = Request::get("users").query("limit", limit); + if let Some(q) = q.filter(|q| !q.is_empty()) { + req = req.query("q", q); + } + if let Some(c) = cursor { + req = req.query("cursor", c); + } + Ok(self.execute(req).await?.data) + } +} + +fn header(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_string) +} + +fn retry_after(headers: &HeaderMap) -> Option { + let v = header(headers, "retry-after")?; + if let Ok(secs) = v.trim().parse::() { + return Some(Duration::from_secs(secs)); + } + let when = httpdate::parse_http_date(&v).ok()?; + when.duration_since(std::time::SystemTime::now()).ok() +} + +fn backoff(attempt: u32) -> Duration { + // 250 ms, 500 ms, … plus up to 50 % jitter. + let base = 250u64 << attempt.min(5); + let jitter = (uuid::Uuid::new_v4().as_u128() % (base as u128 / 2 + 1)) as u64; + Duration::from_millis(base + jitter) +} diff --git a/crates/substratal-core/src/capabilities.rs b/crates/substratal-core/src/capabilities.rs new file mode 100644 index 0000000..4a4f897 --- /dev/null +++ b/crates/substratal-core/src/capabilities.rs @@ -0,0 +1,225 @@ +//! Capabilities: what the signed-in person may see and do +//! (CLIENT-ARCHITECTURE §5, INFORMATION-ARCHITECTURE §2). +//! +//! Navigation and every action read only from [`Capabilities`]; the server +//! stays the authority, and a `403` triggers a background refresh. + +use std::collections::BTreeSet; + +use crate::models::Plan; + +/// The platform permission catalog (Roles & Permissions → Platform permission catalog). +pub mod perm { + pub const USERS_LIST: &str = "users.list"; + pub const USERS_MANAGE: &str = "users.manage"; + pub const ENTITLEMENTS_MANAGE: &str = "entitlements.manage"; + pub const APPLICATIONS_MANAGE: &str = "applications.manage"; + pub const ROLES_MANAGE: &str = "roles.manage"; + pub const ORGANIZATIONS_MANAGE: &str = "organizations.manage"; + pub const BILLING_MANAGE: &str = "billing.manage"; + pub const BILLING_REFUND: &str = "billing.refund"; + pub const AUDIT_VIEW: &str = "audit.view"; + pub const WEBHOOKS_MANAGE: &str = "webhooks.manage"; + pub const API_KEYS_MANAGE: &str = "api_keys.manage"; + pub const NOTIFICATIONS_SEND: &str = "notifications.send"; + pub const TENANTS_MANAGE: &str = "tenants.manage"; + + pub const ALL: [&str; 13] = [ + USERS_LIST, + USERS_MANAGE, + ENTITLEMENTS_MANAGE, + APPLICATIONS_MANAGE, + ROLES_MANAGE, + ORGANIZATIONS_MANAGE, + BILLING_MANAGE, + BILLING_REFUND, + AUDIT_VIEW, + WEBHOOKS_MANAGE, + API_KEYS_MANAGE, + NOTIFICATIONS_SEND, + TENANTS_MANAGE, + ]; +} + +/// Client feature flags (CLIENT-ARCHITECTURE §10). Compile-time defaults +/// until the API serves remote config; a `404`/`501` from a gated endpoint +/// turns a flag off for the session. +pub mod flag { + pub const NOTIFICATIONS: &str = "notifications"; + pub const ORGANIZATIONS: &str = "organizations"; + pub const BILLING: &str = "billing"; + pub const WEBHOOKS: &str = "webhooks"; + pub const SEND_NOTIFICATIONS: &str = "send_notifications"; + pub const PUSH_CREDENTIALS: &str = "push_credentials"; + pub const SCIM: &str = "scim"; + pub const SSO: &str = "sso"; + pub const PASSKEYS: &str = "passkeys"; + pub const AUDIT_EXPORT: &str = "audit_export"; + pub const APP_REGISTRATION_SELF_SERVICE: &str = "app_registration_self_service"; + /// Linux has no push provider, so `push` stays off; notifications arrive by polling. + pub const PUSH: &str = "push"; + + /// Flags on by default in this build. + pub const DEFAULT_ON: &[&str] = &[ + NOTIFICATIONS, + ORGANIZATIONS, + BILLING, + WEBHOOKS, + SEND_NOTIFICATIONS, + PUSH_CREDENTIALS, + SCIM, + AUDIT_EXPORT, + APP_REGISTRATION_SELF_SERVICE, + ]; +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Capabilities { + pub signed_in: bool, + pub permissions: BTreeSet, + pub owned_apps: Vec, + pub owned_tenants: Vec, + pub admin_of_orgs: Vec, + pub plan: Option, + pub mfa_enabled: bool, + pub flags: BTreeSet, +} + +impl Capabilities { + /// A signed-in person with no permissions and the default flags. + pub fn member() -> Self { + Capabilities { + signed_in: true, + flags: flag::DEFAULT_ON.iter().map(|s| s.to_string()).collect(), + ..Default::default() + } + } + + pub fn with_permissions, S: Into>( + mut self, + perms: I, + ) -> Self { + self.permissions.extend(perms.into_iter().map(Into::into)); + self + } + + pub fn has(&self, permission: &str) -> bool { + self.permissions.contains(permission) + } + + pub fn flag(&self, name: &str) -> bool { + self.flags.contains(name) + } + + pub fn disable_flag(&mut self, name: &str) { + self.flags.remove(name); + } + + pub fn owns_app(&self) -> bool { + !self.owned_apps.is_empty() + } + + pub fn can_list_users(&self) -> bool { + self.has(perm::USERS_LIST) + } + pub fn can_manage_users(&self) -> bool { + self.has(perm::USERS_MANAGE) + } + pub fn can_manage_entitlements(&self) -> bool { + self.has(perm::ENTITLEMENTS_MANAGE) + } + pub fn can_manage_roles(&self) -> bool { + self.has(perm::ROLES_MANAGE) + } + pub fn can_view_audit(&self) -> bool { + self.has(perm::AUDIT_VIEW) + } + pub fn can_send_notifications(&self) -> bool { + self.has(perm::NOTIFICATIONS_SEND) + } + pub fn can_manage_tenants(&self) -> bool { + self.has(perm::TENANTS_MANAGE) + } + pub fn can_manage_billing(&self) -> bool { + self.has(perm::BILLING_MANAGE) + } + + /// INFORMATION-ARCHITECTURE §2: Administration section. + pub fn shows_administration(&self) -> bool { + [ + perm::USERS_LIST, + perm::ENTITLEMENTS_MANAGE, + perm::ROLES_MANAGE, + perm::AUDIT_VIEW, + perm::NOTIFICATIONS_SEND, + ] + .iter() + .any(|p| self.has(p)) + || self.owns_app() + } + + /// INFORMATION-ARCHITECTURE §2: Developer section. + pub fn shows_developer(&self) -> bool { + self.owns_app() + || !self.owned_tenants.is_empty() + || [ + perm::APPLICATIONS_MANAGE, + perm::API_KEYS_MANAGE, + perm::WEBHOOKS_MANAGE, + perm::TENANTS_MANAGE, + ] + .iter() + .any(|p| self.has(p)) + } + + /// Anyone holding a platform permission gets the admin protections + /// (idle lock, memory-only admin data). + pub fn is_staff(&self) -> bool { + !self.permissions.is_empty() + } +} + +/// Personas from PRODUCT-SPEC §1, as Capabilities, for tests and previews. +pub mod personas { + use super::*; + + pub fn end_user() -> Capabilities { + Capabilities::member() + } + pub fn app_owner() -> Capabilities { + let mut c = Capabilities::member(); + c.owned_apps = vec!["app_demo".into()]; + c.owned_tenants = vec!["tnt_demo".into()]; + c + } + pub fn support() -> Capabilities { + Capabilities::member().with_permissions([ + perm::USERS_LIST, + perm::ENTITLEMENTS_MANAGE, + perm::AUDIT_VIEW, + perm::TENANTS_MANAGE, + perm::BILLING_MANAGE, + ]) + } + pub fn billing_admin() -> Capabilities { + Capabilities::member().with_permissions([ + perm::BILLING_MANAGE, + perm::BILLING_REFUND, + perm::AUDIT_VIEW, + perm::USERS_LIST, + ]) + } + pub fn superadmin() -> Capabilities { + Capabilities::member().with_permissions(perm::ALL) + } + + pub fn all() -> Vec<(&'static str, Capabilities)> { + vec![ + ("End user", end_user()), + ("App owner", app_owner()), + ("Support", support()), + ("Billing admin", billing_admin()), + ("Superadmin", superadmin()), + ] + } +} diff --git a/crates/substratal-core/src/config.rs b/crates/substratal-core/src/config.rs new file mode 100644 index 0000000..c18ec7c --- /dev/null +++ b/crates/substratal-core/src/config.rs @@ -0,0 +1,124 @@ +//! Environments and well-known URLs. + +use url::Url; + +/// Production API base URL: the host root. There is no `/v1` (see [`crate::version`]). +pub const PRODUCTION_API: &str = "https://api.substratalapps.com"; +/// The API repo's local devserver (`make up && make run`). +pub const LOCAL_API: &str = "http://localhost:8080"; +/// Root of the Substratal Hub help center: the one documentation site for +/// every Hub app, built from `help/` in the specs repo +/// (CompositeCode/substratalapps-applications-specs). +pub const HELP_BASE: &str = + "https://compositecode.github.io/substratalapps-applications-specs/help/"; +/// The Platform API's own specification and reference. +pub const API_DOCS: &str = "https://compositecode.github.io/substratalapps.com/"; +/// The web dashboard, where emailed links and Stripe returns land. +pub const DASHBOARD: &str = "https://dashboard.substratalapps.com"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Environment { + Production, + Local, + Custom(Url), +} + +impl Environment { + /// Reads `SUBSTRATAL_API` (`production`, `local`, or a URL). Defaults to production. + pub fn from_env() -> Self { + match std::env::var("SUBSTRATAL_API") { + Ok(v) => Self::parse(&v), + Err(_) => Environment::Production, + } + } + + pub fn parse(value: &str) -> Self { + match value.trim() { + "" | "production" | "prod" => Environment::Production, + "local" | "devserver" => Environment::Local, + other => match Url::parse(other) { + Ok(u) if u.as_str().trim_end_matches('/') == PRODUCTION_API => { + Environment::Production + } + Ok(u) => Environment::Custom(u), + Err(_) => Environment::Production, + }, + } + } + + pub fn base_url(&self) -> Url { + let raw = match self { + Environment::Production => PRODUCTION_API, + Environment::Local => LOCAL_API, + Environment::Custom(u) => u.as_str(), + }; + let mut url = Url::parse(raw).expect("valid base URL"); + // Paths are joined onto the root; make sure there is exactly one slash. + if !url.path().ends_with('/') { + url.set_path(&format!("{}/", url.path())); + } + url + } + + /// Short label shown in the UI for anything but production. + pub fn label(&self) -> Option { + match self { + Environment::Production => None, + Environment::Local => Some("Local devserver".into()), + Environment::Custom(u) => Some(u.host_str().unwrap_or("custom").to_string()), + } + } + + /// Key used to keep credentials for different environments apart. + pub fn key(&self) -> String { + match self { + Environment::Production => "production".into(), + Environment::Local => "local".into(), + Environment::Custom(u) => u.as_str().trim_end_matches('/').to_string(), + } + } +} + +/// Help center page for a screen, e.g. `…/help/screens/usr-01.html`. Every Hub app +/// (Linux, macOS, Windows, iOS, Android, Web) shares these screen IDs, so the +/// same URL works as a help link from any of them. +pub fn help_url(screen_id: &str) -> String { + format!("{HELP_BASE}screens/{}.html", screen_id.to_ascii_lowercase()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn production_is_host_root_without_version() { + let url = Environment::Production.base_url(); + assert_eq!(url.as_str(), "https://api.substratalapps.com/"); + assert_eq!( + url.join("users").unwrap().as_str(), + "https://api.substratalapps.com/users" + ); + assert!(!url.as_str().contains("/v1")); + } + + #[test] + fn parse_values() { + assert_eq!(Environment::parse("local"), Environment::Local); + assert_eq!( + Environment::parse("https://api.substratalapps.com/"), + Environment::Production + ); + assert!(matches!( + Environment::parse("https://staging.example.com"), + Environment::Custom(_) + )); + } + + #[test] + fn help_urls_are_lowercase() { + assert_eq!( + help_url("USR-01"), + "https://compositecode.github.io/substratalapps-applications-specs/help/screens/usr-01.html" + ); + } +} diff --git a/crates/substratal-core/src/error.rs b/crates/substratal-core/src/error.rs new file mode 100644 index 0000000..1d21251 --- /dev/null +++ b/crates/substratal-core/src/error.rs @@ -0,0 +1,225 @@ +//! Errors, decoded from the API's `{ "error": { code, message, details } }` envelope. + +use std::time::Duration; + +use serde::Deserialize; +use serde_json::Value; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error(transparent)] + Api(Box), + #[error("couldn't reach the Substratal API: {0}")] + Network(String), + #[error("unexpected response from the API: {0}")] + Decode(String), + #[error("you're signed out")] + SignedOut, + #[error("this version of the app is no longer supported by the API")] + UpdateRequired { supported: Vec }, + #[error("secure storage: {0}")] + Secrets(String), +} + +impl From for Error { + fn from(e: ApiError) -> Self { + Error::Api(Box::new(e)) + } +} + +impl Error { + /// The API error code, when there is one (e.g. `version_conflict`). + pub fn code(&self) -> Option<&str> { + match self { + Error::Api(e) => Some(&e.code), + _ => None, + } + } + + pub fn status(&self) -> Option { + match self { + Error::Api(e) => Some(e.status), + _ => None, + } + } + + pub fn request_id(&self) -> Option<&str> { + match self { + Error::Api(e) => e.request_id.as_deref(), + _ => None, + } + } +} + +/// One decoded API error. `message` is for the "Details" disclosure only; +/// user-facing text comes from [`ApiError::user_message`] (COPY-AND-STATES). +#[derive(Debug, Clone, thiserror::Error)] +#[error("{status} {code}: {message}")] +pub struct ApiError { + pub status: u16, + pub code: String, + pub message: String, + pub details: Value, + pub request_id: Option, + pub retry_after: Option, +} + +#[derive(Deserialize)] +struct Envelope { + error: Body, +} + +#[derive(Deserialize)] +struct Body { + code: String, + #[serde(default)] + message: String, + #[serde(default)] + details: Value, +} + +impl ApiError { + pub fn from_body( + status: u16, + body: &[u8], + request_id: Option, + retry_after: Option, + ) -> Self { + match serde_json::from_slice::(body) { + Ok(env) => ApiError { + status, + code: env.error.code, + message: env.error.message, + details: env.error.details, + request_id, + retry_after, + }, + Err(_) => ApiError { + status, + code: fallback_code(status).into(), + message: String::from_utf8_lossy(body).chars().take(300).collect(), + details: Value::Null, + request_id, + retry_after, + }, + } + } + + /// `details.fields[]` as `(field, code)` pairs, for inline form errors. + pub fn field_errors(&self) -> Vec<(String, String)> { + self.details + .get("fields") + .and_then(Value::as_array) + .map(|fields| { + fields + .iter() + .filter_map(|f| { + Some(( + f.get("field")?.as_str()?.to_string(), + f.get("code")?.as_str()?.to_string(), + )) + }) + .collect() + }) + .unwrap_or_default() + } + + /// `details.supported` on version errors. + pub fn supported_versions(&self) -> Vec { + self.details + .get("supported") + .and_then(Value::as_array) + .map(|v| { + v.iter() + .filter_map(|s| s.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default() + } + + /// The person-facing message for this error (COPY-AND-STATES → Errors). + pub fn user_message(&self) -> String { + let text = match self.code.as_str() { + "invalid_credentials" => "Email or password is incorrect.", + "invalid_mfa_code" => { + "That code didn't work. Check your authenticator app and try again." + } + "user_suspended" => "This account is suspended. Contact your administrator.", + "test_user_expired" => "This test account has expired.", + "unauthenticated" | "session_revoked" => "Your session ended. Sign in again.", + "forbidden" => "You don't have permission to do this.", + "tenant_suspended" => "This organization's tenant is suspended, so changes are paused.", + "not_found" => "This item doesn't exist or was removed.", + "version_conflict" => "Someone else changed this while you were editing.", + "idempotency_key_in_flight" => { + "Still working on your last request. Try again in a second." + } + "entitlement_platform_managed" => { + "Managed by Substratal — you can only turn it off or back on." + } + "rate_limited" => "You're doing that too often. Try again shortly.", + "service_unavailable" => { + "Substratal is temporarily unavailable. Try again in a moment." + } + "not_implemented" => "This feature isn't available yet.", + "validation_failed" => "Some fields need attention.", + "api_version_required" | "unsupported_api_version" | "method_not_allowed" => { + "Something went wrong in the app. Try again, and update the app if it keeps happening." + } + _ => match self.status { + 401 => "Your session ended. Sign in again.", + 403 => "You don't have permission to do this.", + 404 => "This item doesn't exist or was removed.", + 409 => "This conflicts with a recent change. Reload and try again.", + 429 => "You're doing that too often. Try again shortly.", + s if s >= 500 => "Something went wrong on our side. Try again in a moment.", + _ => "Something went wrong. Try again.", + }, + }; + text.to_string() + } +} + +fn fallback_code(status: u16) -> &'static str { + match status { + 400 => "bad_request", + 401 => "unauthenticated", + 403 => "forbidden", + 404 => "not_found", + 405 => "method_not_allowed", + 409 => "conflict", + 429 => "rate_limited", + 501 => "not_implemented", + 503 => "service_unavailable", + _ => "http_error", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn decodes_envelope_and_fields() { + let body = br#"{"error":{"code":"validation_failed","message":"bad","details":{"fields":[{"field":"email","code":"invalid"}]}}}"#; + let e = ApiError::from_body(422, body, Some("req_1".into()), None); + assert_eq!(e.code, "validation_failed"); + assert_eq!(e.field_errors(), vec![("email".into(), "invalid".into())]); + assert_eq!(e.user_message(), "Some fields need attention."); + } + + #[test] + fn decodes_version_error() { + let body = br#"{"error":{"code":"api_version_required","details":{"supported":["1"]},"message":"x"}}"#; + let e = ApiError::from_body(400, body, None, None); + assert_eq!(e.supported_versions(), vec!["1".to_string()]); + } + + #[test] + fn non_json_body_falls_back() { + let e = ApiError::from_body(503, b"", None, None); + assert_eq!(e.code, "service_unavailable"); + } +} diff --git a/crates/substratal-core/src/lib.rs b/crates/substratal-core/src/lib.rs new file mode 100644 index 0000000..9a1222c --- /dev/null +++ b/crates/substratal-core/src/lib.rs @@ -0,0 +1,23 @@ +//! Platform-independent core of the Substratal Hub for Linux. +//! +//! Everything here is shared by the GTK app (`substratal-hub`) and the CLI +//! (`substratalctl`), and nothing here depends on GTK. The layering follows +//! the specs' CLIENT-ARCHITECTURE §1: screens never call HTTP directly; they +//! go through [`api::ApiClient`], which owns versioning, auth, idempotency, +//! concurrency and error decoding. + +pub mod api; +pub mod capabilities; +pub mod config; +pub mod error; +pub mod models; +pub mod nav; +pub mod screens; +pub mod secrets; +pub mod session; +pub mod shortcuts; +pub mod version; + +pub use api::ApiClient; +pub use config::Environment; +pub use error::{ApiError, Error, Result}; diff --git a/crates/substratal-core/src/models.rs b/crates/substratal-core/src/models.rs new file mode 100644 index 0000000..365449e --- /dev/null +++ b/crates/substratal-core/src/models.rs @@ -0,0 +1,165 @@ +//! Hand-written models for the operations the skeleton calls today. +//! +//! The full client will be generated from `vendor/openapi.yaml` (see +//! `planning/PLAN-management-app.md` §6.1). Until then these mirror the +//! schemas of the same names in the spec. Every struct ignores unknown +//! fields, and enums carry an `Unknown` case, because additive changes +//! (new fields, new enum values) ship within an API version +//! (CLIENT-ARCHITECTURE §4.1). + +use serde::{Deserialize, Serialize}; + +/// `AuthSession`: the response of login, MFA verify and refresh. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct AuthSession { + pub access_token: String, + pub expires_in: u64, + pub refresh_token: String, + pub session_id: String, + pub user_id: String, + #[serde(default)] + pub email_verified: Option, +} + +/// `MfaChallenge`: login succeeded but TOTP is on. +#[derive(Clone, Debug, Deserialize)] +pub struct MfaChallenge { + pub mfa_token: String, + #[serde(default)] + pub methods: Vec, + pub expires_in: u64, +} + +/// `POST /auth/login` answers with one of these. +#[derive(Clone, Debug)] +pub enum LoginOutcome { + Session(AuthSession), + MfaRequired(MfaChallenge), +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum UserStatus { + Active, + Invited, + Suspended, + Deleted, + #[default] + #[serde(other)] + Unknown, +} + +impl UserStatus { + /// Key into `status-semantics.json`. + pub fn key(self) -> &'static str { + match self { + UserStatus::Active => "active", + UserStatus::Invited => "invited", + UserStatus::Suspended => "suspended", + UserStatus::Deleted => "deleted", + UserStatus::Unknown => "unknown", + } + } +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Plan { + Free, + Standard, + #[default] + #[serde(other)] + Unknown, +} + +/// `User` (the fields the shell and USR-01 use). +#[derive(Clone, Debug, Deserialize)] +pub struct User { + pub id: String, + pub email: String, + #[serde(default)] + pub status: UserStatus, + #[serde(default)] + pub mfa_enabled: bool, + #[serde(default)] + pub email_verified: bool, + #[serde(default)] + pub test_mode: bool, + #[serde(default)] + pub plan: Plan, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub last_login_at: Option, +} + +/// `PageInfo`. +#[derive(Clone, Debug, Default, Deserialize)] +pub struct PageInfo { + pub next_cursor: Option, + pub has_more: bool, +} + +/// Any `*Page` envelope: `{ data: [...], page: { next_cursor, has_more } }`. +#[derive(Clone, Debug, Deserialize)] +pub struct Page { + pub data: Vec, + #[serde(default)] + pub page: PageInfo, +} + +/// `GET /health`. +#[derive(Clone, Debug, Deserialize)] +pub struct Health { + pub status: String, +} + +/// A platform `Role` as listed by `GET /roles?scope=platform`. +#[derive(Clone, Debug, Deserialize)] +pub struct Role { + pub id: String, + #[serde(default)] + pub name: String, + #[serde(default)] + pub permissions: Vec, +} + +/// `UserRoleAssignment`: one row of `GET /users/{id}/roles`. +#[derive(Clone, Debug, Deserialize)] +pub struct RoleAssignment { + pub role_id: String, + #[serde(default)] + pub application_id: Option, + #[serde(default)] + pub implicit: bool, +} + +/// `ApplicationSummary` (enough to know which apps a person owns). +#[derive(Clone, Debug, Deserialize)] +pub struct ApplicationSummary { + pub id: String, + #[serde(default)] + pub name: String, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unknown_enum_values_and_fields_are_tolerated() { + let u: User = serde_json::from_str( + r#"{"id":"usr_1","email":"a@b.c","status":"frozen","plan":"enterprise","new_field":1}"#, + ) + .unwrap(); + assert_eq!(u.status, UserStatus::Unknown); + assert_eq!(u.plan, Plan::Unknown); + } + + #[test] + fn page_envelope() { + let p: Page = serde_json::from_str(r#"{"data":[{"id":"usr_1","email":"a@b.c","status":"active"}],"page":{"next_cursor":null,"has_more":false}}"#).unwrap(); + assert_eq!(p.data.len(), 1); + assert!(!p.page.has_more); + } +} diff --git a/crates/substratal-core/src/nav.rs b/crates/substratal-core/src/nav.rs new file mode 100644 index 0000000..b998d5e --- /dev/null +++ b/crates/substratal-core/src/nav.rs @@ -0,0 +1,345 @@ +//! The navigation tree (INFORMATION-ARCHITECTURE §1) and its visibility +//! rules (§2). Section names, order and icon meanings are identical in every +//! Hub app; the icon column here is the Adwaita one (§5). + +use crate::capabilities::{Capabilities, flag, perm}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Section { + Personal, + Administration, + Operations, + Developer, +} + +impl Section { + pub fn title(self) -> &'static str { + match self { + Section::Personal => "Personal", + Section::Administration => "Administration", + Section::Operations => "Operations", + Section::Developer => "Developer", + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct NavItem { + pub section: Section, + pub title: &'static str, + pub screen_id: &'static str, + pub route: &'static str, + pub icon: &'static str, + /// Web-only items (Console, Reports, Integrations) are listed so the shared + /// help center can describe the whole tree; Linux never shows them. + pub web_only: bool, + visible: fn(&Capabilities) -> bool, +} + +impl NavItem { + pub fn is_visible(&self, caps: &Capabilities) -> bool { + !self.web_only && (self.visible)(caps) + } + + /// The `Alt+N` shortcut for the six Personal destinations (PLATFORM-SPEC §3). + pub fn alt_shortcut(&self) -> Option { + PERSONAL_SHORTCUTS + .iter() + .position(|id| *id == self.screen_id) + .map(|i| i as u8 + 1) + } +} + +const PERSONAL_SHORTCUTS: [&str; 6] = ["APP-01", "NTF-01", "ORG-01", "ACC-01", "BIL-01", "SET-01"]; + +macro_rules! item { + ($section:ident, $title:expr, $id:expr, $route:expr, $icon:expr, $vis:expr) => { + NavItem { + section: Section::$section, + title: $title, + screen_id: $id, + route: $route, + icon: $icon, + web_only: false, + visible: $vis, + } + }; + (web $section:ident, $title:expr, $id:expr, $route:expr, $icon:expr) => { + NavItem { + section: Section::$section, + title: $title, + screen_id: $id, + route: $route, + icon: $icon, + web_only: true, + visible: |_| false, + } + }; +} + +pub static TREE: &[NavItem] = &[ + item!( + Personal, + "Home", + "APP-01", + "/", + "view-app-grid-symbolic", + |_| true + ), + item!( + Personal, + "Inbox", + "NTF-01", + "/inbox", + "mail-inbox-symbolic", + |c| c.flag(flag::NOTIFICATIONS) + ), + item!( + Personal, + "Organizations", + "ORG-01", + "/orgs", + "system-users-symbolic", + |c| c.flag(flag::ORGANIZATIONS) + ), + item!( + Personal, + "Account", + "ACC-01", + "/account", + "avatar-default-symbolic", + |_| true + ), + item!( + Personal, + "Plan & Billing", + "BIL-01", + "/billing", + "wallet-symbolic", + |c| c.flag(flag::BILLING) + ), + item!( + Personal, + "Settings", + "SET-01", + "/settings", + "emblem-system-symbolic", + |_| true + ), + item!( + Administration, + "Users", + "USR-01", + "/admin/users", + "contact-new-symbolic", + |c| c.can_list_users() + ), + item!( + Administration, + "Access", + "ACX-05", + "/admin/access", + "object-select-symbolic", + |c| c.can_manage_entitlements() || c.owns_app() + ), + item!( + Administration, + "Roles", + "ROL-01", + "/admin/roles", + "security-high-symbolic", + |c| c.can_list_users() || c.can_manage_roles() + ), + item!( + Administration, + "Audit Log", + "AUD-01", + "/admin/audit", + "document-open-recent-symbolic", + |c| c.can_view_audit() + ), + item!(web Administration, "Reports", "RPT-01", "/admin/reports", "x-office-spreadsheet-symbolic"), + item!( + Administration, + "Send notification", + "NTF-06", + "/admin/notifications/new", + "bell-symbolic", + |c| c.can_send_notifications() && c.flag(flag::SEND_NOTIFICATIONS) + ), + item!(web Operations, "Overview", "OPS-01", "/ops", "speedometer-symbolic"), + item!(web Operations, "Traffic & latency", "OPS-02", "/ops/traffic", "view-statistics-symbolic"), + item!(web Operations, "Logs", "OPS-03", "/ops/logs", "system-search-symbolic"), + item!(web Operations, "Security events", "SEC-01", "/ops/security", "security-medium-symbolic"), + item!(web Operations, "Alarms", "OPS-04", "/ops/alarms", "dialog-warning-symbolic"), + item!(web Operations, "Jobs & delivery", "OPS-05", "/ops/jobs", "alarm-symbolic"), + item!(web Operations, "Releases", "OPS-06", "/ops/releases", "software-update-available-symbolic"), + item!(web Operations, "Cost", "OPS-07", "/ops/cost", "money-symbolic"), + item!(web Operations, "Configuration", "CFG-01", "/ops/config", "preferences-other-symbolic"), + item!( + Developer, + "Applications", + "APL-01", + "/dev/apps", + "application-x-addon-symbolic", + |c| c.owns_app() || c.has(perm::APPLICATIONS_MANAGE) + ), + item!( + Developer, + "API Keys", + "KEY-01", + "/dev/keys", + "dialog-password-symbolic", + |c| c.owns_app() || c.has(perm::API_KEYS_MANAGE) + ), + item!( + Developer, + "Webhooks", + "WHK-01", + "/dev/webhooks", + "network-transmit-receive-symbolic", + |c| (c.owns_app() || c.has(perm::WEBHOOKS_MANAGE)) && c.flag(flag::WEBHOOKS) + ), + item!(web Developer, "Integrations", "INT-01", "/dev/integrations", "application-x-addon-symbolic"), + item!( + Developer, + "Tenant", + "BIL-04", + "/dev/tenant", + "network-server-symbolic", + |c| !c.owned_tenants.is_empty() || c.can_manage_tenants() + ), +]; + +/// Sections in sidebar order, with their visible items. A section with no +/// visible item, or whose section-level rule fails, is omitted. +pub fn visible_sections(caps: &Capabilities) -> Vec<(Section, Vec<&'static NavItem>)> { + let mut out = Vec::new(); + for section in [ + Section::Personal, + Section::Administration, + Section::Developer, + ] { + let section_ok = match section { + Section::Personal => true, + Section::Administration => caps.shows_administration(), + Section::Developer => caps.shows_developer(), + Section::Operations => false, + }; + if !section_ok { + continue; + } + let items: Vec<_> = TREE + .iter() + .filter(|i| i.section == section && i.is_visible(caps)) + .collect(); + if !items.is_empty() { + out.push((section, items)); + } + } + out +} + +/// The nav item a screen belongs to (for highlighting the sidebar row on +/// detail screens), matched by route prefix. +pub fn item_for_route(route: &str) -> Option<&'static NavItem> { + TREE.iter() + .filter(|i| !i.web_only) + .filter(|i| { + route == i.route || (i.route != "/" && route.starts_with(&format!("{}/", i.route))) + }) + .max_by_key(|i| i.route.len()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::capabilities::personas; + use crate::screens; + + fn titles(c: &Capabilities) -> Vec<&'static str> { + visible_sections(c) + .into_iter() + .flat_map(|(_, items)| items.into_iter().map(|i| i.title)) + .collect() + } + + #[test] + fn every_item_points_at_a_real_screen_and_route() { + for item in TREE { + let s = screens::get(item.screen_id) + .unwrap_or_else(|| panic!("{} missing", item.screen_id)); + assert_eq!(s.route, item.route, "{}", item.screen_id); + assert_eq!(item.web_only, !s.on_linux(), "{}", item.screen_id); + } + } + + #[test] + fn end_user_sees_personal_only() { + let c = personas::end_user(); + assert_eq!( + titles(&c), + [ + "Home", + "Inbox", + "Organizations", + "Account", + "Plan & Billing", + "Settings" + ] + ); + } + + #[test] + fn app_owner_gets_confined_access_and_developer() { + let t = titles(&personas::app_owner()); + assert!(t.contains(&"Access") && !t.contains(&"Users") && !t.contains(&"Audit Log")); + assert!( + t.contains(&"Applications") + && t.contains(&"API Keys") + && t.contains(&"Webhooks") + && t.contains(&"Tenant") + ); + } + + #[test] + fn support_and_billing_admin() { + let s = titles(&personas::support()); + assert!( + s.contains(&"Users") + && s.contains(&"Access") + && s.contains(&"Audit Log") + && s.contains(&"Tenant") + ); + assert!(!s.contains(&"Send notification") && !s.contains(&"API Keys")); + let b = titles(&personas::billing_admin()); + assert!(b.contains(&"Users") && b.contains(&"Roles") && b.contains(&"Audit Log")); + assert!(!b.contains(&"Access") && !b.contains(&"Applications")); + } + + #[test] + fn superadmin_sees_everything_but_web_only() { + let t = titles(&personas::superadmin()); + assert_eq!(t.len(), TREE.iter().filter(|i| !i.web_only).count()); + assert!(!t.contains(&"Overview") && !t.contains(&"Reports")); + } + + #[test] + fn flags_hide_items() { + let mut c = personas::superadmin(); + c.disable_flag(flag::NOTIFICATIONS); + c.disable_flag(flag::WEBHOOKS); + let t = titles(&c); + assert!(!t.contains(&"Inbox") && !t.contains(&"Webhooks")); + } + + #[test] + fn route_to_item() { + assert_eq!( + item_for_route("/admin/users/usr_1").unwrap().screen_id, + "USR-01" + ); + assert_eq!(item_for_route("/").unwrap().screen_id, "APP-01"); + assert_eq!(item_for_route("/apps/past").map(|i| i.screen_id), None); + } +} diff --git a/crates/substratal-core/src/screens.rs b/crates/substratal-core/src/screens.rs new file mode 100644 index 0000000..9abd184 --- /dev/null +++ b/crates/substratal-core/src/screens.rs @@ -0,0 +1,242 @@ +//! The screen registry: every screen in the specs, embedded at build time. +//! +//! `data/spec/screens.json` is generated from the specs package by +//! `scripts/sync-specs.py` and never edited by hand. The app builds its +//! scaffold pages from it, the router matches routes against it, and the +//! help center generates one page per screen from it, so all three agree. + +use std::sync::OnceLock; + +use serde::Deserialize; + +static RAW: &str = include_str!("../../../data/spec/screens.json"); + +#[derive(Clone, Debug, Deserialize)] +pub struct Element { + pub name: String, + pub kind: String, + /// The GTK/libadwaita control the Linux spec names (empty for web-only screens). + #[serde(default)] + pub control: String, + #[serde(default)] + pub data: String, + #[serde(default)] + pub rules: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Action { + pub action: String, + #[serde(default)] + pub call: String, + #[serde(default)] + pub result: String, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct Screen { + pub id: String, + pub title: String, + pub section_heading: Option, + pub domain: String, + pub route: String, + pub archetype: String, + pub phase: String, + pub who: String, + pub purpose: String, + pub loads: String, + pub states: String, + pub notes: String, + pub entry_points: String, + pub container: String, + pub elements: Vec, + pub actions: Vec, + pub platforms: Vec, +} + +impl Screen { + /// False for the web-only Console screens (OPEN-QUESTIONS #27). + pub fn on_linux(&self) -> bool { + self.platforms.iter().any(|p| p == "all" || p == "linux") + } + + /// Screens outside the app shell (AUTH-*). + pub fn is_auth(&self) -> bool { + self.archetype == "auth" + } + + /// The route with `:params` substituted, if all are given. + pub fn path_with(&self, params: &[(&str, &str)]) -> String { + let mut out = self.route.clone(); + for (k, v) in params { + out = out.replace(&format!(":{k}"), v); + } + out + } + + pub fn help_url(&self) -> String { + crate::config::help_url(&self.id) + } +} + +#[derive(Debug, Deserialize)] +struct Registry { + commit: String, + screens: Vec, +} + +fn registry() -> &'static Registry { + static REG: OnceLock = OnceLock::new(); + REG.get_or_init(|| serde_json::from_str(RAW).expect("data/spec/screens.json is valid")) +} + +/// Every screen, in spec order. +pub fn all() -> &'static [Screen] { + ®istry().screens +} + +/// The specs commit the registry was generated from. +pub fn spec_commit() -> &'static str { + ®istry().commit +} + +pub fn get(id: &str) -> Option<&'static Screen> { + all().iter().find(|s| s.id.eq_ignore_ascii_case(id)) +} + +/// A matched route: the screen and its `:params`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RouteMatch { + pub screen_id: String, + pub params: Vec<(String, String)>, +} + +/// Matches a path (with or without query) against every route, preferring +/// the most specific (fewest parameters). Only routes of screens available +/// on Linux match, unless `include_web` is set. +pub fn match_route(path: &str, include_web: bool) -> Option { + let path = path.split(['?', '#']).next().unwrap_or(""); + let want: Vec<&str> = path + .trim_matches('/') + .split('/') + .filter(|s| !s.is_empty()) + .collect(); + let mut best: Option<(usize, RouteMatch)> = None; + for s in all() { + if !s.route.starts_with('/') || (!include_web && !s.on_linux()) { + continue; + } + let pattern: Vec<&str> = s + .route + .trim_matches('/') + .split('/') + .filter(|x| !x.is_empty()) + .collect(); + if pattern.len() != want.len() { + continue; + } + let mut params = Vec::new(); + let ok = pattern.iter().zip(&want).all(|(p, w)| { + if let Some(name) = p.strip_prefix(':') { + params.push((name.to_string(), (*w).to_string())); + true + } else { + p == w + } + }); + if ok { + let score = params.len(); + if best.as_ref().is_none_or(|(b, _)| score < *b) { + best = Some(( + score, + RouteMatch { + screen_id: s.id.clone(), + params, + }, + )); + } + } + } + best.map(|(_, m)| m) +} + +/// Parses a `substratal://` deep link (or a dashboard URL) into a route. +/// Deep links are untrusted input (CLIENT-ARCHITECTURE §11): only known +/// routes are accepted. +pub fn match_link(link: &str) -> Option { + let rest = link + .strip_prefix("substratal://") + .map(|r| format!("/{r}")) + .or_else(|| { + link.strip_prefix(crate::config::DASHBOARD) + .map(str::to_string) + })?; + match_route(&rest, false) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_has_every_screen() { + assert_eq!(all().iter().filter(|s| s.on_linux()).count(), 75); + assert!( + all() + .iter() + .filter(|s| !s.on_linux()) + .all(|s| s.phase.contains("web only")) + ); + let mut ids: Vec<_> = all().iter().map(|s| s.id.as_str()).collect(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), all().len(), "screen ids are unique"); + } + + #[test] + fn routes_match_most_specific() { + assert_eq!( + match_route("/admin/users", false).unwrap().screen_id, + "USR-01" + ); + let m = match_route("/admin/users/usr_123", false).unwrap(); + assert_eq!(m.screen_id, "USR-03"); + assert_eq!( + m.params, + vec![("userId".to_string(), "usr_123".to_string())] + ); + assert_eq!( + match_route("/apps/past", false).unwrap().screen_id, + "APP-05" + ); + assert_eq!(match_route("/", false).unwrap().screen_id, "APP-01"); + assert_eq!( + match_route("/admin/users?status=active", false) + .unwrap() + .screen_id, + "USR-01" + ); + } + + #[test] + fn web_only_routes_need_opt_in() { + assert!(match_route("/ops/logs", false).is_none()); + assert_eq!(match_route("/ops/logs", true).unwrap().screen_id, "OPS-03"); + } + + #[test] + fn deep_links() { + assert_eq!( + match_link("substratal://admin/roles").unwrap().screen_id, + "ROL-01" + ); + assert_eq!( + match_link("https://dashboard.substratalapps.com/billing") + .unwrap() + .screen_id, + "BIL-01" + ); + assert!(match_link("https://evil.example/admin/roles").is_none()); + assert!(match_link("substratal://nope/nothing").is_none()); + } +} diff --git a/crates/substratal-core/src/secrets.rs b/crates/substratal-core/src/secrets.rs new file mode 100644 index 0000000..5812f2c --- /dev/null +++ b/crates/substratal-core/src/secrets.rs @@ -0,0 +1,138 @@ +//! Secure storage for the refresh token (CLIENT-ARCHITECTURE §3). +//! +//! Only the platform refresh token is ever persisted, in the Secret Service +//! (GNOME Keyring, KWallet, or the Secret portal inside the Flatpak). Access +//! tokens live in memory. API key secrets, webhook secrets, SCIM tokens and +//! recovery codes are never stored anywhere. + +use std::collections::HashMap; +use std::sync::Mutex; + +#[cfg(feature = "keyring")] +use crate::error::Error; +use crate::error::Result; + +const APP_ATTR: &str = "com.substratalapps.Hub"; + +pub enum SecretStore { + /// Process memory only. Used by tests, by `substratalctl --no-keyring`, + /// and when no Secret Service is reachable (the person signs in each launch). + Memory(Mutex>), + #[cfg(feature = "keyring")] + Keyring(oo7::Keyring), +} + +impl SecretStore { + pub fn memory() -> Self { + SecretStore::Memory(Mutex::new(HashMap::new())) + } + + /// The Secret Service, falling back to memory when it isn't available. + pub async fn system() -> Self { + #[cfg(feature = "keyring")] + { + match oo7::Keyring::new().await { + Ok(k) => return SecretStore::Keyring(k), + Err(e) => eprintln!( + "substratal: Secret Service unavailable ({e}); credentials won't be remembered" + ), + } + } + SecretStore::memory() + } + + pub fn is_persistent(&self) -> bool { + !matches!(self, SecretStore::Memory(_)) + } + + /// Stores `value` under `name` for one environment (production, local, …). + pub async fn set(&self, env: &str, name: &str, value: &str) -> Result<()> { + match self { + SecretStore::Memory(m) => { + m.lock() + .unwrap() + .insert(format!("{env}/{name}"), value.to_string()); + Ok(()) + } + #[cfg(feature = "keyring")] + SecretStore::Keyring(k) => { + let attrs = attributes(env, name); + k.create_item(&format!("Substratal ({env}) {name}"), &attrs, value, true) + .await + .map_err(|e| Error::Secrets(e.to_string())) + } + } + } + + pub async fn get(&self, env: &str, name: &str) -> Result> { + match self { + SecretStore::Memory(m) => Ok(m.lock().unwrap().get(&format!("{env}/{name}")).cloned()), + #[cfg(feature = "keyring")] + SecretStore::Keyring(k) => { + let items = k + .search_items(&attributes(env, name)) + .await + .map_err(|e| Error::Secrets(e.to_string()))?; + let Some(item) = items.first() else { + return Ok(None); + }; + let secret = item + .secret() + .await + .map_err(|e| Error::Secrets(e.to_string()))?; + Ok(Some(String::from_utf8_lossy(&secret).into_owned())) + } + } + } + + pub async fn delete(&self, env: &str, name: &str) -> Result<()> { + match self { + SecretStore::Memory(m) => { + m.lock().unwrap().remove(&format!("{env}/{name}")); + Ok(()) + } + #[cfg(feature = "keyring")] + SecretStore::Keyring(k) => k + .delete(&attributes(env, name)) + .await + .map_err(|e| Error::Secrets(e.to_string())), + } + } +} + +#[cfg(feature = "keyring")] +fn attributes<'a>(env: &'a str, name: &'a str) -> HashMap<&'a str, &'a str> { + HashMap::from([ + ("application", APP_ATTR), + ("environment", env), + ("name", name), + ]) +} + +#[allow(dead_code)] +const _: &str = APP_ATTR; + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn memory_round_trip_is_per_environment() { + let s = SecretStore::memory(); + s.set("production", "refresh_token", "rtk_a").await.unwrap(); + s.set("local", "refresh_token", "rtk_b").await.unwrap(); + assert_eq!( + s.get("production", "refresh_token") + .await + .unwrap() + .as_deref(), + Some("rtk_a") + ); + s.delete("production", "refresh_token").await.unwrap(); + assert_eq!(s.get("production", "refresh_token").await.unwrap(), None); + assert_eq!( + s.get("local", "refresh_token").await.unwrap().as_deref(), + Some("rtk_b") + ); + } +} diff --git a/crates/substratal-core/src/session.rs b/crates/substratal-core/src/session.rs new file mode 100644 index 0000000..78deed4 --- /dev/null +++ b/crates/substratal-core/src/session.rs @@ -0,0 +1,209 @@ +//! The platform session (CLIENT-ARCHITECTURE §3). +//! +//! Rules this enforces: +//! 1. **Single-flight refresh.** Concurrent `401`s wait on one refresh; a +//! refresh token is never used twice (reuse revokes the whole session and +//! raises a `security.session_reuse_detected` alert). +//! 2. The rotated refresh token is persisted **before** the new access token +//! is used. +//! 3. Access tokens live in memory only. + +use std::time::{Duration, Instant}; + +use tokio::sync::Mutex; + +use crate::models::AuthSession; +use crate::secrets::SecretStore; + +pub const REFRESH_TOKEN: &str = "refresh_token"; +/// Refresh proactively this long before the access token expires. +pub const REFRESH_MARGIN: Duration = Duration::from_secs(60); + +#[derive(Clone, Debug)] +pub struct Tokens { + pub access_token: String, + pub refresh_token: String, + pub expires_at: Instant, + pub session_id: String, + pub user_id: String, +} + +impl Tokens { + pub fn from_session(s: &AuthSession, now: Instant) -> Self { + Tokens { + access_token: s.access_token.clone(), + refresh_token: s.refresh_token.clone(), + expires_at: now + Duration::from_secs(s.expires_in), + session_id: s.session_id.clone(), + user_id: s.user_id.clone(), + } + } + + pub fn needs_refresh(&self, now: Instant) -> bool { + now + REFRESH_MARGIN >= self.expires_at + } +} + +pub struct SessionStore { + env_key: String, + secrets: SecretStore, + tokens: Mutex>, + /// Held for the whole refresh round-trip: this is the single flight. + refresh_gate: Mutex<()>, +} + +impl SessionStore { + pub fn new(env_key: String, secrets: SecretStore) -> Self { + SessionStore { + env_key, + secrets, + tokens: Mutex::new(None), + refresh_gate: Mutex::new(()), + } + } + + pub fn secrets(&self) -> &SecretStore { + &self.secrets + } + + pub async fn current(&self) -> Option { + self.tokens.lock().await.clone() + } + + pub async fn is_signed_in(&self) -> bool { + self.tokens.lock().await.is_some() + } + + /// Stores a fresh session: refresh token to secure storage first, then + /// the tokens in memory. + pub async fn establish(&self, session: &AuthSession) -> crate::Result { + self.secrets + .set(&self.env_key, REFRESH_TOKEN, &session.refresh_token) + .await?; + let tokens = Tokens::from_session(session, Instant::now()); + *self.tokens.lock().await = Some(tokens.clone()); + Ok(tokens) + } + + /// The refresh token to restore a session at launch, if one was saved. + pub async fn saved_refresh_token(&self) -> crate::Result> { + self.secrets.get(&self.env_key, REFRESH_TOKEN).await + } + + /// Wipes credentials (sign out, `session_revoked`, failed refresh). + pub async fn clear(&self) -> crate::Result<()> { + *self.tokens.lock().await = None; + self.secrets.delete(&self.env_key, REFRESH_TOKEN).await + } + + /// Runs `refresh` at most once for concurrent callers. `seen_access` is + /// the access token the caller was using when it got a `401`: if another + /// caller already replaced it, the new tokens are returned without a + /// second refresh. + pub async fn refresh_single_flight( + &self, + seen_access: Option<&str>, + refresh: F, + ) -> crate::Result + where + F: FnOnce(String) -> Fut, + Fut: std::future::Future>, + { + let _gate = self.refresh_gate.lock().await; + if let Some(t) = self.current().await { + let replaced = seen_access.is_some_and(|seen| seen != t.access_token); + if replaced || !t.needs_refresh(Instant::now()) && seen_access.is_none() { + return Ok(t); + } + } + let refresh_token = match self.current().await { + Some(t) => t.refresh_token, + None => self + .saved_refresh_token() + .await? + .ok_or(crate::Error::SignedOut)?, + }; + match refresh(refresh_token).await { + Ok(session) => self.establish(&session).await, + Err(e) => { + if matches!(e.status(), Some(401) | Some(403)) { + self.clear().await?; + } + Err(e) + } + } + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + + fn session(n: usize) -> AuthSession { + AuthSession { + access_token: format!("at_{n}"), + expires_in: 900, + refresh_token: format!("rtk_{n}"), + session_id: "ses_1".into(), + user_id: "usr_1".into(), + email_verified: Some(true), + } + } + + #[tokio::test] + async fn concurrent_401s_refresh_once() { + let store = Arc::new(SessionStore::new("test".into(), SecretStore::memory())); + store.establish(&session(0)).await.unwrap(); + let calls = Arc::new(AtomicUsize::new(0)); + let mut handles = Vec::new(); + for _ in 0..8 { + let store = store.clone(); + let calls = calls.clone(); + handles.push(tokio::spawn(async move { + store + .refresh_single_flight(Some("at_0"), |rt| { + let calls = calls.clone(); + async move { + assert_eq!(rt, "rtk_0", "a refresh token must never be reused"); + let n = calls.fetch_add(1, Ordering::SeqCst) + 1; + tokio::time::sleep(Duration::from_millis(20)).await; + Ok(session(n)) + } + }) + .await + .unwrap() + })); + } + for h in handles { + assert_eq!(h.await.unwrap().access_token, "at_1"); + } + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!( + store.saved_refresh_token().await.unwrap().as_deref(), + Some("rtk_1") + ); + } + + #[tokio::test] + async fn failed_refresh_clears_credentials() { + let store = SessionStore::new("test".into(), SecretStore::memory()); + store.establish(&session(0)).await.unwrap(); + let err = store + .refresh_single_flight(Some("at_0"), |_| async { + Err(crate::Error::from(crate::ApiError::from_body( + 401, + br#"{"error":{"code":"session_revoked","message":""}}"#, + None, + None, + ))) + }) + .await + .unwrap_err(); + assert_eq!(err.code(), Some("session_revoked")); + assert!(!store.is_signed_in().await); + assert_eq!(store.saved_refresh_token().await.unwrap(), None); + } +} diff --git a/crates/substratal-core/src/shortcuts.rs b/crates/substratal-core/src/shortcuts.rs new file mode 100644 index 0000000..219a8f7 --- /dev/null +++ b/crates/substratal-core/src/shortcuts.rs @@ -0,0 +1,183 @@ +//! Keyboard shortcuts (linux/PLATFORM-SPEC.md §3). +//! +//! One table drives the app's `GtkShortcutController`s, its Keyboard +//! Shortcuts dialog, and the help center's shortcuts page. + +#[derive(Clone, Copy, Debug)] +pub struct Shortcut { + /// GTK accelerator syntax, e.g. `k`. + pub accels: &'static [&'static str], + /// The `GAction` it triggers (`app.` or `win.`), if it maps to one. + pub action: Option<&'static str>, + pub label: &'static str, + pub group: &'static str, +} + +pub static SHORTCUTS: &[Shortcut] = &[ + Shortcut { + accels: &["k"], + action: Some("win.command-palette"), + label: "Command palette", + group: "General", + }, + Shortcut { + accels: &["f"], + action: Some("win.search"), + label: "Search", + group: "General", + }, + Shortcut { + accels: &["F1"], + action: Some("win.help"), + label: "Help for this screen", + group: "General", + }, + Shortcut { + accels: &["comma"], + action: Some("win.preferences"), + label: "Preferences", + group: "General", + }, + Shortcut { + accels: &["question"], + action: Some("win.show-shortcuts"), + label: "Keyboard shortcuts", + group: "General", + }, + Shortcut { + accels: &["w"], + action: Some("window.close"), + label: "Close window", + group: "General", + }, + Shortcut { + accels: &["q"], + action: Some("app.quit"), + label: "Quit", + group: "General", + }, + Shortcut { + accels: &["1"], + action: Some("win.go::APP-01"), + label: "Home", + group: "Navigation", + }, + Shortcut { + accels: &["2"], + action: Some("win.go::NTF-01"), + label: "Inbox", + group: "Navigation", + }, + Shortcut { + accels: &["3"], + action: Some("win.go::ORG-01"), + label: "Organizations", + group: "Navigation", + }, + Shortcut { + accels: &["4"], + action: Some("win.go::ACC-01"), + label: "Account", + group: "Navigation", + }, + Shortcut { + accels: &["5"], + action: Some("win.go::BIL-01"), + label: "Plan & Billing", + group: "Navigation", + }, + Shortcut { + accels: &["6"], + action: Some("win.go::SET-01"), + label: "Settings", + group: "Navigation", + }, + Shortcut { + accels: &["Left"], + action: Some("win.back"), + label: "Back", + group: "Navigation", + }, + Shortcut { + accels: &["F9"], + action: Some("win.toggle-sidebar"), + label: "Show or hide the sidebar", + group: "Navigation", + }, + Shortcut { + accels: &["n"], + action: Some("win.new"), + label: "New (in the current list)", + group: "Editing", + }, + Shortcut { + accels: &["r", "F5"], + action: Some("win.refresh"), + label: "Refresh", + group: "Editing", + }, + Shortcut { + accels: &["z"], + action: Some("win.undo"), + label: "Undo an access change", + group: "Editing", + }, + Shortcut { + accels: &["Delete"], + action: Some("win.delete"), + label: "Delete or turn off the selection (asks first)", + group: "Editing", + }, + Shortcut { + accels: &["u"], + action: Some("win.mark-all-read"), + label: "Mark all as read (Inbox)", + group: "Editing", + }, +]; + +/// Human-readable form of an accelerator, e.g. `u` → `Ctrl+Shift+U`. +pub fn display(accel: &str) -> String { + let mut parts = Vec::new(); + let mut rest = accel; + while let Some(stripped) = rest.strip_prefix('<') { + let end = stripped.find('>').unwrap_or(stripped.len()); + parts.push(match &stripped[..end] { + "Control" | "Primary" => "Ctrl".to_string(), + other => other.to_string(), + }); + rest = &stripped[(end + 1).min(stripped.len())..]; + } + let key = match rest { + "comma" => ",".to_string(), + "question" => "?".to_string(), + "Left" => "←".to_string(), + k if k.len() == 1 => k.to_uppercase(), + k => k.to_string(), + }; + parts.push(key); + parts.join("+") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display_accelerators() { + assert_eq!(display("u"), "Ctrl+Shift+U"); + assert_eq!(display("question"), "Ctrl+?"); + assert_eq!(display("F5"), "F5"); + assert_eq!(display("Left"), "Alt+←"); + } + + #[test] + fn no_accelerator_is_bound_twice() { + let mut seen = std::collections::HashSet::new(); + for s in SHORTCUTS { + for a in s.accels { + assert!(seen.insert(*a), "{a} bound twice"); + } + } + } +} diff --git a/crates/substratal-core/src/version.rs b/crates/substratal-core/src/version.rs new file mode 100644 index 0000000..ebc7ef5 --- /dev/null +++ b/crates/substratal-core/src/version.rs @@ -0,0 +1,89 @@ +//! API versioning (CLIENT-ARCHITECTURE §4.1, API Conventions → Versioning). +//! +//! The Platform API's version is a **request header, not part of the URL**. +//! Since API v1.7.0 (`openapi.yaml` 1.0.0-draft.4) there is no `/v1` path +//! prefix: every endpoint lives at the host root (`https://api.substratalapps.com/users`) +//! and `/v1/...` returns `404 not_found`. Each request names its version in +//! `Substratal-Version`. Each build of this app is written against exactly +//! one version, a compile-time constant that is never negotiated or read +//! from remote config. Moving to a new version is a code change shipped as +//! an app update. + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// The API version this build is written against. +pub const API_VERSION: &str = "1"; + +/// The request (and echoed response) header that carries [`API_VERSION`]. +pub const VERSION_HEADER: &str = "Substratal-Version"; + +/// Whether the server still serves this build's API version. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum VersionStatus { + /// Served and not deprecated. + #[default] + Current, + /// Responses carry `Deprecation` (RFC 9745) and usually `Sunset` (RFC 8594). + /// The UI shows GLB-03 "Update available" once a day. + Deprecated { + deprecated_at: Option, + sunset: Option, + }, + /// `400 unsupported_api_version`: the version is past its sunset (or the + /// environment is wrong). The UI stops API traffic and shows GLB-03 + /// "Update required". + Unsupported { supported: Vec }, +} + +impl VersionStatus { + pub fn is_unsupported(&self) -> bool { + matches!(self, VersionStatus::Unsupported { .. }) + } +} + +/// Parses a `Deprecation` header. RFC 9745 sends a structured-field date, +/// `@`. Earlier drafts sent `true` or an HTTP-date; accept +/// those too, since any value means "deprecated". +pub fn parse_deprecation(value: &str) -> Option { + let v = value.trim(); + if let Some(secs) = v.strip_prefix('@') { + return secs + .parse::() + .ok() + .map(|s| UNIX_EPOCH + Duration::from_secs(s)); + } + httpdate::parse_http_date(v).ok() +} + +/// Parses a `Sunset` header (an HTTP-date, RFC 8594). +pub fn parse_sunset(value: &str) -> Option { + httpdate::parse_http_date(value.trim()).ok() +} + +/// Formats a time as an HTTP-date for display and logs. +pub fn fmt_date(t: SystemTime) -> String { + httpdate::fmt_http_date(t) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn deprecation_structured_field_date() { + let t = parse_deprecation("@1688169599").unwrap(); + assert_eq!(t, UNIX_EPOCH + Duration::from_secs(1_688_169_599)); + } + + #[test] + fn deprecation_http_date_and_garbage() { + assert!(parse_deprecation("Sun, 30 Jun 2024 23:59:59 GMT").is_some()); + assert!(parse_deprecation("true").is_none()); + } + + #[test] + fn sunset_http_date() { + let t = parse_sunset("Wed, 01 Jul 2026 00:00:00 GMT").unwrap(); + assert_eq!(fmt_date(t), "Wed, 01 Jul 2026 00:00:00 GMT"); + } +} diff --git a/crates/substratal-core/tests/api_client.rs b/crates/substratal-core/tests/api_client.rs new file mode 100644 index 0000000..e8dc6e2 --- /dev/null +++ b/crates/substratal-core/tests/api_client.rs @@ -0,0 +1,316 @@ +//! Drives `ApiClient` against a stub HTTP server to check the request +//! conventions end to end: the version header, deprecation handling, +//! "update required", and single refresh on 401. + +use std::sync::{Arc, Mutex}; + +use substratal_core::api::Request; +use substratal_core::models::LoginOutcome; +use substratal_core::secrets::SecretStore; +use substratal_core::version::VersionStatus; +use substratal_core::{ApiClient, Environment, Error}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use url::Url; + +#[derive(Clone, Debug)] +struct Seen { + method: String, + path: String, + headers: Vec<(String, String)>, +} + +impl Seen { + fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + } +} + +type Handler = Arc (u16, Vec<(&'static str, String)>, String) + Send + Sync>; + +async fn stub(handler: Handler) -> (Url, Arc>>) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let seen = Arc::new(Mutex::new(Vec::new())); + let log = seen.clone(); + tokio::spawn(async move { + loop { + let Ok((mut sock, _)) = listener.accept().await else { + return; + }; + let handler = handler.clone(); + let log = log.clone(); + tokio::spawn(async move { + let mut buf = Vec::new(); + let mut chunk = [0u8; 4096]; + loop { + // Read one request (headers plus any Content-Length body). + let n = sock.read(&mut chunk).await.unwrap_or(0); + if n == 0 { + return; + } + buf.extend_from_slice(&chunk[..n]); + let Some(end) = buf.windows(4).position(|w| w == b"\r\n\r\n") else { + continue; + }; + let head = String::from_utf8_lossy(&buf[..end]).to_string(); + let mut lines = head.lines(); + let mut first = lines.next().unwrap().split(' '); + let method = first.next().unwrap().to_string(); + let path = first.next().unwrap().to_string(); + let headers: Vec<(String, String)> = lines + .filter_map(|l| { + l.split_once(':') + .map(|(k, v)| (k.trim().to_string(), v.trim().to_string())) + }) + .collect(); + let len: usize = headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("content-length")) + .map(|(_, v)| v.parse().unwrap()) + .unwrap_or(0); + if buf.len() < end + 4 + len { + continue; + } + buf.drain(..end + 4 + len); + let req = Seen { + method, + path, + headers, + }; + log.lock().unwrap().push(req.clone()); + let (status, extra, body) = handler(&req); + let mut resp = format!( + "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\n", + body.len() + ); + for (k, v) in extra { + resp.push_str(&format!("{k}: {v}\r\n")); + } + resp.push_str("\r\n"); + resp.push_str(&body); + sock.write_all(resp.as_bytes()).await.unwrap(); + } + }); + } + }); + (Url::parse(&format!("http://{addr}")).unwrap(), seen) +} + +fn client(base: Url) -> ApiClient { + ApiClient::new( + Environment::Custom(base), + SecretStore::memory(), + "Substratal-Linux/test", + "en-US", + ) + .unwrap() +} + +const SESSION: &str = r#"{"token_type":"Bearer","access_token":"at_1","expires_in":900,"refresh_token":"rtk_1","session_id":"ses_1","user_id":"usr_1"}"#; + +#[tokio::test] +async fn every_request_sends_the_version_header_at_the_host_root() { + let (base, seen) = stub(Arc::new(|_| { + ( + 200, + vec![("Substratal-Version", "1".into())], + r#"{"status":"ok"}"#.into(), + ) + })) + .await; + let api = client(base); + api.health().await.unwrap(); + let r = seen.lock().unwrap()[0].clone(); + assert_eq!(r.path, "/health", "no /v1 prefix"); + assert_eq!(r.header("substratal-version"), Some("1")); + assert_eq!(r.header("user-agent"), Some("Substratal-Linux/test")); +} + +#[tokio::test] +async fn login_then_authenticated_call_carries_bearer() { + let (base, seen) = stub(Arc::new(|r| match r.path.as_str() { + "/auth/login" => (200, vec![], SESSION.into()), + "/users/me" => ( + 200, + vec![("ETag", "\"v3\"".into())], + r#"{"id":"usr_1","email":"a@b.c","status":"active"}"#.into(), + ), + _ => ( + 404, + vec![], + r#"{"error":{"code":"not_found","message":""}}"#.into(), + ), + })) + .await; + let api = client(base); + assert!(matches!( + api.login("a@b.c", "pw").await.unwrap(), + LoginOutcome::Session(_) + )); + let me = api.me().await.unwrap(); + assert_eq!(me.data.id, "usr_1"); + assert_eq!(me.etag.as_deref(), Some("\"v3\"")); + let log = seen.lock().unwrap(); + assert_eq!(log[1].header("authorization"), Some("Bearer at_1")); + assert_eq!(log[0].header("authorization"), None, "login is anonymous"); +} + +#[tokio::test] +async fn mfa_challenge_is_returned_not_stored() { + let (base, _) = stub(Arc::new(|_| { + ( + 200, + vec![], + r#"{"mfa_required":true,"mfa_token":"mfa_x","methods":["totp"],"expires_in":300}"# + .into(), + ) + })) + .await; + let api = client(base); + match api.login("a@b.c", "pw").await.unwrap() { + LoginOutcome::MfaRequired(c) => assert_eq!(c.mfa_token, "mfa_x"), + other => panic!("{other:?}"), + } + assert!(!api.session().is_signed_in().await); +} + +#[tokio::test] +async fn deprecation_and_sunset_are_reported() { + let (base, _) = stub(Arc::new(|_| { + ( + 200, + vec![ + ("Deprecation", "@1767225600".into()), + ("Sunset", "Wed, 01 Jul 2026 00:00:00 GMT".into()), + ], + r#"{"status":"ok"}"#.into(), + ) + })) + .await; + let api = client(base); + let status = api.version_status(); + api.health().await.unwrap(); + match &*status.borrow() { + VersionStatus::Deprecated { + deprecated_at, + sunset, + } => { + assert!(deprecated_at.is_some()); + assert_eq!( + substratal_core::version::fmt_date(sunset.unwrap()), + "Wed, 01 Jul 2026 00:00:00 GMT" + ); + } + other => panic!("{other:?}"), + } +} + +#[tokio::test] +async fn unsupported_version_stops_all_traffic() { + let (base, seen) = stub(Arc::new(|_| { + (400, vec![], r#"{"error":{"code":"unsupported_api_version","message":"no","details":{"supported":["2"]}}}"#.into()) + })) + .await; + let api = client(base); + match api.health().await { + Err(Error::UpdateRequired { supported }) => assert_eq!(supported, vec!["2".to_string()]), + other => panic!("{other:?}"), + } + assert!(api.version_status().borrow().is_unsupported()); + assert!(matches!( + api.health().await, + Err(Error::UpdateRequired { .. }) + )); + assert_eq!( + seen.lock().unwrap().len(), + 1, + "no request after update-required" + ); +} + +#[tokio::test] +async fn expired_access_token_refreshes_once_and_replays() { + let (base, seen) = stub(Arc::new(|r| { + match ( + r.method.as_str(), + r.path.as_str(), + r.header("authorization"), + ) { + ("POST", "/auth/login", _) => (200, vec![], SESSION.into()), + ("POST", "/auth/token/refresh", _) => ( + 200, + vec![], + SESSION.replace("at_1", "at_2").replace("rtk_1", "rtk_2"), + ), + ("GET", "/users/me", Some("Bearer at_1")) => ( + 401, + vec![], + r#"{"error":{"code":"unauthenticated","message":""}}"#.into(), + ), + ("GET", "/users/me", Some("Bearer at_2")) => { + (200, vec![], r#"{"id":"usr_1","email":"a@b.c"}"#.into()) + } + _ => (500, vec![], "{}".into()), + } + })) + .await; + let api = client(base); + api.login("a@b.c", "pw").await.unwrap(); + assert_eq!(api.me().await.unwrap().data.id, "usr_1"); + let paths: Vec<_> = seen + .lock() + .unwrap() + .iter() + .map(|s| s.path.clone()) + .collect(); + assert_eq!( + paths, + [ + "/auth/login", + "/users/me", + "/auth/token/refresh", + "/users/me" + ] + ); + assert_eq!( + api.session() + .saved_refresh_token() + .await + .unwrap() + .as_deref(), + Some("rtk_2") + ); +} + +#[tokio::test] +async fn writes_send_idempotency_key_and_if_match() { + let (base, seen) = stub(Arc::new(|r| match r.path.as_str() { + "/auth/login" => (200, vec![], SESSION.into()), + _ => (200, vec![], "{}".into()), + })) + .await; + let api = client(base); + api.login("a@b.c", "pw").await.unwrap(); + let key = substratal_core::api::new_idempotency_key(); + api.execute_raw( + Request::post("users") + .idempotency_key(&key) + .json(&serde_json::json!({"email":"x@y.z"})), + ) + .await + .unwrap(); + api.execute_raw( + Request::patch("users/usr_1") + .if_match("\"v3\"") + .json(&serde_json::json!({})), + ) + .await + .unwrap(); + let log = seen.lock().unwrap(); + assert_eq!(log[1].header("idempotency-key"), Some(key.as_str())); + assert_eq!(log[2].header("if-match"), Some("\"v3\"")); +} diff --git a/crates/substratal-hub/Cargo.toml b/crates/substratal-hub/Cargo.toml new file mode 100644 index 0000000..5d3366b --- /dev/null +++ b/crates/substratal-hub/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "substratal-hub" +description = "Substratal Hub for Linux (GNOME): GTK 4 + libadwaita client of the Substratal Apps Platform API." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[[bin]] +name = "substratal-hub" +path = "src/main.rs" + +[dependencies] +substratal-core = { workspace = true, features = ["keyring"] } +adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] } +gtk = { package = "gtk4", version = "0.11", features = ["v4_14"] } +async-channel.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true + +[build-dependencies] +glib-build-tools = "0.22" diff --git a/crates/substratal-hub/build.rs b/crates/substratal-hub/build.rs new file mode 100644 index 0000000..941caa5 --- /dev/null +++ b/crates/substratal-hub/build.rs @@ -0,0 +1,106 @@ +//! Compiles the Blueprint UI files and bundles them, the theme CSS and the +//! brand SVGs into one GResource (`/com/substratalapps/Hub/…`). + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::{env, fs}; + +fn files(dir: &Path, ext: &str) -> Vec { + let mut out: Vec<_> = fs::read_dir(dir) + .map(|rd| { + rd.filter_map(|e| e.ok().map(|e| e.path())) + .filter(|p| p.extension().is_some_and(|x| x == ext)) + .collect() + }) + .unwrap_or_default(); + out.sort(); + out +} + +fn main() { + let manifest = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let res = manifest.join("resources"); + let out = PathBuf::from(env::var("OUT_DIR").unwrap()); + let ui_out = out.join("ui"); + fs::create_dir_all(&ui_out).unwrap(); + println!("cargo:rerun-if-changed=resources"); + + // Blueprint → GtkBuilder XML. + let blueprints = files(&res.join("ui"), "blp"); + if !blueprints.is_empty() { + let status = Command::new("blueprint-compiler") + .arg("batch-compile") + .arg(&ui_out) + .arg(res.join("ui")) + .args(&blueprints) + .status() + .expect("blueprint-compiler is required (apt install blueprint-compiler)"); + assert!(status.success(), "blueprint-compiler failed"); + } + + let mut xml = String::from( + "\n\n \n", + ); + let mut add = |alias: String, file: PathBuf, compressed: bool| { + let c = if compressed { + " compressed=\"true\"" + } else { + "" + }; + writeln!( + xml, + " {}", + file.display() + ) + .unwrap(); + }; + for bp in &blueprints { + let name = bp.file_stem().unwrap().to_string_lossy().to_string(); + add( + format!("ui/{name}.ui"), + ui_out.join(format!("{name}.ui")), + true, + ); + } + for mode in ["light", "dark"] { + for css in files(&res.join("theme").join(mode), "css") { + add( + format!( + "theme/{mode}/{}", + css.file_name().unwrap().to_string_lossy() + ), + css, + true, + ); + } + } + for svg in files(&res.join("brand"), "svg") { + add( + format!("brand/{}", svg.file_name().unwrap().to_string_lossy()), + svg, + true, + ); + } + // Symbolic icons the spec names that current Adwaita no longer ships. + // GTK adds /icons to the icon theme automatically. + for svg in files(&res.join("icons/scalable/actions"), "svg") { + add( + format!( + "icons/scalable/actions/{}", + svg.file_name().unwrap().to_string_lossy() + ), + svg, + true, + ); + } + add("style.css".into(), res.join("style.css"), true); + xml.push_str(" \n\n"); + let xml_path = out.join("hub.gresource.xml"); + fs::write(&xml_path, xml).unwrap(); + glib_build_tools::compile_resources( + &[out.as_path()], + xml_path.to_str().unwrap(), + "hub.gresource", + ); +} diff --git a/crates/substratal-hub/resources/brand/lockup-horizontal-on-dark.svg b/crates/substratal-hub/resources/brand/lockup-horizontal-on-dark.svg new file mode 100644 index 0000000..e821d6f --- /dev/null +++ b/crates/substratal-hub/resources/brand/lockup-horizontal-on-dark.svg @@ -0,0 +1 @@ +Substratal Apps \ No newline at end of file diff --git a/crates/substratal-hub/resources/brand/lockup-horizontal.svg b/crates/substratal-hub/resources/brand/lockup-horizontal.svg new file mode 100644 index 0000000..8bce699 --- /dev/null +++ b/crates/substratal-hub/resources/brand/lockup-horizontal.svg @@ -0,0 +1 @@ +Substratal Apps \ No newline at end of file diff --git a/crates/substratal-hub/resources/brand/mark-on-dark.svg b/crates/substratal-hub/resources/brand/mark-on-dark.svg new file mode 100644 index 0000000..5115972 --- /dev/null +++ b/crates/substratal-hub/resources/brand/mark-on-dark.svg @@ -0,0 +1 @@ +Substratal \ No newline at end of file diff --git a/crates/substratal-hub/resources/brand/mark.svg b/crates/substratal-hub/resources/brand/mark.svg new file mode 100644 index 0000000..d003aa3 --- /dev/null +++ b/crates/substratal-hub/resources/brand/mark.svg @@ -0,0 +1 @@ +Substratal \ No newline at end of file diff --git a/crates/substratal-hub/resources/icons/scalable/actions/bell-symbolic.svg b/crates/substratal-hub/resources/icons/scalable/actions/bell-symbolic.svg new file mode 100644 index 0000000..af8f7ad --- /dev/null +++ b/crates/substratal-hub/resources/icons/scalable/actions/bell-symbolic.svg @@ -0,0 +1 @@ + diff --git a/crates/substratal-hub/resources/icons/scalable/actions/mail-inbox-symbolic.svg b/crates/substratal-hub/resources/icons/scalable/actions/mail-inbox-symbolic.svg new file mode 100644 index 0000000..0aad26d --- /dev/null +++ b/crates/substratal-hub/resources/icons/scalable/actions/mail-inbox-symbolic.svg @@ -0,0 +1 @@ + diff --git a/crates/substratal-hub/resources/icons/scalable/actions/wallet-symbolic.svg b/crates/substratal-hub/resources/icons/scalable/actions/wallet-symbolic.svg new file mode 100644 index 0000000..c62d39e --- /dev/null +++ b/crates/substratal-hub/resources/icons/scalable/actions/wallet-symbolic.svg @@ -0,0 +1 @@ + diff --git a/crates/substratal-hub/resources/style.css b/crates/substratal-hub/resources/style.css new file mode 100644 index 0000000..1af1d1d --- /dev/null +++ b/crates/substratal-hub/resources/style.css @@ -0,0 +1,42 @@ +/* App-specific styles. Colours only from theme roles (named colours), never + literals (DoD: "colours only from theme roles"). */ + +.screen-id { + font-family: monospace; + font-size: 0.85em; + padding: 1px 6px; + border-radius: 6px; + background: alpha(@sub_accent, 0.12); + color: @sub_accent_strong; +} + +.spec-caption { + color: @sub_text_secondary; +} + +.status-badge { + padding: 2px 8px; + border-radius: 999px; + font-weight: bold; + font-size: 0.85em; +} +.status-badge.success { background: alpha(@success_color, 0.15); color: @success_color; } +.status-badge.info { background: alpha(@accent_color, 0.15); color: @accent_color; } +.status-badge.warning { background: alpha(@warning_color, 0.18); color: @warning_color; } +.status-badge.critical { background: alpha(@error_color, 0.15); color: @error_color; } +.status-badge.neutral, .status-badge.accent { background: alpha(@sub_text_secondary, 0.15); color: @sub_text_secondary; } + +.theme-swatch { + min-width: 40px; + min-height: 40px; + border-radius: 999px; + padding: 0; +} +.theme-swatch:checked { + outline: 3px solid @accent_color; + outline-offset: 2px; +} + +.app-tile { + padding: 18px; +} diff --git a/crates/substratal-hub/resources/theme/dark/deep-teal.css b/crates/substratal-hub/resources/theme/dark/deep-teal.css new file mode 100644 index 0000000..c49e55d --- /dev/null +++ b/crates/substratal-hub/resources/theme/dark/deep-teal.css @@ -0,0 +1,43 @@ +/* Deep Teal (dark) — generated from shared/themes/themes.json */ +@define-color accent_bg_color #55CFC9; +@define-color accent_fg_color #062423; +@define-color accent_color #55CFC9; +@define-color window_bg_color #0D1819; +@define-color window_fg_color #E3F1F0; +@define-color view_bg_color #1A2E30; +@define-color view_fg_color #E3F1F0; +@define-color headerbar_bg_color #0D1819; +@define-color headerbar_fg_color #E3F1F0; +@define-color headerbar_border_color #E3F1F0; +@define-color headerbar_backdrop_color #0D1819; +@define-color sidebar_bg_color #122021; +@define-color sidebar_fg_color #E3F1F0; +@define-color sidebar_backdrop_color #122021; +@define-color sidebar_border_color #26403F; +@define-color secondary_sidebar_bg_color #162729; +@define-color secondary_sidebar_fg_color #E3F1F0; +@define-color card_bg_color #1A2E30; +@define-color card_fg_color #E3F1F0; +@define-color dialog_bg_color #1A2E30; +@define-color dialog_fg_color #E3F1F0; +@define-color popover_bg_color #1A2E30; +@define-color popover_fg_color #E3F1F0; +@define-color thumbnail_bg_color #1A2E30; +@define-color thumbnail_fg_color #E3F1F0; +@define-color sub_background #0D1819; +@define-color sub_sidebar #122021; +@define-color sub_surface #162729; +@define-color sub_raised #1A2E30; +@define-color sub_text #E3F1F0; +@define-color sub_text_secondary #93ADAC; +@define-color sub_text_tertiary #6E8081; +@define-color sub_separator #26403F; +@define-color sub_accent #55CFC9; +@define-color sub_accent_strong #8BE3DE; +@define-color sub_on_accent #062423; +@define-color sub_accent_subtle #133C3B; +@define-color sub_link #55CFC9; +@define-color sub_selection #133C3B; +@define-color sub_control_fill #283C3D; +@define-color sub_control_fill_strong #324547; +@define-color sub_focus_ring #55CFC9; diff --git a/crates/substratal-hub/resources/theme/dark/ember-clay.css b/crates/substratal-hub/resources/theme/dark/ember-clay.css new file mode 100644 index 0000000..c637dfd --- /dev/null +++ b/crates/substratal-hub/resources/theme/dark/ember-clay.css @@ -0,0 +1,43 @@ +/* Ember Clay (dark) — generated from shared/themes/themes.json */ +@define-color accent_bg_color #FF9A80; +@define-color accent_fg_color #3D1206; +@define-color accent_color #FF9A80; +@define-color window_bg_color #191412; +@define-color window_fg_color #F4ECE8; +@define-color view_bg_color #2C231F; +@define-color view_fg_color #F4ECE8; +@define-color headerbar_bg_color #191412; +@define-color headerbar_fg_color #F4ECE8; +@define-color headerbar_border_color #F4ECE8; +@define-color headerbar_backdrop_color #191412; +@define-color sidebar_bg_color #201916; +@define-color sidebar_fg_color #F4ECE8; +@define-color sidebar_backdrop_color #201916; +@define-color sidebar_border_color #3B302B; +@define-color secondary_sidebar_bg_color #261E1A; +@define-color secondary_sidebar_fg_color #F4ECE8; +@define-color card_bg_color #2C231F; +@define-color card_fg_color #F4ECE8; +@define-color dialog_bg_color #2C231F; +@define-color dialog_fg_color #F4ECE8; +@define-color popover_bg_color #2C231F; +@define-color popover_fg_color #F4ECE8; +@define-color thumbnail_bg_color #2C231F; +@define-color thumbnail_fg_color #F4ECE8; +@define-color sub_background #191412; +@define-color sub_sidebar #201916; +@define-color sub_surface #261E1A; +@define-color sub_raised #2C231F; +@define-color sub_text #F4ECE8; +@define-color sub_text_secondary #B3A39B; +@define-color sub_text_tertiary #807773; +@define-color sub_separator #3B302B; +@define-color sub_accent #FF9A80; +@define-color sub_accent_strong #FFBCA9; +@define-color sub_on_accent #3D1206; +@define-color sub_accent_subtle #44261D; +@define-color sub_link #FF9A80; +@define-color sub_selection #44261D; +@define-color sub_control_fill #3A312D; +@define-color sub_control_fill_strong #443B37; +@define-color sub_focus_ring #FF9A80; diff --git a/crates/substratal-hub/resources/theme/dark/forest-floor.css b/crates/substratal-hub/resources/theme/dark/forest-floor.css new file mode 100644 index 0000000..7d646cc --- /dev/null +++ b/crates/substratal-hub/resources/theme/dark/forest-floor.css @@ -0,0 +1,43 @@ +/* Forest Floor (dark) — generated from shared/themes/themes.json */ +@define-color accent_bg_color #B8E26A; +@define-color accent_fg_color #1A2608; +@define-color accent_color #B8E26A; +@define-color window_bg_color #10150F; +@define-color window_fg_color #E8EFE2; +@define-color view_bg_color #1F291E; +@define-color view_fg_color #E8EFE2; +@define-color headerbar_bg_color #10150F; +@define-color headerbar_fg_color #E8EFE2; +@define-color headerbar_border_color #E8EFE2; +@define-color headerbar_backdrop_color #10150F; +@define-color sidebar_bg_color #151C14; +@define-color sidebar_fg_color #E8EFE2; +@define-color sidebar_backdrop_color #151C14; +@define-color sidebar_border_color #2C3929; +@define-color secondary_sidebar_bg_color #1A2219; +@define-color secondary_sidebar_fg_color #E8EFE2; +@define-color card_bg_color #1F291E; +@define-color card_fg_color #E8EFE2; +@define-color dialog_bg_color #1F291E; +@define-color dialog_fg_color #E8EFE2; +@define-color popover_bg_color #1F291E; +@define-color popover_fg_color #E8EFE2; +@define-color thumbnail_bg_color #1F291E; +@define-color thumbnail_fg_color #E8EFE2; +@define-color sub_background #10150F; +@define-color sub_sidebar #151C14; +@define-color sub_surface #1A2219; +@define-color sub_raised #1F291E; +@define-color sub_text #E8EFE2; +@define-color sub_text_secondary #9DAA95; +@define-color sub_text_tertiary #737C70; +@define-color sub_separator #2C3929; +@define-color sub_accent #B8E26A; +@define-color sub_accent_strong #D3F09A; +@define-color sub_on_accent #1A2608; +@define-color sub_accent_subtle #2C3B19; +@define-color sub_link #B8E26A; +@define-color sub_selection #2C3B19; +@define-color sub_control_fill #2D372C; +@define-color sub_control_fill_strong #374136; +@define-color sub_focus_ring #B8E26A; diff --git a/crates/substratal-hub/resources/theme/dark/graphite-spore.css b/crates/substratal-hub/resources/theme/dark/graphite-spore.css new file mode 100644 index 0000000..6c496b9 --- /dev/null +++ b/crates/substratal-hub/resources/theme/dark/graphite-spore.css @@ -0,0 +1,43 @@ +/* Graphite Spore (dark) — generated from shared/themes/themes.json */ +@define-color accent_bg_color #FF86AE; +@define-color accent_fg_color #3A0A1E; +@define-color accent_color #FF86AE; +@define-color window_bg_color #161618; +@define-color window_fg_color #F1EFF1; +@define-color view_bg_color #28282C; +@define-color view_fg_color #F1EFF1; +@define-color headerbar_bg_color #161618; +@define-color headerbar_fg_color #F1EFF1; +@define-color headerbar_border_color #F1EFF1; +@define-color headerbar_backdrop_color #161618; +@define-color sidebar_bg_color #1D1D20; +@define-color sidebar_fg_color #F1EFF1; +@define-color sidebar_backdrop_color #1D1D20; +@define-color sidebar_border_color #34343A; +@define-color secondary_sidebar_bg_color #222226; +@define-color secondary_sidebar_fg_color #F1EFF1; +@define-color card_bg_color #28282C; +@define-color card_fg_color #F1EFF1; +@define-color dialog_bg_color #28282C; +@define-color dialog_fg_color #F1EFF1; +@define-color popover_bg_color #28282C; +@define-color popover_fg_color #F1EFF1; +@define-color thumbnail_bg_color #28282C; +@define-color thumbnail_fg_color #F1EFF1; +@define-color sub_background #161618; +@define-color sub_sidebar #1D1D20; +@define-color sub_surface #222226; +@define-color sub_raised #28282C; +@define-color sub_text #F1EFF1; +@define-color sub_text_secondary #A3A0A7; +@define-color sub_text_tertiary #7C7C7F; +@define-color sub_separator #34343A; +@define-color sub_accent #FF86AE; +@define-color sub_accent_strong #FFB0CA; +@define-color sub_on_accent #3A0A1E; +@define-color sub_accent_subtle #3E2030; +@define-color sub_link #FF86AE; +@define-color sub_selection #3E2030; +@define-color sub_control_fill #36363A; +@define-color sub_control_fill_strong #404044; +@define-color sub_focus_ring #FF86AE; diff --git a/crates/substratal-hub/resources/theme/dark/night-soil.css b/crates/substratal-hub/resources/theme/dark/night-soil.css new file mode 100644 index 0000000..dc0ab30 --- /dev/null +++ b/crates/substratal-hub/resources/theme/dark/night-soil.css @@ -0,0 +1,43 @@ +/* Night Soil (dark) — generated from shared/themes/themes.json */ +@define-color accent_bg_color #8E8CFF; +@define-color accent_fg_color #15132E; +@define-color accent_color #8E8CFF; +@define-color window_bg_color #15132E; +@define-color window_fg_color #ECEBFA; +@define-color view_bg_color #25234B; +@define-color view_fg_color #ECEBFA; +@define-color headerbar_bg_color #15132E; +@define-color headerbar_fg_color #ECEBFA; +@define-color headerbar_border_color #ECEBFA; +@define-color headerbar_backdrop_color #15132E; +@define-color sidebar_bg_color #1B1938; +@define-color sidebar_fg_color #ECEBFA; +@define-color sidebar_backdrop_color #1B1938; +@define-color sidebar_border_color #34325C; +@define-color secondary_sidebar_bg_color #211F42; +@define-color secondary_sidebar_fg_color #ECEBFA; +@define-color card_bg_color #25234B; +@define-color card_fg_color #ECEBFA; +@define-color dialog_bg_color #25234B; +@define-color dialog_fg_color #ECEBFA; +@define-color popover_bg_color #25234B; +@define-color popover_fg_color #ECEBFA; +@define-color thumbnail_bg_color #25234B; +@define-color thumbnail_fg_color #ECEBFA; +@define-color sub_background #15132E; +@define-color sub_sidebar #1B1938; +@define-color sub_surface #211F42; +@define-color sub_raised #25234B; +@define-color sub_text #ECEBFA; +@define-color sub_text_secondary #A6A4C8; +@define-color sub_text_tertiary #797794; +@define-color sub_separator #34325C; +@define-color sub_accent #8E8CFF; +@define-color sub_accent_strong #B4B3FF; +@define-color sub_on_accent #15132E; +@define-color sub_accent_subtle #2C2A63; +@define-color sub_link #8E8CFF; +@define-color sub_selection #2C2A63; +@define-color sub_control_fill #333157; +@define-color sub_control_fill_strong #3D3B60; +@define-color sub_focus_ring #8E8CFF; diff --git a/crates/substratal-hub/resources/theme/light/coral-clay.css b/crates/substratal-hub/resources/theme/light/coral-clay.css new file mode 100644 index 0000000..d19da95 --- /dev/null +++ b/crates/substratal-hub/resources/theme/light/coral-clay.css @@ -0,0 +1,43 @@ +/* Coral Clay (light) — generated from shared/themes/themes.json */ +@define-color accent_bg_color #B44529; +@define-color accent_fg_color #FFFFFF; +@define-color accent_color #B44529; +@define-color window_bg_color #F9F5F2; +@define-color window_fg_color #2A1D17; +@define-color view_bg_color #FFFFFF; +@define-color view_fg_color #2A1D17; +@define-color headerbar_bg_color #F9F5F2; +@define-color headerbar_fg_color #2A1D17; +@define-color headerbar_border_color #2A1D17; +@define-color headerbar_backdrop_color #F9F5F2; +@define-color sidebar_bg_color #F1EBE6; +@define-color sidebar_fg_color #2A1D17; +@define-color sidebar_backdrop_color #F1EBE6; +@define-color sidebar_border_color #EAE0D8; +@define-color secondary_sidebar_bg_color #FBF8F6; +@define-color secondary_sidebar_fg_color #2A1D17; +@define-color card_bg_color #FFFFFF; +@define-color card_fg_color #2A1D17; +@define-color dialog_bg_color #FFFFFF; +@define-color dialog_fg_color #2A1D17; +@define-color popover_bg_color #FFFFFF; +@define-color popover_fg_color #2A1D17; +@define-color thumbnail_bg_color #FFFFFF; +@define-color thumbnail_fg_color #2A1D17; +@define-color sub_background #F9F5F2; +@define-color sub_sidebar #F1EBE6; +@define-color sub_surface #FBF8F6; +@define-color sub_raised #FFFFFF; +@define-color sub_text #2A1D17; +@define-color sub_text_secondary #6E5D55; +@define-color sub_text_tertiary #A6A09E; +@define-color sub_separator #EAE0D8; +@define-color sub_accent #B44529; +@define-color sub_accent_strong #90361F; +@define-color sub_on_accent #FFFFFF; +@define-color sub_accent_subtle #F7E3DB; +@define-color sub_link #B44529; +@define-color sub_selection #F7E3DB; +@define-color sub_control_fill #F0EFEF; +@define-color sub_control_fill_strong #E5E4E3; +@define-color sub_focus_ring #B44529; diff --git a/crates/substratal-hub/resources/theme/light/indigo-root.css b/crates/substratal-hub/resources/theme/light/indigo-root.css new file mode 100644 index 0000000..8508dae --- /dev/null +++ b/crates/substratal-hub/resources/theme/light/indigo-root.css @@ -0,0 +1,43 @@ +/* Indigo Root (light) — generated from shared/themes/themes.json */ +@define-color accent_bg_color #3D3BB8; +@define-color accent_fg_color #FFFFFF; +@define-color accent_color #3D3BB8; +@define-color window_bg_color #F4F4FA; +@define-color window_fg_color #1A1838; +@define-color view_bg_color #FFFFFF; +@define-color view_fg_color #1A1838; +@define-color headerbar_bg_color #F4F4FA; +@define-color headerbar_fg_color #1A1838; +@define-color headerbar_border_color #1A1838; +@define-color headerbar_backdrop_color #F4F4FA; +@define-color sidebar_bg_color #EBEBF5; +@define-color sidebar_fg_color #1A1838; +@define-color sidebar_backdrop_color #EBEBF5; +@define-color sidebar_border_color #DEDEEB; +@define-color secondary_sidebar_bg_color #F7F7FC; +@define-color secondary_sidebar_fg_color #1A1838; +@define-color card_bg_color #FFFFFF; +@define-color card_fg_color #1A1838; +@define-color dialog_bg_color #FFFFFF; +@define-color dialog_fg_color #1A1838; +@define-color popover_bg_color #FFFFFF; +@define-color popover_fg_color #1A1838; +@define-color thumbnail_bg_color #FFFFFF; +@define-color thumbnail_fg_color #1A1838; +@define-color sub_background #F4F4FA; +@define-color sub_sidebar #EBEBF5; +@define-color sub_surface #F7F7FC; +@define-color sub_raised #FFFFFF; +@define-color sub_text #1A1838; +@define-color sub_text_secondary #5D5B78; +@define-color sub_text_tertiary #9F9EAB; +@define-color sub_separator #DEDEEB; +@define-color sub_accent #3D3BB8; +@define-color sub_accent_strong #2C2A8F; +@define-color sub_on_accent #FFFFFF; +@define-color sub_accent_subtle #E6E6FA; +@define-color sub_link #3D3BB8; +@define-color sub_selection #E6E6FA; +@define-color sub_control_fill #EFEFF1; +@define-color sub_control_fill_strong #E4E3E7; +@define-color sub_focus_ring #3D3BB8; diff --git a/crates/substratal-hub/resources/theme/light/moss-paper.css b/crates/substratal-hub/resources/theme/light/moss-paper.css new file mode 100644 index 0000000..945f40a --- /dev/null +++ b/crates/substratal-hub/resources/theme/light/moss-paper.css @@ -0,0 +1,43 @@ +/* Moss Paper (light) — generated from shared/themes/themes.json */ +@define-color accent_bg_color #4A7322; +@define-color accent_fg_color #FFFFFF; +@define-color accent_color #4A7322; +@define-color window_bg_color #F5F4EE; +@define-color window_fg_color #22261A; +@define-color view_bg_color #FFFFFE; +@define-color view_fg_color #22261A; +@define-color headerbar_bg_color #F5F4EE; +@define-color headerbar_fg_color #22261A; +@define-color headerbar_border_color #22261A; +@define-color headerbar_backdrop_color #F5F4EE; +@define-color sidebar_bg_color #EDECE3; +@define-color sidebar_fg_color #22261A; +@define-color sidebar_backdrop_color #EDECE3; +@define-color sidebar_border_color #E1DFD2; +@define-color secondary_sidebar_bg_color #F9F8F3; +@define-color secondary_sidebar_fg_color #22261A; +@define-color card_bg_color #FFFFFE; +@define-color card_fg_color #22261A; +@define-color dialog_bg_color #FFFFFE; +@define-color dialog_fg_color #22261A; +@define-color popover_bg_color #FFFFFE; +@define-color popover_fg_color #22261A; +@define-color thumbnail_bg_color #FFFFFE; +@define-color thumbnail_fg_color #22261A; +@define-color sub_background #F5F4EE; +@define-color sub_sidebar #EDECE3; +@define-color sub_surface #F9F8F3; +@define-color sub_raised #FFFFFE; +@define-color sub_text #22261A; +@define-color sub_text_secondary #5F6455; +@define-color sub_text_tertiary #A2A49E; +@define-color sub_separator #E1DFD2; +@define-color sub_accent #4A7322; +@define-color sub_accent_strong #375719; +@define-color sub_on_accent #FFFFFF; +@define-color sub_accent_subtle #E6EFD8; +@define-color sub_link #4A7322; +@define-color sub_selection #E6EFD8; +@define-color sub_control_fill #F0F0EE; +@define-color sub_control_fill_strong #E4E5E3; +@define-color sub_focus_ring #4A7322; diff --git a/crates/substratal-hub/resources/theme/light/spore-pink.css b/crates/substratal-hub/resources/theme/light/spore-pink.css new file mode 100644 index 0000000..7313a14 --- /dev/null +++ b/crates/substratal-hub/resources/theme/light/spore-pink.css @@ -0,0 +1,43 @@ +/* Spore Pink (light) — generated from shared/themes/themes.json */ +@define-color accent_bg_color #B3285B; +@define-color accent_fg_color #FFFFFF; +@define-color accent_color #B3285B; +@define-color window_bg_color #F9F6F8; +@define-color window_fg_color #2A1622; +@define-color view_bg_color #FFFFFF; +@define-color view_fg_color #2A1622; +@define-color headerbar_bg_color #F9F6F8; +@define-color headerbar_fg_color #2A1622; +@define-color headerbar_border_color #2A1622; +@define-color headerbar_backdrop_color #F9F6F8; +@define-color sidebar_bg_color #F2ECF0; +@define-color sidebar_fg_color #2A1622; +@define-color sidebar_backdrop_color #F2ECF0; +@define-color sidebar_border_color #EADFE5; +@define-color secondary_sidebar_bg_color #FBF8FA; +@define-color secondary_sidebar_fg_color #2A1622; +@define-color card_bg_color #FFFFFF; +@define-color card_fg_color #2A1622; +@define-color dialog_bg_color #FFFFFF; +@define-color dialog_fg_color #2A1622; +@define-color popover_bg_color #FFFFFF; +@define-color popover_fg_color #2A1622; +@define-color thumbnail_bg_color #FFFFFF; +@define-color thumbnail_fg_color #2A1622; +@define-color sub_background #F9F6F8; +@define-color sub_sidebar #F2ECF0; +@define-color sub_surface #FBF8FA; +@define-color sub_raised #FFFFFF; +@define-color sub_text #2A1622; +@define-color sub_text_secondary #6E5A66; +@define-color sub_text_tertiary #A69DA2; +@define-color sub_separator #EADFE5; +@define-color sub_accent #B3285B; +@define-color sub_accent_strong #8F1F48; +@define-color sub_on_accent #FFFFFF; +@define-color sub_accent_subtle #F7E0E9; +@define-color sub_link #B3285B; +@define-color sub_selection #F7E0E9; +@define-color sub_control_fill #F0EFF0; +@define-color sub_control_fill_strong #E5E3E4; +@define-color sub_focus_ring #B3285B; diff --git a/crates/substratal-hub/resources/theme/light/teal-ledger.css b/crates/substratal-hub/resources/theme/light/teal-ledger.css new file mode 100644 index 0000000..725890f --- /dev/null +++ b/crates/substratal-hub/resources/theme/light/teal-ledger.css @@ -0,0 +1,43 @@ +/* Teal Ledger (light) — generated from shared/themes/themes.json */ +@define-color accent_bg_color #137C7A; +@define-color accent_fg_color #FFFFFF; +@define-color accent_color #137C7A; +@define-color window_bg_color #F3F6F6; +@define-color window_fg_color #10272A; +@define-color view_bg_color #FFFFFF; +@define-color view_fg_color #10272A; +@define-color headerbar_bg_color #F3F6F6; +@define-color headerbar_fg_color #10272A; +@define-color headerbar_border_color #10272A; +@define-color headerbar_backdrop_color #F3F6F6; +@define-color sidebar_bg_color #EAF0F0; +@define-color sidebar_fg_color #10272A; +@define-color sidebar_backdrop_color #EAF0F0; +@define-color sidebar_border_color #D9E2E2; +@define-color secondary_sidebar_bg_color #F6F9F9; +@define-color secondary_sidebar_fg_color #10272A; +@define-color card_bg_color #FFFFFF; +@define-color card_fg_color #10272A; +@define-color dialog_bg_color #FFFFFF; +@define-color dialog_fg_color #10272A; +@define-color popover_bg_color #FFFFFF; +@define-color popover_fg_color #10272A; +@define-color thumbnail_bg_color #FFFFFF; +@define-color thumbnail_fg_color #10272A; +@define-color sub_background #F3F6F6; +@define-color sub_sidebar #EAF0F0; +@define-color sub_surface #F6F9F9; +@define-color sub_raised #FFFFFF; +@define-color sub_text #10272A; +@define-color sub_text_secondary #56686A; +@define-color sub_text_tertiary #9BA4A6; +@define-color sub_separator #D9E2E2; +@define-color sub_accent #137C7A; +@define-color sub_accent_strong #0E5F5D; +@define-color sub_on_accent #FFFFFF; +@define-color sub_accent_subtle #DDF0EE; +@define-color sub_link #137C7A; +@define-color sub_selection #DDF0EE; +@define-color sub_control_fill #EEF0F0; +@define-color sub_control_fill_strong #E2E5E5; +@define-color sub_focus_ring #137C7A; diff --git a/crates/substratal-hub/resources/ui/sign-in.blp b/crates/substratal-hub/resources/ui/sign-in.blp new file mode 100644 index 0000000..2297042 --- /dev/null +++ b/crates/substratal-hub/resources/ui/sign-in.blp @@ -0,0 +1,167 @@ +// AUTH-01 Sign in and AUTH-02 Two-factor challenge (linux/SCREENS.md). +// A separate AdwWindow shown before the main window; AdwClamp keeps the +// form readable from 360 sp phones to 4K monitors. +using Gtk 4.0; +using Adw 1; + +Adw.Window sign_in_window { + title: _("Sign in — Substratal"); + default-width: 480; + default-height: 640; + width-request: 360; + height-request: 294; + + content: Adw.ToastOverlay toasts { + Adw.NavigationView nav { + Adw.NavigationPage { + title: _("Sign in"); + tag: "AUTH-01"; + + Adw.ToolbarView { + [top] + Adw.HeaderBar { + show-title: false; + + [end] + Gtk.Button help_button { + icon-name: "help-about-symbolic"; + tooltip-text: _("Help (F1)"); + action-name: "signin.help"; + } + } + + [top] + Adw.Banner env_banner { + revealed: false; + } + + content: Gtk.ScrolledWindow { + hscrollbar-policy: never; + + Adw.Clamp { + maximum-size: 400; + margin-top: 24; + margin-bottom: 24; + margin-start: 16; + margin-end: 16; + + Gtk.Box { + orientation: vertical; + spacing: 24; + valign: center; + + Gtk.Picture logo { + height-request: 64; + can-shrink: true; + content-fit: contain; + alternative-text: _("Substratal Apps"); + } + + Gtk.Label { + label: _("Sign in to Substratal"); + styles ["title-1"] + } + + Gtk.ListBox { + selection-mode: none; + styles ["boxed-list"] + + Adw.EntryRow email_row { + title: _("Email"); + input-purpose: email; + activates-default: true; + } + + Adw.PasswordEntryRow password_row { + title: _("Password"); + activates-default: true; + } + } + + Gtk.Label error_label { + visible: false; + wrap: true; + xalign: 0; + styles ["error"] + } + + Gtk.Button sign_in_button { + label: _("Sign in"); + sensitive: false; + halign: center; + styles ["suggested-action", "pill"] + } + + Gtk.Box { + halign: center; + spacing: 12; + + Gtk.LinkButton forgot_link { + label: _("Forgot password?"); + } + + Gtk.LinkButton sign_up_link { + label: _("Create an account"); + } + } + } + } + }; + } + } + + Adw.NavigationPage mfa_page { + title: _("Two-factor authentication"); + tag: "AUTH-02"; + + Adw.ToolbarView { + [top] + Adw.HeaderBar {} + + content: Adw.Clamp { + maximum-size: 400; + margin-top: 24; + margin-start: 16; + margin-end: 16; + + Gtk.Box { + orientation: vertical; + spacing: 24; + + Adw.StatusPage { + icon-name: "dialog-password-symbolic"; + title: _("Enter your code"); + description: _("Open your authenticator app and enter the 6-digit code for Substratal. You can also use one of your recovery codes."); + } + + Gtk.ListBox { + selection-mode: none; + styles ["boxed-list"] + + Adw.EntryRow code_row { + title: _("6-digit code or recovery code"); + input-purpose: digits; + activates-default: true; + } + } + + Gtk.Label mfa_error_label { + visible: false; + wrap: true; + xalign: 0; + styles ["error"] + } + + Gtk.Button verify_button { + label: _("Verify"); + sensitive: false; + halign: center; + styles ["suggested-action", "pill"] + } + } + }; + } + } + } + }; +} diff --git a/crates/substratal-hub/src/context.rs b/crates/substratal-hub/src/context.rs new file mode 100644 index 0000000..0090abd --- /dev/null +++ b/crates/substratal-hub/src/context.rs @@ -0,0 +1,67 @@ +//! Shared app state: the API client, the person and their Capabilities. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use substratal_core::capabilities::Capabilities; +use substratal_core::models::User; +use substratal_core::secrets::SecretStore; +use substratal_core::{ApiClient, Environment}; + +pub struct AppContext { + pub api: Arc, + pub caps: RefCell, + pub me: RefCell>, + /// Developer preview persona (`SUBSTRATAL_PREVIEW`), if any. + pub preview: Option, +} + +impl AppContext { + pub fn new() -> Rc { + let env = Environment::from_env(); + let preview = std::env::var("SUBSTRATAL_PREVIEW") + .ok() + .filter(|s| !s.is_empty()); + let secrets = if preview.is_some() { + SecretStore::memory() + } else { + crate::runtime::block_on(SecretStore::system()) + }; + let api = + ApiClient::new(env, secrets, &user_agent(), &accept_language()).expect("HTTP client"); + Rc::new(AppContext { + api: Arc::new(api), + caps: RefCell::new(Capabilities::default()), + me: RefCell::new(None), + preview, + }) + } + + pub fn is_preview(&self) -> bool { + self.preview.is_some() + } +} + +/// `Substratal-Linux/{version} ({distro})`, shown to people in their Sessions list. +fn user_agent() -> String { + let distro = std::fs::read_to_string("/etc/os-release") + .ok() + .and_then(|t| { + t.lines().find_map(|l| { + l.strip_prefix("PRETTY_NAME=") + .map(|v| v.trim_matches('"').to_string()) + }) + }) + .unwrap_or_else(|| "Linux".into()); + format!("Substratal-Linux/{} ({distro})", crate::VERSION) +} + +fn accept_language() -> String { + gtk::glib::language_names() + .iter() + .map(|l| l.as_str()) + .find(|l| *l != "C" && !l.contains('.')) + .map(|l| l.replace('_', "-")) + .unwrap_or_else(|| "en-US".into()) +} diff --git a/crates/substratal-hub/src/dialogs.rs b/crates/substratal-hub/src/dialogs.rs new file mode 100644 index 0000000..325dfb3 --- /dev/null +++ b/crates/substratal-hub/src/dialogs.rs @@ -0,0 +1,168 @@ +//! GLB-01 Command palette, the Keyboard Shortcuts dialog and About. + +use std::rc::Rc; + +use adw::prelude::*; +use substratal_core::{config, nav, screens, shortcuts}; + +use crate::shell::Shell; + +/// GLB-01: jump to any screen the person can open, by name, ID or route. +pub fn command_palette(shell: &Rc) { + let dialog = adw::Dialog::builder() + .title("Go to") + .content_width(520) + .content_height(480) + .build(); + let search = gtk::SearchEntry::builder() + .placeholder_text("Search screens by name, ID or route") + .hexpand(true) + .build(); + let list = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::Browse) + .build(); + list.add_css_class("boxed-list"); + + let caps = shell.ctx.caps.borrow().clone(); + let targets: Vec<&'static screens::Screen> = screens::all() + .iter() + .filter(|s| { + s.on_linux() && !s.is_auth() && !s.route.contains(':') && s.route.starts_with('/') + }) + .filter(|s| nav::item_for_route(&s.route).is_none_or(|i| i.is_visible(&caps))) + .collect(); + for s in &targets { + let row = adw::ActionRow::builder() + .title(gtk::glib::markup_escape_text(&s.title)) + .subtitle(gtk::glib::markup_escape_text(&s.route)) + .activatable(true) + .build(); + row.add_suffix(&crate::widgets::id_chip(&s.id)); + row.set_widget_name(&s.route); + list.append(&row); + } + let needle = Rc::new(std::cell::RefCell::new(String::new())); + let n = needle.clone(); + list.set_filter_func(move |row| { + let q = n.borrow(); + if q.is_empty() { + return true; + } + let Some(r) = row.downcast_ref::() else { + return true; + }; + let hay = format!( + "{} {} {}", + r.title(), + r.subtitle().unwrap_or_default(), + r.widget_name() + ) + .to_lowercase(); + q.split_whitespace().all(|w| hay.contains(w)) + }); + let l = list.clone(); + search.connect_search_changed(move |e| { + *needle.borrow_mut() = e.text().to_lowercase(); + l.invalidate_filter(); + }); + let go = { + let shell = shell.clone(); + let dialog = dialog.clone(); + move |route: String| { + dialog.close(); + shell.navigate(&route); + } + }; + let go1 = go.clone(); + list.connect_row_activated(move |_, row| go1(row.widget_name().to_string())); + let l = list.clone(); + search.connect_activate(move |_| { + let mut i = 0; + while let Some(row) = l.row_at_index(i) { + if row.is_child_visible() { + go(row.widget_name().to_string()); + return; + } + i += 1; + } + }); + + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(12) + .margin_top(12) + .margin_bottom(12) + .margin_start(12) + .margin_end(12) + .build(); + content.append(&search); + content.append( + >k::ScrolledWindow::builder() + .vexpand(true) + .child(&list) + .build(), + ); + let view = adw::ToolbarView::new(); + view.add_top_bar(&adw::HeaderBar::new()); + view.set_content(Some(&content)); + dialog.set_child(Some(&view)); + dialog.present(Some(&shell.window)); + search.grab_focus(); +} + +/// Keyboard shortcuts, from the same table that binds them. +pub fn shortcuts(shell: &Rc) { + let dialog = adw::Dialog::builder() + .title("Keyboard Shortcuts") + .content_width(480) + .content_height(600) + .build(); + let page = adw::PreferencesPage::new(); + let mut groups: Vec<(&str, adw::PreferencesGroup)> = Vec::new(); + for s in shortcuts::SHORTCUTS { + let group = match groups.iter().find(|(g, _)| *g == s.group) { + Some((_, g)) => g.clone(), + None => { + let g = adw::PreferencesGroup::builder().title(s.group).build(); + page.add(&g); + groups.push((s.group, g.clone())); + g + } + }; + let row = adw::ActionRow::builder().title(s.label).build(); + row.add_suffix( + >k::ShortcutLabel::builder() + .accelerator(s.accels.join(" ")) + .valign(gtk::Align::Center) + .build(), + ); + group.add(&row); + } + let view = adw::ToolbarView::new(); + view.add_top_bar(&adw::HeaderBar::new()); + view.set_content(Some(&page)); + dialog.set_child(Some(&view)); + dialog.present(Some(&shell.window)); +} + +pub fn about(shell: &Rc) { + let about = adw::AboutDialog::builder() + .application_name("Substratal") + .application_icon(crate::APP_ID) + .developer_name("Composite Code") + .version(crate::VERSION) + .website(config::HELP_BASE) + .support_url(config::HELP_BASE) + .issue_url("https://github.com/CompositeCode/substratalapps-linux/issues") + .license_type(gtk::License::MitX11) + .comments(format!( + "Substratal Hub for Linux.\nPlatform API version {} (Substratal-Version header) at {}.\nScreens from specs {}.", + substratal_core::version::API_VERSION, + shell.ctx.api.environment().base_url(), + &screens::spec_commit()[..7.min(screens::spec_commit().len())], + )) + .build(); + about.add_link("Help center", config::HELP_BASE); + about.add_link("Platform API reference", config::API_DOCS); + about.present(Some(&shell.window)); +} diff --git a/crates/substratal-hub/src/main.rs b/crates/substratal-hub/src/main.rs new file mode 100644 index 0000000..9e5ae68 --- /dev/null +++ b/crates/substratal-hub/src/main.rs @@ -0,0 +1,176 @@ +//! Substratal Hub for Linux (GNOME). +//! +//! GTK 4 + libadwaita client of the Substratal Apps Platform API, built to +//! `linux/PLATFORM-SPEC.md` and `linux/SCREENS.md` in the specs package. +//! Every request sends `Substratal-Version: 1` to the host root +//! (`https://api.substratalapps.com`, no `/v1`); see `substratal_core::version`. + +mod context; +mod dialogs; +mod pages; +mod prefs; +mod runtime; +mod shell; +mod sign_in; +mod theme; +mod widgets; + +use std::cell::RefCell; +use std::rc::Rc; + +use adw::prelude::*; +use gtk::{gio, glib}; +use substratal_core::{nav, screens, shortcuts}; + +use crate::context::AppContext; +use crate::shell::Shell; + +pub const APP_ID: &str = "com.substratalapps.Hub"; +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +thread_local! { + static SHELL: RefCell>> = const { RefCell::new(None) }; + static PENDING_ROUTE: RefCell> = const { RefCell::new(None) }; +} + +fn main() -> glib::ExitCode { + gio::resources_register_include!("hub.gresource").expect("bundled resources"); + glib::set_application_name("Substratal"); + + let app = adw::Application::builder() + .application_id(APP_ID) + .flags(gio::ApplicationFlags::HANDLES_OPEN) + .build(); + + app.connect_startup(|app| { + theme::init(); + for s in shortcuts::SHORTCUTS { + if let Some(action) = s.action { + app.set_accels_for_action(action, s.accels); + } + } + let quit = gio::SimpleAction::new("quit", None); + quit.connect_activate(glib::clone!( + #[weak] + app, + move |_, _| app.quit() + )); + app.add_action(&quit); + }); + + app.connect_activate(activate); + + // Deep links: `substratal://admin/users` (x-scheme-handler/substratal, + // DBusActivatable so they reach the running instance). + app.connect_open(|app, files, _| { + for f in files { + let uri = f.uri(); + match screens::match_link(&uri) { + Some(m) => { + let route = screens::get(&m.screen_id) + .map(|s| { + s.path_with( + &m.params + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect::>(), + ) + }) + .unwrap_or_default(); + open_route(route); + } + None => eprintln!("substratal: ignoring unknown link {uri}"), + } + } + activate(app); + }); + + app.run() +} + +fn open_route(route: String) { + let shown = SHELL.with(|s| { + s.borrow() + .as_ref() + .map(|shell| shell.navigate(&route)) + .is_some() + }); + if !shown { + PENDING_ROUTE.with(|p| *p.borrow_mut() = Some(route)); + } +} + +fn activate(app: &adw::Application) { + if let Some(w) = app.active_window() { + w.present(); + return; + } + let ctx = AppContext::new(); + + // Developer preview: `SUBSTRATAL_PREVIEW=superadmin` shows the shell as + // that persona without signing in (screenshots, layout work). It only + // changes what the UI shows; every API call still needs a real session. + if let Some(persona) = ctx.preview.clone() { + let caps = substratal_core::capabilities::personas::all() + .into_iter() + .find(|(name, _)| name.to_lowercase().replace(' ', "_") == persona) + .map(|(_, c)| c) + .unwrap_or_else(substratal_core::capabilities::personas::superadmin); + *ctx.caps.borrow_mut() = caps; + show_shell(app, ctx); + return; + } + + // Keep the application alive while the session is restored off the + // main thread; otherwise it quits for want of a window. + let hold = app.hold(); + let api = ctx.api.clone(); + runtime::spawn( + async move { + match api.restore().await { + Ok(true) => Some(api.capabilities().await), + Ok(false) => None, + Err(e) => Some(Err(e)), + } + }, + glib::clone!( + #[weak] + app, + move |restored| { + let _hold = hold; + match restored { + Some(Ok((me, caps))) => { + *ctx.me.borrow_mut() = Some(me); + *ctx.caps.borrow_mut() = caps; + show_shell(&app, ctx); + } + Some(Err(e)) => { + eprintln!("substratal: couldn't restore the session: {e}"); + sign_in::present(&app, ctx, Some(e.to_string())); + } + None => sign_in::present(&app, ctx, None), + } + } + ), + ); +} + +/// Opens the main window for a signed-in (or preview) person. +pub fn show_shell(app: &adw::Application, ctx: Rc) { + let shell = Shell::new(app, ctx); + let start = PENDING_ROUTE + .with(|p| p.borrow_mut().take()) + .or_else(|| std::env::var("SUBSTRATAL_ROUTE").ok()); + shell.navigate(start.as_deref().unwrap_or(nav::TREE[0].route)); + shell.window.present(); + SHELL.with(|s| *s.borrow_mut() = Some(shell)); +} + +/// Back to AUTH-01 after sign-out or a revoked session, keeping the destination. +pub fn show_sign_in(app: &adw::Application, ctx: Rc, message: Option) { + if let Some(shell) = SHELL.with(|s| s.borrow_mut().take()) { + PENDING_ROUTE.with(|p| *p.borrow_mut() = Some(shell.current_route())); + shell.window.close(); + } + sign_in::present(app, ctx, message); +} diff --git a/crates/substratal-hub/src/pages/account.rs b/crates/substratal-hub/src/pages/account.rs new file mode 100644 index 0000000..ccfdeb1 --- /dev/null +++ b/crates/substratal-hub/src/pages/account.rs @@ -0,0 +1,56 @@ +//! ACC-01 Account & profile: identity from `GET /users/me`; the editable +//! profile comes from the spec scaffold until it's built. + +use std::rc::Rc; + +use adw::prelude::*; +use substratal_core::screens::Screen; + +use crate::shell::Shell; +use crate::widgets; + +pub fn page(shell: &Rc, screen: &'static Screen) -> gtk::Widget { + let outer = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + if let Some(me) = shell.ctx.me.borrow().as_ref() { + let group = adw::PreferencesGroup::builder() + .title("Signed in as") + .build(); + let email = adw::ActionRow::builder() + .title(gtk::glib::markup_escape_text(&me.email)) + .subtitle("Email") + .build(); + email.add_suffix(&widgets::status_badge(me.status.key())); + group.add(&email); + let id = adw::ActionRow::builder().title("User ID").build(); + id.add_suffix(&widgets::id_chip(&me.id)); + group.add(&id); + group.add( + &adw::ActionRow::builder() + .title("Two-factor authentication") + .subtitle(if me.mfa_enabled { "On" } else { "Off" }) + .build(), + ); + let column = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .margin_top(24) + .margin_start(16) + .margin_end(16) + .build(); + column.append(&group); + outer.append( + &adw::Clamp::builder() + .maximum_size(760) + .child(&column) + .build(), + ); + } + outer.append(&super::scaffold_content(shell, screen)); + gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vexpand(true) + .child(&outer) + .build() + .upcast() +} diff --git a/crates/substratal-hub/src/pages/mod.rs b/crates/substratal-hub/src/pages/mod.rs new file mode 100644 index 0000000..2fbf0bb --- /dev/null +++ b/crates/substratal-hub/src/pages/mod.rs @@ -0,0 +1,30 @@ +//! Screens. Hand-built screens are listed in [`build`]; everything else +//! renders from the spec through [`scaffold`]. + +mod account; +mod scaffold; +mod settings; +mod users; + +use std::rc::Rc; + +use substratal_core::screens::Screen; + +use crate::shell::Shell; + +pub fn build( + shell: &Rc, + screen: &'static Screen, + params: &[(String, String)], +) -> gtk::Widget { + let _ = params; + match screen.id.as_str() { + "USR-01" => users::page(shell, screen), + "SET-01" => settings::page(shell, screen), + "ACC-01" => account::page(shell, screen), + _ => scaffold::page(shell, screen), + } +} + +/// Hand-built screens reuse the scaffold's header for consistency. +pub(crate) use scaffold::{content as scaffold_content, page as scaffold_page}; diff --git a/crates/substratal-hub/src/pages/scaffold.rs b/crates/substratal-hub/src/pages/scaffold.rs new file mode 100644 index 0000000..798aca0 --- /dev/null +++ b/crates/substratal-hub/src/pages/scaffold.rs @@ -0,0 +1,475 @@ +//! Spec-driven scaffold for screens not hand-built yet. +//! +//! Every screen in `linux/SCREENS.md` has a route, an archetype, the +//! elements with their GTK/libadwaita control, the API calls and the states. +//! This page lays a screen out by its archetype and renders each element +//! with the control the spec names, so every one of the 75 screens is +//! reachable, navigable and reviewable now, and each hand-built screen +//! replaces its scaffold without changing routes or navigation. + +use std::rc::Rc; + +use adw::prelude::*; +use substratal_core::screens::{self, Element, Screen}; + +use crate::shell::Shell; +use crate::widgets; + +pub fn page(shell: &Rc, screen: &'static Screen) -> gtk::Widget { + gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vexpand(true) + .child(&content(shell, screen)) + .build() + .upcast() +} + +/// The scaffold without its scroller, for hand-built screens that embed it. +pub fn content(shell: &Rc, screen: &'static Screen) -> gtk::Widget { + let column = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .margin_top(24) + .margin_bottom(24) + .margin_start(16) + .margin_end(16) + .build(); + column.append(&header(shell, screen)); + + match screen.archetype.as_str() { + "table" | "list" | "grid" => column.append(&collection(screen)), + "detail" => column.append(&detail(shell, screen)), + "settings" => column.append(&form_preview(screen)), + "form" | "wizard" | "confirm" | "secret" | "popover" | "handoff" => { + column.append(&dialog_launcher(shell, screen)) + } + _ => {} + } + column.append(&spec_groups(screen)); + + adw::Clamp::builder() + .maximum_size( + if matches!(screen.archetype.as_str(), "table" | "list" | "grid") { + 1200 + } else { + 760 + }, + ) + .child(&column) + .build() + .upcast() +} + +fn header(shell: &Rc, screen: &'static Screen) -> gtk::Widget { + let b = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .build(); + let title = gtk::Label::builder() + .label(&screen.title) + .xalign(0.0) + .wrap(true) + .build(); + title.add_css_class("title-1"); + b.append(&title); + if !screen.purpose.is_empty() { + let p = gtk::Label::builder() + .label(&screen.purpose) + .xalign(0.0) + .wrap(true) + .build(); + p.add_css_class("dim-label"); + b.append(&p); + } + let tags = gtk::Box::builder().spacing(8).build(); + tags.append(&widgets::id_chip(&screen.id)); + for t in [&screen.archetype, &screen.phase] { + let l = gtk::Label::new(Some(t)); + l.add_css_class("caption"); + l.add_css_class("dim-label"); + tags.append(&l); + } + let spacer = gtk::Box::builder().hexpand(true).build(); + tags.append(&spacer); + let help = gtk::Button::builder().label("Help").build(); + help.add_css_class("flat"); + let s = shell.clone(); + let url = screen.help_url(); + help.connect_clicked(move |_| s.open_uri(&url)); + tags.append(&help); + b.append(&tags); + if !screen.who.is_empty() { + let who = gtk::Label::builder() + .label(format!("Who: {}", screen.who)) + .xalign(0.0) + .wrap(true) + .build(); + who.add_css_class("caption"); + who.add_css_class("spec-caption"); + b.append(&who); + } + b.upcast() +} + +fn collection(screen: &Screen) -> gtk::Widget { + let b = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(12) + .build(); + if screen.elements.iter().any(|e| e.kind == "search") || screen.archetype != "grid" { + let search = gtk::SearchEntry::builder() + .placeholder_text(format!("Search {}", screen.title.to_lowercase())) + .build(); + b.append(&search); + } + let chips: Vec<&Element> = screen + .elements + .iter() + .filter(|e| e.kind == "chips" || e.kind.starts_with("segmented")) + .collect(); + for c in chips { + let opts = options(&c.data); + if opts.len() < 2 { + continue; + } + let row = gtk::Box::builder().spacing(6).build(); + for (i, opt) in opts.into_iter().take(6).enumerate() { + let t = gtk::ToggleButton::builder() + .label(opt) + .active(i == 0) + .build(); + t.add_css_class("pill"); + row.append(&t); + } + b.append(&row); + } + let empty = adw::StatusPage::builder() + .icon_name("folder-documents-symbolic") + .title("Nothing loaded yet") + .description(widgets::glib_escape(&if screen.loads.is_empty() { + "This list loads from the API.".to_string() + } else { + format!("Loads {}", screen.loads.replace('`', "")) + })) + .build(); + empty.add_css_class("compact"); + let frame = gtk::Frame::builder().child(&empty).build(); + frame.add_css_class("card"); + b.append(&frame); + b.upcast() +} + +/// A detail screen: a view switcher over the tab screens nested under its route. +fn detail(shell: &Rc, screen: &'static Screen) -> gtk::Widget { + let prefix = screen.route.clone(); + let tabs: Vec<&'static Screen> = screens::all() + .iter() + .filter(|s| { + s.archetype == "tab" + && s.on_linux() + && s.route.starts_with(&prefix) + && s.route != prefix + }) + .collect(); + if tabs.is_empty() { + return form_preview(screen); + } + let stack = gtk::Stack::builder() + .transition_type(gtk::StackTransitionType::Crossfade) + .build(); + stack.add_titled(&form_preview(screen), Some(&screen.id), "Overview"); + for t in &tabs { + let short = t.title.rsplit(" — ").next().unwrap_or(&t.title); + stack.add_titled(&tab_body(shell, t), Some(&t.id), short); + } + let switcher = gtk::StackSwitcher::builder() + .stack(&stack) + .halign(gtk::Align::Center) + .build(); + let b = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(12) + .build(); + b.append(&switcher); + b.append(&stack); + b.upcast() +} + +fn tab_body(shell: &Rc, tab: &'static Screen) -> gtk::Widget { + let b = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(12) + .build(); + let row = gtk::Box::builder().spacing(8).build(); + row.append(&widgets::id_chip(&tab.id)); + let p = gtk::Label::builder() + .label(&tab.purpose) + .xalign(0.0) + .wrap(true) + .hexpand(true) + .build(); + p.add_css_class("dim-label"); + row.append(&p); + let help = gtk::Button::builder() + .icon_name("help-browser-symbolic") + .tooltip_text("Help for this tab") + .build(); + help.add_css_class("flat"); + let s = shell.clone(); + let url = tab.help_url(); + help.connect_clicked(move |_| s.open_uri(&url)); + row.append(&help); + b.append(&row); + b.append(&form_preview(tab)); + b.upcast() +} + +/// A screen that opens as a dialog: explain where it's reached from and offer a preview. +fn dialog_launcher(shell: &Rc, screen: &'static Screen) -> gtk::Widget { + let group = adw::PreferencesGroup::builder() + .title("Dialog") + .description(widgets::glib_escape(&format!( + "Shown as {} ({}).", + if screen.container.is_empty() { + "a dialog" + } else { + &screen.container + }, + screen.archetype + ))) + .build(); + let row = adw::ActionRow::builder() + .title("Preview this dialog") + .subtitle("Controls only; nothing is sent to the API") + .activatable(true) + .build(); + row.add_suffix(>k::Image::from_icon_name("go-next-symbolic")); + let s = shell.clone(); + row.connect_activated(move |_| { + let d = adw::Dialog::builder() + .title(&screen.title) + .content_width(480) + .build(); + let view = adw::ToolbarView::new(); + view.add_top_bar(&adw::HeaderBar::new()); + let clamp = adw::Clamp::builder() + .maximum_size(440) + .margin_top(12) + .margin_bottom(24) + .margin_start(12) + .margin_end(12) + .child(&form_preview(screen)) + .build(); + view.set_content(Some( + >k::ScrolledWindow::builder() + .propagate_natural_height(true) + .child(&clamp) + .build(), + )); + d.set_child(Some(&view)); + d.present(Some(&s.window)); + }); + group.add(&row); + group.upcast() +} + +/// Renders the spec's elements with the control each one names. +fn form_preview(screen: &Screen) -> gtk::Widget { + let b = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(18) + .build(); + let group = adw::PreferencesGroup::new(); + let mut buttons = gtk::Box::builder() + .spacing(12) + .halign(gtk::Align::End) + .build(); + let mut has_rows = false; + for e in &screen.elements { + let kind = e.kind.as_str(); + let title = gtk::glib::markup_escape_text(&e.name).to_string(); + let w: Option = if kind.contains("password") { + Some( + adw::PasswordEntryRow::builder() + .title(&title) + .build() + .upcast(), + ) + } else if kind.ends_with("-field") || kind == "text-area" || kind == "search" { + Some(adw::EntryRow::builder().title(&title).build().upcast()) + } else if kind == "toggle" || kind == "checkbox" { + Some( + adw::SwitchRow::builder() + .title(&title) + .subtitle(gtk::glib::markup_escape_text(&e.data)) + .build() + .upcast(), + ) + } else if kind == "select" || kind == "radio-group" || kind.starts_with("segmented") { + let opts = options(&e.data); + let model = gtk::StringList::new(&opts.iter().map(String::as_str).collect::>()); + Some( + adw::ComboRow::builder() + .title(&title) + .model(&model) + .build() + .upcast(), + ) + } else if kind == "primary-button" || kind == "button" || kind == "destructive-button" { + let btn = gtk::Button::builder().label(&e.name).build(); + match kind { + "primary-button" => btn.add_css_class("suggested-action"), + "destructive-button" => btn.add_css_class("destructive-action"), + _ => {} + } + btn.add_css_class("pill"); + btn.set_sensitive(false); + btn.set_tooltip_text(Some(&format!("{} — not wired yet", e.data))); + buttons.append(&btn); + None + } else if kind == "banner" || kind == "caption" || kind == "text" { + let l = gtk::Label::builder() + .label(format!("{} — {}", e.name, e.data)) + .xalign(0.0) + .wrap(true) + .build(); + l.add_css_class("dim-label"); + b.append(&l); + None + } else { + let row = adw::ActionRow::builder() + .title(&title) + .subtitle(gtk::glib::markup_escape_text(&e.data)) + .build(); + row.add_suffix(&kind_label(kind)); + Some(row.upcast()) + }; + if let Some(w) = w { + w.set_sensitive(!matches!(kind, "key-value" | "list" | "table")); + group.add(&w); + has_rows = true; + } + } + if has_rows { + b.append(&group); + } + if buttons.first_child().is_some() { + b.append(&std::mem::replace( + &mut buttons, + gtk::Box::new(gtk::Orientation::Horizontal, 0), + )); + } + b.upcast() +} + +fn kind_label(kind: &str) -> gtk::Label { + let l = gtk::Label::new(Some(kind)); + l.add_css_class("caption"); + l.add_css_class("dim-label"); + l +} + +/// Options named in a spec data cell: `status active | invited | suspended` → [active, invited, suspended]. +fn options(data: &str) -> Vec { + let parts: Vec = data + .split(['|', '·', ',']) + .map(|p| p.trim().trim_matches('`').to_string()) + .filter(|p| !p.is_empty()) + .collect(); + let mut parts = parts; + if let Some(first) = parts.first_mut() + && let Some((_, last_word)) = first.rsplit_once(' ') + { + *first = last_word.to_string(); + } + if parts.is_empty() { + vec!["—".into()] + } else { + parts + } +} + +/// The screen's spec, for reviewers: API calls and states. +fn spec_groups(screen: &Screen) -> gtk::Widget { + let expander = gtk::Expander::builder().label("Specification").build(); + let b = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(18) + .margin_top(12) + .build(); + if !screen.container.is_empty() || !screen.loads.is_empty() { + let g = adw::PreferencesGroup::builder() + .title("Layout and data") + .build(); + if !screen.container.is_empty() { + g.add( + &adw::ActionRow::builder() + .title("Container") + .subtitle(gtk::glib::markup_escape_text(&screen.container)) + .build(), + ); + } + if !screen.loads.is_empty() { + g.add( + &adw::ActionRow::builder() + .title("Loads") + .subtitle(gtk::glib::markup_escape_text( + &screen.loads.replace('`', ""), + )) + .subtitle_selectable(true) + .build(), + ); + } + b.append(&g); + } + if !screen.elements.is_empty() { + let g = adw::PreferencesGroup::builder().title("Elements").build(); + for e in &screen.elements { + let subtitle = [e.control.as_str(), e.data.as_str(), e.rules.as_str()] + .iter() + .filter(|s| !s.is_empty()) + .cloned() + .collect::>() + .join(" · "); + let row = adw::ActionRow::builder() + .title(gtk::glib::markup_escape_text(&e.name)) + .subtitle(gtk::glib::markup_escape_text(&subtitle)) + .subtitle_lines(3) + .build(); + row.add_suffix(&kind_label(&e.kind)); + g.add(&row); + } + b.append(&g); + } + if !screen.actions.is_empty() { + let g = adw::PreferencesGroup::builder().title("Actions").build(); + for a in &screen.actions { + let subtitle = [a.call.as_str(), a.result.as_str()] + .iter() + .filter(|s| !s.is_empty()) + .cloned() + .collect::>() + .join(" → "); + g.add( + &adw::ActionRow::builder() + .title(gtk::glib::markup_escape_text(&a.action)) + .subtitle(gtk::glib::markup_escape_text(&subtitle)) + .subtitle_lines(4) + .build(), + ); + } + b.append(&g); + } + if !screen.states.is_empty() { + let l = gtk::Label::builder() + .label(format!("States: {}", screen.states)) + .xalign(0.0) + .wrap(true) + .build(); + l.add_css_class("caption"); + l.add_css_class("dim-label"); + b.append(&l); + } + expander.set_child(Some(&b)); + expander.upcast() +} diff --git a/crates/substratal-hub/src/pages/settings.rs b/crates/substratal-hub/src/pages/settings.rs new file mode 100644 index 0000000..4ee9b94 --- /dev/null +++ b/crates/substratal-hub/src/pages/settings.rs @@ -0,0 +1,160 @@ +//! SET-01 Settings: the Appearance group (THEMES.md, PLATFORM-SPEC §4c) +//! is live; the rest of the screen comes from the spec scaffold. + +use std::rc::Rc; + +use adw::prelude::*; +use substratal_core::screens::Screen; + +use crate::shell::Shell; +use crate::{prefs, theme}; + +pub fn page(shell: &Rc, screen: &'static Screen) -> gtk::Widget { + let appearance = adw::PreferencesGroup::builder() + .title("Appearance") + .description( + "Applies on this device now and follows you to your other devices once signed in.", + ) + .build(); + + // Style: Match system / Light / Dark (Settings.theme). + let style_row = adw::ActionRow::builder().title("Style").build(); + let linked = gtk::Box::builder().valign(gtk::Align::Center).build(); + linked.add_css_class("linked"); + let current = prefs::get().color_scheme; + let mut group_leader: Option = None; + for (id, label) in [ + ("system", "Match system"), + ("light", "Light"), + ("dark", "Dark"), + ] { + let b = gtk::ToggleButton::builder() + .label(label) + .active(current == id) + .build(); + if let Some(lead) = &group_leader { + b.set_group(Some(lead)); + } else { + group_leader = Some(b.clone()); + } + b.connect_toggled(move |b| { + if b.is_active() { + prefs::update(|p| p.color_scheme = id.to_string()); + theme::apply_color_scheme(); + } + }); + linked.append(&b); + } + style_row.add_suffix(&linked); + appearance.add(&style_row); + + for (mode, title) in [("light", "Light theme"), ("dark", "Dark theme")] { + appearance.add(&swatch_row(mode, title)); + } + + let column = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(24) + .margin_top(24) + .margin_start(16) + .margin_end(16) + .build(); + column.append(&appearance); + let clamp = adw::Clamp::builder() + .maximum_size(760) + .child(&column) + .build(); + let outer = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .build(); + outer.append(&clamp); + // The rest of SET-01 (locale, notifications, privacy…) from the spec. + outer.append(&super::scaffold_content(shell, screen)); + gtk::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .vexpand(true) + .child(&outer) + .build() + .upcast() +} + +/// A `GtkFlowBox` of swatch buttons for one mode. +fn swatch_row(mode: &'static str, title: &str) -> gtk::Widget { + let selected = match mode { + "light" => prefs::get().light_theme, + _ => prefs::get().dark_theme, + }; + let row = adw::PreferencesRow::builder().activatable(false).build(); + let b = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .margin_top(12) + .margin_bottom(12) + .margin_start(12) + .margin_end(12) + .build(); + let heading = gtk::Label::builder().label(title).xalign(0.0).build(); + b.append(&heading); + let name = gtk::Label::builder().xalign(0.0).build(); + name.add_css_class("dim-label"); + let flow = gtk::FlowBox::builder() + .selection_mode(gtk::SelectionMode::None) + .max_children_per_line(10) + .min_children_per_line(5) + .column_spacing(12) + .row_spacing(12) + .homogeneous(false) + .halign(gtk::Align::Start) + .build(); + let mut leader: Option = None; + for t in theme::themes_for(mode) { + let button = gtk::ToggleButton::builder() + .tooltip_text(&t.name) + .active(t.id == selected) + .halign(gtk::Align::Start) + .valign(gtk::Align::Center) + .width_request(40) + .height_request(40) + .build(); + button.add_css_class("theme-swatch"); + button.update_property(&[ + gtk::accessible::Property::Label(&t.name), + gtk::accessible::Property::Description(&t.description), + ]); + // The swatch's own colour comes from the theme data, not a literal. + if let Some(color) = &t.swatch { + let css = gtk::CssProvider::new(); + css.load_from_string(&format!("button {{ background: {color}; }}")); + #[allow(deprecated)] + button + .style_context() + .add_provider(&css, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 2); + } + if let Some(l) = &leader { + button.set_group(Some(l)); + } else { + leader = Some(button.clone()); + } + if t.id == selected { + name.set_label(&t.name); + } + let (id, label) = (t.id.clone(), t.name.clone()); + let name = name.clone(); + button.connect_toggled(move |btn| { + if !btn.is_active() { + return; + } + prefs::update(|p| match mode { + "light" => p.light_theme = id.clone(), + _ => p.dark_theme = id.clone(), + }); + name.set_label(&label); + theme::apply(); + }); + flow.append(&button); + } + b.append(&flow); + b.append(&name); + row.set_child(Some(&b)); + row.upcast() +} diff --git a/crates/substratal-hub/src/pages/users.rs b/crates/substratal-hub/src/pages/users.rs new file mode 100644 index 0000000..7a8333f --- /dev/null +++ b/crates/substratal-hub/src/pages/users.rs @@ -0,0 +1,206 @@ +//! USR-01 Users: `GtkColumnView` (.data-table) over `GET /users`. + +use std::cell::RefCell; +use std::rc::Rc; + +use adw::prelude::*; +use gtk::{gio, glib}; +use substratal_core::models::User; +use substratal_core::screens::Screen; + +use crate::shell::Shell; +use crate::{runtime, widgets}; + +const PAGE_SIZE: u32 = 50; + +pub fn page(shell: &Rc, screen: &'static Screen) -> gtk::Widget { + if shell.ctx.is_preview() { + return super::scaffold_page(shell, screen); + } + let store = gio::ListStore::new::(); + let selection = gtk::MultiSelection::new(Some(store.clone())); + let view = gtk::ColumnView::builder() + .model(&selection) + .show_row_separators(true) + .reorderable(true) + .vexpand(true) + .build(); + view.add_css_class("data-table"); + + add_text_column(&view, "Email", true, |u| u.email.clone()); + add_widget_column(&view, "Status", |u| widgets::status_badge(u.status.key())); + add_text_column(&view, "ID", false, |u| u.id.clone()); + add_text_column(&view, "Last sign-in", false, |u| { + u.last_login_at.clone().unwrap_or_else(|| "—".into()) + }); + add_text_column(&view, "Created", false, |u| { + u.created_at.clone().unwrap_or_default() + }); + + let search = gtk::SearchEntry::builder() + .placeholder_text("Search by name, email or usr_ ID") + .hexpand(true) + .build(); + let invite = gtk::Button::builder() + .label("Invite user") + .action_name("win.navigate") + .action_target(&"/admin/users/invite".to_variant()) + .build(); + invite.add_css_class("suggested-action"); + invite.add_css_class("pill"); + invite.set_visible(shell.ctx.caps.borrow().can_manage_users()); + let more = gtk::Button::builder() + .label("Load more") + .halign(gtk::Align::Center) + .visible(false) + .build(); + let status = gtk::Label::builder().xalign(0.0).build(); + status.add_css_class("dim-label"); + + let toolbar = gtk::Box::builder().spacing(12).build(); + toolbar.append(&search); + toolbar.append(&invite); + + let cursor: Rc>> = Rc::default(); + let load: Rc = { + let (api, store, more, status, search, cursor) = ( + shell.ctx.api.clone(), + store.clone(), + more.clone(), + status.clone(), + search.clone(), + cursor.clone(), + ); + Rc::new(move |append: bool| { + let q = search.text().to_string(); + let c = if append { + cursor.borrow().clone() + } else { + None + }; + status.set_label("Loading…"); + let api = api.clone(); + let (store, more, status, cursor) = + (store.clone(), more.clone(), status.clone(), cursor.clone()); + runtime::spawn( + async move { api.list_users(Some(&q), c.as_deref(), PAGE_SIZE).await }, + move |r| match r { + Ok(page) => { + if !append { + store.remove_all(); + } + for u in page.data { + store.append(&glib::BoxedAnyObject::new(u)); + } + *cursor.borrow_mut() = page.page.next_cursor; + more.set_visible(page.page.has_more); + status.set_label(&format!("{} loaded", store.n_items())); + } + Err(e) => { + let msg = match &e { + substratal_core::Error::Api(a) => format!( + "{} (request {})", + a.user_message(), + a.request_id.as_deref().unwrap_or("—") + ), + other => other.to_string(), + }; + status.set_label(&msg); + } + }, + ); + }) + }; + let l = load.clone(); + search.connect_search_changed(move |_| l(false)); + let l = load.clone(); + more.connect_clicked(move |_| l(true)); + let s = shell.clone(); + view.connect_activate(move |v, pos| { + let Some(obj) = v + .model() + .and_then(|m| m.item(pos)) + .and_downcast::() + else { + return; + }; + let id = obj.borrow::().id.clone(); + s.navigate(&format!("/admin/users/{id}")); + }); + load(false); + + let frame = gtk::ScrolledWindow::builder() + .vexpand(true) + .child(&view) + .build(); + frame.add_css_class("card"); + let column = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(12) + .margin_top(18) + .margin_bottom(18) + .margin_start(16) + .margin_end(16) + .build(); + column.append(&toolbar); + column.append(&frame); + column.append(&status); + column.append(&more); + column.upcast() +} + +fn add_text_column( + view: >k::ColumnView, + title: &str, + expand: bool, + text: impl Fn(&User) -> String + 'static, +) { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + let label = gtk::Label::builder() + .xalign(0.0) + .ellipsize(gtk::pango::EllipsizeMode::End) + .build(); + item.downcast_ref::() + .unwrap() + .set_child(Some(&label)); + }); + factory.connect_bind(move |_, item| { + let item = item.downcast_ref::().unwrap(); + let (Some(obj), Some(label)) = ( + item.item().and_downcast::(), + item.child().and_downcast::(), + ) else { + return; + }; + label.set_label(&text(&obj.borrow::())); + }); + view.append_column( + >k::ColumnViewColumn::builder() + .title(title) + .factory(&factory) + .expand(expand) + .resizable(true) + .build(), + ); +} + +fn add_widget_column( + view: >k::ColumnView, + title: &str, + make: impl Fn(&User) -> gtk::Widget + 'static, +) { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_bind(move |_, item| { + let item = item.downcast_ref::().unwrap(); + if let Some(obj) = item.item().and_downcast::() { + item.set_child(Some(&make(&obj.borrow::()))); + } + }); + view.append_column( + >k::ColumnViewColumn::builder() + .title(title) + .factory(&factory) + .build(), + ); +} diff --git a/crates/substratal-hub/src/prefs.rs b/crates/substratal-hub/src/prefs.rs new file mode 100644 index 0000000..933250f --- /dev/null +++ b/crates/substratal-hub/src/prefs.rs @@ -0,0 +1,93 @@ +//! Device-local preferences: theme choice and window size. +//! +//! The theme choice is applied before the first window paints, then +//! reconciled with the account's settings once signed in (CLIENT-ARCHITECTURE +//! §13; OPEN-QUESTIONS #22 covers where the server stores the light/dark +//! theme ids). Nothing secret is ever written here. + +use std::cell::RefCell; +use std::path::PathBuf; + +use gtk::glib; +use serde_json::{Value, json}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Prefs { + /// `light`, `dark` or `system` (Settings.theme). + pub color_scheme: String, + pub light_theme: String, + pub dark_theme: String, + pub width: i32, + pub height: i32, +} + +impl Default for Prefs { + fn default() -> Self { + Prefs { + color_scheme: "system".into(), + light_theme: crate::theme::DEFAULT_LIGHT.into(), + dark_theme: crate::theme::DEFAULT_DARK.into(), + width: 1280, + height: 800, + } + } +} + +fn path() -> PathBuf { + glib::user_config_dir() + .join(crate::APP_ID) + .join("preferences.json") +} + +thread_local! { + static PREFS: RefCell = RefCell::new(load()); +} + +fn load() -> Prefs { + let Ok(text) = std::fs::read_to_string(path()) else { + return Prefs::default(); + }; + let Ok(v) = serde_json::from_str::(&text) else { + return Prefs::default(); + }; + let d = Prefs::default(); + let s = |k: &str, def: &str| v.get(k).and_then(Value::as_str).unwrap_or(def).to_string(); + let i = |k: &str, def: i32| { + v.get(k) + .and_then(Value::as_i64) + .map(|n| n as i32) + .unwrap_or(def) + }; + Prefs { + color_scheme: s("color_scheme", &d.color_scheme), + light_theme: s("light_theme", &d.light_theme), + dark_theme: s("dark_theme", &d.dark_theme), + width: i("width", d.width), + height: i("height", d.height), + } +} + +pub fn get() -> Prefs { + PREFS.with(|p| p.borrow().clone()) +} + +pub fn update(f: impl FnOnce(&mut Prefs)) { + PREFS.with(|p| { + let mut p = p.borrow_mut(); + f(&mut p); + let v = json!({ + "color_scheme": p.color_scheme, + "light_theme": p.light_theme, + "dark_theme": p.dark_theme, + "width": p.width, + "height": p.height, + }); + let file = path(); + if let Some(dir) = file.parent() { + let _ = std::fs::create_dir_all(dir); + } + if let Err(e) = std::fs::write(&file, serde_json::to_string_pretty(&v).unwrap()) { + eprintln!("substratal: couldn't save preferences: {e}"); + } + }); +} diff --git a/crates/substratal-hub/src/runtime.rs b/crates/substratal-hub/src/runtime.rs new file mode 100644 index 0000000..a77f934 --- /dev/null +++ b/crates/substratal-hub/src/runtime.rs @@ -0,0 +1,42 @@ +//! Bridges the GLib main loop (UI) and a tokio runtime (HTTP). +//! +//! `reqwest` needs tokio, and the UI must never block, so API work runs on +//! a tokio worker pool and its result comes back to the main thread. + +use std::future::Future; +use std::sync::OnceLock; + +use gtk::glib; + +fn runtime() -> &'static tokio::runtime::Runtime { + static RT: OnceLock = OnceLock::new(); + RT.get_or_init(|| { + tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .thread_name("substratal-io") + .build() + .expect("tokio runtime") + }) +} + +/// Runs `fut` on the I/O runtime and hands its output to `done` on the +/// main thread. +pub fn spawn(fut: F, done: impl FnOnce(T) + 'static) +where + F: Future + Send + 'static, + T: Send + 'static, +{ + let handle = runtime().spawn(fut); + glib::spawn_future_local(async move { + match handle.await { + Ok(v) => done(v), + Err(e) => eprintln!("substratal: background task failed: {e}"), + } + }); +} + +/// Blocks on `fut`. Only for start-up, before the first window exists. +pub fn block_on(fut: F) -> F::Output { + runtime().block_on(fut) +} diff --git a/crates/substratal-hub/src/shell.rs b/crates/substratal-hub/src/shell.rs new file mode 100644 index 0000000..1b9c868 --- /dev/null +++ b/crates/substratal-hub/src/shell.rs @@ -0,0 +1,484 @@ +//! The main window (linux/PLATFORM-SPEC.md §2): `AdwApplicationWindow` with +//! an `AdwNavigationSplitView` (sidebar · content), collapsing below 600 sp, +//! the GLB-03 system-state banners, and the window actions. + +use std::cell::RefCell; +use std::rc::Rc; + +use adw::prelude::*; +use gtk::{gio, glib}; +use substratal_core::nav::{self, Section}; +use substratal_core::version::{self, VersionStatus}; +use substratal_core::{config, screens}; + +use crate::context::AppContext; +use crate::{dialogs, pages, prefs, runtime}; + +/// A window action's handler. +type ShellAction = Box)>; + +pub struct Shell { + pub window: adw::ApplicationWindow, + pub ctx: Rc, + split: adw::NavigationSplitView, + sidebar: gtk::ListBox, + content_page: adw::NavigationPage, + title: adw::WindowTitle, + body: adw::Bin, + toasts: adw::ToastOverlay, + update_banner: adw::Banner, + offline_banner: adw::Banner, + route: RefCell, + history: RefCell>, +} + +impl Shell { + pub fn new(app: &adw::Application, ctx: Rc) -> Rc { + let p = prefs::get(); + let window = adw::ApplicationWindow::builder() + .application(app) + .title("Substratal") + .default_width(p.width) + .default_height(p.height) + .width_request(360) + .height_request(294) + .icon_name(crate::APP_ID) + .build(); + + // ── Sidebar ───────────────────────────────────────────────────── + let sidebar = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::Single) + .build(); + sidebar.add_css_class("navigation-sidebar"); + let sidebar_header = adw::HeaderBar::new(); + sidebar_header.set_title_widget(Some(&adw::WindowTitle::new("Substratal", ""))); + let search_button = gtk::Button::builder() + .icon_name("system-search-symbolic") + .tooltip_text("Search (Ctrl+K)") + .action_name("win.command-palette") + .build(); + sidebar_header.pack_start(&search_button); + sidebar_header.pack_end(&primary_menu_button()); + let sidebar_view = adw::ToolbarView::new(); + sidebar_view.add_top_bar(&sidebar_header); + sidebar_view.set_content(Some( + >k::ScrolledWindow::builder() + .hscrollbar_policy(gtk::PolicyType::Never) + .child(&sidebar) + .build(), + )); + let sidebar_page = adw::NavigationPage::builder() + .title("Substratal") + .tag("sidebar") + .child(&sidebar_view) + .build(); + + // ── Content ───────────────────────────────────────────────────── + let title = adw::WindowTitle::new("", ""); + let header = adw::HeaderBar::new(); + header.set_title_widget(Some(&title)); + let help = gtk::Button::builder() + .icon_name("help-browser-symbolic") + .tooltip_text("Help for this screen (F1)") + .action_name("win.help") + .build(); + let refresh = gtk::Button::builder() + .icon_name("view-refresh-symbolic") + .tooltip_text("Refresh (Ctrl+R)") + .action_name("win.refresh") + .build(); + header.pack_end(&help); + header.pack_end(&refresh); + + let update_banner = adw::Banner::builder() + .button_label("Update") + .revealed(false) + .build(); + let offline_banner = adw::Banner::builder() + .title("You're offline. Showing what was loaded; changes are paused.") + .revealed(false) + .build(); + let env_banner = adw::Banner::new(""); + if let Some(label) = ctx.api.environment().label() { + env_banner.set_title(&format!("Connected to {label}, not production")); + env_banner.set_revealed(true); + } + let preview_banner = + adw::Banner::new("Preview mode: layout only, nothing is loaded from the API"); + preview_banner.set_revealed(ctx.is_preview()); + + let body = adw::Bin::new(); + let content_view = adw::ToolbarView::new(); + content_view.add_top_bar(&header); + for b in [ + &preview_banner, + &env_banner, + &update_banner, + &offline_banner, + ] { + content_view.add_top_bar(b); + } + content_view.set_content(Some(&body)); + let content_page = adw::NavigationPage::builder() + .title("") + .tag("content") + .child(&content_view) + .build(); + + let split = adw::NavigationSplitView::builder() + .sidebar(&sidebar_page) + .content(&content_page) + .min_sidebar_width(220.0) + .build(); + let toasts = adw::ToastOverlay::new(); + toasts.set_child(Some(&split)); + window.set_content(Some(&toasts)); + + // ≤ 600 sp: the sidebar becomes the first page (PLATFORM-SPEC §2). + let narrow = + adw::Breakpoint::new(adw::BreakpointCondition::parse("max-width: 600sp").unwrap()); + narrow.add_setter(&split, "collapsed", Some(&true.to_value())); + window.add_breakpoint(narrow); + + let shell = Rc::new(Shell { + window, + ctx, + split, + sidebar, + content_page, + title, + body, + toasts, + update_banner, + offline_banner, + route: RefCell::new(String::new()), + history: RefCell::new(Vec::new()), + }); + shell.build_sidebar(); + shell.install_actions(); + shell.watch_version(); + shell.watch_network(); + shell.window.connect_close_request(|w| { + let (width, height) = (w.default_width(), w.default_height()); + prefs::update(|p| { + p.width = width; + p.height = height; + }); + glib::Propagation::Proceed + }); + shell + } + + fn build_sidebar(self: &Rc) { + while let Some(row) = self.sidebar.row_at_index(0) { + self.sidebar.remove(&row); + } + let caps = self.ctx.caps.borrow().clone(); + let mut first_of_section = Vec::new(); + for (section, items) in nav::visible_sections(&caps) { + for (i, item) in items.iter().enumerate() { + let row_box = gtk::Box::builder() + .spacing(12) + .margin_top(6) + .margin_bottom(6) + .margin_start(6) + .margin_end(6) + .build(); + row_box.append(>k::Image::from_icon_name(item.icon)); + let label = gtk::Label::builder() + .label(item.title) + .xalign(0.0) + .hexpand(true) + .build(); + row_box.append(&label); + let row = gtk::ListBoxRow::builder() + .child(&row_box) + .name(item.screen_id) + .build(); + row.update_property(&[gtk::accessible::Property::Label(item.title)]); + if i == 0 && section != Section::Personal { + first_of_section.push((row.clone(), section)); + } + self.sidebar.append(&row); + } + } + // Section headings above Administration and Developer. + self.sidebar.set_header_func(move |row, _| { + let heading = first_of_section + .iter() + .find(|(r, _)| r == row) + .map(|(_, s)| s.title()); + row.set_header( + heading + .map(|h| { + let l = gtk::Label::builder() + .label(h) + .xalign(0.0) + .margin_start(12) + .margin_top(12) + .margin_bottom(6) + .build(); + l.add_css_class("heading"); + l.add_css_class("dim-label"); + l.upcast::() + }) + .as_ref(), + ); + }); + let weak = Rc::downgrade(self); + self.sidebar.connect_row_activated(move |_, row| { + if let Some(shell) = weak.upgrade() + && let Some(item) = nav::TREE + .iter() + .find(|i| i.screen_id == row.widget_name().as_str()) + { + shell.navigate(item.route); + shell.split.set_show_content(true); + } + }); + } + + pub fn current_route(&self) -> String { + self.route.borrow().clone() + } + + pub fn current_screen(&self) -> Option<&'static screens::Screen> { + screens::match_route(&self.route.borrow(), false).and_then(|m| screens::get(&m.screen_id)) + } + + /// Shows the screen for `route`. Unknown routes show "Not found"; a + /// route the person can't use shows Forbidden, not a redirect + /// (INFORMATION-ARCHITECTURE §4). + pub fn navigate(self: &Rc, route: &str) { + let previous = self.route.replace(route.to_string()); + if !previous.is_empty() && previous != route { + self.history.borrow_mut().push(previous); + } + let matched = screens::match_route(route, false); + let (widget, title, subtitle) = match matched + .as_ref() + .and_then(|m| screens::get(&m.screen_id).map(|s| (s, m))) + { + Some((screen, m)) => { + let allowed = nav::item_for_route(route) + .is_none_or(|item| item.is_visible(&self.ctx.caps.borrow())); + let w = if allowed { + pages::build(self, screen, &m.params) + } else { + widgets::status_page( + "action-unavailable-symbolic", + "You don't have access to this", + "Ask an administrator if you need it.", + ) + }; + (w, screen.title.clone(), screen.id.clone()) + } + None => ( + widgets::status_page("find-location-symbolic", "Page not found", route), + "Not found".into(), + String::new(), + ), + }; + self.title.set_title(&title); + self.title.set_subtitle(&subtitle); + self.content_page.set_title(&title); + self.window + .set_title(Some(&format!("{title} — Substratal"))); + self.body.set_child(Some(&widget)); + self.select_sidebar_row(route); + } + + fn select_sidebar_row(&self, route: &str) { + let target = nav::item_for_route(route).map(|i| i.screen_id); + let mut i = 0; + while let Some(row) = self.sidebar.row_at_index(i) { + if Some(row.widget_name().as_str()) == target { + self.sidebar.select_row(Some(&row)); + return; + } + i += 1; + } + self.sidebar.select_row(None::<>k::ListBoxRow>); + } + + pub fn toast(&self, text: &str) { + self.toasts.add_toast(adw::Toast::new(text)); + } + + /// Opens a URL with the OpenURI portal. + pub fn open_uri(&self, uri: &str) { + gtk::UriLauncher::new(uri).launch(Some(&self.window), gio::Cancellable::NONE, |r| { + if let Err(e) = r { + eprintln!("substratal: couldn't open link: {e}"); + } + }); + } + + fn help_for_current(&self) { + let url = self + .current_screen() + .map(|s| s.help_url()) + .unwrap_or_else(|| config::HELP_BASE.to_string()); + self.open_uri(&url); + } + + fn install_actions(self: &Rc) { + let win = &self.window; + let add = |name: &str, f: ShellAction| { + let action = gio::SimpleAction::new(name, None); + let weak = Rc::downgrade(self); + action.connect_activate(move |_, _| { + if let Some(s) = weak.upgrade() { + f(&s); + } + }); + win.add_action(&action); + }; + add("help", Box::new(|s| s.help_for_current())); + add("command-palette", Box::new(dialogs::command_palette)); + add("search", Box::new(dialogs::command_palette)); + add("show-shortcuts", Box::new(dialogs::shortcuts)); + add("about", Box::new(dialogs::about)); + add("preferences", Box::new(|s| s.navigate("/settings"))); + add("refresh", Box::new(|s| s.navigate(&s.current_route()))); + add( + "toggle-sidebar", + Box::new(|s| s.split.set_collapsed(!s.split.is_collapsed())), + ); + add( + "back", + Box::new(|s| { + let prev = s.history.borrow_mut().pop(); + if let Some(prev) = prev { + s.navigate(&prev); + s.history.borrow_mut().pop(); + } + }), + ); + add("sign-out", Box::new(|s| s.sign_out())); + for name in ["new", "undo", "delete", "mark-all-read"] { + add( + name, + Box::new(|s| s.toast("Nothing to do on this screen yet")), + ); + } + + // win.go::SCREEN-ID (Alt+1…6) and win.navigate::/route. + for (name, by_id) in [("go", true), ("navigate", false)] { + let action = gio::SimpleAction::new(name, Some(glib::VariantTy::STRING)); + let weak = Rc::downgrade(self); + action.connect_activate(move |_, param| { + let (Some(s), Some(arg)) = (weak.upgrade(), param.and_then(|p| p.get::())) + else { + return; + }; + let route = if by_id { + screens::get(&arg).map(|sc| sc.route.clone()) + } else { + Some(arg) + }; + if let Some(route) = route { + s.navigate(&route); + } + }); + win.add_action(&action); + } + } + + fn sign_out(self: &Rc) { + let ctx = self.ctx.clone(); + let app = self.window.application().and_downcast::(); + let api = ctx.api.clone(); + runtime::spawn(async move { api.logout().await }, move |r| { + if let Err(e) = r { + eprintln!("substratal: sign-out: {e}"); + } + ctx.me.replace(None); + ctx.caps.replace(Default::default()); + if let Some(app) = app { + crate::show_sign_in(&app, ctx, None); + } + }); + } + + /// GLB-03 Update available (banner) and Update required (blocking). + fn watch_version(self: &Rc) { + let mut rx = self.ctx.api.version_status(); + let (tx, ui_rx) = async_channel::unbounded::(); + runtime::spawn( + async move { + while rx.changed().await.is_ok() { + let v = rx.borrow().clone(); + if tx.send(v).await.is_err() { + break; + } + } + }, + |_| {}, + ); + let weak = Rc::downgrade(self); + glib::spawn_future_local(async move { + while let Ok(status) = ui_rx.recv().await { + let Some(s) = weak.upgrade() else { break }; + match status { + VersionStatus::Current => s.update_banner.set_revealed(false), + VersionStatus::Deprecated { sunset, .. } => { + let when = sunset + .map(version::fmt_date) + .unwrap_or_else(|| "soon".into()); + s.update_banner.set_title(&format!("A newer version of Substratal is available. This version stops working {when}.")); + s.update_banner.set_revealed(true); + } + VersionStatus::Unsupported { .. } => { + s.body.set_child(Some(&widgets::status_page( + "software-update-urgent-symbolic", + "Update required", + "This version of Substratal no longer works with the service. Update the app to keep using it.", + ))); + s.title.set_title("Update required"); + } + } + } + }); + let weak = Rc::downgrade(self); + self.update_banner.connect_button_clicked(move |_| { + if let Some(s) = weak.upgrade() { + s.open_uri("appstream://com.substratalapps.Hub"); + } + }); + } + + /// GLB-03 Offline banner from GNetworkMonitor. + fn watch_network(self: &Rc) { + let monitor = gio::NetworkMonitor::default(); + self.offline_banner + .set_revealed(!monitor.is_network_available()); + let weak = Rc::downgrade(self); + monitor.connect_network_changed(move |_, available| { + if let Some(s) = weak.upgrade() { + s.offline_banner.set_revealed(!available); + } + }); + } +} + +fn primary_menu_button() -> gtk::MenuButton { + let menu = gio::Menu::new(); + let section = gio::Menu::new(); + section.append(Some("_Preferences"), Some("win.preferences")); + section.append(Some("_Keyboard Shortcuts"), Some("win.show-shortcuts")); + section.append(Some("_Help"), Some("win.help")); + section.append(Some("_About Substratal"), Some("win.about")); + menu.append_section(None, §ion); + let account = gio::Menu::new(); + account.append(Some("_Sign Out"), Some("win.sign-out")); + menu.append_section(None, &account); + gtk::MenuButton::builder() + .icon_name("open-menu-symbolic") + .menu_model(&menu) + .tooltip_text("Main menu") + .primary(true) + .build() +} + +use crate::widgets; diff --git a/crates/substratal-hub/src/sign_in.rs b/crates/substratal-hub/src/sign_in.rs new file mode 100644 index 0000000..4e95e07 --- /dev/null +++ b/crates/substratal-hub/src/sign_in.rs @@ -0,0 +1,263 @@ +//! AUTH-01 Sign in and AUTH-02 Two-factor challenge, from `ui/sign-in.blp`. + +use std::cell::RefCell; +use std::rc::Rc; + +use adw::prelude::*; +use gtk::{gio, glib}; +use substratal_core::models::LoginOutcome; +use substratal_core::{Error, config}; + +use crate::context::AppContext; +use crate::runtime; + +fn obj>(b: >k::Builder, id: &str) -> T { + b.object(id) + .unwrap_or_else(|| panic!("sign-in.ui is missing {id}")) +} + +fn error_text(e: &Error) -> String { + match e { + Error::Api(a) if a.status == 429 => match a.retry_after { + Some(d) => format!( + "Too many attempts. Try again in {} seconds.", + d.as_secs().max(1) + ), + None => a.user_message(), + }, + Error::Api(a) => a.user_message(), + Error::Network(_) => { + "Couldn't reach Substratal. Check your connection and try again.".into() + } + Error::UpdateRequired { .. } => { + "This version of Substratal no longer works with the service. Update the app.".into() + } + other => other.to_string(), + } +} + +pub fn present(app: &adw::Application, ctx: Rc, message: Option) { + let b = gtk::Builder::from_resource("/com/substratalapps/Hub/ui/sign-in.ui"); + let window: adw::Window = obj(&b, "sign_in_window"); + window.set_application(Some(app)); + let nav: adw::NavigationView = obj(&b, "nav"); + let mfa_page: adw::NavigationPage = obj(&b, "mfa_page"); + let email: adw::EntryRow = obj(&b, "email_row"); + let password: adw::PasswordEntryRow = obj(&b, "password_row"); + let error: gtk::Label = obj(&b, "error_label"); + let sign_in: gtk::Button = obj(&b, "sign_in_button"); + let code: adw::EntryRow = obj(&b, "code_row"); + let mfa_error: gtk::Label = obj(&b, "mfa_error_label"); + let verify: gtk::Button = obj(&b, "verify_button"); + let logo: gtk::Picture = obj(&b, "logo"); + let env_banner: adw::Banner = obj(&b, "env_banner"); + let forgot: gtk::LinkButton = obj(&b, "forgot_link"); + let sign_up: gtk::LinkButton = obj(&b, "sign_up_link"); + + // BrandLockup above the form, swapped for the on-dark variant in dark mode. + let set_logo = glib::clone!( + #[weak] + logo, + move |sm: &adw::StyleManager| { + let file = if sm.is_dark() { + "lockup-horizontal-on-dark.svg" + } else { + "lockup-horizontal.svg" + }; + logo.set_resource(Some(&format!("/com/substratalapps/Hub/brand/{file}"))); + } + ); + let sm = adw::StyleManager::default(); + set_logo(&sm); + sm.connect_dark_notify(set_logo); + + if let Some(label) = ctx.api.environment().label() { + env_banner.set_title(&format!("Signing in to {label}, not production")); + env_banner.set_revealed(true); + } + // AUTH-03 and AUTH-05 aren't built yet; until then they open on the web dashboard. + forgot.set_uri(&format!("{}/forgot-password", config::DASHBOARD)); + sign_up.set_uri(&format!("{}/sign-up", config::DASHBOARD)); + + if let Some(m) = message { + error.set_label(&m); + error.set_visible(true); + } + + let help = gio::SimpleAction::new("help", None); + help.connect_activate(glib::clone!( + #[weak] + window, + #[weak] + nav, + move |_, _| { + let id = nav + .visible_page() + .and_then(|p| p.tag()) + .map(|t| t.to_string()) + .unwrap_or_else(|| "AUTH-01".into()); + gtk::UriLauncher::new(&config::help_url(&id)).launch( + Some(&window), + gio::Cancellable::NONE, + |_| {}, + ); + } + )); + let group = gio::SimpleActionGroup::new(); + group.add_action(&help); + window.insert_action_group("signin", Some(&group)); + let f1 = gtk::ShortcutController::new(); + f1.add_shortcut(gtk::Shortcut::new( + gtk::ShortcutTrigger::parse_string("F1"), + Some(gtk::NamedAction::new("signin.help")), + )); + window.add_controller(f1); + + // Sign in is disabled until both fields are filled (AUTH-01 rules). + let update = glib::clone!( + #[weak] + email, + #[weak] + password, + #[weak] + sign_in, + move || { + sign_in.set_sensitive(!email.text().trim().is_empty() && !password.text().is_empty()); + } + ); + let u = update.clone(); + email.connect_changed(move |_| u()); + password.connect_changed(move |_| update()); + code.connect_changed(glib::clone!( + #[weak] + verify, + move |c| verify.set_sensitive(!c.text().trim().is_empty()) + )); + + let mfa_token: Rc>> = Rc::default(); + + let finish = { + let app = app.clone(); + let ctx = ctx.clone(); + let window = window.clone(); + Rc::new(move || { + let api = ctx.api.clone(); + let (app, ctx, window) = (app.clone(), ctx.clone(), window.clone()); + runtime::spawn(async move { api.capabilities().await }, move |r| match r { + Ok((me, caps)) => { + *ctx.me.borrow_mut() = Some(me); + *ctx.caps.borrow_mut() = caps; + crate::show_shell(&app, ctx); + window.close(); + } + Err(e) => eprintln!("substratal: couldn't load your permissions: {e}"), + }); + }) + }; + + let submit = { + let (ctx, nav, mfa_page, mfa_token, finish) = ( + ctx.clone(), + nav.clone(), + mfa_page.clone(), + mfa_token.clone(), + finish.clone(), + ); + let (email, password, error, sign_in) = ( + email.clone(), + password.clone(), + error.clone(), + sign_in.clone(), + ); + move || { + if !sign_in.is_sensitive() { + return; + } + sign_in.set_sensitive(false); + email.set_editable(false); + password.set_editable(false); + error.set_visible(false); + let (api, e, p) = ( + ctx.api.clone(), + email.text().trim().to_string(), + password.text().to_string(), + ); + let (nav, mfa_page, mfa_token, finish) = ( + nav.clone(), + mfa_page.clone(), + mfa_token.clone(), + finish.clone(), + ); + let (email, password, error, sign_in) = ( + email.clone(), + password.clone(), + error.clone(), + sign_in.clone(), + ); + runtime::spawn(async move { api.login(&e, &p).await }, move |r| { + email.set_editable(true); + password.set_editable(true); + sign_in.set_sensitive(true); + match r { + Ok(LoginOutcome::Session(_)) => finish(), + Ok(LoginOutcome::MfaRequired(c)) => { + *mfa_token.borrow_mut() = Some(c.mfa_token); + nav.push(&mfa_page); + } + Err(e) => { + error.set_label(&error_text(&e)); + error.set_visible(true); + password.set_text(""); + } + } + }); + } + }; + let s = submit.clone(); + sign_in.connect_clicked(move |_| s()); + let s = submit.clone(); + email.connect_entry_activated(move |_| s()); + password.connect_entry_activated(move |_| submit()); + + let verify_now = { + let (ctx, code, mfa_error, verify, mfa_token, finish) = ( + ctx.clone(), + code.clone(), + mfa_error.clone(), + verify.clone(), + mfa_token.clone(), + finish.clone(), + ); + move || { + let Some(token) = mfa_token.borrow().clone() else { + return; + }; + verify.set_sensitive(false); + mfa_error.set_visible(false); + let (api, c) = (ctx.api.clone(), code.text().to_string()); + let (code, mfa_error, verify, finish) = ( + code.clone(), + mfa_error.clone(), + verify.clone(), + finish.clone(), + ); + runtime::spawn(async move { api.verify_mfa(&token, &c).await }, move |r| { + verify.set_sensitive(true); + match r { + Ok(_) => finish(), + Err(e) => { + mfa_error.set_label(&error_text(&e)); + mfa_error.set_visible(true); + code.set_text(""); + } + } + }); + } + }; + let v = verify_now.clone(); + verify.connect_clicked(move |_| v()); + code.connect_entry_activated(move |_| verify_now()); + + window.present(); + email.grab_focus(); +} diff --git a/crates/substratal-hub/src/theme.rs b/crates/substratal-hub/src/theme.rs new file mode 100644 index 0000000..4a3bd92 --- /dev/null +++ b/crates/substratal-hub/src/theme.rs @@ -0,0 +1,106 @@ +//! The theme system (THEMES.md, linux/theme/README.md): 5 light and 5 dark +//! themes as libadwaita named colours, one application-priority +//! `GtkCssProvider`, reloaded on `notify::dark`, skipped in high contrast. + +use std::sync::OnceLock; + +use gtk::gdk; +use serde::Deserialize; + +pub const DEFAULT_LIGHT: &str = "moss-paper"; +pub const DEFAULT_DARK: &str = "forest-floor"; + +#[derive(Clone, Debug, Deserialize)] +pub struct ThemeInfo { + pub id: String, + pub name: String, + pub mode: String, + pub description: String, + pub swatch: Option, +} + +#[derive(Deserialize)] +struct ThemeFile { + themes: Vec, +} + +pub fn themes() -> &'static [ThemeInfo] { + static T: OnceLock> = OnceLock::new(); + T.get_or_init(|| { + let f: ThemeFile = serde_json::from_str(include_str!("../../../data/spec/themes.json")) + .expect("themes.json"); + f.themes + }) +} + +pub fn themes_for(mode: &str) -> impl Iterator { + themes().iter().filter(move |t| t.mode == mode) +} + +fn known(mode: &str, id: &str) -> bool { + themes_for(mode).any(|t| t.id == id) +} + +thread_local! { + static PROVIDER: gtk::CssProvider = gtk::CssProvider::new(); +} + +/// Installs the providers and starts following the style manager. +pub fn init() { + let display = gdk::Display::default().expect("a display"); + let app_css = gtk::CssProvider::new(); + app_css.load_from_resource("/com/substratalapps/Hub/style.css"); + // The theme provider sits below the app stylesheet so `style.css` can use its colours. + PROVIDER.with(|p| { + gtk::style_context_add_provider_for_display( + &display, + p, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + }); + gtk::style_context_add_provider_for_display( + &display, + &app_css, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 1, + ); + + let sm = adw::StyleManager::default(); + sm.connect_dark_notify(|_| apply()); + sm.connect_high_contrast_notify(|_| apply()); + apply_color_scheme(); +} + +/// Maps Settings.theme to AdwStyleManager:color-scheme, then loads the CSS. +pub fn apply_color_scheme() { + let scheme = match crate::prefs::get().color_scheme.as_str() { + "light" => adw::ColorScheme::ForceLight, + "dark" => adw::ColorScheme::ForceDark, + _ => adw::ColorScheme::PreferLight, + }; + adw::StyleManager::default().set_color_scheme(scheme); + apply(); +} + +/// Loads the active light or dark theme. An unknown id falls back to the +/// default for its mode without telling the person (CLIENT-ARCHITECTURE §13). +pub fn apply() { + let sm = adw::StyleManager::default(); + PROVIDER.with(|p| { + if sm.is_high_contrast() { + p.load_from_string(""); + return; + } + let prefs = crate::prefs::get(); + let (mode, id, fallback) = if sm.is_dark() { + ("dark", prefs.dark_theme, DEFAULT_DARK) + } else { + ("light", prefs.light_theme, DEFAULT_LIGHT) + }; + let id = if known(mode, &id) { + id + } else { + fallback.to_string() + }; + p.load_from_resource(&format!("/com/substratalapps/Hub/theme/{mode}/{id}.css")); + }); +} diff --git a/crates/substratal-hub/src/widgets.rs b/crates/substratal-hub/src/widgets.rs new file mode 100644 index 0000000..7d86eec --- /dev/null +++ b/crates/substratal-hub/src/widgets.rs @@ -0,0 +1,97 @@ +//! Small shared widgets (COMPONENTS.md). + +use std::collections::HashMap; +use std::sync::OnceLock; + +use adw::prelude::*; +use serde_json::Value; + +/// A full-page `AdwStatusPage`. +pub fn status_page(icon: &str, title: &str, description: &str) -> gtk::Widget { + adw::StatusPage::builder() + .icon_name(icon) + .title(title) + .description(glib_escape(description)) + .vexpand(true) + .build() + .upcast() +} + +/// Escapes text for Pango markup (status page descriptions use markup). +pub fn glib_escape(s: &str) -> String { + gtk::glib::markup_escape_text(s).to_string() +} + +struct StatusInfo { + label: String, + role: String, + icon: Option, +} + +fn statuses() -> &'static HashMap { + static S: OnceLock> = OnceLock::new(); + S.get_or_init(|| { + let v: Value = + serde_json::from_str(include_str!("../../../data/spec/status-semantics.json")) + .unwrap_or(Value::Null); + v.get("statuses") + .and_then(Value::as_object) + .map(|m| { + m.iter() + .map(|(k, s)| { + ( + k.clone(), + StatusInfo { + label: s + .get("label") + .and_then(Value::as_str) + .unwrap_or(k) + .to_string(), + role: s + .get("role") + .and_then(Value::as_str) + .unwrap_or("neutral") + .to_string(), + icon: s + .pointer("/icon/adwaita") + .and_then(Value::as_str) + .map(str::to_string), + }, + ) + }) + .collect() + }) + .unwrap_or_default() + }) +} + +/// StatusBadge: icon + label, never colour alone (status-semantics.json). +pub fn status_badge(status: &str) -> gtk::Widget { + let (label, role, icon) = match statuses().get(status) { + Some(s) => (s.label.clone(), s.role.clone(), s.icon.clone()), + None => (status.to_string(), "neutral".to_string(), None), + }; + let b = gtk::Box::builder() + .spacing(4) + .halign(gtk::Align::Start) + .valign(gtk::Align::Center) + .build(); + b.add_css_class("status-badge"); + b.add_css_class(&role); + if let Some(icon) = icon { + b.append(>k::Image::from_icon_name(&icon)); + } + b.append(>k::Label::new(Some(&label))); + b.update_property(&[gtk::accessible::Property::Label(&format!( + "Status: {label}" + ))]); + b.upcast() +} + +/// A monospace chip showing a screen or object id. +pub fn id_chip(text: &str) -> gtk::Label { + let l = gtk::Label::new(Some(text)); + l.add_css_class("screen-id"); + l.set_selectable(true); + l +} diff --git a/crates/substratalctl/Cargo.toml b/crates/substratalctl/Cargo.toml new file mode 100644 index 0000000..b7af50c --- /dev/null +++ b/crates/substratalctl/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "substratalctl" +description = "Command-line client for the Substratal Apps Platform API, sharing the Hub's core." +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "substratalctl" +path = "src/main.rs" + +[features] +default = ["keyring"] +keyring = ["substratal-core/keyring"] + +[dependencies] +substratal-core.workspace = true +anyhow.workspace = true +clap.workspace = true +serde_json.workspace = true +tokio.workspace = true diff --git a/crates/substratalctl/src/lib.rs b/crates/substratalctl/src/lib.rs new file mode 100644 index 0000000..056b30f --- /dev/null +++ b/crates/substratalctl/src/lib.rs @@ -0,0 +1,252 @@ +//! `substratalctl`: the Substratal Platform API from a terminal, sharing the +//! Hub's core (same versioning, auth, refresh, errors and permissions). +//! +//! The command definition lives in the library so `xtask` can generate the +//! CLI reference page in the help center from it. + +use std::io::{BufRead, Write}; + +use anyhow::{Context, Result, bail}; +use clap::{Parser, Subcommand}; +use substratal_core::models::LoginOutcome; +use substratal_core::secrets::SecretStore; +use substratal_core::{ApiClient, Environment, config, screens, version}; + +#[derive(Parser, Debug)] +#[command(name = "substratalctl", version, about = "Command-line client for the Substratal Apps Platform API", long_about = None)] +pub struct Cli { + /// API environment: `production`, `local` (the devserver on :8080) or a base URL. + #[arg( + long, + global = true, + env = "SUBSTRATAL_API", + default_value = "production" + )] + pub api: String, + /// Don't use the Secret Service; you sign in again each run. + #[arg(long, global = true)] + pub no_keyring: bool, + /// Print JSON instead of a table. + #[arg(long, global = true)] + pub json: bool, + #[command(subcommand)] + pub command: Command, +} + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Check that the API is up (`GET /health`). + Health, + /// Show this client's version, the API version it sends, and the base URL. + Version, + /// Sign in with email and password (and a TOTP code if two-factor is on). + Login { + #[arg(long)] + email: String, + /// Read the password from this environment variable instead of prompting. + #[arg(long, value_name = "VAR")] + password_env: Option, + }, + /// Sign out and forget the saved session. + Logout, + /// Show the signed-in user and their platform permissions. + Whoami, + /// Manage users. + #[command(subcommand)] + Users(UsersCommand), + /// List the Hub's screens (IDs, routes, who can use them). + Screens { + /// Include web-only screens (the Console). + #[arg(long)] + all: bool, + }, + /// Resolve a route or `substratal://` link to its screen. + Route { path: String }, + /// Print the help-center URL for a screen ID, e.g. `USR-01`. + HelpUrl { screen_id: String }, +} + +#[derive(Subcommand, Debug)] +pub enum UsersCommand { + /// List users (`GET /users`); needs `users.list`. + List { + /// Search by name, email or `usr_` ID prefix. + #[arg(long, short)] + query: Option, + #[arg(long, default_value_t = 50)] + limit: u32, + }, +} + +/// The clap command, for documentation generation. +pub fn command() -> clap::Command { + ::command() +} + +pub async fn run(cli: Cli) -> Result<()> { + let env = Environment::parse(&cli.api); + match &cli.command { + Command::Screens { all } => return list_screens(*all, cli.json), + Command::Route { path } => return route(path), + Command::HelpUrl { screen_id } => { + let s = screens::get(screen_id).with_context(|| format!("no screen {screen_id}"))?; + println!("{}", s.help_url()); + return Ok(()); + } + Command::Version => { + println!("substratalctl {}", env!("CARGO_PKG_VERSION")); + println!( + "API version {}: {} (header, not in the URL)", + version::VERSION_HEADER, + version::API_VERSION + ); + println!("API base URL {}", env.base_url()); + println!("Specs {}", screens::spec_commit()); + return Ok(()); + } + _ => {} + } + + // Only commands that use a session touch the Secret Service. + let needs_session = !matches!(cli.command, Command::Health); + let secrets = if cli.no_keyring || !needs_session { + SecretStore::memory() + } else { + SecretStore::system().await + }; + let ua = format!( + "Substratal-CLI/{} ({})", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS + ); + let api = ApiClient::new(env, secrets, &ua, "en-US")?; + + match cli.command { + Command::Health => { + let h = api.health().await?; + println!("{}", h.status); + } + Command::Login { + email, + password_env, + } => { + let password = match password_env { + Some(var) => std::env::var(&var).with_context(|| format!("{var} isn't set"))?, + None => prompt("Password: ")?, + }; + match api.login(&email, &password).await? { + LoginOutcome::Session(s) => println!("Signed in as {}", s.user_id), + LoginOutcome::MfaRequired(c) => { + let code = prompt("Two-factor code (or recovery code): ")?; + let s = api.verify_mfa(&c.mfa_token, &code).await?; + println!("Signed in as {}", s.user_id); + } + } + if !api.session().secrets().is_persistent() { + eprintln!("Note: no Secret Service, so this session ends when the command exits."); + } + } + Command::Logout => { + api.restore().await?; + api.logout().await?; + println!("Signed out"); + } + Command::Whoami => { + ensure_session(&api).await?; + let (me, caps) = api.capabilities().await?; + if cli.json { + println!( + "{}", + serde_json::json!({ "id": me.id, "email": me.email, "permissions": caps.permissions, "owned_apps": caps.owned_apps, "mfa_enabled": me.mfa_enabled }) + ); + } else { + println!("{} {} ({:?})", me.id, me.email, me.status); + println!( + "permissions: {}", + if caps.permissions.is_empty() { + "none".into() + } else { + caps.permissions + .iter() + .cloned() + .collect::>() + .join(", ") + } + ); + if !caps.owned_apps.is_empty() { + println!("owns apps: {}", caps.owned_apps.join(", ")); + } + } + } + Command::Users(UsersCommand::List { query, limit }) => { + ensure_session(&api).await?; + let page = api.list_users(query.as_deref(), None, limit).await?; + if cli.json { + let rows: Vec<_> = page.data.iter().map(|u| serde_json::json!({"id": u.id, "email": u.email, "status": u.status.key()})).collect(); + println!("{}", serde_json::Value::Array(rows)); + } else { + for u in &page.data { + println!("{:<30} {:<10} {}", u.id, u.status.key(), u.email); + } + if page.page.has_more { + eprintln!("(more results; raise --limit)"); + } + } + } + Command::Screens { .. } + | Command::Route { .. } + | Command::HelpUrl { .. } + | Command::Version => unreachable!(), + } + Ok(()) +} + +async fn ensure_session(api: &ApiClient) -> Result<()> { + if !api.restore().await? { + bail!("not signed in; run `substratalctl login --email you@example.com`"); + } + Ok(()) +} + +fn prompt(label: &str) -> Result { + eprint!("{label}"); + std::io::stderr().flush().ok(); + let mut line = String::new(); + std::io::stdin().lock().read_line(&mut line)?; + Ok(line.trim_end_matches(['\r', '\n']).to_string()) +} + +fn list_screens(all: bool, json: bool) -> Result<()> { + let list: Vec<_> = screens::all() + .iter() + .filter(|s| all || s.on_linux()) + .collect(); + if json { + let rows: Vec<_> = list + .iter() + .map(|s| serde_json::json!({"id": s.id, "title": s.title, "route": s.route, "archetype": s.archetype, "who": s.who, "platforms": s.platforms, "help": s.help_url()})) + .collect(); + println!("{}", serde_json::to_string_pretty(&rows)?); + } else { + for s in list { + println!("{:<8} {:<34} {}", s.id, s.route, s.title); + } + } + Ok(()) +} + +fn route(path: &str) -> Result<()> { + let m = if path.contains("://") { + screens::match_link(path) + } else { + screens::match_route(path, true) + }; + let m = m.with_context(|| format!("no screen matches {path}"))?; + let s = screens::get(&m.screen_id).unwrap(); + println!("{} {} ({})", s.id, s.title, s.route); + for (k, v) in m.params { + println!(" {k} = {v}"); + } + println!(" help: {}", config::help_url(&s.id)); + Ok(()) +} diff --git a/crates/substratalctl/src/main.rs b/crates/substratalctl/src/main.rs new file mode 100644 index 0000000..b4f56bb --- /dev/null +++ b/crates/substratalctl/src/main.rs @@ -0,0 +1,16 @@ +use clap::Parser; + +#[tokio::main(flavor = "current_thread")] +async fn main() { + let cli = substratalctl::Cli::parse(); + if let Err(e) = substratalctl::run(cli).await { + eprintln!("substratalctl: {e:#}"); + if let Some(id) = e + .downcast_ref::() + .and_then(|e| e.request_id()) + { + eprintln!("request id: {id}"); + } + std::process::exit(1); + } +} diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml new file mode 100644 index 0000000..b9ef382 --- /dev/null +++ b/crates/xtask/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "xtask" +description = "Repo automation: generates the help center's reference pages from the code and the specs." +version.workspace = true +edition.workspace = true +publish = false + +[dependencies] +substratal-core.workspace = true +substratalctl.workspace = true +anyhow.workspace = true +clap.workspace = true +clap-markdown = "0.1" diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs new file mode 100644 index 0000000..3364a0d --- /dev/null +++ b/crates/xtask/src/main.rs @@ -0,0 +1,127 @@ +//! Repo automation. +//! +//! cargo xtask gen-help --out /help write the Linux pages of the Hub help center +//! cargo xtask gen-help --out /help --check fail if they're out of date +//! +//! The Hub help center lives in the specs repo +//! (CompositeCode/substratalapps-applications-specs, `help/`) and is the one +//! documentation site for every Hub app. Most of it is generated there from +//! the specs and the API. The pages that come from this app's code are +//! generated here, so they always match what the Linux app does: +//! +//! | Page | Source | +//! |---------------------------------------|-----------------------------------------| +//! | guides/linux-keyboard-shortcuts.md | substratal_core::shortcuts::SHORTCUTS | +//! | developers/substratalctl.md | substratalctl's clap definition | +//! +//! `.github/workflows/docs-dispatch.yml` runs this on every push to main and +//! opens a pull request in the specs repo when a page changed. + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::path::PathBuf; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use substratal_core::{shortcuts, version}; + +const SOURCE: &str = "GENERATED by `cargo xtask gen-help` in CompositeCode/substratalapps-linux. Don't edit here; change the app and regenerate."; + +#[derive(Parser)] +enum Cmd { + /// Write the Linux app's pages of the Hub help center. + GenHelp { + /// The specs repo's `help/` directory. + #[arg(long)] + out: PathBuf, + /// Fail instead of writing when a page is out of date. + #[arg(long)] + check: bool, + }, +} + +fn main() -> Result<()> { + match Cmd::parse() { + Cmd::GenHelp { out, check } => gen_help(out, check), + } +} + +fn gen_help(out: PathBuf, check: bool) -> Result<()> { + if !out.join("guides").is_dir() { + bail!( + "{} doesn't look like the help center's help/ directory", + out.display() + ); + } + let mut pages = BTreeMap::new(); + pages.insert("guides/linux-keyboard-shortcuts.md", shortcuts_page()); + pages.insert("developers/substratalctl.md", cli_page()); + + let mut changed = Vec::new(); + for (rel, body) in &pages { + let path = out.join(rel); + if std::fs::read_to_string(&path).ok().as_deref() != Some(body.as_str()) { + changed.push(*rel); + if !check { + std::fs::write(&path, body).with_context(|| path.display().to_string())?; + } + } + } + if check && !changed.is_empty() { + bail!( + "help pages out of date: {}\nRun `cargo xtask gen-help --out /help`.", + changed.join(", ") + ); + } + println!( + "gen-help: {} of {} pages {}", + changed.len(), + pages.len(), + if check { "differ" } else { "written" } + ); + Ok(()) +} + +fn shortcuts_page() -> String { + let mut p = format!( + "---\ntitle: Keyboard shortcuts on Linux\nsummary: Every keyboard shortcut in the Linux app, and what it does.\naudience: everyone\norder: 21\nscreens: [GLB-01]\n---\n\n\n\n# Keyboard shortcuts on Linux\n\nEvery shortcut also has a visible button, menu item or command-palette entry. Press **Ctrl+?** in the app to see this list. See also [Substratal on Linux](linux.html).\n" + ); + let mut group = ""; + for s in shortcuts::SHORTCUTS { + if s.group != group { + let _ = write!(p, "\n## {}\n\n| Keys | Does |\n|---|---|\n", s.group); + group = s.group; + } + let keys: Vec<_> = s + .accels + .iter() + .map(|a| format!("**{}**", shortcuts::display(a))) + .collect(); + let _ = writeln!(p, "| {} | {} |", keys.join(" or "), s.label); + } + p +} + +fn cli_page() -> String { + let mut p = format!( + "---\ntitle: substratalctl\nsummary: The Linux Hub's command-line companion: check the API, sign in, list users and look up screens from a terminal or script.\naudience: developer\norder: 10\n---\n\n\n\n# substratalctl\n\n`substratalctl` uses the Linux app's code, so it signs in the same way, sends `Substratal-Version: {v}` on every request and gets the same errors. It's built with the app: `cargo build -p substratalctl`, or run it from the Flatpak with `flatpak run --command=substratalctl com.substratalapps.Hub`.\n\n## Examples\n\n```bash\nsubstratalctl health # GET /health\nsubstratalctl version # this client, the API version it sends, the base URL\nsubstratalctl login --email you@example.com # prompts for the password, then a 2FA code if needed\nsubstratalctl whoami --json\nsubstratalctl users list --query dana\nsubstratalctl route substratal://admin/users/usr_42\nsubstratalctl help-url KEY-03\n```\n\nOn a desktop the session goes into your keyring. On a server without one, pass `--no-keyring`; you then sign in on each run. Use `--api local` (or `SUBSTRATAL_API=local`) for the API's devserver on `localhost:8080`, or pass any base URL. On failure it exits with status 1 and prints the **request ID**, which you can quote to support.\n\n## Reference\n\n", + v = version::API_VERSION + ); + let md = clap_markdown::help_markdown_command(&substratalctl::command()); + // clap-markdown starts with its own H1 and a table of contents; keep the + // sections, one level down under "Reference". + let body: Vec = md + .lines() + .skip_while(|l| !l.starts_with("## ")) + .map(|l| { + if l.starts_with("## ") { + format!("#{l}") + } else { + l.to_string() + } + }) + .collect(); + p.push_str(&body.join("\n")); + p.push('\n'); + p +} diff --git a/data/com.substratalapps.Hub.desktop b/data/com.substratalapps.Hub.desktop new file mode 100644 index 0000000..3bd1bd1 --- /dev/null +++ b/data/com.substratalapps.Hub.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Name=Substratal +Comment=Your Substratal apps, organizations and access +Exec=substratal-hub %U +Icon=com.substratalapps.Hub +Terminal=false +Type=Application +Categories=Office;Network; +Keywords=substratal;apps;admin;access;api; +MimeType=x-scheme-handler/substratal; +StartupNotify=true +DBusActivatable=true diff --git a/data/com.substratalapps.Hub.metainfo.xml b/data/com.substratalapps.Hub.metainfo.xml new file mode 100644 index 0000000..f16fa44 --- /dev/null +++ b/data/com.substratalapps.Hub.metainfo.xml @@ -0,0 +1,45 @@ + + + com.substratalapps.Hub + CC0-1.0 + MIT + Substratal + Your Substratal apps, organizations and access + + Composite Code + + +

The Substratal Hub: launch your Substratal apps, read your notifications, choose your plan and run your organizations.

+

For staff and app developers it is also the management console for the Substratal Apps Platform API: users, access, roles, applications, API keys, webhooks, push credentials and the audit log.

+
+ + Office + Network + + com.substratalapps.Hub.desktop + com.substratalapps.Hub + + #E8E8F7 + #15132E + + https://compositecode.github.io/substratalapps-applications-specs/help/guides/linux.html + https://compositecode.github.io/substratalapps-applications-specs/help/ + https://github.com/CompositeCode/substratalapps-linux/issues + https://github.com/CompositeCode/substratalapps-linux + + + 360 + + + keyboard + pointing + touch + + + + +

First skeleton: navigation, sign-in, users, themes, help.

+
+
+
+
diff --git a/data/com.substratalapps.Hub.service b/data/com.substratalapps.Hub.service new file mode 100644 index 0000000..a71dc53 --- /dev/null +++ b/data/com.substratalapps.Hub.service @@ -0,0 +1,3 @@ +[D-BUS Service] +Name=com.substratalapps.Hub +Exec=/app/bin/substratal-hub --gapplication-service diff --git a/data/icons/hicolor/128x128/apps/com.substratalapps.Hub.png b/data/icons/hicolor/128x128/apps/com.substratalapps.Hub.png new file mode 100644 index 0000000..0f0348d Binary files /dev/null and b/data/icons/hicolor/128x128/apps/com.substratalapps.Hub.png differ diff --git a/data/icons/hicolor/16x16/apps/com.substratalapps.Hub.png b/data/icons/hicolor/16x16/apps/com.substratalapps.Hub.png new file mode 100644 index 0000000..a914780 Binary files /dev/null and b/data/icons/hicolor/16x16/apps/com.substratalapps.Hub.png differ diff --git a/data/icons/hicolor/24x24/apps/com.substratalapps.Hub.png b/data/icons/hicolor/24x24/apps/com.substratalapps.Hub.png new file mode 100644 index 0000000..a923149 Binary files /dev/null and b/data/icons/hicolor/24x24/apps/com.substratalapps.Hub.png differ diff --git a/data/icons/hicolor/256x256/apps/com.substratalapps.Hub.png b/data/icons/hicolor/256x256/apps/com.substratalapps.Hub.png new file mode 100644 index 0000000..ab21d2d Binary files /dev/null and b/data/icons/hicolor/256x256/apps/com.substratalapps.Hub.png differ diff --git a/data/icons/hicolor/32x32/apps/com.substratalapps.Hub.png b/data/icons/hicolor/32x32/apps/com.substratalapps.Hub.png new file mode 100644 index 0000000..9533f13 Binary files /dev/null and b/data/icons/hicolor/32x32/apps/com.substratalapps.Hub.png differ diff --git a/data/icons/hicolor/48x48/apps/com.substratalapps.Hub.png b/data/icons/hicolor/48x48/apps/com.substratalapps.Hub.png new file mode 100644 index 0000000..b50cd48 Binary files /dev/null and b/data/icons/hicolor/48x48/apps/com.substratalapps.Hub.png differ diff --git a/data/icons/hicolor/512x512/apps/com.substratalapps.Hub.png b/data/icons/hicolor/512x512/apps/com.substratalapps.Hub.png new file mode 100644 index 0000000..5fe4d39 Binary files /dev/null and b/data/icons/hicolor/512x512/apps/com.substratalapps.Hub.png differ diff --git a/data/icons/hicolor/64x64/apps/com.substratalapps.Hub.png b/data/icons/hicolor/64x64/apps/com.substratalapps.Hub.png new file mode 100644 index 0000000..fd3bec2 Binary files /dev/null and b/data/icons/hicolor/64x64/apps/com.substratalapps.Hub.png differ diff --git a/data/icons/hicolor/scalable/apps/com.substratalapps.Hub.svg b/data/icons/hicolor/scalable/apps/com.substratalapps.Hub.svg new file mode 100644 index 0000000..4529f2d --- /dev/null +++ b/data/icons/hicolor/scalable/apps/com.substratalapps.Hub.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/icons/hicolor/symbolic/apps/com.substratalapps.Hub-symbolic.svg b/data/icons/hicolor/symbolic/apps/com.substratalapps.Hub-symbolic.svg new file mode 100644 index 0000000..e3ae10b --- /dev/null +++ b/data/icons/hicolor/symbolic/apps/com.substratalapps.Hub-symbolic.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/spec/SOURCE b/data/spec/SOURCE new file mode 100644 index 0000000..fc12738 --- /dev/null +++ b/data/spec/SOURCE @@ -0,0 +1 @@ +CompositeCode/substratalapps-applications-specs@cebf6794b2d0ddf505abc82e7d61d175496c25df diff --git a/data/spec/screens.json b/data/spec/screens.json new file mode 100644 index 0000000..0b5390a --- /dev/null +++ b/data/spec/screens.json @@ -0,0 +1,4981 @@ +{ + "source": "CompositeCode/substratalapps-applications-specs", + "commit": "cebf6794b2d0ddf505abc82e7d61d175496c25df", + "screens": [ + { + "id": "AUTH-01", + "title": "Sign in", + "section_heading": "D1 · Sign-in & Identity", + "domain": "D1 Sign-in & Identity", + "route": "/sign-in", + "archetype": "auth", + "phase": "MVP", + "who": "Unauthenticated", + "purpose": "Exchange email + password for a session, or start an MFA challenge.", + "loads": "", + "states": "Submitting: button spinner, fields read-only; Locked out (5 failures / 15 min): explain wait time", + "notes": "`test_mode` is never exposed in end-user UI; developer builds may show a hidden toggle (long-press logo / ⌥-click) for test-mode sign-in. The horizontal logo lockup (BrandLockup, `brand/BRAND.md`) sits above the form.", + "entry_points": "App launch with no stored session; Session expired / revoked (preserves `?next=` destination); Sign out", + "container": "Separate `AdwWindow` with `AdwClamp`", + "elements": [ + { + "name": "Email", + "kind": "email-field", + "control": "AdwEntryRow input-purpose=email", + "data": "body.email", + "rules": "required; RFC 5322; trim; autofill username" + }, + { + "name": "Password", + "kind": "password-field", + "control": "AdwPasswordEntryRow", + "data": "body.password", + "rules": "required; reveal toggle; autofill current-password" + }, + { + "name": "Sign in", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "POST /auth/login", + "rules": "disabled until both fields non-empty" + }, + { + "name": "Forgot password?", + "kind": "link", + "control": "GtkButton .flat / GtkLinkButton", + "data": "→ AUTH-05", + "rules": "" + }, + { + "name": "Create an account", + "kind": "link", + "control": "GtkButton .flat / GtkLinkButton", + "data": "→ AUTH-03", + "rules": "" + }, + { + "name": "Continue with SSO", + "kind": "button", + "control": "GtkButton", + "data": "POST /auth/sso/{provider}/callback", + "rules": "hidden behind flag `sso` (API returns 501 today)" + }, + { + "name": "Sign in with a passkey", + "kind": "button", + "control": "GtkButton", + "data": "—", + "rules": "hidden behind flag `passkeys`; reserved for when the API supports WebAuthn" + } + ], + "actions": [ + { + "action": "Sign in", + "call": "POST /auth/login", + "result": "200 AuthSession → store tokens → go to `next` or APP-01. 200 MfaChallenge → AUTH-02 with mfa_token." + }, + { + "action": "—", + "call": "", + "result": "401 → inline form error \"Email or password is incorrect.\" (never reveal which). 403 → account suspended/locked message; `test_user_expired` → \"This test account has expired.\" 429 → disable button, countdown from Retry-After." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "AUTH-02", + "title": "Two-factor challenge", + "section_heading": "D1 · Sign-in & Identity", + "domain": "D1 Sign-in & Identity", + "route": "/sign-in/mfa", + "archetype": "auth", + "phase": "MVP", + "who": "Holder of mfa_token", + "purpose": "Complete sign-in with a TOTP code or a recovery code.", + "loads": "", + "states": "Challenge expired", + "notes": "", + "entry_points": "AUTH-01 returned MfaChallenge", + "container": "Separate `AdwWindow` with `AdwClamp`", + "elements": [ + { + "name": "Code", + "kind": "otp-field", + "control": "AdwEntryRow input-purpose=digits", + "data": "body.code", + "rules": "6 digits; paste fills all; auto-submit on 6th digit; OS one-time-code autofill" + }, + { + "name": "Use a recovery code", + "kind": "link", + "control": "GtkButton .flat / GtkLinkButton", + "data": "swaps to Recovery code field", + "rules": "only if `methods` includes recovery_code" + }, + { + "name": "Recovery code", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "body.recovery_code", + "rules": "format xxxx-xxxx; case-insensitive" + }, + { + "name": "Expiry countdown", + "kind": "caption", + "control": "GtkLabel .caption .dim-label", + "data": "MfaChallenge.expires_in", + "rules": "announce at 60 s and 10 s" + }, + { + "name": "Verify", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "POST /auth/mfa/verify", + "rules": "" + } + ], + "actions": [ + { + "action": "Verify", + "call": "POST /auth/mfa/verify", + "result": "200 → same as AUTH-01 success. 401 → \"That code didn't work.\" clear field. Expired → back to AUTH-01 with message. 429 → countdown." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "AUTH-03", + "title": "Create account", + "section_heading": "D1 · Sign-in & Identity", + "domain": "D1 Sign-in & Identity", + "route": "/sign-up", + "archetype": "auth", + "phase": "MVP", + "who": "Unauthenticated", + "purpose": "Self-service signup with email and password.", + "loads": "", + "states": "", + "notes": "Pass `application_id` through when signup started from an app's link; it is recorded as signup_application_id. Every new User starts on the Free plan and receives platform-managed `free` Entitlements to every free Application, including the Hub itself (`app_hub`). No plan choice at signup; upgrading is BIL-01.", + "entry_points": "AUTH-01 link; Marketing/app deep link with optional `application_id`", + "container": "Separate `AdwWindow` with `AdwClamp`", + "elements": [ + { + "name": "Display name", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "body.display_name", + "rules": "optional; ≤ 100" + }, + { + "name": "Email", + "kind": "email-field", + "control": "AdwEntryRow input-purpose=email", + "data": "body.email", + "rules": "required" + }, + { + "name": "Password", + "kind": "password-field", + "control": "AdwPasswordEntryRow", + "data": "body.password", + "rules": "required; strength meter; autofill new-password; show policy hint" + }, + { + "name": "Create account", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "POST /auth/signup", + "rules": "" + }, + { + "name": "Terms / Privacy", + "kind": "links", + "control": "GtkLinkButton", + "data": "external", + "rules": "" + } + ], + "actions": [ + { + "action": "Create account", + "call": "POST /auth/signup", + "result": "201 AuthSession → AUTH-04 (verify pending) then APP-01. 409 email_taken → \"An account with this email may already exist. Sign in or reset your password.\" (with details.reason invitation_pending the API re-sends the invitation: say \"Check your email for your invitation.\") 422 → field errors." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "AUTH-04", + "title": "Verify your email", + "section_heading": "D1 · Sign-in & Identity", + "domain": "D1 Sign-in & Identity", + "route": "/verify-email", + "archetype": "auth", + "phase": "MVP", + "who": "Signed in (unverified) or token holder", + "purpose": "Tell the user to check email; complete verification from the emailed link.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "After signup; Persistent banner on APP-01 while `email_verified` is false; Deep link `/verify-email?token=…`", + "container": "Separate `AdwWindow` with `AdwClamp`", + "elements": [ + { + "name": "Instruction", + "kind": "text", + "control": "GtkLabel", + "data": "User.email", + "rules": "\"We sent a link to {email}.\"" + }, + { + "name": "Resend email", + "kind": "button", + "control": "GtkButton", + "data": "POST /auth/email/verify/resend", + "rules": "30 s cooldown after each send" + }, + { + "name": "Continue to Substratal", + "kind": "button", + "control": "GtkButton", + "data": "→ APP-01", + "rules": "unverified users may continue" + }, + { + "name": "Change email", + "kind": "link", + "control": "GtkButton .flat / GtkLinkButton", + "data": "→ ACC-02", + "rules": "" + } + ], + "actions": [ + { + "action": "Deep link opened", + "call": "POST /auth/email/verify {token}", + "result": "204 → success state, refresh User. 400 → \"This link has expired.\" + Resend." + }, + { + "action": "Resend email", + "call": "POST /auth/email/verify/resend", + "result": "Always 202 → toast \"If that address needs verifying, we sent a new link.\"" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "AUTH-05", + "title": "Forgot password", + "section_heading": "D1 · Sign-in & Identity", + "domain": "D1 Sign-in & Identity", + "route": "/forgot-password", + "archetype": "auth", + "phase": "MVP", + "who": "Unauthenticated", + "purpose": "Request a password-reset email.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "Separate `AdwWindow` with `AdwClamp`", + "elements": [ + { + "name": "Email", + "kind": "email-field", + "control": "AdwEntryRow input-purpose=email", + "data": "body.email", + "rules": "prefilled from AUTH-01" + }, + { + "name": "Send reset link", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "POST /auth/password/forgot", + "rules": "" + } + ], + "actions": [ + { + "action": "Send reset link", + "call": "POST /auth/password/forgot", + "result": "Always 202 → confirmation \"If an account exists for {email}, we sent a reset link.\" 429 → countdown." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "AUTH-06", + "title": "Set a new password", + "section_heading": "D1 · Sign-in & Identity", + "domain": "D1 Sign-in & Identity", + "route": "/reset-password", + "archetype": "auth", + "phase": "MVP", + "who": "Reset-token holder", + "purpose": "Choose a new password from the emailed reset link.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "Deep link `/reset-password?token=…`", + "container": "Separate `AdwWindow` with `AdwClamp`", + "elements": [ + { + "name": "New password", + "kind": "password-field", + "control": "AdwPasswordEntryRow", + "data": "body.new_password", + "rules": "strength meter; autofill new-password" + }, + { + "name": "Confirm password", + "kind": "password-field", + "control": "AdwPasswordEntryRow", + "data": "client only", + "rules": "must match" + }, + { + "name": "Notice", + "kind": "caption", + "control": "GtkLabel .caption .dim-label", + "data": "", + "rules": "\"You'll be signed out on all devices.\"" + }, + { + "name": "Save password", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "POST /auth/password/reset", + "rules": "" + } + ], + "actions": [ + { + "action": "Save password", + "call": "POST /auth/password/reset", + "result": "204 → clear local session → AUTH-01 with success banner. 400 → expired link + link to AUTH-05. 422 → field errors." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "AUTH-07", + "title": "Accept invitation", + "section_heading": "D1 · Sign-in & Identity", + "domain": "D1 Sign-in & Identity", + "route": "/invite", + "archetype": "auth", + "phase": "MVP", + "who": "Invitation-token holder", + "purpose": "Activate an account an admin created: set password and sign in.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "Deep link `/invite?token=…` from the invitation email", + "container": "Separate `AdwWindow` with `AdwClamp`", + "elements": [ + { + "name": "Display name", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "body.display_name", + "rules": "optional" + }, + { + "name": "Password", + "kind": "password-field", + "control": "AdwPasswordEntryRow", + "data": "body.password", + "rules": "strength meter" + }, + { + "name": "Accept and continue", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "POST /auth/invitations/accept", + "rules": "" + } + ], + "actions": [ + { + "action": "Accept and continue", + "call": "POST /auth/invitations/accept", + "result": "200 AuthSession → APP-01. 400 → \"This invitation is no longer valid. Ask your admin to resend it.\"" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ACC-01", + "title": "Account & profile", + "section_heading": "D2 · Account & Security", + "domain": "D2 Account & Security", + "route": "/account", + "archetype": "settings", + "phase": "MVP", + "who": "Self", + "purpose": "View and edit the signed-in user's global profile.", + "loads": "`GET /users/me` · `GET /users/me/profile`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "*The API stores `avatar_url` only. Upload target is a client-side decision (spec gap — see OPEN-QUESTIONS).", + "entry_points": "", + "container": "`AdwPreferencesPage`", + "elements": [ + { + "name": "Avatar", + "kind": "image-picker", + "control": "GtkFileDialog (portal)", + "data": "Profile.avatar_url", + "rules": "camera / photo library / file / paste; client uploads to app asset storage then PATCHes URL*" + }, + { + "name": "Display name", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "Profile.display_name", + "rules": "required; ≤ 100; save on blur/Return" + }, + { + "name": "Email", + "kind": "row → ACC-02", + "control": "AdwActionRow activatable", + "data": "User.email + email_verified + pending_email", + "rules": "shows \"Pending: {pending_email}\" when set" + }, + { + "name": "User ID", + "kind": "id-chip", + "control": "GtkLabel .monospace + copy", + "data": "User.id", + "rules": "copy" + }, + { + "name": "Contact email", + "kind": "email-field", + "control": "AdwEntryRow input-purpose=email", + "data": "Profile.contact_email", + "rules": "optional" + }, + { + "name": "Contact phone", + "kind": "phone-field", + "control": "AdwEntryRow input-purpose=phone", + "data": "Profile.contact_phone", + "rules": "optional; E.164 formatting by locale" + }, + { + "name": "Plan", + "kind": "row → BIL-01", + "control": "AdwActionRow activatable", + "data": "User.plan free\\", + "rules": "standard + plan_sources" + }, + { + "name": "Member since / last sign-in", + "kind": "caption", + "control": "GtkLabel .caption .dim-label", + "data": "User.created_at, last_login_at", + "rules": "locale formatted" + }, + { + "name": "Sign out", + "kind": "button", + "control": "GtkButton", + "data": "POST /auth/logout", + "rules": "always visible at the bottom of Account (mobile) / account menu (desktop)" + } + ], + "actions": [ + { + "action": "Edit field", + "call": "PATCH /users/me/profile (If-Match)", + "result": "Save per field; 409 version_conflict → reload prompt; 422 → field error." + }, + { + "action": "Sign out", + "call": "POST /auth/logout", + "result": "best effort; always wipe local credentials and cache → AUTH-01" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ACC-02", + "title": "Change email", + "section_heading": "D2 · Account & Security", + "domain": "D2 Account & Security", + "route": "/account/email", + "archetype": "form", + "phase": "MVP", + "who": "Self", + "purpose": "Start an email change; the new address must be verified.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`AdwDialog` with `AdwPreferencesGroup`", + "elements": [ + { + "name": "New email", + "kind": "email-field", + "control": "AdwEntryRow input-purpose=email", + "data": "body.email", + "rules": "required; differs from current" + }, + { + "name": "Save", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "PATCH /users/me", + "rules": "" + } + ], + "actions": [ + { + "action": "Save", + "call": "PATCH /users/me {email}", + "result": "200 → User.pending_email set; show AUTH-04-style \"check your new inbox\". 409 not_unique → \"That email is already in use.\"" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ACC-03", + "title": "Change password", + "section_heading": "D2 · Account & Security", + "domain": "D2 Account & Security", + "route": "/account/security/password", + "archetype": "form", + "phase": "MVP", + "who": "Self", + "purpose": "Change password knowing the current one.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`AdwDialog` with `AdwPreferencesGroup`", + "elements": [ + { + "name": "Current password", + "kind": "password-field", + "control": "AdwPasswordEntryRow", + "data": "body.current_password", + "rules": "autofill current-password" + }, + { + "name": "New password", + "kind": "password-field", + "control": "AdwPasswordEntryRow", + "data": "body.new_password", + "rules": "strength meter; autofill new-password" + }, + { + "name": "Save", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "POST /users/me/password", + "rules": "" + } + ], + "actions": [ + { + "action": "Save", + "call": "POST /users/me/password", + "result": "204 → toast \"Password changed\". 401 → \"Current password is incorrect.\" 422 → policy errors." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ACC-04", + "title": "Security", + "section_heading": "D2 · Account & Security", + "domain": "D2 Account & Security", + "route": "/account/security", + "archetype": "settings", + "phase": "MVP", + "who": "Self", + "purpose": "Password, two-factor and active sessions in one place.", + "loads": "`GET /users/me` · `GET /users/me/sessions`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "*IP → city lookup is client-side/optional; never show the raw IP to non-staff unless the user expands details.", + "entry_points": "", + "container": "`AdwPreferencesPage`", + "elements": [ + { + "name": "Password", + "kind": "row → ACC-03", + "control": "AdwActionRow activatable", + "data": "", + "rules": "\"Change…\"" + }, + { + "name": "Two-factor authentication", + "kind": "toggle", + "control": "AdwSwitchRow", + "data": "User.mfa_enabled", + "rules": "on → ACC-05; off → ACC-06" + }, + { + "name": "Sessions", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "Session[]: user_agent → device name/icon, ip_address → approximate location*, last_seen_at, current", + "rules": "current session marked \"This device\" and not revocable here" + }, + { + "name": "Sign out", + "kind": "row-button", + "control": "suffix GtkButton .flat", + "data": "DELETE /users/me/sessions/{sessionId}", + "rules": "per non-current row" + }, + { + "name": "Sign out of all other sessions", + "kind": "destructive-button", + "control": "GtkButton .destructive-action", + "data": "POST /users/me/sessions/revoke-all {keep_current: true}", + "rules": "confirm first; also stops push to those sessions' devices" + }, + { + "name": "Devices & notifications", + "kind": "row → NTF-03", + "control": "AdwActionRow activatable", + "data": "count of active devices", + "rules": "" + } + ], + "actions": [ + { + "action": "Revoke one", + "call": "DELETE /users/me/sessions/{sessionId}", + "result": "Optimistic remove; 404 → already gone, refresh." + }, + { + "action": "Sign out everywhere else", + "call": "POST /users/me/sessions/revoke-all {keep_current: true}", + "result": "Confirm first (COPY). `keep_current` keeps this device signed in (`logout all_sessions` would sign it out too). 200 `{revoked}` → toast \"Signed out of {n} other sessions\", refresh the list. Revoking a session deactivates the push devices it registered." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ACC-05", + "title": "Turn on two-factor", + "section_heading": "D2 · Account & Security", + "domain": "D2 Account & Security", + "route": "/account/security/2fa/enable", + "archetype": "wizard", + "phase": "MVP", + "who": "Self", + "purpose": "Enroll an authenticator app (TOTP) and save recovery codes.", + "loads": "", + "states": "Cancel before confirm leaves MFA off (no server cleanup needed)", + "notes": "", + "entry_points": "", + "container": "`AdwDialog` with `AdwNavigationView` pages", + "elements": [ + { + "name": "Step 1 — Scan", + "kind": "qr + secret", + "control": "GtkPicture", + "data": "POST /users/me/mfa/totp → otpauth_uri, secret", + "rules": "QR, \"Copy setup key\", \"Open in authenticator\" (otpauth:// link on mobile)" + }, + { + "name": "Step 2 — Confirm", + "kind": "otp-field", + "control": "AdwEntryRow input-purpose=digits", + "data": "body.code", + "rules": "6 digits" + }, + { + "name": "Step 3 — Recovery codes", + "kind": "secret-list", + "control": "SecretReveal + GtkFileDialog/print", + "data": "confirm response: 10 recovery codes", + "rules": "Copy all · Save as file · Print · Share (mobile); \"I've saved these\" checkbox gates Done" + } + ], + "actions": [ + { + "action": "Start", + "call": "POST /users/me/mfa/totp", + "result": "409 → already enabled; close." + }, + { + "action": "Confirm", + "call": "POST /users/me/mfa/totp/confirm", + "result": "200 → step 3. 401 → wrong code." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ACC-06", + "title": "Turn off two-factor", + "section_heading": "D2 · Account & Security", + "domain": "D2 Account & Security", + "route": "/account/security/2fa/disable", + "archetype": "confirm", + "phase": "MVP", + "who": "Self", + "purpose": "Disable TOTP with a current code or a recovery code.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`AdwAlertDialog` (`extra-child` for fields)", + "elements": [ + { + "name": "Code or recovery code", + "kind": "otp-field / text-field", + "control": "AdwEntryRow input-purpose=digits", + "data": "body.code \\", + "rules": "body.recovery_code" + }, + { + "name": "Turn off", + "kind": "destructive-button", + "control": "GtkButton .destructive-action", + "data": "POST /users/me/mfa/totp/disable", + "rules": "" + } + ], + "actions": [ + { + "action": "Turn off", + "call": "POST /users/me/mfa/totp/disable", + "result": "204 → toggle off, toast. 401 → wrong code. 429 → countdown." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ACC-07", + "title": "Privacy", + "section_heading": "D2 · Account & Security", + "domain": "D2 Account & Security", + "route": "/account/privacy", + "archetype": "settings", + "phase": "MVP", + "who": "Self", + "purpose": "Export personal data and delete the account.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`AdwPreferencesPage`", + "elements": [ + { + "name": "Download my data", + "kind": "button", + "control": "GtkButton", + "data": "GET /users/me/export", + "rules": "saves `substratal-export-{date}.json` via save dialog / share sheet / download" + }, + { + "name": "Delete account", + "kind": "destructive-row → ACC-08", + "control": "AdwButtonRow .destructive-action", + "data": "", + "rules": "" + } + ], + "actions": [ + { + "action": "Download", + "call": "GET /users/me/export", + "result": "200 → save file. 429 → \"You can export again in {n} minutes.\"" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ACC-08", + "title": "Delete account", + "section_heading": "D2 · Account & Security", + "domain": "D2 Account & Security", + "route": "/account/privacy/delete", + "archetype": "confirm", + "phase": "MVP", + "who": "Self", + "purpose": "Request erasure: deactivate now, erase after 7 days.", + "loads": "", + "states": "", + "notes": "If the User has an individual Standard subscription, warn first and link to \"Manage billing\" (BIL-03) — erasure doesn't cancel Stripe billing by itself (see OPEN-QUESTIONS).", + "entry_points": "", + "container": "`AdwAlertDialog` (`extra-child` for fields)", + "elements": [ + { + "name": "What happens", + "kind": "text", + "control": "GtkLabel", + "data": "", + "rules": "list: signed out everywhere; apps lose access; erased on {date}; support can cancel within 7 days" + }, + { + "name": "Reason", + "kind": "text-area", + "control": "GtkTextView in .card", + "data": "body.reason", + "rules": "optional" + }, + { + "name": "Type DELETE", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "client only", + "rules": "must equal localized confirm word" + }, + { + "name": "Delete account", + "kind": "destructive-button", + "control": "GtkButton .destructive-action", + "data": "POST /users/me/erasure-requests", + "rules": "" + } + ], + "actions": [ + { + "action": "Delete account", + "call": "POST /users/me/erasure-requests", + "result": "202 ErasureRequest → clear local data → AUTH-01 with \"Your account is scheduled for deletion on {scheduled_for}.\"" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "APP-01", + "title": "Home — My Apps", + "section_heading": "D3 · My Apps", + "domain": "D3 My Apps", + "route": "/", + "archetype": "grid", + "phase": "MVP", + "who": "Signed in", + "purpose": "See every app you can use, its access status, and launch it.", + "loads": "`GET /applications (catalog the caller can see; each has access_tier free|standard)` · `GET /users/me/entitlements?include_inactive=true` · `GET /users/me (plan, plan_sources)` · `GET /organizations?status=active (for pending invites)` · `GET /users/me/notifications/unread-count (bell badge)`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES; Empty: \"No apps yet\" (COPY); Offline: show cached grid read-only, launch disabled", + "notes": "Resolved status per app: if any path active → Active; else precedence scheduled > disabled > expired > revoked > none (matches the API's resolved_entitlement_status). Use `GET /users/me/apps/{appId}/effective-permissions` for the authoritative answer when the user opens APP-02. Tile helper text by source of the active path: `free` → \"Free\"; `subscription` → \"Standard\" (or \"Standard · via {org}\"); developer grants as before. The Hub itself (`app_hub`) is hidden from the grid.", + "entry_points": "", + "container": "Content page, `GtkFlowBox` in `AdwClamp`", + "elements": [ + { + "name": "Search", + "kind": "search", + "control": "GtkSearchEntry", + "data": "client filter on name/description", + "rules": "" + }, + { + "name": "Grid / List", + "kind": "segmented", + "control": "linked GtkToggleButtons", + "data": "client preference", + "rules": "persisted locally" + }, + { + "name": "Invitation banner", + "kind": "banner", + "control": "AdwBanner", + "data": "Organization.my_membership_status = pending", + "rules": "\"{org} invited you…\" → ORG-09" + }, + { + "name": "Verify-email banner", + "kind": "banner", + "control": "AdwBanner", + "data": "User.email_verified = false", + "rules": "→ AUTH-04" + }, + { + "name": "Your apps", + "kind": "tile-grid", + "control": "GtkFlowBox .card children", + "data": "join Application × resolved entitlement per app", + "rules": "tile = icon, name, StatusBadge, helper text (COPY); order: recently launched, then A–Z; pin to top (local)" + }, + { + "name": "Included with Standard", + "kind": "tile-grid", + "control": "GtkFlowBox .card children", + "data": "standard apps with status none while User.plan = free", + "rules": "badge \"Standard\"; tap → upsell sheet → BIL-01 (\"Get every app for $6/month\")" + }, + { + "name": "Available", + "kind": "tile-grid", + "control": "GtkFlowBox .card children", + "data": "other public apps with status none", + "rules": "\"Get access\" → app support/launch page" + }, + { + "name": "Inbox bell", + "kind": "toolbar button + count", + "control": "AdwHeaderBar buttons", + "data": "NotificationUnreadCount.unread_count", + "rules": "→ NTF-01" + }, + { + "name": "Past apps", + "kind": "row → APP-05", + "control": "AdwActionRow activatable", + "data": "count of revoked/expired", + "rules": "" + } + ], + "actions": [ + { + "action": "Open tile (active)", + "call": "→ APP-03", + "result": "Launch" + }, + { + "action": "Open tile (other status)", + "call": "→ APP-02", + "result": "Explain status" + }, + { + "action": "Context menu / long-press", + "call": "", + "result": "Open · App settings (SET-02) · App profile (APP-04) · Pin · Copy app ID" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "APP-02", + "title": "App info", + "section_heading": "D3 · My Apps", + "domain": "D3 My Apps", + "route": "/apps/:appId", + "archetype": "popover", + "phase": "MVP", + "who": "Signed in", + "purpose": "Explain an app's access status and how the user has access.", + "loads": "`GET /applications/{appId}` · `GET /users/me/apps/{appId}/effective-permissions`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "`GtkPopover` or `AdwOverlaySplitView` sidebar", + "elements": [ + { + "name": "Header", + "kind": "icon + name + StatusBadge", + "control": "GtkImage + labels + badge", + "data": "Application, EffectivePermissions.entitlement_status", + "rules": "" + }, + { + "name": "Description", + "kind": "text", + "control": "GtkLabel", + "data": "Application.description", + "rules": "" + }, + { + "name": "Access paths", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "EffectivePermissions.access_paths[]: source, status, organization_id, member_decision", + "rules": "\"Free\", \"Standard · your subscription\", \"Standard · via Northwind\", \"Personal · Granted by admin\", \"Via Northwind (seat)\"; member_decision excluded explained" + }, + { + "name": "Tier", + "kind": "badge", + "control": "StatusBadge (.status-badge box)", + "data": "Application.access_tier", + "rules": "\"Free\" or \"Standard\"" + }, + { + "name": "Your roles", + "kind": "chips", + "control": "GtkToggleButton pills", + "data": "EffectivePermissions.roles", + "rules": "Phase 2" + }, + { + "name": "Primary action", + "kind": "button", + "control": "GtkButton", + "data": "", + "rules": "Active → Open; none + standard tier + plan free → Upgrade to Standard (BIL-01); none+public → Get access; disabled/expired/revoked → Contact support (support_url)" + }, + { + "name": "Notification settings", + "kind": "row → SET-02", + "control": "AdwActionRow activatable", + "data": "resolved notifications.push for this app", + "rules": "\"Push notifications: On/Off\"" + }, + { + "name": "App settings / App profile", + "kind": "rows", + "control": "AdwActionRows", + "data": "→ SET-02, APP-04", + "rules": "only when active" + } + ], + "actions": [], + "platforms": [ + "all" + ] + }, + { + "id": "APP-03", + "title": "Launch app", + "section_heading": "D3 · My Apps", + "domain": "D3 My Apps", + "route": "/apps/:appId/launch", + "archetype": "handoff", + "phase": "MVP", + "who": "Active entitlement", + "purpose": "Mint an app token (or authorization code) and open the app.", + "loads": "", + "states": "", + "notes": "Record launch time locally for \"recently launched\" ordering.", + "entry_points": "", + "container": "`GtkUriLauncher` (OpenURI portal)", + "elements": [ + { + "name": "Progress", + "kind": "inline spinner on tile", + "control": "GtkSpinner on tile", + "data": "", + "rules": "≤ 1 s target" + }, + { + "name": "Organization picker", + "kind": "menu", + "control": "GtkMenuButton", + "data": "body.organization_id", + "rules": "only when the user has access via >1 org and the app needs org context" + } + ], + "actions": [ + { + "action": "Embedded launch", + "call": "POST /auth/app-tokens {application_id, organization_id?}", + "result": "200 AppToken (5-min JWT) → open launch_url with token per app contract; never cache the token. 403 → refresh status, show APP-02; `embedded_login_not_allowed` (a third-party, self-registered app) → hosted launch below, or open `launch_url` without a token while `hosted_launch` is off. 409 → choose org." + }, + { + "action": "Hosted launch (Phase 3)", + "call": "POST /auth/oauth/authorization-codes (PKCE S256)", + "result": "201 → open redirect_uri?code=…&state=… in system browser / auth session" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "APP-04", + "title": "App profile", + "section_heading": "D3 · My Apps", + "domain": "D3 My Apps", + "route": "/apps/:appId/profile", + "archetype": "form", + "phase": "Phase 2", + "who": "Self, active entitlement", + "purpose": "Per-app display handle and custom profile fields.", + "loads": "`GET /users/me/apps/{appId}/profile`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "`AdwDialog` with `AdwPreferencesGroup`", + "elements": [ + { + "name": "Display handle", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "AppProfile.display_handle", + "rules": "optional; shows effective_display_name as placeholder" + }, + { + "name": "Custom fields", + "kind": "key/value editor", + "control": "AdwPreferencesGroup of AdwEntryRow pairs", + "data": "AppProfile.custom", + "rules": "only keys the app documents; null removes a key" + } + ], + "actions": [ + { + "action": "Save", + "call": "PATCH /users/me/apps/{appId}/profile (If-Match)", + "result": "" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "APP-05", + "title": "Past apps", + "section_heading": "D3 · My Apps", + "domain": "D3 My Apps", + "route": "/apps/past", + "archetype": "list", + "phase": "MVP", + "who": "Signed in", + "purpose": "Apps whose access was revoked or expired.", + "loads": "`GET /users/me/entitlements?include_inactive=true&status=revoked,expired`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "Nested `AdwNavigationSplitView` list pane", + "elements": [ + { + "name": "Rows", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "Application name/icon, status, ended/revoked date, source", + "rules": "tap → APP-02" + } + ], + "actions": [], + "platforms": [ + "all" + ] + }, + { + "id": "SET-01", + "title": "Settings — Global", + "section_heading": "D4 · Settings", + "domain": "D4 Settings", + "route": "/settings", + "archetype": "settings", + "phase": "MVP", + "who": "Self", + "purpose": "Preferences that apply to every app.", + "loads": "`GET /users/me/settings` · `GET /users/me/apps/app_hub/settings` (theme choices)", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "`AdwPreferencesPage`", + "elements": [ + { + "name": "Language", + "kind": "select", + "control": "AdwComboRow", + "data": "Settings.locale", + "rules": "BCP 47; default = OS language; changing re-localizes the UI live" + }, + { + "name": "Time zone", + "kind": "searchable select", + "control": "AdwComboRow enable-search", + "data": "Settings.timezone", + "rules": "IANA; default = OS zone; \"Use device time zone\" option" + }, + { + "name": "Appearance", + "kind": "segmented", + "control": "linked GtkToggleButtons", + "data": "Settings.theme light/dark/system", + "rules": "Light · Dark · Match system (default). Applies instantly; Match system follows OS changes live. See THEMES.md" + }, + { + "name": "Light theme", + "kind": "swatch picker", + "control": "GtkFlowBox of swatch GtkCheckButtons (grouped) in an AdwPreferencesGroup", + "data": "`app_hub` setting `appearance.light_theme`", + "rules": "5 themes: Teal Ledger, Indigo Root, **Moss Paper (default)**, Coral Clay, Spore Pink. Each swatch previews background, sidebar, accent and text; selected swatch shows a check and accent ring; accessible name \"{name} light theme, selected\"" + }, + { + "name": "Dark theme", + "kind": "swatch picker", + "control": "GtkFlowBox of swatch GtkCheckButtons (grouped) in an AdwPreferencesGroup", + "data": "`app_hub` setting `appearance.dark_theme`", + "rules": "5 themes: Night Soil, Deep Teal, Graphite Spore, **Forest Floor (default)**, Ember Clay. Same behaviour as Light theme" + }, + { + "name": "Push notifications", + "kind": "toggle", + "control": "AdwSwitchRow", + "data": "Settings.notifications.push (missing = on)", + "rules": "global master switch for push from every app; inbox still fills (NTF-01). Shows OS permission state beneath: \"Allowed in system settings\" / \"Blocked — Open system settings\"" + }, + { + "name": "Email notifications", + "kind": "toggle", + "control": "AdwSwitchRow", + "data": "Settings.notifications.email", + "rules": "" + }, + { + "name": "SMS notifications", + "kind": "toggle", + "control": "AdwSwitchRow", + "data": "Settings.notifications.sms", + "rules": "disabled until Profile.contact_phone set" + }, + { + "name": "Share usage analytics", + "kind": "toggle", + "control": "AdwSwitchRow", + "data": "client preference", + "rules": "default per platform policy; see CLIENT-ARCHITECTURE §10" + }, + { + "name": "Scope list", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "Global + each app with active access", + "rules": "→ SET-02 (desktop: middle column)" + } + ], + "actions": [ + { + "action": "Change any row", + "call": "PATCH /users/me/settings (If-Match)", + "result": "Optimistic; revert + toast on error." + }, + { + "action": "Choose a light or dark theme", + "call": "PATCH /users/me/apps/app_hub/settings (If-Match)` with `appearance.light_theme` / `appearance.dark_theme", + "result": "Apply instantly, cache locally, then sync; 409 → refetch and re-apply; 422 → revert + toast \"Couldn't save your theme. Try again.\"" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "SET-02", + "title": "Settings — Per app", + "section_heading": "D4 · Settings", + "domain": "D4 Settings", + "route": "/settings/apps/:appId", + "archetype": "settings", + "phase": "Phase 2", + "who": "Self, active entitlement", + "purpose": "Override global settings and set app-specific preferences from the app's settings_schema.", + "loads": "`GET /applications/{appId} (settings_schema)` · `GET /users/me/apps/{appId}/settings`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "`AdwPreferencesPage`", + "elements": [ + { + "name": "Schema form", + "kind": "generated rows", + "control": "SchemaForm", + "data": "settings_schema properties × AppSettingsResolved.resolved", + "rules": "string+enum → select; string → text; string date-time → date-time picker; boolean → toggle; integer/number with min/max → stepper/number; object/array → read-only JSON preview" + }, + { + "name": "Source caption", + "kind": "caption per row", + "control": "GtkLabel .caption .dim-label", + "data": "AppSettingsResolved.sources[key]", + "rules": "app_override → \"Changed for this app · Reset\"; global → \"Using your global setting\"; app_default → \"App default\"" + }, + { + "name": "Reset", + "kind": "link per overridden row", + "control": "AdwActionRow activatable", + "data": "PATCH overrides {key:null}", + "rules": "" + }, + { + "name": "Push notifications for this app", + "kind": "toggle", + "control": "AdwSwitchRow", + "data": "resolved notifications.push; override via overrides.notifications.push", + "rules": "source caption as other rows; Reset clears the override" + }, + { + "name": "Stale overrides", + "kind": "banner", + "control": "AdwBanner", + "data": "AppSettingsResolved.stale_overrides", + "rules": "\"Some saved settings no longer apply\" → Clear" + } + ], + "actions": [ + { + "action": "Change row", + "call": "PATCH /users/me/apps/{appId}/settings {overrides:{key:value}} (If-Match)", + "result": "422 → field error from details (enum_mismatch, out_of_range, undeclared_key)." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ORG-01", + "title": "Organizations", + "section_heading": "D5 · Organizations", + "domain": "D5 Organizations", + "route": "/orgs", + "archetype": "list", + "phase": "MVP", + "who": "Signed in (all orgs with organizations.manage)", + "purpose": "Organizations you belong to, plus pending invitations.", + "loads": "`GET /organizations`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "Nested `AdwNavigationSplitView` list pane", + "elements": [ + { + "name": "Pending invitations", + "kind": "section", + "control": "AdwPreferencesGroup", + "data": "my_membership_status = pending", + "rules": "Accept / Decline inline → ORG-09" + }, + { + "name": "Rows", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "Organization: name, member_count, my_role, status", + "rules": "suspended orgs show badge" + }, + { + "name": "Search", + "kind": "search", + "control": "GtkSearchEntry", + "data": "q", + "rules": "server-side when > 50" + }, + { + "name": "New organization", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "→ ORG-02", + "rules": "" + } + ], + "actions": [], + "platforms": [ + "all" + ] + }, + { + "id": "ORG-02", + "title": "New organization", + "section_heading": "D5 · Organizations", + "domain": "D5 Organizations", + "route": "/orgs/new", + "archetype": "form", + "phase": "MVP", + "who": "Any active user", + "purpose": "Create an organization; creator becomes org admin.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`AdwDialog` with `AdwPreferencesGroup`", + "elements": [ + { + "name": "Name", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "body.name", + "rules": "required; ≤ 100" + } + ], + "actions": [ + { + "action": "Create", + "call": "POST /organizations (Idempotency-Key)", + "result": "201 → ORG-03. 429 → rate limited." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ORG-03", + "title": "Organization — Members", + "section_heading": "D5 · Organizations", + "domain": "D5 Organizations", + "route": "/orgs/:orgId/members", + "archetype": "detail", + "phase": "MVP", + "who": "Member (read), org_admin / organizations.manage (write)", + "purpose": "See and manage members and pending invitations.", + "loads": "`GET /organizations/{id}` · `GET /organizations/{id}/members`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "Content pane; `AdwViewSwitcher`; header-bar actions", + "elements": [ + { + "name": "Header", + "kind": "avatar + name + id-chip + status", + "control": "GtkLabel .monospace + copy", + "data": "Organization", + "rules": "my_role shown as caption" + }, + { + "name": "Tabs", + "kind": "tabs", + "control": "AdwViewSwitcher / AdwInlineViewSwitcher", + "data": "Members · Apps & seats · Subscription · Activity · Settings", + "rules": "Subscription tab = BIL-02 (org_admin / billing.manage only)" + }, + { + "name": "Filter", + "kind": "chips", + "control": "GtkToggleButton pills", + "data": "role, membership_status", + "rules": "" + }, + { + "name": "Rows", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "OrganizationMember: display_name/email, role, membership_status, joined_at/invited_at", + "rules": "pending rows show Invited badge + invited date" + }, + { + "name": "Invite", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "→ ORG-04", + "rules": "org_admin only" + } + ], + "actions": [ + { + "action": "Row menu", + "call": "→ ORG-05", + "result": "Change role · Remove · Withdraw invitation" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ORG-04", + "title": "Invite member", + "section_heading": "D5 · Organizations", + "domain": "D5 Organizations", + "route": "/orgs/:orgId/members/invite", + "archetype": "form", + "phase": "MVP", + "who": "org_admin; organizations.manage may add by user_id", + "purpose": "Invite people by email (or add an existing user, staff only).", + "loads": "", + "states": "", + "notes": "Existing Users also receive an `account.organization_invitation` platform alert (NTF-01, and a push where the Hub has a device).", + "entry_points": "", + "container": "`AdwDialog` with `AdwPreferencesGroup`", + "elements": [ + { + "name": "Emails", + "kind": "token-field", + "control": "custom entry with pills", + "data": "body.email (one request per address)", + "rules": "paste a list separated by commas/newlines" + }, + { + "name": "Role", + "kind": "radio-group", + "control": "GtkCheckButton group", + "data": "body.role org_admin \\", + "rules": "member" + }, + { + "name": "Add existing user", + "kind": "user-picker", + "control": "AdwDialog with GtkSearchEntry list", + "data": "body.user_id", + "rules": "organizations.manage only" + } + ], + "actions": [ + { + "action": "Send invitations", + "call": "POST /organizations/{id}/members × n", + "result": "Per-address results summary; 409 → \"already a member\" per address. Responses look identical whether or not the address has an account (by design)." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ORG-05", + "title": "Member actions", + "section_heading": "D5 · Organizations", + "domain": "D5 Organizations", + "route": "/orgs/:orgId/members/:userId", + "archetype": "confirm", + "phase": "MVP", + "who": "org_admin / organizations.manage", + "purpose": "Change a member's role or remove them.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`AdwAlertDialog` (`extra-child` for fields)", + "elements": [ + { + "name": "Role", + "kind": "radio-group", + "control": "GtkCheckButton group", + "data": "body.role", + "rules": "" + }, + { + "name": "Remove from organization", + "kind": "destructive-button", + "control": "GtkButton .destructive-action", + "data": "DELETE …/members/{userId}", + "rules": "COPY confirm" + } + ], + "actions": [ + { + "action": "Change role", + "call": "PATCH /organizations/{id}/members/{userId}", + "result": "409 → last admin can't be demoted (explain)." + }, + { + "action": "Remove", + "call": "DELETE /organizations/{id}/members/{userId}", + "result": "409 → last admin can't leave/remove." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ORG-06", + "title": "Organization — Apps & seats", + "section_heading": "D5 · Organizations", + "domain": "D5 Organizations", + "route": "/orgs/:orgId/apps", + "archetype": "tab", + "phase": "Phase 2", + "who": "Member (read), org_admin (write)", + "purpose": "Org-wide app grants and who they include.", + "loads": "`GET /organizations/{id}/entitlements`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "`AdwViewStack` page", + "elements": [ + { + "name": "Rows", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "Entitlement (org): app, status, source (org_seat \\", + "rules": "subscription), member_scope, included_member_count, starts_at/ends_at" + }, + { + "name": "Grant app", + "kind": "button", + "control": "GtkButton", + "data": "→ ORG-07", + "rules": "" + } + ], + "actions": [ + { + "action": "Toggle", + "call": "PATCH /entitlements/{id} {status}", + "result": "Off requires confirm (COPY). On platform-managed rows only active ⇄ disabled is allowed; anything else 409 entitlement_platform_managed." + }, + { + "action": "Edit scope", + "call": "→ ORG-07 (org_seat) / BIL-02 (subscription)", + "result": "" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ORG-07", + "title": "Grant app to organization / member scope", + "section_heading": "D5 · Organizations", + "domain": "D5 Organizations", + "route": "/orgs/:orgId/apps/grant", + "archetype": "form", + "phase": "Phase 2", + "who": "entitlements.manage or app owner; scope edits by org_admin", + "purpose": "Create an org-wide grant or change who it includes.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`AdwDialog` with `AdwPreferencesGroup`", + "elements": [ + { + "name": "App", + "kind": "select", + "control": "AdwComboRow", + "data": "body.application_id", + "rules": "create only" + }, + { + "name": "Who gets access", + "kind": "radio-group", + "control": "GtkCheckButton group", + "data": "member_scope all_members \\", + "rules": "allowlist \\" + }, + { + "name": "Members", + "kind": "member-picker", + "control": "AdwDialog with GtkSearchEntry list", + "data": "member_overrides", + "rules": "visible for allowlist/denylist" + }, + { + "name": "Starts / Ends", + "kind": "date-fields", + "control": "GtkCalendar in popover", + "data": "starts_at, ends_at", + "rules": "optional" + }, + { + "name": "Order ID", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "order_id", + "rules": "optional; staff/app owner" + } + ], + "actions": [ + { + "action": "Grant", + "call": "POST /organizations/{id}/entitlements (Idempotency-Key)", + "result": "409 entitlement_already_exists → already granted (view it). 422 → field errors." + }, + { + "action": "Save scope", + "call": "PATCH /entitlements/{id} {member_scope, member_overrides}", + "result": "" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ORG-08", + "title": "Organization — Settings", + "section_heading": "D5 · Organizations", + "domain": "D5 Organizations", + "route": "/orgs/:orgId/settings", + "archetype": "settings", + "phase": "MVP", + "who": "org_admin (rename, SCIM), organizations.manage (suspend, SCIM), any member (leave)", + "purpose": "Rename, leave, set up SCIM provisioning, or (staff) suspend the organization.", + "loads": "", + "states": "", + "notes": "SCIM provisioning *invites* people (they join when they accept, via ORG-09) and deprovisioning removes the membership, never the account. Provisioned members show in ORG-03 like any invitation. SCIM itself (`/scim/v2`) is called by the identity provider, never by the Hub.", + "entry_points": "", + "container": "`AdwPreferencesPage`", + "elements": [ + { + "name": "Name", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "Organization.name", + "rules": "" + }, + { + "name": "Leave organization", + "kind": "destructive-row", + "control": "AdwButtonRow .destructive-action", + "data": "DELETE …/members/me", + "rules": "" + }, + { + "name": "SCIM provisioning", + "kind": "section", + "control": "AdwPreferencesGroup", + "data": "GET …/scim-token → `created_at`, `created_by`, `last_used_at`, `base_url`", + "rules": "org_admin or organizations.manage; behind `scim` flag; 404 scim_token_not_found = \"Not set up\"" + }, + { + "name": "Set up / Rotate token", + "kind": "button", + "control": "AdwButtonRow", + "data": "POST …/scim-token → token + base_url", + "rules": "rotate confirms first (\"Your identity provider stops syncing until you paste the new token\"); secret shown once (KEY-03 pattern)" + }, + { + "name": "Turn off SCIM", + "kind": "destructive-row", + "control": "AdwButtonRow .destructive-action", + "data": "DELETE …/scim-token", + "rules": "confirm; members already provisioned stay" + }, + { + "name": "Suspend / Reactivate", + "kind": "destructive-row", + "control": "AdwButtonRow .destructive-action", + "data": "PATCH {status, reason}", + "rules": "staff only; reason required" + } + ], + "actions": [ + { + "action": "Rename", + "call": "PATCH /organizations/{id} {name}", + "result": "" + }, + { + "action": "Leave", + "call": "DELETE /organizations/{id}/members/me", + "result": "409 last admin" + }, + { + "action": "Load SCIM status", + "call": "GET /organizations/{id}/scim-token", + "result": "404 scim_token_not_found → \"Not set up\" with a Set up button" + }, + { + "action": "Create / rotate SCIM token", + "call": "POST /organizations/{id}/scim-token", + "result": "201 → one-time secret sheet showing `base_url` and `token` with Copy; the previous token stops working at once" + }, + { + "action": "Turn off SCIM", + "call": "DELETE /organizations/{id}/scim-token", + "result": "204 → \"Not set up\"" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ORG-09", + "title": "Invitation", + "section_heading": "D5 · Organizations", + "domain": "D5 Organizations", + "route": "/orgs/:orgId/invitation", + "archetype": "confirm", + "phase": "MVP", + "who": "Invited user", + "purpose": "Accept or decline an organization invitation.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "Invitation banner on APP-01; ORG-01 pending section; `account.organization_invitation` notification (NTF-01 / push action \"Accept\")", + "container": "`AdwAlertDialog` (`extra-child` for fields)", + "elements": [ + { + "name": "Org name + inviter", + "kind": "text", + "control": "GtkLabel", + "data": "Organization", + "rules": "" + }, + { + "name": "Accept", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "", + "rules": "" + }, + { + "name": "Decline", + "kind": "button", + "control": "GtkButton", + "data": "", + "rules": "" + } + ], + "actions": [ + { + "action": "Accept", + "call": "POST /organizations/{id}/members/me/accept", + "result": "200 → ORG-03; apps granted via org (and the org's Standard subscription, if it covers the member) appear on APP-01. Mark the related notification read." + }, + { + "action": "Decline", + "call": "DELETE /organizations/{id}/members/me", + "result": "" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "USR-01", + "title": "Users", + "section_heading": "D6 · Users", + "domain": "D6 Users", + "route": "/admin/users", + "archetype": "table", + "phase": "MVP", + "who": "users.list (write actions need users.manage)", + "purpose": "Find and manage user accounts.", + "loads": "`GET /users?limit=50&cursor&status&q&application_id&organization_id&created_after&created_before`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "`GtkColumnView` (.data-table) in a card", + "elements": [ + { + "name": "Search", + "kind": "search", + "control": "GtkSearchEntry", + "data": "q (name, email, ID prefix usr_)", + "rules": "debounced 300 ms; exact ID jumps to USR-03" + }, + { + "name": "Status filter", + "kind": "chips", + "control": "GtkToggleButton pills", + "data": "status active \\", + "rules": "invited \\" + }, + { + "name": "More filters", + "kind": "menu", + "control": "GtkMenuButton", + "data": "application_id, organization_id, created range", + "rules": "" + }, + { + "name": "Columns", + "kind": "table", + "control": "GtkColumnView .data-table", + "data": "display name + email, status, id, last_login_at, created_at", + "rules": "sortable client-side within loaded page; server order = created_at desc" + }, + { + "name": "Multi-select", + "kind": "selection", + "control": "AdwComboRow", + "data": "", + "rules": "bulk: Suspend, Resend invitation, Export CSV (client-side of loaded rows)" + }, + { + "name": "Invite user", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "→ USR-02", + "rules": "users.manage" + } + ], + "actions": [ + { + "action": "Open row", + "call": "→ USR-03", + "result": "" + }, + { + "action": "Row menu", + "call": "", + "result": "Suspend · Reactivate · Resend invitation · Copy ID · Copy email" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "USR-02", + "title": "Invite user", + "section_heading": "D6 · Users", + "domain": "D6 Users", + "route": "/admin/users/new", + "archetype": "form", + "phase": "MVP", + "who": "users.manage", + "purpose": "Create a user and send an invitation (or create active).", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`AdwDialog` with `AdwPreferencesGroup`", + "elements": [ + { + "name": "Email", + "kind": "email-field", + "control": "AdwEntryRow input-purpose=email", + "data": "body.email", + "rules": "required" + }, + { + "name": "Display name", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "body.display_name", + "rules": "optional" + }, + { + "name": "Send invitation email", + "kind": "toggle", + "control": "AdwSwitchRow", + "data": "body.send_invitation", + "rules": "default on" + }, + { + "name": "Create as active", + "kind": "toggle", + "control": "AdwSwitchRow", + "data": "body.status active\\", + "rules": "invited" + } + ], + "actions": [ + { + "action": "Invite", + "call": "POST /users (Idempotency-Key)", + "result": "201 → USR-03. 409 → \"A user with that email exists\" + link." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "USR-03", + "title": "User — Overview", + "section_heading": "D6 · Users", + "domain": "D6 Users", + "route": "/admin/users/:userId", + "archetype": "detail", + "phase": "MVP", + "who": "users.list (read), users.manage (write)", + "purpose": "One user's account state and administrative actions.", + "loads": "`GET /users/{id}` · `GET /users/{id}/profile` · `GET /users/{id}/subscription (billing.manage only)`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "Content pane; `AdwViewSwitcher`; header-bar actions", + "elements": [ + { + "name": "Header", + "kind": "avatar, display name, email, id-chip, StatusBadge", + "control": "GtkLabel .monospace + copy", + "data": "User + Profile", + "rules": "" + }, + { + "name": "Tabs", + "kind": "tabs", + "control": "AdwViewSwitcher / AdwInlineViewSwitcher", + "data": "Overview · Access · Roles · Sessions · Notifications · Activity", + "rules": "Activity = AUD-01 filtered target_user_id; Notifications = NTF-05" + }, + { + "name": "Facts", + "kind": "key-value list", + "control": "AdwActionRow subtitle", + "data": "email_verified, pending_email, mfa_enabled, plan, plan_sources, created_at, last_login_at, signup_application_id, test_mode, expires_at (test-mode Users only: \"Expires in 3 h\" / \"Expired\")", + "rules": "plan_sources link to the org or to BIL-01 subscription view (billing.manage)" + }, + { + "name": "Actions", + "kind": "toolbar/menu", + "control": "GtkMenuButton", + "data": "", + "rules": "Suspend (USR-05) · Reactivate · Resend invitation · Reset two-factor · Export data · Delete · Restore · Cancel scheduled erasure" + } + ], + "actions": [ + { + "action": "Reactivate / Restore", + "call": "PATCH /users/{id} {status:active} (If-Match)", + "result": "" + }, + { + "action": "Resend invitation", + "call": "POST /users/{id}/invitation", + "result": "202 toast; 409 → not in invited state" + }, + { + "action": "Reset two-factor", + "call": "DELETE /users/{id}/mfa/totp", + "result": "confirm" + }, + { + "action": "Export", + "call": "GET /users/{id}/export", + "result": "save file" + }, + { + "action": "Delete", + "call": "DELETE /users/{id}", + "result": "confirm" + }, + { + "action": "Cancel erasure", + "call": "DELETE /users/{id}/erasure-requests/current", + "result": "visible during 7-day window" + }, + { + "action": "Extend (test-mode)", + "call": "PATCH /users/{id} {expires_at} (If-Match)", + "result": "users.manage; at least 15 min from now, at most 30 days after created_at (422 `expires_at_out_of_range`). Extending an expired User brings them back." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "USR-04", + "title": "User — Sessions", + "section_heading": "D6 · Users", + "domain": "D6 Users", + "route": "/admin/users/:userId/sessions", + "archetype": "tab", + "phase": "MVP", + "who": "users.manage", + "purpose": "See and revoke a user's sessions.", + "loads": "`GET /users/{id}/sessions`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "`AdwViewStack` page", + "elements": [ + { + "name": "Rows", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "Session: user_agent, ip_address, amr, created_at, last_seen_at, expires_at", + "rules": "staff see full IP" + } + ], + "actions": [ + { + "action": "Revoke", + "call": "DELETE /users/{id}/sessions/{sessionId}", + "result": "" + }, + { + "action": "Sign out everywhere", + "call": "POST /users/{id}/sessions/revoke-all", + "result": "Confirm (COPY \"Sign out everywhere (staff)\"). 200 `{revoked}` → toast; the list empties. Their account stays active." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "USR-05", + "title": "Suspend user", + "section_heading": "D6 · Users", + "domain": "D6 Users", + "route": "/admin/users/:userId/suspend", + "archetype": "confirm", + "phase": "MVP", + "who": "users.manage", + "purpose": "Suspend an account (signs them out everywhere).", + "loads": "", + "states": "", + "notes": "Suspension revokes sessions and deactivates every push device the user holds; say so in the confirm body.", + "entry_points": "", + "container": "`AdwAlertDialog` (`extra-child` for fields)", + "elements": [ + { + "name": "Reason", + "kind": "text-area", + "control": "GtkTextView in .card", + "data": "body.reason", + "rules": "optional, written to audit" + } + ], + "actions": [ + { + "action": "Suspend", + "call": "POST /users/{id}/suspend", + "result": "200 → badge Suspended. 409 → already suspended." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ACX-01", + "title": "User — Access", + "section_heading": "D7 · Access", + "domain": "D7 Access", + "route": "/admin/users/:userId/access", + "archetype": "tab", + "phase": "MVP", + "who": "users.list (read), entitlements.manage or app owner (write)", + "purpose": "Turn apps on or off for one person; see every path that grants access.", + "loads": "`GET /users/{id}/entitlements?include_inactive=true` · `GET /applications`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "The switch on a platform-managed row only disables / re-enables it (the platform never re-enables a row an admin disabled). Term, source and scope edits are hidden for those rows.", + "entry_points": "", + "container": "`AdwViewStack` page", + "elements": [ + { + "name": "Rows (one per app)", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "resolved status per app + paths (personal Entitlement, org grants)", + "rules": "StatusBadge + AccessSwitch on the personal path" + }, + { + "name": "Paths disclosure", + "kind": "expandable", + "control": "AdwExpanderRow", + "data": "Entitlement.granted_via, source, subscription_id, organization_id, member_decision, starts_at/ends_at, disabled_reason", + "rules": "platform-managed paths (`free`, `subscription`) show a \"Managed by Substratal\" caption; developer grants and platform rows can coexist" + }, + { + "name": "Org-only access", + "kind": "caption + link", + "control": "GtkLabel .caption .dim-label", + "data": "", + "rules": "switch replaced by \"Via {org}\" → ORG-06" + }, + { + "name": "Grant access", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "→ ACX-02", + "rules": "" + }, + { + "name": "Why?", + "kind": "button", + "control": "GtkButton", + "data": "→ ACX-04", + "rules": "" + } + ], + "actions": [ + { + "action": "Switch off", + "call": "→ ACX-03 confirm → PATCH /entitlements/{id} {status:disabled, disabled_reason}", + "result": "Optimistic with Undo (PATCH status:active) for 10 s" + }, + { + "action": "Switch on", + "call": "PATCH /entitlements/{id} {status:active}", + "result": "No confirm" + }, + { + "action": "Remove grant made in error", + "call": "DELETE /entitlements/{id}", + "result": "Only developer grants with no order_id and < 24 h old; never on `free`/`subscription` rows (409 entitlement_platform_managed); 409 otherwise" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ACX-02", + "title": "Grant access", + "section_heading": "D7 · Access", + "domain": "D7 Access", + "route": "/admin/users/:userId/access/grant", + "archetype": "form", + "phase": "MVP", + "who": "entitlements.manage or app owner (own app)", + "purpose": "Give a person access to an app.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`AdwDialog` with `AdwPreferencesGroup`", + "elements": [ + { + "name": "App", + "kind": "select", + "control": "AdwComboRow", + "data": "body.application_id", + "rules": "app owners see only their apps" + }, + { + "name": "Type", + "kind": "radio-group", + "control": "GtkCheckButton group", + "data": "body.source purchase \\", + "rules": "trial \\" + }, + { + "name": "Order ID", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "body.order_id", + "rules": "required feel for purchase" + }, + { + "name": "Starts", + "kind": "date-time", + "control": "GtkCalendar in popover", + "data": "body.starts_at", + "rules": "default now" + }, + { + "name": "Ends", + "kind": "date-time", + "control": "GtkCalendar in popover", + "data": "body.ends_at", + "rules": "optional; required for trial" + } + ], + "actions": [ + { + "action": "Grant", + "call": "POST /users/{id}/entitlements (Idempotency-Key, required)", + "result": "201 → row Active. 409 entitlement_already_exists → \"Already has access — view it\". A platform-managed row doesn't block a grant (they coexist)." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ACX-03", + "title": "Turn off / edit access", + "section_heading": "D7 · Access", + "domain": "D7 Access", + "route": "/admin/entitlements/:entId", + "archetype": "confirm", + "phase": "MVP", + "who": "entitlements.manage or app owner", + "purpose": "Confirm turning off access, or edit term/source.", + "loads": "`GET /entitlements/{id}`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "`AdwAlertDialog` (`extra-child` for fields)", + "elements": [ + { + "name": "Title", + "kind": "text", + "control": "GtkLabel", + "data": "", + "rules": "\"Turn off {app} for {name}?\"" + }, + { + "name": "Reason", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "body.disabled_reason", + "rules": "optional; audit" + }, + { + "name": "Edit term (advanced)", + "kind": "disclosure", + "control": "AdwExpanderRow", + "data": "ends_at, source trial→purchase, order_id", + "rules": "hidden for platform-managed rows" + } + ], + "actions": [ + { + "action": "Turn off", + "call": "PATCH /entitlements/{id} {status:disabled, disabled_reason} (If-Match)", + "result": "409 version_conflict → reload and re-apply" + }, + { + "action": "Save changes", + "call": "PATCH /entitlements/{id}", + "result": "" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ACX-04", + "title": "Access check (Why?)", + "section_heading": "D7 · Access", + "domain": "D7 Access", + "route": "/admin/users/:userId/apps/:appId/why", + "archetype": "popover", + "phase": "MVP", + "who": "users.list or app owner", + "purpose": "Explain exactly why a person can or can't use an app.", + "loads": "`GET /users/{id}/apps/{appId}/effective-permissions`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "`GtkPopover` or `AdwOverlaySplitView` sidebar", + "elements": [ + { + "name": "Verdict", + "kind": "banner", + "control": "AdwBanner", + "data": "allowed", + "rules": "\"Dana can use Timetrack\" / \"can't\" + first failing reason (user_status, entitlement_status)" + }, + { + "name": "Checks", + "kind": "checklist", + "control": "GtkListBox with status icons", + "data": "user_status, entitlement_status, access_paths[], roles, effective_permissions", + "rules": "each with pass/fail icon" + }, + { + "name": "Computed at", + "kind": "caption", + "control": "GtkLabel .caption .dim-label", + "data": "computed_at", + "rules": "" + } + ], + "actions": [], + "platforms": [ + "all" + ] + }, + { + "id": "ACX-05", + "title": "Access (all)", + "section_heading": "D7 · Access", + "domain": "D7 Access", + "route": "/admin/access", + "archetype": "table", + "phase": "MVP", + "who": "entitlements.manage or app owner (confined to own apps)", + "purpose": "Every grant across users: audit expirations, trials, revocations.", + "loads": "`GET /entitlements?application_id&status&source&user_id&organization_id&order_id&ends_before&include_inactive`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "`GtkColumnView` (.data-table) in a card", + "elements": [ + { + "name": "Filters", + "kind": "chips/menus", + "control": "GtkToggleButton pills", + "data": "app, status, source, ends before (Expiring in 7/30 days presets)", + "rules": "" + }, + { + "name": "Columns", + "kind": "table", + "control": "GtkColumnView .data-table", + "data": "user, app, status, source (incl. free / subscription), subscription_id, starts_at, ends_at, order_id, granted_via", + "rules": "source filter defaults to developer grants; \"Show platform-managed\" chip adds free/subscription rows" + }, + { + "name": "Multi-select", + "kind": "selection", + "control": "AdwComboRow", + "data": "", + "rules": "bulk Turn off (confirm, one PATCH per row, progress + per-row result)" + } + ], + "actions": [ + { + "action": "Open row", + "call": "→ ACX-01 for that user", + "result": "" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ROL-01", + "title": "Roles", + "section_heading": "D8 · Roles & Permissions", + "domain": "D8 Roles & Permissions", + "route": "/admin/roles", + "archetype": "list", + "phase": "MVP", + "who": "users.list (read), roles.manage (write)", + "purpose": "Platform and app roles.", + "loads": "`GET /roles?scope` · `GET /permissions (includes notifications.send)`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "Nested `AdwNavigationSplitView` list pane", + "elements": [ + { + "name": "Scope", + "kind": "segmented", + "control": "linked GtkToggleButtons", + "data": "scope platform \\", + "rules": "{application_id}" + }, + { + "name": "Rows", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "Role: name, scope, seed, assignment_count, description", + "rules": "seed → lock icon" + }, + { + "name": "New role", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "→ ROL-03", + "rules": "roles.manage" + } + ], + "actions": [], + "platforms": [ + "all" + ] + }, + { + "id": "ROL-02", + "title": "Role detail", + "section_heading": "D8 · Roles & Permissions", + "domain": "D8 Roles & Permissions", + "route": "/admin/roles/:roleId", + "archetype": "detail", + "phase": "MVP", + "who": "users.list (read), roles.manage (write)", + "purpose": "View or edit a role's permissions.", + "loads": "`GET /roles/{id}` · `GET /permissions?scope`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "Content pane; `AdwViewSwitcher`; header-bar actions", + "elements": [ + { + "name": "Header", + "kind": "name, id-chip, scope, assignment_count", + "control": "GtkLabel .monospace + copy", + "data": "", + "rules": "" + }, + { + "name": "Description", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "Role.description", + "rules": "read-only when seed" + }, + { + "name": "Permissions", + "kind": "checkbox-list grouped by prefix", + "control": "AdwPreferencesGroup of check rows", + "data": "Role.permissions ⊂ Permission[].key", + "rules": "read-only when seed; show Permission.description" + }, + { + "name": "Holders", + "kind": "link", + "control": "GtkButton .flat / GtkLinkButton", + "data": "→ USR-01 filtered by `GET /users?role_id={id}`", + "rules": "explicit assignments only; for an app's `default_app_role`, add \"and everyone with access to {app}\" → ACX-05 filtered" + }, + { + "name": "Delete role", + "kind": "destructive-button", + "control": "GtkButton .destructive-action", + "data": "DELETE /roles/{id}", + "rules": "only non-seed with assignment_count 0" + } + ], + "actions": [ + { + "action": "Save", + "call": "PATCH /roles/{id} {description, permissions} (If-Match)", + "result": "" + }, + { + "action": "Delete", + "call": "DELETE /roles/{id}", + "result": "409 → still assigned" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ROL-03", + "title": "New role", + "section_heading": "D8 · Roles & Permissions", + "domain": "D8 Roles & Permissions", + "route": "/admin/roles/new", + "archetype": "form", + "phase": "MVP", + "who": "roles.manage", + "purpose": "Define a custom role.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`AdwDialog` with `AdwPreferencesGroup`", + "elements": [ + { + "name": "Name", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "body.name", + "rules": "^[a-z][a-z0-9_]{1,40}$; live validation" + }, + { + "name": "Scope", + "kind": "select", + "control": "AdwComboRow", + "data": "body.scope", + "rules": "platform or an application" + }, + { + "name": "Description", + "kind": "text-area", + "control": "GtkTextView in .card", + "data": "body.description", + "rules": "" + }, + { + "name": "Permissions", + "kind": "checkbox-list", + "control": "AdwPreferencesGroup of check rows", + "data": "body.permissions", + "rules": "≥ 1" + } + ], + "actions": [ + { + "action": "Create", + "call": "POST /roles", + "result": "409 → name taken in scope" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "ROL-04", + "title": "User — Roles", + "section_heading": "D8 · Roles & Permissions", + "domain": "D8 Roles & Permissions", + "route": "/admin/users/:userId/roles", + "archetype": "tab", + "phase": "MVP", + "who": "users.list (read), roles.manage (write)", + "purpose": "Assign and remove roles for a user.", + "loads": "`GET /users/{id}/roles`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "`AdwViewStack` page", + "elements": [ + { + "name": "Rows", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "UserRoleAssignment: role, application_id, assigned_at, assigned_by, implicit", + "rules": "implicit → \"Granted with app access\", not removable" + }, + { + "name": "Assign role", + "kind": "button → role picker", + "control": "AdwDialog with GtkSearchEntry list", + "data": "", + "rules": "" + } + ], + "actions": [ + { + "action": "Assign", + "call": "POST /users/{id}/roles/{roleId}", + "result": "idempotent; 200 already held" + }, + { + "action": "Remove", + "call": "DELETE /users/{id}/roles/{roleId}", + "result": "409 → implicit role" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "APL-01", + "title": "Applications", + "section_heading": "D9 · Applications", + "domain": "D9 Applications", + "route": "/dev/apps", + "archetype": "list", + "phase": "MVP", + "who": "Any (catalog), owner/applications.manage (manage)", + "purpose": "Applications you own or administer.", + "loads": "`GET /applications?owned=true (staff: all, filter review_status)`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "Nested `AdwNavigationSplitView` list pane", + "elements": [ + { + "name": "Rows", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "ApplicationSummary: icon, name, slug, visibility, review_status", + "rules": "" + }, + { + "name": "Register app", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "→ APL-02", + "rules": "applications.manage, or anyone with a verified email (self-service, flag `app_registration_self_service`); unverified → disabled with \"Verify your email to register apps\"" + } + ], + "actions": [], + "platforms": [ + "all" + ] + }, + { + "id": "APL-02", + "title": "Register application", + "section_heading": "D9 · Applications", + "domain": "D9 Applications", + "route": "/dev/apps/new", + "archetype": "wizard", + "phase": "MVP", + "who": "applications.manage, or anyone with a verified email (self-service)", + "purpose": "Register a new application.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`AdwDialog` with `AdwNavigationView` pages", + "elements": [ + { + "name": "Name", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "name", + "rules": "" + }, + { + "name": "Slug", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "slug", + "rules": "^[a-z][a-z0-9-]{1,63}$; 'platform' reserved; immutable — warn" + }, + { + "name": "Launch URL", + "kind": "url-field", + "control": "AdwEntryRow input-purpose=url", + "data": "launch_url", + "rules": "https" + }, + { + "name": "Redirect URIs", + "kind": "url-list", + "control": "AdwPreferencesGroup of AdwEntryRow + add button", + "data": "redirect_uris", + "rules": "" + }, + { + "name": "Visibility", + "kind": "radio-group", + "control": "GtkCheckButton group", + "data": "visibility public \\", + "rules": "invite_only \\" + }, + { + "name": "Access tier", + "kind": "radio-group", + "control": "GtkCheckButton group", + "data": "access_tier free \\", + "rules": "standard (default standard)" + }, + { + "name": "Owner", + "kind": "select", + "control": "AdwComboRow", + "data": "owner_user_id \\", + "rules": "owner_organization_id" + }, + { + "name": "Icon", + "kind": "image-picker", + "control": "GtkFileDialog (portal)", + "data": "icon_url", + "rules": "" + }, + { + "name": "Support URL", + "kind": "url-field", + "control": "AdwEntryRow input-purpose=url", + "data": "support_url", + "rules": "" + }, + { + "name": "Email from name", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "email_from_name", + "rules": "" + } + ], + "actions": [ + { + "action": "Register", + "call": "POST /applications (Idempotency-Key)", + "result": "409 slug taken; 422 field errors; 403 `email_not_verified` / `moderation_field_forbidden`; 429 → 10 a day. Staff registration grants the platform-managed Entitlements its access_tier calls for; a self-service app starts `pending_review` and is granted them only once approved (APL-06)." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "APL-03", + "title": "Application — Overview", + "section_heading": "D9 · Applications", + "domain": "D9 Applications", + "route": "/dev/apps/:appId", + "archetype": "detail", + "phase": "MVP", + "who": "Owner (configure), applications.manage (anything)", + "purpose": "Configure an application.", + "loads": "`GET /applications/{id}`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "Content pane; `AdwViewSwitcher`; header-bar actions", + "elements": [ + { + "name": "Header", + "kind": "icon, name, id-chip, review StatusBadge, \"Third-party\" chip when `self_registered`", + "control": "GtkLabel .monospace + copy", + "data": "", + "rules": "" + }, + { + "name": "Tabs", + "kind": "tabs", + "control": "AdwViewSwitcher / AdwInlineViewSwitcher", + "data": "Overview · Settings schema · Permissions & roles · Push · Keys · Webhooks · Access · Activity", + "rules": "Push = APL-07; Keys/Webhooks/Access = KEY-01/WHK-01/ACX-05 filtered to app" + }, + { + "name": "Fields", + "kind": "settings rows", + "control": "Adw*Rows", + "data": "name, description, launch_url, redirect_uris, support_url, email_from_name, icon_url (owner); visibility, access_tier (applications.manage)", + "rules": "slug read-only with lock" + }, + { + "name": "Platform alerts", + "kind": "toggle", + "control": "AdwSwitchRow", + "data": "receives_platform_notifications", + "rules": "owner-writable; explain that security/account alerts will push under this app's name and icon. For a `self_registered` app only applications.manage can turn it on (owner sees it disabled with a reason)" + }, + { + "name": "Review notes", + "kind": "banner", + "control": "AdwBanner", + "data": "review_status rejected/suspended + review_notes", + "rules": "" + } + ], + "actions": [ + { + "action": "Save field", + "call": "PATCH /applications/{id} (If-Match)", + "result": "422 → field errors" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "APL-04", + "title": "Settings schema", + "section_heading": "D9 · Applications", + "domain": "D9 Applications", + "route": "/dev/apps/:appId/schema", + "archetype": "tab", + "phase": "Phase 2", + "who": "Owner / applications.manage", + "purpose": "Declare per-user settings the app stores in Substratal.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`AdwViewStack` page", + "elements": [ + { + "name": "Properties", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "settings_schema.properties: key, type, enum, default, min/max, maxLength", + "rules": "add/edit via form" + }, + { + "name": "JSON editor", + "kind": "code-editor", + "control": "GtkSourceView (JSON)", + "data": "settings_schema", + "rules": "JSON validation; diff before save" + }, + { + "name": "Preview", + "kind": "rendered SET-02 form", + "control": "SchemaForm preview", + "data": "", + "rules": "shows exactly what users will see" + }, + { + "name": "Stats", + "kind": "caption", + "control": "GtkLabel .caption .dim-label", + "data": "settings_schema_stats", + "rules": "" + } + ], + "actions": [ + { + "action": "Save", + "call": "PATCH /applications/{id} {settings_schema}", + "result": "422 → schema rule errors" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "APL-05", + "title": "Permissions & roles", + "section_heading": "D9 · Applications", + "domain": "D9 Applications", + "route": "/dev/apps/:appId/roles", + "archetype": "tab", + "phase": "Phase 2", + "who": "Owner / applications.manage", + "purpose": "Declare app permissions, available app roles and the default role.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`AdwViewStack` page", + "elements": [ + { + "name": "Permissions", + "kind": "editable list", + "control": "GtkColumnView .data-table", + "data": "permissions[] {key, description}", + "rules": "key app..*" + }, + { + "name": "Available app roles", + "kind": "token list", + "control": "custom entry with pills", + "data": "available_app_roles (≤ 20)", + "rules": "" + }, + { + "name": "Default app role", + "kind": "select", + "control": "AdwComboRow", + "data": "default_app_role", + "rules": "held implicitly by everyone with access" + } + ], + "actions": [ + { + "action": "Save", + "call": "PATCH /applications/{id}", + "result": "" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "APL-06", + "title": "Review", + "section_heading": "D9 · Applications", + "domain": "D9 Applications", + "route": "/dev/apps/:appId/review", + "archetype": "form", + "phase": "Phase 3", + "who": "applications.manage", + "purpose": "Staff moderation of an application.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`AdwDialog` with `AdwPreferencesGroup`", + "elements": [ + { + "name": "Review status", + "kind": "radio-group", + "control": "GtkCheckButton group", + "data": "review_status", + "rules": "" + }, + { + "name": "Notes", + "kind": "text-area", + "control": "GtkTextView in .card", + "data": "review_notes", + "rules": "visible to owner" + } + ], + "actions": [ + { + "action": "Save", + "call": "PATCH /applications/{id} {review_status, review_notes}", + "result": "" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "APL-07", + "title": "Push credentials", + "section_heading": "D9 · Applications", + "domain": "D9 Applications", + "route": "/dev/apps/:appId/push", + "archetype": "tab", + "phase": "Phase 2", + "who": "Owner or applications.manage with a User token (write); app key / owner (read)", + "purpose": "Configure APNs, FCM and Web Push for an Application so it can send push notifications.", + "loads": "`GET /applications/{id}/push-credentials`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "To prove a credential end to end, the owner registers a device of their own app and uses its device test (NTF-03 lists only devices the owner holds).", + "entry_points": "", + "container": "`AdwViewStack` page", + "elements": [ + { + "name": "Providers", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "PushCredential per provider apns \\", + "rules": "fcm \\" + }, + { + "name": "Add APNs", + "kind": "form", + "control": "AdwDialog form", + "data": "POST body: provider apns, team_id (10), key_id (10), topic (bundle id), private_key (.p8)", + "rules": "file picker / drop / paste for the .p8; key never shown again" + }, + { + "name": "Add FCM", + "kind": "form", + "control": "AdwDialog form", + "data": "POST body: provider fcm, service_account (JSON file)", + "rules": "drop/pick the downloaded JSON; validate shape client-side" + }, + { + "name": "Add Web Push", + "kind": "form", + "control": "AdwDialog form", + "data": "POST body: provider web_push, subject (mailto:/https:)", + "rules": "platform generates VAPID keys; show vapid_public_key with Copy for the web client" + }, + { + "name": "Rotate / fix", + "kind": "form", + "control": "AdwDialog form", + "data": "PATCH /applications/{id}/push-credentials/{provider} {key material \\", + "rules": "status: active}" + }, + { + "name": "Remove", + "kind": "destructive-button", + "control": "GtkButton .destructive-action", + "data": "DELETE /applications/{id}/push-credentials/{provider}", + "rules": "confirm; web_push removal deactivates every browser subscription" + }, + { + "name": "Platform alerts", + "kind": "toggle", + "control": "AdwSwitchRow", + "data": "Application.receives_platform_notifications", + "rules": "mirror of APL-03 toggle" + } + ], + "actions": [ + { + "action": "Add", + "call": "POST /applications/{id}/push-credentials", + "result": "201; 409 push_credential_exists → Edit; 422 invalid_push_credential → field error; 400 user_token_required never happens in the Hub (User token)" + }, + { + "action": "Fetch one", + "call": "GET /applications/{id}/push-credentials/{provider}", + "result": "on Edit" + }, + { + "action": "Save", + "call": "PATCH /applications/{id}/push-credentials/{provider}", + "result": "409 invalid_status_transition" + }, + { + "action": "Remove", + "call": "DELETE /applications/{id}/push-credentials/{provider}", + "result": "204" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "KEY-01", + "title": "API keys", + "section_heading": "D10 · Developer Credentials", + "domain": "D10 Developer Credentials", + "route": "/dev/keys", + "archetype": "table", + "phase": "MVP", + "who": "api_keys.manage or app owner", + "purpose": "Service and agent credentials.", + "loads": "`GET /api-keys?scope&mode&intended_use&include_inactive`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "`GtkColumnView` (.data-table) in a card", + "elements": [ + { + "name": "Filters", + "kind": "chips", + "control": "GtkToggleButton pills", + "data": "mode live\\", + "rules": "test, intended_use, include revoked" + }, + { + "name": "Columns", + "kind": "table", + "control": "GtkColumnView .data-table", + "data": "name, scope, secret_hint, mode, intended_use, restrict_destructive, last_used_at, expires_at, status(revoked_at)", + "rules": "" + }, + { + "name": "Create key", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "→ KEY-02", + "rules": "" + } + ], + "actions": [], + "platforms": [ + "all" + ] + }, + { + "id": "KEY-02", + "title": "Create API key", + "section_heading": "D10 · Developer Credentials", + "domain": "D10 Developer Credentials", + "route": "/dev/keys/new", + "archetype": "form", + "phase": "MVP", + "who": "api_keys.manage or app owner (app-scoped)", + "purpose": "Create a key with least-privilege permissions.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`AdwDialog` with `AdwPreferencesGroup`", + "elements": [ + { + "name": "Name", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "name", + "rules": "" + }, + { + "name": "Scope", + "kind": "select", + "control": "AdwComboRow", + "data": "scope platform \\", + "rules": "application" + }, + { + "name": "Mode", + "kind": "segmented", + "control": "linked GtkToggleButtons", + "data": "mode live \\", + "rules": "test" + }, + { + "name": "Used by", + "kind": "radio-group", + "control": "GtkCheckButton group", + "data": "intended_use service \\", + "rules": "agent" + }, + { + "name": "Block destructive operations", + "kind": "toggle", + "control": "AdwSwitchRow", + "data": "restrict_destructive", + "rules": "" + }, + { + "name": "Permissions", + "kind": "checkbox-list", + "control": "AdwPreferencesGroup of check rows", + "data": "permissions", + "rules": "filtered by scope via GET /permissions; app-scoped keys offer the five app-confined platform permissions incl. notifications.send" + }, + { + "name": "Expires", + "kind": "date-field", + "control": "GtkCalendar in popover", + "data": "expires_at", + "rules": "optional; presets 30/90/365 days" + } + ], + "actions": [ + { + "action": "Create", + "call": "POST /api-keys", + "result": "201 ApiKeyWithSecret → KEY-03" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "KEY-03", + "title": "Secret reveal", + "section_heading": "D10 · Developer Credentials", + "domain": "D10 Developer Credentials", + "route": "(modal)", + "archetype": "secret", + "phase": "MVP", + "who": "Creator", + "purpose": "Show a new API key secret, webhook signing secret, or recovery codes exactly once.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`AdwDialog` `can-close=false`", + "elements": [ + { + "name": "Secret", + "kind": "monospace selectable text", + "control": "GtkLabel selectable .monospace", + "data": "secret", + "rules": "never logged, never persisted, excluded from screenshots where the OS allows" + }, + { + "name": "Copy", + "kind": "button", + "control": "GtkButton", + "data": "clipboard (sensitive)", + "rules": "auto-clear after 60 s where supported" + }, + { + "name": "Acknowledge", + "kind": "checkbox", + "control": "GtkCheckButton", + "data": "client", + "rules": "gates Done; modal not dismissible until checked" + } + ], + "actions": [], + "platforms": [ + "all" + ] + }, + { + "id": "KEY-04", + "title": "API key detail", + "section_heading": "D10 · Developer Credentials", + "domain": "D10 Developer Credentials", + "route": "/dev/keys/:keyId", + "archetype": "detail", + "phase": "MVP", + "who": "api_keys.manage or app owner", + "purpose": "Edit, rotate or revoke a key.", + "loads": "`GET /api-keys/{id}` · `GET /audit-events?actor_id={id}`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "Content pane; `AdwViewSwitcher`; header-bar actions", + "elements": [ + { + "name": "Fields", + "kind": "settings rows", + "control": "Adw*Rows", + "data": "name, permissions, restrict_destructive (editable); scope, mode, intended_use, expires_at, created_by, last_used_at (read-only)", + "rules": "" + }, + { + "name": "Rotate", + "kind": "button", + "control": "GtkButton", + "data": "POST /api-keys/{id}/rotate", + "rules": "confirm: no overlap" + }, + { + "name": "Revoke", + "kind": "destructive-button", + "control": "GtkButton .destructive-action", + "data": "DELETE /api-keys/{id}", + "rules": "confirm" + }, + { + "name": "Recent activity", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "audit events by this key", + "rules": "" + } + ], + "actions": [ + { + "action": "Save", + "call": "PATCH /api-keys/{id}", + "result": "" + }, + { + "action": "Rotate", + "call": "POST …/rotate", + "result": "200 → KEY-03" + }, + { + "action": "Revoke", + "call": "DELETE /api-keys/{id}", + "result": "204 → row shows Revoked" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "WHK-01", + "title": "Webhooks", + "section_heading": "D10 · Developer Credentials", + "domain": "D10 Developer Credentials", + "route": "/dev/webhooks", + "archetype": "list", + "phase": "Phase 2", + "who": "webhooks.manage or app owner", + "purpose": "Endpoints that receive access-change events.", + "loads": "`GET /webhooks?application_id`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "Nested `AdwNavigationSplitView` list pane", + "elements": [ + { + "name": "Rows", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "Webhook: url, events count, status healthy\\", + "rules": "unhealthy\\" + }, + { + "name": "Add webhook", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "→ WHK-02", + "rules": "" + } + ], + "actions": [], + "platforms": [ + "all" + ] + }, + { + "id": "WHK-02", + "title": "Add / edit webhook", + "section_heading": "D10 · Developer Credentials", + "domain": "D10 Developer Credentials", + "route": "/dev/webhooks/new", + "archetype": "form", + "phase": "Phase 2", + "who": "webhooks.manage or app owner", + "purpose": "Subscribe a URL to events.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`AdwDialog` with `AdwPreferencesGroup`", + "elements": [ + { + "name": "URL", + "kind": "url-field", + "control": "AdwEntryRow input-purpose=url", + "data": "url", + "rules": "https required" + }, + { + "name": "Events", + "kind": "checkbox-list grouped", + "control": "AdwPreferencesGroup of check rows", + "data": "events (WebhookEvent enum: access.*, entitlement.*, role.*, user.*, organization.*, application.*, device.*, notification.push_failed, push_credential.failed, webhook.test)", + "rules": "presets: Access changes (access.granted, access.revoked) · Push health (notification.push_failed, push_credential.failed, device.deactivated)" + }, + { + "name": "Description", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "description", + "rules": "" + }, + { + "name": "Scope", + "kind": "select", + "control": "AdwComboRow", + "data": "scope", + "rules": "" + }, + { + "name": "Payload version", + "kind": "select", + "control": "AdwComboRow", + "data": "api_version (a date)", + "rules": "edit only; the webhook payload version, independent of the `Substratal-Version` request header (CLIENT-ARCHITECTURE §4.1)" + }, + { + "name": "Enabled", + "kind": "toggle", + "control": "AdwSwitchRow", + "data": "status disabled ↔ healthy", + "rules": "edit only (re-enable)" + } + ], + "actions": [ + { + "action": "Create", + "call": "POST /webhooks", + "result": "201 WebhookWithSecret → KEY-03" + }, + { + "action": "Save", + "call": "PATCH /webhooks/{id}", + "result": "" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "WHK-03", + "title": "Webhook — Deliveries", + "section_heading": "D10 · Developer Credentials", + "domain": "D10 Developer Credentials", + "route": "/dev/webhooks/:hookId", + "archetype": "detail", + "phase": "Phase 2", + "who": "webhooks.manage or app owner", + "purpose": "Delivery history, testing and secret rotation.", + "loads": "`GET /webhooks/{id}` · `GET /webhooks/{id}/deliveries?status&since&event_id`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "Content pane; `AdwViewSwitcher`; header-bar actions", + "elements": [ + { + "name": "Header actions", + "kind": "toolbar", + "control": "AdwHeaderBar buttons", + "data": "", + "rules": "Send test · Rotate secret · Edit · Delete" + }, + { + "name": "Filter", + "kind": "chips", + "control": "GtkToggleButton pills", + "data": "status pending\\", + "rules": "succeeded\\" + }, + { + "name": "Rows", + "kind": "table", + "control": "GtkColumnView .data-table", + "data": "WebhookDelivery: status, response_status, event_type, event_id, attempt, duration_ms, created_at, next_retry_at, error", + "rules": "failed → Redeliver" + } + ], + "actions": [ + { + "action": "Send test", + "call": "POST /webhooks/{id}/test", + "result": "202 → new pending row appears on refresh (poll 2 s × 5)" + }, + { + "action": "Rotate secret", + "call": "POST /webhooks/{id}/rotate-secret", + "result": "confirm (24 h overlap) → KEY-03" + }, + { + "action": "Redeliver", + "call": "POST /webhooks/{id}/deliveries/{deliveryId}/redeliver", + "result": "202 toast; same event_id" + }, + { + "action": "Delete", + "call": "DELETE /webhooks/{id}", + "result": "confirm" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "WHK-04", + "title": "Delivery detail", + "section_heading": "D10 · Developer Credentials", + "domain": "D10 Developer Credentials", + "route": "/dev/webhooks/:hookId/deliveries/:deliveryId", + "archetype": "popover", + "phase": "Phase 2", + "who": "webhooks.manage or app owner", + "purpose": "One delivery attempt in detail.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`GtkPopover` or `AdwOverlaySplitView` sidebar", + "elements": [ + { + "name": "Facts", + "kind": "key-value", + "control": "AdwActionRow subtitle", + "data": "all WebhookDelivery fields", + "rules": "" + }, + { + "name": "Redeliver", + "kind": "button", + "control": "GtkButton", + "data": "", + "rules": "" + } + ], + "actions": [], + "platforms": [ + "all" + ] + }, + { + "id": "BIL-01", + "title": "My plan", + "section_heading": "D11 · Plan & Billing", + "domain": "D11 Plan & Billing", + "route": "/billing", + "archetype": "settings", + "phase": "Phase 2", + "who": "Self; billing.manage can view any User's (read-only)", + "purpose": "Free or Standard: what covers you, your own subscription, upgrade and manage billing.", + "loads": "`GET /users/me/subscription`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES; After return from checkout: poll GET /users/me/subscription every 2 s (max 30 s) until plan = standard, showing \"Finishing up…\"; Downgraded: \"Your Standard access ended on {date}. Your data is kept.\"", + "notes": "Business/Enterprise aren't offered (422 plan_requires_sales); show \"Contact sales\" link only. Annual plans and a Standard trial are undecided in the API (DECISIONS #31) — no UI for them.", + "entry_points": "", + "container": "`AdwPreferencesPage`", + "elements": [ + { + "name": "Plan card", + "kind": "card", + "control": ".card box", + "data": "UserSubscription.plan free\\", + "rules": "standard" + }, + { + "name": "Covered by", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "plan_sources[]: {type user\\", + "rules": "organization, id}" + }, + { + "name": "Double coverage tip", + "kind": "banner", + "control": "AdwBanner", + "data": "plan_sources has both user and organization", + "rules": "\"Your organization already covers you. You can cancel your own subscription in Manage billing.\" (no automatic refund)" + }, + { + "name": "Your subscription", + "kind": "key-value", + "control": "AdwActionRow subtitle", + "data": "Subscription.status, current_period_end, cancel_at_period_end, unit_amount, currency", + "rules": "status badge via status-semantics; past_due → warning banner (Stripe retries ~3 weeks)" + }, + { + "name": "Upgrade to Standard", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "→ BIL-03 checkout", + "rules": "when no covering subscription, or own subscription is incomplete/incomplete_expired/canceled/unpaid; price \"$6 per month\" from unit_amount when known, else from pricing config; store flags on iOS/Android" + }, + { + "name": "Manage billing", + "kind": "button", + "control": "GtkButton", + "data": "→ BIL-03 portal", + "rules": "when the user has completed a checkout (own subscription exists and isn't incomplete)" + }, + { + "name": "What Standard includes", + "kind": "link", + "control": "GtkButton .flat / GtkLinkButton", + "data": "standard apps from GET /applications?access_tier=standard", + "rules": "count + list" + } + ], + "actions": [], + "platforms": [ + "all" + ] + }, + { + "id": "BIL-02", + "title": "Organization subscription", + "section_heading": "D11 · Plan & Billing", + "domain": "D11 Plan & Billing", + "route": "/orgs/:orgId/subscription", + "archetype": "tab", + "phase": "Phase 2", + "who": "org_admin (manage); billing.manage (read + portal)", + "purpose": "An organization's Standard subscription and which members it covers.", + "loads": "`GET /organizations/{id}/subscription` · `GET /organizations/{id}/members (for the member picker)`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "Members gained or lost take effect at once; say so before saving.", + "entry_points": "", + "container": "`AdwViewStack` page", + "elements": [ + { + "name": "Plan card", + "kind": "card", + "control": ".card box", + "data": "OrganizationSubscription.plan + Subscription.status, current_period_end, cancel_at_period_end", + "rules": "" + }, + { + "name": "Billed users", + "kind": "key-value", + "control": "AdwActionRow subtitle", + "data": "quantity.current (live), quantity.last_synced + last_synced_at, unit_amount", + "rules": "\"31 covered · 30 billed on next invoice (synced daily)\"" + }, + { + "name": "Who's covered", + "kind": "radio-group", + "control": "GtkCheckButton group", + "data": "member_scope all_members \\", + "rules": "allowlist \\" + }, + { + "name": "Members", + "kind": "member-picker", + "control": "AdwDialog with GtkSearchEntry list", + "data": "member_overrides (active members only)", + "rules": "visible for allowlist/denylist" + }, + { + "name": "Subscribe the organization", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "→ BIL-03 org checkout (member_scope optional)", + "rules": "org_admin; when no subscription or not in force" + }, + { + "name": "Manage billing", + "kind": "button", + "control": "GtkButton", + "data": "→ BIL-03 org portal", + "rules": "org_admin or billing.manage" + } + ], + "actions": [ + { + "action": "Save coverage", + "call": "PATCH /organizations/{id}/subscription {member_scope, member_overrides} (If-Match)", + "result": "effective immediately (access.granted/revoked); billed from next daily sync. 422 member_override_not_a_member → mark rows. 404 subscription_not_found → show Subscribe." + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "BIL-03", + "title": "Checkout / billing portal", + "section_heading": "D11 · Plan & Billing", + "domain": "D11 Plan & Billing", + "route": "/billing/return", + "archetype": "handoff", + "phase": "Phase 2", + "who": "Self (individual); org_admin (org checkout); org_admin or billing.manage (org portal)", + "purpose": "Hand off to Stripe in the browser and confirm the result on return.", + "loads": "", + "states": "", + "notes": "success_url / cancel_url / return_url must be https: use https://dashboard.substratalapps.com/billing/return… (Universal/App Links open the native app).", + "entry_points": "", + "container": "`GtkUriLauncher` (OpenURI portal)", + "elements": [], + "actions": [ + { + "action": "Upgrade (individual)", + "call": "POST /users/me/billing/checkout-sessions {plan:standard, success_url, cancel_url} (Idempotency-Key)", + "result": "201 {url, expires_at} → open url externally. 409 subscription_exists → open portal instead. 409 test_mode_billing_unavailable / 503 billing_unavailable → explain." + }, + { + "action": "Manage billing (individual)", + "call": "POST /users/me/billing/portal-sessions {return_url}", + "result": "201 → open url externally. 409 no_billing_account → hide button." + }, + { + "action": "Subscribe organization", + "call": "POST /organizations/{id}/billing/checkout-sessions {plan:standard, success_url, cancel_url, member_scope?, member_overrides?} (Idempotency-Key)", + "result": "as individual" + }, + { + "action": "Manage organization billing", + "call": "POST /organizations/{id}/billing/portal-sessions {return_url}", + "result": "as individual" + }, + { + "action": "Return", + "call": "deep link /billing/return?checkout=success\\", + "result": "cancelled (or /orgs/:orgId/subscription?…)`" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "BIL-04", + "title": "Tenant & infrastructure", + "section_heading": "D11 · Plan & Billing", + "domain": "D11 Plan & Billing", + "route": "/dev/tenant", + "archetype": "detail", + "phase": "Phase 2", + "who": "Tenant owner (read), tenants.manage (write)", + "purpose": "Where an Application owner's data lives, and support-run tier changes. Tenants aren't billed.", + "loads": "`GET /tenants` · `GET /tenants/{id}` · `GET /tenants/{id}/tier-change-requests`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "Content pane; `AdwViewSwitcher`; header-bar actions", + "elements": [ + { + "name": "Facts", + "kind": "key-value", + "control": "AdwActionRow subtitle", + "data": "Tenant: tier, region, status, owner, application_count", + "rules": "status migrating → banner, writes may 503" + }, + { + "name": "Tier change requests", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "TenantTierChangeRequest: from→requested tier/region, status, scheduled_for, reason, notes", + "rules": "" + }, + { + "name": "Request change", + "kind": "button", + "control": "GtkButton", + "data": "→ form: requested_tier, requested_region, reason", + "rules": "tenants.manage only; owners see \"isolated and dedicated-region tiers come with business pricing — contact sales\"" + } + ], + "actions": [ + { + "action": "Open request", + "call": "POST /tenants/{id}/tier-change-requests", + "result": "422/409 errors" + }, + { + "action": "Open request detail", + "call": "GET /tenants/{id}/tier-change-requests/{requestId}", + "result": "polls every 30 s while visible" + }, + { + "action": "Advance request", + "call": "PATCH /tenants/{id}/tier-change-requests/{requestId} {status, scheduled_for, notes} (If-Match)", + "result": "staff only; status scheduled → in_progress → completed, or cancelled" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "AUD-01", + "title": "Audit log", + "section_heading": "D12 · Audit Log", + "domain": "D12 Audit Log", + "route": "/admin/audit", + "archetype": "table", + "phase": "MVP", + "who": "audit.view", + "purpose": "Who changed what, when.", + "loads": "`GET /audit-events?since&until&actor_type&actor_id&action&target_type&target_id&target_user_id&application_id&organization_id&tenant_id`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "Hot window is one year for every Tenant; read the `Audit-Hot-Window-Start` response header and show it in an info caption. **Export…** (flag `audit_export`): `POST /audit-exports {…the current filters, format: ndjson|csv}` (Idempotency-Key) → 202; poll `GET /audit-exports/{id}` every 3 s while `pending`/`running`, then offer `download_url` (signed for 15 minutes: re-fetch the export for a fresh link; kept 7 days). 429 → 20 exports a day. New actions to group in the filter: subscription.*, push_credential.*, device.deleted, audit.exported, user.sessions_revoked, user.expired.", + "entry_points": "", + "container": "`GtkColumnView` (.data-table) in a card", + "elements": [ + { + "name": "Filters", + "kind": "search + menus", + "control": "GtkSearchEntry", + "data": "time range (presets 1h/24h/7d/custom), actor type, action (grouped by prefix, wildcard), resource", + "rules": "filters encoded in URL/state for sharing" + }, + { + "name": "Rows", + "kind": "table", + "control": "GtkColumnView .data-table", + "data": "timestamp, actor (type icon + name/id), action, target (type + link), app/org", + "rules": "relative time with absolute on hover/long-press" + }, + { + "name": "Inspector", + "kind": "side panel", + "control": "AdwOverlaySplitView", + "data": "→ AUD-02", + "rules": "desktop: trailing pane; compact: push" + } + ], + "actions": [], + "platforms": [ + "all" + ] + }, + { + "id": "AUD-02", + "title": "Audit event", + "section_heading": "D12 · Audit Log", + "domain": "D12 Audit Log", + "route": "/admin/audit/:eventId", + "archetype": "popover", + "phase": "MVP", + "who": "audit.view", + "purpose": "One event with before/after diff.", + "loads": "`GET /audit-events/{id}`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "`GtkPopover` or `AdwOverlaySplitView` sidebar", + "elements": [ + { + "name": "Facts", + "kind": "key-value", + "control": "AdwActionRow subtitle", + "data": "action, actor, target, target_user_id, application_id, organization_id, tenant_id, request_id, timestamp", + "rules": "each id links to its screen" + }, + { + "name": "Diff", + "kind": "diff view", + "control": "GtkSourceView diff", + "data": "before / after", + "rules": "JSON diff, changed keys highlighted" + } + ], + "actions": [], + "platforms": [ + "all" + ] + }, + { + "id": "NTF-01", + "title": "Inbox", + "section_heading": "D13 · Notifications", + "domain": "D13 Notifications", + "route": "/inbox", + "archetype": "list", + "phase": "Phase 2", + "who": "Self", + "purpose": "Every notification the person has received — from every app and from Substratal — with read and archived state.", + "loads": "`GET /users/me/notifications?state&application_id&source&category&priority&created_after&created_before&include_inactive` · `GET /users/me/notifications/unread-count` · `GET /applications (names, icons)`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES; Empty: \"You're all caught up\"; Feature off (501 not_implemented): hide Inbox entry points; badges fall back to none; Retention: items older than 90 days disappear (caption in empty Archived view)", + "notes": "The Inbox shows notifications from every Application plus platform alerts, but push banners for another app's notifications arrive through that app's own install — the Hub only pushes its own (`app_hub`) notifications and platform alerts.", + "entry_points": "Bell in the toolbar / Inbox in the sidebar (desktop) / Inbox tab (compact); Tapping a push; Badges (app icon, Dock/taskbar, PWA)", + "container": "Nested `AdwNavigationSplitView` list pane", + "elements": [ + { + "name": "Filter", + "kind": "segmented", + "control": "linked GtkToggleButtons", + "data": "Unread · All · Archived (state)", + "rules": "default All (unarchived)" + }, + { + "name": "App filter", + "kind": "menu", + "control": "GtkMenuButton", + "data": "application_id, source=platform (\"Substratal\")", + "rules": "per-app unread counts from unread-count.applications[]" + }, + { + "name": "Rows", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "Notification: app icon (or Substratal mark for source=platform), title, body (2 lines), category, priority high → \"Important\" marker, created_at relative, unread dot", + "rules": "grouped Today / Yesterday / Earlier; high priority security.* rows show a shield icon" + }, + { + "name": "Mark all as read", + "kind": "button", + "control": "GtkButton", + "data": "POST /users/me/notifications/mark-read {before: newest loaded created_at, application_id?}", + "rules": "respects current app filter; uses `before` so items arriving now aren't swept" + }, + { + "name": "Multi-select", + "kind": "selection", + "control": "AdwComboRow", + "data": "", + "rules": "bulk Mark read / Mark unread / Archive (one PATCH per row)" + }, + { + "name": "Row actions", + "kind": "row-button", + "control": "suffix GtkButton .flat", + "data": "PATCH /users/me/notifications/{notificationId} {state}", + "rules": "swipe/hover: Read ⇄ Unread, Archive" + } + ], + "actions": [ + { + "action": "Open row", + "call": "→ NTF-02 and PATCH {state:read}", + "result": "optimistic; badge decrements" + }, + { + "action": "Archive", + "call": "PATCH /users/me/notifications/{notificationId} {state:archived}", + "result": "toast with Undo (PATCH state:read)" + }, + { + "action": "Mark read / unread", + "call": "PATCH /users/me/notifications/{notificationId} {state}", + "result": "" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "NTF-02", + "title": "Notification", + "section_heading": "D13 · Notifications", + "domain": "D13 Notifications", + "route": "/inbox/:notificationId", + "archetype": "popover", + "phase": "Phase 2", + "who": "Self (users.list via NTF-05)", + "purpose": "One notification in full, with where it leads and how its push went.", + "loads": "`GET /users/me/notifications/{notificationId}`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "`GtkPopover` or `AdwOverlaySplitView` sidebar", + "elements": [ + { + "name": "Header", + "kind": "icon + app name + time", + "control": "GtkImage + labels + badge", + "data": "application_id / source, created_at", + "rules": "" + }, + { + "name": "Title & body", + "kind": "text", + "control": "GtkLabel", + "data": "title, body", + "rules": "full text" + }, + { + "name": "Open", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "action_url", + "rules": "Hub route → navigate in-app; https → open in browser / installed app (Universal/App Link); private scheme → hand to OS; hidden when null" + }, + { + "name": "Contextual actions", + "kind": "buttons", + "control": "GtkButton", + "data": "by category", + "rules": "security.new_sign_in → \"Review sessions\" (ACC-04) · security.password_changed / email_changed / mfa_disabled / session_reuse_detected → \"Secure my account\" (ACC-04) · account.organization_invitation → Accept / Decline (ORG-09) · account.access_granted → Open app (APP-03)" + }, + { + "name": "Delivery", + "kind": "disclosure", + "control": "AdwExpanderRow", + "data": "push.status, devices_targeted/sent/failed, skip_reason, deliveries[] (30 days)", + "rules": "plain-language: \"Pushed to 2 devices\", \"Not pushed — notifications are off for this app\"" + }, + { + "name": "Mark unread / Archive", + "kind": "toolbar", + "control": "AdwHeaderBar buttons", + "data": "PATCH {state}", + "rules": "" + } + ], + "actions": [ + { + "action": "Opened", + "call": "PATCH /users/me/notifications/{notificationId} {state:read}", + "result": "on open if unread" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "NTF-03", + "title": "Devices & notifications", + "section_heading": "D13 · Notifications", + "domain": "D13 Notifications", + "route": "/account/devices", + "archetype": "settings", + "phase": "Phase 2", + "who": "Self", + "purpose": "Where push notifications reach this person, and controls for this device.", + "loads": "`GET /users/me/devices?include_inactive=true` · `GET /users/me/settings (notifications.push)`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "Device tokens are never displayed (only token_hint).", + "entry_points": "", + "container": "`AdwPreferencesPage`", + "elements": [ + { + "name": "This device", + "kind": "card", + "control": ".card box", + "data": "Hub Device for this install (matched by stored device id)", + "rules": "status, \"Notifications are on\" / off / blocked by OS; button Turn on (→ NTF-04) or Turn off" + }, + { + "name": "Push notifications (all apps)", + "kind": "toggle", + "control": "AdwSwitchRow", + "data": "Settings.notifications.push", + "rules": "same control as SET-01" + }, + { + "name": "Devices", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "Device: name, application_id (app icon), provider, environment, status, inactive_reason, last_registered_at, last_delivery_at, token_hint", + "rules": "grouped by app; inactive rows dimmed with reason (\"Signed out\", \"Not opened in 9 months\"…)" + }, + { + "name": "Rename", + "kind": "row-button", + "control": "suffix GtkButton .flat", + "data": "PATCH /users/me/devices/{deviceId} {name}", + "rules": "" + }, + { + "name": "Send test notification", + "kind": "row-button", + "control": "suffix GtkButton .flat", + "data": "POST /users/me/devices/{deviceId}/test", + "rules": "Hub devices only; shows provider result inline (sent / failed + error) within 10 s" + }, + { + "name": "Remove", + "kind": "row-button", + "control": "suffix GtkButton .flat", + "data": "DELETE /users/me/devices/{deviceId}", + "rules": "confirm; that install stops receiving pushes until it registers again" + } + ], + "actions": [ + { + "action": "Turn off on this device", + "call": "DELETE /users/me/devices/{deviceId}", + "result": "and stop re-registering until the user turns it back on" + }, + { + "action": "Test", + "call": "POST /users/me/devices/{deviceId}/test", + "result": "429 → \"Try again later (10 tests per hour)\"; 409 push_not_configured → \"Push isn't set up for this app yet\"" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "NTF-04", + "title": "Turn on notifications", + "section_heading": "D13 · Notifications", + "domain": "D13 Notifications", + "route": "/account/devices/enable", + "archetype": "wizard", + "phase": "Phase 2", + "who": "Self", + "purpose": "Explain, request the OS permission, and register this install for push.", + "loads": "", + "states": "", + "notes": "Re-register on launch at most once a day (keeps the device from going stale after 270 days). Windows and Linux have no supported push provider in the API: this screen is replaced by an in-app explanation and local notifications from polling (see their PLATFORM-SPEC).", + "entry_points": "First time a notification-worthy moment happens (org invite, first launch after sign-in on mobile, enabling in NTF-03/SET-01) — never on cold start before sign-in", + "container": "`AdwDialog` with `AdwNavigationView` pages", + "elements": [ + { + "name": "Primer", + "kind": "text + illustration", + "control": "GtkLabel", + "data": "", + "rules": "what you'll get: security alerts, invitations, access changes; \"You can change this anytime\"" + }, + { + "name": "Turn on", + "kind": "primary-button", + "control": "GtkButton .suggested-action .pill", + "data": "OS permission prompt", + "rules": "platform API per PLATFORM-SPEC" + }, + { + "name": "Not now", + "kind": "button", + "control": "GtkButton", + "data": "", + "rules": "snooze 14 days" + } + ], + "actions": [ + { + "action": "Permission granted", + "call": "POST /users/me/devices {application_id: app_hub, provider, token \\", + "result": "subscription, environment (apns), name, app_version}`" + }, + { + "action": "Token rotated by OS", + "call": "PATCH /users/me/devices/{deviceId} {token \\", + "result": "subscription}`" + }, + { + "action": "Permission denied", + "call": "—", + "result": "Settings shows \"Blocked — Open system settings\" deep link" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "NTF-05", + "title": "User — Notifications & devices", + "section_heading": "D13 · Notifications", + "domain": "D13 Notifications", + "route": "/admin/users/:userId/notifications", + "archetype": "tab", + "phase": "Phase 2", + "who": "users.list (read), users.manage (delete devices)", + "purpose": "Support view of a person's inbox and push devices for investigations.", + "loads": "`GET /users/{id}/notifications?include_inactive=true` · `GET /users/{id}/notifications/{notificationId}` · `GET /users/{id}/devices?include_inactive=true` · `GET /users/{id}/devices/{deviceId}`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES", + "notes": "", + "entry_points": "", + "container": "`AdwViewStack` page", + "elements": [ + { + "name": "Inbox", + "kind": "table", + "control": "GtkColumnView .data-table", + "data": "Notification: created_at, application/source, category, priority, title, state, push.status, skip_reason", + "rules": "read-only; never mark the user's items read" + }, + { + "name": "Delivery detail", + "kind": "side panel", + "control": "AdwOverlaySplitView", + "data": "deliveries[]: provider, status, attempts, error, sent_at", + "rules": "" + }, + { + "name": "Devices", + "kind": "table", + "control": "GtkColumnView .data-table", + "data": "Device: app, provider, environment, status, inactive_reason, name, app_version, last_registered_at, last_delivery_at", + "rules": "" + }, + { + "name": "Delete device", + "kind": "destructive-button", + "control": "GtkButton .destructive-action", + "data": "DELETE /users/{id}/devices/{deviceId}", + "rules": "users.manage; audited (device.deleted)" + } + ], + "actions": [], + "platforms": [ + "all" + ] + }, + { + "id": "NTF-06", + "title": "Send notification", + "section_heading": "D13 · Notifications", + "domain": "D13 Notifications", + "route": "/admin/notifications/new", + "archetype": "form", + "phase": "Phase 2", + "who": "platform notifications.send", + "purpose": "Staff tool: send one notification to one person on behalf of an Application.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "`AdwDialog` with `AdwPreferencesGroup`", + "elements": [ + { + "name": "Recipient", + "kind": "picker", + "control": "AdwDialog with GtkSearchEntry list", + "data": "user (GET /users?q)", + "rules": "must have active access to the chosen app" + }, + { + "name": "Application", + "kind": "select", + "control": "AdwComboRow", + "data": "body.application_id", + "rules": "required for platform senders" + }, + { + "name": "Category", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "body.category", + "rules": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)*$; security./account./billing. reserved (422 reserved_category)" + }, + { + "name": "Priority", + "kind": "segmented", + "control": "linked GtkToggleButtons", + "data": "body.priority normal \\", + "rules": "high" + }, + { + "name": "Title", + "kind": "text-field", + "control": "AdwEntryRow", + "data": "body.title", + "rules": "1–120" + }, + { + "name": "Body", + "kind": "text-area", + "control": "GtkTextView in .card", + "data": "body.body", + "rules": "≤ 1,000" + }, + { + "name": "Data", + "kind": "key/value editor", + "control": "AdwPreferencesGroup of AdwEntryRow pairs", + "data": "body.data", + "rules": "≤ 20 string pairs, 2 KB; key `substratal` reserved" + }, + { + "name": "Open link", + "kind": "url-field", + "control": "AdwEntryRow input-purpose=url", + "data": "body.action_url", + "rules": "https or private scheme ≤ 2,048" + }, + { + "name": "Advanced", + "kind": "disclosure", + "control": "AdwExpanderRow", + "data": "push (bool), badge, sound default\\", + "rules": "silent, collapse_key, ttl_seconds" + }, + { + "name": "Payload size", + "kind": "meters", + "control": "GtkLevelBar", + "data": "client estimate of the 4 KB push payload", + "rules": "422 notification_too_large → details.bytes/max" + }, + { + "name": "Preview", + "kind": "card", + "control": ".card box", + "data": "lock-screen style preview per platform", + "rules": "warns: lock screens show title/body — keep sensitive content out" + } + ], + "actions": [ + { + "action": "Send", + "call": "POST /users/{id}/notifications (Idempotency-Key)", + "result": "201 → result panel with push.status / skip_reason; 403 entitlement_required → recipient has no access; 429 scope sender\\" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "GLB-01", + "title": "Command palette", + "section_heading": "G · Global / Shell", + "domain": "G Global / Shell", + "route": "(overlay)", + "archetype": "overlay", + "phase": "MVP", + "who": "Signed in", + "purpose": "Jump anywhere and run any action by typing.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "Popover / overlay", + "elements": [ + { + "name": "Query", + "kind": "search", + "control": "GtkSearchEntry", + "data": "", + "rules": "Cmd/Ctrl+K (desktop), search button (touch)" + }, + { + "name": "Results", + "kind": "grouped list", + "control": "GtkListBox headers", + "data": "Navigation, Apps (launch), Users (GET /users?q), Orgs, Inbox (recent notifications), Actions (Invite user, Create key, Upgrade to Standard, Mark all read…)", + "rules": "permission-filtered; recent items first; ID prefixes resolve directly" + } + ], + "actions": [], + "platforms": [ + "all" + ] + }, + { + "id": "GLB-02", + "title": "Manage hub", + "section_heading": "G · Global / Shell", + "domain": "G Global / Shell", + "route": "/manage", + "archetype": "hub", + "phase": "MVP", + "who": "Any admin or developer permission", + "purpose": "Compact-layout landing list for the Administration and Developer sections.", + "loads": "", + "states": "", + "notes": "", + "entry_points": "", + "container": "Collapsed sidebar page", + "elements": [ + { + "name": "Administration", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "Users, Access, Roles, Audit Log, Send notification — those the caller may open, each with a live count", + "rules": "section hidden when no rights" + }, + { + "name": "Developer", + "kind": "list", + "control": "GtkListBox .boxed-list", + "data": "Applications, API Keys, Webhooks, Tenant — with counts and health (unhealthy webhooks, failed push credentials)", + "rules": "section hidden when no rights" + } + ], + "actions": [], + "platforms": [ + "all" + ] + }, + { + "id": "GLB-03", + "title": "System states", + "section_heading": "G · Global / Shell", + "domain": "G Global / Shell", + "route": "(global)", + "archetype": "overlay", + "phase": "MVP", + "who": "—", + "purpose": "App-wide banners and blocking states.", + "loads": "", + "states": "", + "notes": "`POST /auth/oauth/token` is called by the launched Application (PKCE exchange), never by the Hub.", + "entry_points": "", + "container": "Popover / overlay", + "elements": [ + { + "name": "Offline", + "kind": "banner", + "control": "AdwBanner", + "data": "network status", + "rules": "reads from cache, writes disabled with explanation" + }, + { + "name": "Maintenance / tenant migrating", + "kind": "banner", + "control": "AdwBanner", + "data": "503 service_unavailable + Retry-After", + "rules": "countdown, auto-retry reads" + }, + { + "name": "Session ended", + "kind": "modal → AUTH-01", + "control": "AdwAlertDialog → AUTH-01", + "data": "401 after failed refresh", + "rules": "preserve destination" + }, + { + "name": "Forbidden", + "kind": "inline state", + "control": "GtkSpinner on tile", + "data": "403", + "rules": "COPY" + }, + { + "name": "Not built yet", + "kind": "inline state", + "control": "GtkSpinner on tile", + "data": "501 not_implemented (Notifications today)", + "rules": "hide the feature behind its flag; never show an error to end users" + }, + { + "name": "Update available", + "kind": "banner", + "control": "AdwBanner", + "data": "`Deprecation` + `Sunset` response headers on this build's API version", + "rules": "once per day; shows the sunset date; \"Update\" opens the store / updater (Web: reload); dismissible until 14 days before `Sunset`" + }, + { + "name": "Update required", + "kind": "blocking screen", + "control": "AdwStatusPage window", + "data": "400 unsupported_api_version (`details.supported`)", + "rules": "stops all API traffic; \"Update\" opens the store / updater (Web: reload); re-checks on foreground. Never shown for `api_version_required`, which is a client bug" + } + ], + "actions": [ + { + "action": "Silent token refresh", + "call": "POST /auth/token/refresh", + "result": "single-flight; rotate refresh token atomically (CLIENT-ARCHITECTURE §2)" + }, + { + "action": "Connectivity probe", + "call": "GET /health", + "result": "used after offline/503 to decide when to clear the banner (exempt from `Substratal-Version`)" + }, + { + "action": "Forced sign-out", + "call": "POST /auth/logout", + "result": "on session_revoked or user request; the server deactivates this session's push devices" + }, + { + "action": "Badge refresh", + "call": "GET /users/me/notifications/unread-count", + "result": "on launch, foreground, after a push arrives, and every 60 s while visible (Windows/Linux: also in background) → app icon/Dock/taskbar/PWA badge" + } + ], + "platforms": [ + "all" + ] + }, + { + "id": "OPS-01", + "title": "Overview", + "section_heading": "C · Console", + "domain": "C Console", + "route": "/ops", + "archetype": "detail", + "phase": "Console M3 (web only)", + "who": "operations.view (superadmin until G10)", + "purpose": "Is production healthy right now, and is anything costing or failing?", + "loads": "`GET /health` (API) · `GET /ops/metrics?panel=overview&range=1h` · `GET /ops/alarms` · `GET /ops/cost` · `GET /audit-events?limit=10`", + "states": "Loading: skeleton after 300 ms; Error: per tile, so one failing source doesn't blank the page; Gateway `403` → \"Operations needs the operations.view permission.\"", + "notes": "", + "entry_points": "", + "container": "", + "elements": [ + { + "name": "Health", + "kind": "StatusBadge", + "control": "", + "data": "`GET /health`", + "rules": "Down → banner linking OPS-04" + }, + { + "name": "Alarms", + "kind": "summary chips", + "control": "", + "data": "firing / OK counts", + "rules": "any firing → sidebar badge on Operations" + }, + { + "name": "Traffic tiles", + "kind": "metric tiles", + "control": "", + "data": "requests, 4xx rate, 5xx rate, p95 latency, Lambda errors and throttles, Aurora ACU, webhook DLQ depth", + "rules": "each tile → OPS-02 at that metric" + }, + { + "name": "Cost", + "kind": "progress bar", + "control": "", + "data": "month-to-date vs warn and hard budgets", + "rules": "→ OPS-07" + }, + { + "name": "Recent security events", + "kind": "list", + "control": "", + "data": "→ SEC-01", + "rules": "5 newest" + }, + { + "name": "Recent admin activity", + "kind": "list", + "control": "", + "data": "`GET /audit-events`", + "rules": "→ AUD-01" + } + ], + "actions": [], + "platforms": [ + "web" + ] + }, + { + "id": "OPS-02", + "title": "Traffic & latency", + "section_heading": "C · Console", + "domain": "C Console", + "route": "/ops/traffic", + "archetype": "detail", + "phase": "Console M3 (web only)", + "who": "operations.view", + "purpose": "Trends and SLO tracking.", + "loads": "`GET /ops/metrics?panel=traffic&range={1h|24h|7d|30d}`", + "states": "Loading: skeleton after 300 ms; Error: inline with Retry", + "notes": "", + "entry_points": "", + "container": "", + "elements": [ + { + "name": "Range", + "kind": "segmented control", + "control": "", + "data": "1h · 24h · 7d · 30d", + "rules": "in the URL" + }, + { + "name": "Charts", + "kind": "time series", + "control": "", + "data": "requests, 4xx, 5xx, p95 latency, throttles", + "rules": "accessible table view of each chart (ACCESSIBILITY)" + }, + { + "name": "SLOs", + "kind": "progress + burn rate", + "control": "", + "data": "99.9 % non-5xx; p95 < 150 ms on effective-permissions, app-tokens and oauth/token; p95 < 400 ms elsewhere", + "rules": "per-operation SLOs hidden until [G6 #43](https://github.com/CompositeCode/substratalapps.com/issues/43) gives per-operation data" + } + ], + "actions": [], + "platforms": [ + "web" + ] + }, + { + "id": "OPS-03", + "title": "Logs", + "section_heading": "C · Console", + "domain": "C Console", + "route": "/ops/logs", + "archetype": "table", + "phase": "Console M3 (web only)", + "who": "operations.view", + "purpose": "Find what happened to one request, user or key.", + "loads": "`POST /ops/logs/query {template, params, range}` → `GET /ops/logs/query/{id}` (poll every 2 s until complete)", + "states": "Loading: progress while the query runs; Empty: \"No log lines match.\"; Error: inline with Retry", + "notes": "", + "entry_points": "", + "container": "", + "elements": [ + { + "name": "Query", + "kind": "select + typed fields", + "control": "", + "data": "a template from the gateway's catalog (by request ID, user, key, status, error code), never free text in v1", + "rules": "`?request_id=` deep link prefills" + }, + { + "name": "Range", + "kind": "segmented control", + "control": "", + "data": "default 24 h", + "rules": "longer ranges warn about query cost" + }, + { + "name": "Rows", + "kind": "table", + "control": "", + "data": "timestamp, log group, level, message fields", + "rules": "request ID links to AUD-01 filtered and back" + } + ], + "actions": [], + "platforms": [ + "web" + ] + }, + { + "id": "SEC-01", + "title": "Security events", + "section_heading": "C · Console", + "domain": "C Console", + "route": "/ops/security", + "archetype": "table", + "phase": "Console M3 (web only)", + "who": "operations.view", + "purpose": "Spot attacks and lockouts.", + "loads": "`POST /ops/logs/query {template: security_events, range}` · `GET /ops/metrics?panel=security`", + "states": "", + "notes": "Calls through the web Hub's backend show the backend's IP until [G5 #42](https://github.com/CompositeCode/substratalapps.com/issues/42) adds a trusted client-IP header; say so in a caption.", + "entry_points": "", + "container": "", + "elements": [ + { + "name": "Group by", + "kind": "segmented control", + "control": "", + "data": "user · IP · key", + "rules": "" + }, + { + "name": "Rows", + "kind": "table", + "control": "", + "data": "failed logins, lockouts, MFA challenges, refresh-token reuse, 403 denials, `destructive_operation_restricted`, `api_key_in_browser`", + "rules": "user → USR-03; key → KEY-04" + }, + { + "name": "Actions", + "kind": "menu", + "control": "", + "data": "→ USR-05 Suspend · USR-04 Sign out everywhere · KEY-04 Revoke", + "rules": "each is the normal API call, audited with the person as actor" + } + ], + "actions": [], + "platforms": [ + "web" + ] + }, + { + "id": "OPS-04", + "title": "Alarms", + "section_heading": "C · Console", + "domain": "C Console", + "route": "/ops/alarms", + "archetype": "table", + "phase": "Console M3 (web only)", + "who": "operations.view", + "purpose": "What's firing, what fired, and who gets told.", + "loads": "`GET /ops/alarms`", + "states": "", + "notes": "", + "entry_points": "", + "container": "", + "elements": [ + { + "name": "Rows", + "kind": "table", + "control": "", + "data": "alarm name, StatusBadge (OK · ALARM · INSUFFICIENT_DATA), since, threshold", + "rules": "firing first" + }, + { + "name": "History", + "kind": "popover", + "control": "", + "data": "state changes for one alarm", + "rules": "links to the matching OPS-03 query" + }, + { + "name": "Subscribers", + "kind": "list", + "control": "", + "data": "email subscriptions to the alarm topic, with pending confirmations", + "rules": "read-only in v1; changing them is Terraform" + } + ], + "actions": [], + "platforms": [ + "web" + ] + }, + { + "id": "OPS-05", + "title": "Jobs & delivery", + "section_heading": "C · Console", + "domain": "C Console", + "route": "/ops/jobs", + "archetype": "table", + "phase": "Console M3 (web only)", + "who": "operations.view", + "purpose": "Are the scheduled jobs and deliveries keeping up?", + "loads": "`GET /ops/jobs`", + "states": "", + "notes": "", + "entry_points": "", + "container": "", + "elements": [ + { + "name": "Jobs", + "kind": "table", + "control": "", + "data": "each scheduled job: schedule, last run, outcome, duration", + "rules": "outcome is inferred from logs until [G9 #46](https://github.com/CompositeCode/substratalapps.com/issues/46) records runs" + }, + { + "name": "Delivery", + "kind": "metric tiles", + "control": "", + "data": "webhook and push queue depth, DLQ depth, failure rate", + "rules": "failures across subscriptions need [G8 #45](https://github.com/CompositeCode/substratalapps.com/issues/45); until then link WHK-03 per webhook" + } + ], + "actions": [], + "platforms": [ + "web" + ] + }, + { + "id": "OPS-06", + "title": "Releases", + "section_heading": "C · Console", + "domain": "C Console", + "route": "/ops/releases", + "archetype": "table", + "phase": "Console M3 (web only)", + "who": "operations.view", + "purpose": "What's deployed.", + "loads": "`GET /ops/releases`", + "states": "", + "notes": "", + "entry_points": "", + "container": "", + "elements": [ + { + "name": "Functions", + "kind": "table", + "control": "", + "data": "function, `live` alias → version → commit SHA, deployed at", + "rules": "SHA links to the commit" + }, + { + "name": "Docs", + "kind": "key-value", + "control": "", + "data": "published spec version", + "rules": "" + }, + { + "name": "Links", + "kind": "link-buttons", + "control": "", + "data": "release, deploy and rollback workflows", + "rules": "open GitHub; the console never deploys" + } + ], + "actions": [], + "platforms": [ + "web" + ] + }, + { + "id": "OPS-07", + "title": "Cost", + "section_heading": "C · Console", + "domain": "C Console", + "route": "/ops/cost", + "archetype": "detail", + "phase": "Console M3 (web only)", + "who": "operations.view", + "purpose": "Spend against the budgets.", + "loads": "`GET /ops/cost` (cached 6 h by the gateway)", + "states": "", + "notes": "Show \"Updated {time}\" because the data is up to 6 hours old.", + "entry_points": "", + "container": "", + "elements": [ + { + "name": "Budget", + "kind": "progress bar", + "control": "", + "data": "month-to-date and forecast vs warn and hard budgets", + "rules": "forecast over warn → warning banner" + }, + { + "name": "By service", + "kind": "bar chart + table", + "control": "", + "data": "cost by AWS service, this month", + "rules": "" + }, + { + "name": "Anomalies", + "kind": "list", + "control": "", + "data": "recent cost anomalies", + "rules": "" + } + ], + "actions": [], + "platforms": [ + "web" + ] + }, + { + "id": "CFG-01", + "title": "Configuration", + "section_heading": "C · Console", + "domain": "C Console", + "route": "/ops/config", + "archetype": "settings", + "phase": "Console M3 (web only)", + "who": "operations.view", + "purpose": "What production is actually running with.", + "loads": "`GET /ops/config`", + "states": "", + "notes": "Read-only. Terraform-owned values say \"Change this in the API repo\" and link to the variable.", + "entry_points": "", + "container": "", + "elements": [ + { + "name": "Per function", + "kind": "key-value groups", + "control": "", + "data": "non-secret settings from an allow-list: URLs, CORS origins, backends, log level", + "rules": "secrets never leave the gateway" + }, + { + "name": "Policy", + "kind": "key-value", + "control": "", + "data": "rate-limit defaults, token lifetimes, password policy, API version served", + "rules": "each with its source (Terraform variable or code constant)" + } + ], + "actions": [], + "platforms": [ + "web" + ] + }, + { + "id": "CFG-02", + "title": "Feature flags", + "section_heading": "C · Console", + "domain": "C Console", + "route": "/ops/config/flags", + "archetype": "settings", + "phase": "Console M4 (web only)", + "who": "operations.view (read), operations.manage (write, after G10)", + "purpose": "See, and later change, the client feature flags (CLIENT-ARCHITECTURE §10).", + "loads": "until [G11 #48](https://github.com/CompositeCode/substratalapps.com/issues/48): the web build's `/config/flags.json`; after: the API's settings endpoint", + "states": "", + "notes": "", + "entry_points": "", + "container": "", + "elements": [ + { + "name": "Flags", + "kind": "toggle list", + "control": "", + "data": "every flag in CLIENT-ARCHITECTURE §10", + "rules": "read-only until G11; then editable and audited" + }, + { + "name": "Platform settings", + "kind": "settings rows", + "control": "", + "data": "signup open, read-only maintenance mode, per-key rate-limit overrides ([G12 #49](https://github.com/CompositeCode/substratalapps.com/issues/49))", + "rules": "hidden until the API has them" + } + ], + "actions": [], + "platforms": [ + "web" + ] + }, + { + "id": "RPT-01", + "title": "Reports", + "section_heading": "C · Console", + "domain": "C Console", + "route": "/admin/reports", + "archetype": "detail", + "phase": "Console M4 (web only)", + "who": "users.list (platform reports); an Application owner sees only their own app", + "purpose": "Growth, access, usage, subscribers and delivery over time.", + "loads": "after [G7 #44](https://github.com/CompositeCode/substratalapps.com/issues/44): `GET /usage` · `GET /stats`. Before: only reports small enough to build by paging list endpoints.", + "states": "", + "notes": "Revenue stays in Stripe; link to the Stripe dashboard instead of mirroring it.", + "entry_points": "", + "container": "", + "elements": [ + { + "name": "Report", + "kind": "select", + "control": "", + "data": "platform growth · access · usage · subscribers · delivery", + "rules": "subscribers needs [G20 #57](https://github.com/CompositeCode/substratalapps.com/issues/57)" + }, + { + "name": "Range", + "kind": "date range", + "control": "", + "data": "", + "rules": "in the URL" + }, + { + "name": "Chart + table", + "kind": "chart, table", + "control": "", + "data": "the report's series", + "rules": "accessible table view" + }, + { + "name": "Export", + "kind": "button", + "control": "", + "data": "CSV of the loaded series", + "rules": "client-side" + } + ], + "actions": [], + "platforms": [ + "web" + ] + }, + { + "id": "INT-01", + "title": "Integrations", + "section_heading": "C · Console", + "domain": "C Console", + "route": "/dev/integrations", + "archetype": "list", + "phase": "Console M2 (web only)", + "who": "webhooks.manage or api_keys.manage", + "purpose": "Every way to connect Substratal to other tools, in one place.", + "loads": "`GET /webhooks?scope=platform` · `GET /api-keys`", + "states": "", + "notes": "", + "entry_points": "", + "container": "", + "elements": [ + { + "name": "Platform webhooks", + "kind": "list", + "control": "", + "data": "→ WHK-01 filtered to `scope: platform`", + "rules": "for SIEM, warehouse or chat tools" + }, + { + "name": "Agent and service keys", + "kind": "list", + "control": "", + "data": "→ KEY-01 filtered by `intended_use`", + "rules": "" + }, + { + "name": "MCP server", + "kind": "instructions", + "control": "", + "data": "`https://api.substratalapps.com/mcp` with a restricted agent key", + "rules": "copy button" + }, + { + "name": "Alarm destinations", + "kind": "list", + "control": "", + "data": "→ OPS-04 subscribers; chat delivery via AWS Chatbot", + "rules": "Terraform-owned: link to the variable" + }, + { + "name": "Stripe", + "kind": "key-value", + "control": "", + "data": "portal links; `billing_unavailable` state", + "rules": "" + }, + { + "name": "Log export", + "kind": "text", + "control": "", + "data": "a CloudWatch subscription filter, configured in Terraform", + "rules": "documentation only" + } + ], + "actions": [], + "platforms": [ + "web" + ] + } + ] +} diff --git a/data/spec/status-semantics.json b/data/spec/status-semantics.json new file mode 100644 index 0000000..7683c9a --- /dev/null +++ b/data/spec/status-semantics.json @@ -0,0 +1,252 @@ +{ + "$description": "Status meaning shared by every app (spec v1.8.0). Colors are intentionally absent: the theming effort maps each semantic role to a palette. Every badge = icon + label.", + "semanticRoles": [ + "success", + "info", + "warning", + "critical", + "neutral", + "accent" + ], + "statuses": { + "active": { + "label": "Active", + "role": "success", + "icon": { + "sfSymbol": "checkmark.circle.fill", + "fluent": "CheckmarkCircle", + "material": "check_circle", + "adwaita": "emblem-ok-symbolic", + "lucide": "circle-check" + } + }, + "scheduled": { + "label": "Scheduled", + "role": "info", + "icon": { + "sfSymbol": "calendar", + "fluent": "Calendar", + "material": "calendar_today", + "adwaita": "x-office-calendar-symbolic", + "lucide": "calendar" + } + }, + "disabled": { + "label": "Turned off", + "role": "warning", + "icon": { + "sfSymbol": "pause.circle.fill", + "fluent": "PauseCircle", + "material": "pause_circle", + "adwaita": "media-playback-pause-symbolic", + "lucide": "circle-pause" + } + }, + "expired": { + "label": "Expired", + "role": "neutral", + "icon": { + "sfSymbol": "clock", + "fluent": "Clock", + "material": "schedule", + "adwaita": "alarm-symbolic", + "lucide": "clock" + } + }, + "revoked": { + "label": "Revoked", + "role": "critical", + "icon": { + "sfSymbol": "xmark.circle.fill", + "fluent": "DismissCircle", + "material": "cancel", + "adwaita": "process-stop-symbolic", + "lucide": "circle-x" + } + }, + "none": { + "label": "No access", + "role": "neutral", + "icon": { + "sfSymbol": "minus.circle", + "fluent": "SubtractCircle", + "material": "do_not_disturb_on", + "adwaita": "list-remove-symbolic", + "lucide": "circle-minus" + } + }, + "invited": { + "label": "Invited", + "role": "accent", + "icon": { + "sfSymbol": "envelope", + "fluent": "Mail", + "material": "mail", + "adwaita": "mail-unread-symbolic", + "lucide": "mail" + } + }, + "suspended": { + "label": "Suspended", + "role": "warning", + "icon": { + "sfSymbol": "pause.circle.fill", + "fluent": "PauseCircle", + "material": "pause_circle", + "adwaita": "media-playback-pause-symbolic", + "lucide": "circle-pause" + } + }, + "deleted": { + "label": "Deleted", + "role": "critical", + "icon": { + "sfSymbol": "trash", + "fluent": "Delete", + "material": "delete", + "adwaita": "user-trash-symbolic", + "lucide": "trash-2" + } + }, + "pending": { + "label": "Pending", + "role": "info", + "icon": { + "sfSymbol": "hourglass", + "fluent": "HourglassHalf", + "material": "hourglass_empty", + "adwaita": "content-loading-symbolic", + "lucide": "hourglass" + } + }, + "important": { + "label": "Important", + "role": "critical", + "icon": { + "sfSymbol": "exclamationmark.circle", + "fluent": "Important", + "material": "priority_high", + "adwaita": "dialog-warning-symbolic", + "lucide": "circle-alert" + } + }, + "unread": { + "label": "Unread", + "role": "accent", + "icon": { + "sfSymbol": "circle.fill", + "fluent": "CircleSmall", + "material": "fiber_manual_record", + "adwaita": "media-record-symbolic", + "lucide": "dot" + } + } + }, + "mappings": { + "entitlement.resolved_status": [ + "active", + "scheduled", + "disabled", + "expired", + "revoked", + "none" + ], + "user.status": [ + "active", + "invited", + "suspended", + "deleted" + ], + "organization.status": { + "active": "active", + "suspended": "suspended" + }, + "membership_status": { + "pending": "invited", + "active": "active" + }, + "application.review_status": { + "approved": "active", + "pending_review": "pending", + "rejected": "revoked", + "suspended": "suspended" + }, + "webhook.status": { + "healthy": "active", + "unhealthy": "disabled", + "disabled": "expired" + }, + "webhook_delivery.status": { + "pending": "pending", + "succeeded": "active", + "failed": "revoked" + }, + "tenant.status": { + "active": "active", + "migrating": "scheduled", + "suspended": "suspended" + }, + "tier_change_request.status": { + "pending": "pending", + "scheduled": "scheduled", + "in_progress": "scheduled", + "completed": "active", + "cancelled": "expired" + }, + "erasure_request.status": { + "scheduled": "scheduled", + "cancelled": "expired", + "completed": "deleted" + }, + "subscription.status": { + "active": "active", + "trialing": "scheduled", + "past_due": "disabled", + "canceled": "expired", + "unpaid": "revoked", + "incomplete": "pending", + "incomplete_expired": "expired", + "paused": "suspended" + }, + "api_key": { + "active": "active", + "expired (expires_at past)": "expired", + "revoked (revoked_at set)": "revoked" + }, + "user.plan": { + "free": "none", + "standard": "active" + }, + "entitlement.source_labels": { + "purchase": "Purchased", + "trial": "Trial", + "admin_grant": "Granted by admin", + "org_seat": "Via {org}", + "free": "Free (managed by Substratal)", + "subscription": "Standard (managed by Substratal)" + }, + "device.status": { + "active": "active", + "inactive": "expired" + }, + "notification.state": { + "unread": "unread", + "read": "(no badge)", + "archived": "(no badge)" + }, + "notification.priority": { + "high": "important", + "normal": "(no badge)" + }, + "notification.push.status": { + "queued": "pending", + "sent": "active", + "failed": "revoked", + "skipped": "none" + }, + "push_credential.status": { + "active": "active", + "failed": "disabled" + } + } +} \ No newline at end of file diff --git a/data/spec/themes.json b/data/spec/themes.json new file mode 100644 index 0000000..8aa1b26 --- /dev/null +++ b/data/spec/themes.json @@ -0,0 +1,88 @@ +{ + "defaults": { + "light": "moss-paper", + "dark": "forest-floor" + }, + "themes": [ + { + "id": "teal-ledger", + "name": "Teal Ledger", + "mode": "light", + "default": false, + "description": "The wordmark's teal on cool, barely-tinted greys. Calm and closest to the current mockups.", + "swatch": "#137C7A" + }, + { + "id": "indigo-root", + "name": "Indigo Root", + "mode": "light", + "default": false, + "description": "The mark's root indigo with lavender-tinted neutrals taken from the icon surface.", + "swatch": "#3D3BB8" + }, + { + "id": "moss-paper", + "name": "Moss Paper", + "mode": "light", + "default": true, + "description": "A deepened lime (moss) on warm paper. Earthy, the most 'substrate' of the five.", + "swatch": "#4A7322" + }, + { + "id": "coral-clay", + "name": "Coral Clay", + "mode": "light", + "default": false, + "description": "The bracket coral, darkened for text contrast, on warm clay neutrals.", + "swatch": "#B44529" + }, + { + "id": "spore-pink", + "name": "Spore Pink", + "mode": "light", + "default": false, + "description": "The cap pink, deepened to raspberry, on faintly rosy neutrals. The boldest light option.", + "swatch": "#B3285B" + }, + { + "id": "night-soil", + "name": "Night Soil", + "mode": "dark", + "default": false, + "description": "The icon's night indigo as the base, periwinkle accent. Matches the dark app icon exactly.", + "swatch": "#8E8CFF" + }, + { + "id": "deep-teal", + "name": "Deep Teal", + "mode": "dark", + "default": false, + "description": "Blue-green depths with the wordmark's light teal. Continuous with Teal Ledger.", + "swatch": "#55CFC9" + }, + { + "id": "graphite-spore", + "name": "Graphite Spore", + "mode": "dark", + "default": false, + "description": "Neutral graphite, so the OS chrome feels native, with the cap pink as a single bright accent.", + "swatch": "#FF86AE" + }, + { + "id": "forest-floor", + "name": "Forest Floor", + "mode": "dark", + "default": true, + "description": "Green-black soil with a lime accent. Pairs with Moss Paper.", + "swatch": "#B8E26A" + }, + { + "id": "ember-clay", + "name": "Ember Clay", + "mode": "dark", + "default": false, + "description": "Warm charcoal with the light coral accent. Pairs with Coral Clay.", + "swatch": "#FF9A80" + } + ] +} diff --git a/docs/PLAN-api-gaps.md b/planning/PLAN-api-gaps.md similarity index 97% rename from docs/PLAN-api-gaps.md rename to planning/PLAN-api-gaps.md index f6a683b..0cee611 100644 --- a/docs/PLAN-api-gaps.md +++ b/planning/PLAN-api-gaps.md @@ -1,5 +1,10 @@ # Plan 2: What the API is missing for a management app +> **Update, 2026-10-23.** Merged since this was written: #58 test-user expiry, #60 self-service apps, #61 audit export, #62 SCIM, and #68 `GET /users?role_id=` (§7.2) plus sign out everywhere (`POST /users/{id}/sessions/revoke-all`, part of §4). #69 exposes `Deprecation`/`Sunset` to browsers. The spec is now 1.0.0-draft.9 with 124 operations. +> +> The specs moved the Console to the web Hub with its own Ops gateway (specs OPEN-QUESTIONS #27). The API-side asks for it are tracked as API issues G5–G20 (#42–#57), so §3's `ops` Lambda is one option rather than the plan of record. + + **Status:** proposal, 2026-10-21. Once agreed, this belongs in the API repo (`CompositeCode/substratalapps.com`) and follows that repo's "Rules for every change": spec page and changelog first, then `make gen`, contract tests, and expand-only migrations. **Verified against:** `origin/main` `364ce5b` (2026-10-21, `openapi.yaml` 1.0.0-draft.4, 118 operations), `infra/terraform/prod`, `internal/core`, the open PRs #58/#60/#61/#62, and the live API (`https://api.substratalapps.com`, probed 2026-10-21). **Companion:** [PLAN-management-app.md](PLAN-management-app.md). The section numbers §1–§10 below are the ones Plan 1 cites. They match the macOS plan's numbering so the API team gets one list from every client team. §10 is new here. diff --git a/docs/PLAN-management-app.md b/planning/PLAN-management-app.md similarity index 98% rename from docs/PLAN-management-app.md rename to planning/PLAN-management-app.md index 771988c..11fe177 100644 --- a/docs/PLAN-management-app.md +++ b/planning/PLAN-management-app.md @@ -1,5 +1,12 @@ # Plan 1: Substratal Console for Linux (management app for the API tier) +> **Update, 2026-10-23.** Two things changed after this plan was written. See [PREREQUISITES.md](PREREQUISITES.md). +> 1. **API versioning:** the API version is the `Substratal-Version: 1` request header, and URLs have no `/v1` prefix (API v1.7.0+). This plan already uses unprefixed paths. The macOS plan's `/v1/ops/*` should read `/ops/*`. +> 2. **The specs now make the Console web-only** (OPEN-QUESTIONS #27): its OPS-*, SEC-01, CFG-*, RPT-01 and INT-01 screens read AWS through the web Hub's Ops gateway, and the native apps skip them. So the Linux build follows the spec's 75 Hub screens, and §3's Operations section and §4.5–4.11 are **deferred** for Linux. The screen IDs in this plan (OPS-01…10) also differ from the spec's. The spec's IDs win. +> +> Since 2026-10-21, the API has also shipped several Plan 2 items: `GET /users?role_id=`, sign out everywhere, audit export, self-service registration, SCIM and test-user expiry. + + **Status:** proposal, 2026-10-21 **Target:** `https://api.substratalapps.com`, with every request sending `Substratal-Version: 1`. Live on 2026-10-21: `GET /health` returns `200 {"status":"ok"}`. The old `/v1/...` paths now return `404`. **Inputs reviewed:** diff --git a/planning/PREREQUISITES.md b/planning/PREREQUISITES.md new file mode 100644 index 0000000..1619229 --- /dev/null +++ b/planning/PREREQUISITES.md @@ -0,0 +1,51 @@ +# Prerequisites check before build-out + +**Checked:** 2026-10-23, before the first code in this repo. +**Result:** ready to build. Nothing blocks the skeleton. Items marked ⚠️ limit what can be verified, not what can be built. + +## API versioning (read this first) + +The Platform API's version is a **request header, not part of the URL**. + +- Since API v1.7.0 (`openapi.yaml` 1.0.0-draft.4, 2026-10-21) there is **no `/v1` path prefix**. Endpoints live at the host root: `https://api.substratalapps.com/users`. +- Every request sends `Substratal-Version: 1`. Without it: `400 api_version_required`. With an unknown value: `400 unsupported_api_version`. Both list `details.supported`. +- Old `/v1/...` paths return `404 not_found`, with no redirect. +- Exempt: `GET /health`, `POST /auth/oauth/token`, `/.well-known/*`, `/scim/v2`. +- A deprecated version gets `Deprecation` (RFC 9745) and `Sunset` (RFC 8594) headers, and keeps working for at least 6 months. + +**Where this is recorded:** +- the API's Conventions → Versioning page +- the client specs (CLIENT-ARCHITECTURE §4.1, CHANGES §00, OPEN-QUESTIONS #23) +- this repo's code: `crates/substratal-core/src/version.rs` and `config.rs` +- the Hub help center's [API versioning](https://compositecode.github.io/substratalapps-applications-specs/help/developers/api-versioning.html) page + +The two plan documents in this folder were written without `/v1` paths. The sibling macOS plan still uses `/v1/ops/...`. + +## Verified + +| Area | Check | Result | +|---|---|---| +| Live API | `GET https://api.substratalapps.com/health` | `200 {"status":"ok"}` | +| Live API | `GET /v1/health` | `404 not_found`: the prefix is gone | +| Live API | `GET /users`, no header | `400 api_version_required`, `details.supported: ["1"]` | +| Live API | `GET /users`, `Substratal-Version: 1`, no token | `401 unauthenticated`, response echoes `substratal-version: 1` | +| Live API | `POST /auth/login` (one attempt, nonexistent account, via `substratalctl`) | `401 invalid_credentials` with `X-Request-Id`: error envelope and request IDs decode correctly | +| API spec | `origin/main` of `CompositeCode/substratalapps.com` | `openapi.yaml` **1.0.0-draft.9** (API v1.8.0), 124 operations, OpenAPI 3.1; vendored to `vendor/openapi.yaml` | +| API changes since Plan 2 | merged | Plan 2 gaps closed since 2026-10-21: test-user expiry (#58), self-service apps (#60), audit export (#61), SCIM (#62), `GET /users?role_id=` and sign-out-everywhere (#68), `Deprecation`/`Sunset` exposed to browsers (#69) | +| Client specs | `CompositeCode/substratalapps-applications-specs` @ `cebf679` | Targets API v1.8.0. `linux/` has PLATFORM-SPEC, all 75 screens in SCREENS.md, theme CSS (5 light + 5 dark), brand assets, mockups. Synced into `data/spec/` by `scripts/sync-specs.py` | +| Client specs | Console screens (OPS-*, SEC-01, CFG-*, RPT-01, INT-01) | **Web-only for now** (OPEN-QUESTIONS #27), read through a web-Hub Ops gateway rather than an API ops Lambda. This supersedes Plan 1's Linux Operations section. The registry keeps them as `web` so the shared help center documents them. | +| Toolchain (Mac) | Rust 1.96, Docker 20.10, Ruby 3.2 + Jekyll 4.3, Node 25, gh 2.92 | OK. GTK 4 and libadwaita are **not** installed on the Mac, so builds run in the container. | +| Toolchain (Linux) | `build-aux/docker/Dockerfile.dev` (Ubuntu 24.04) | Rust 1.99, **GTK 4.14.5, libadwaita 1.5.0** (the spec's minimums), Blueprint 0.12, Xvfb for headless screenshots. `scripts/dev.sh` wraps it. | +| Flatpak | `ghcr.io/flathub-infra/flatpak-github-actions` tags | gnome-44 … gnome-51 exist; the manifest targets GNOME 50. Built in CI (Flatpak can't run on macOS). | +| GitHub | `CompositeCode/substratalapps-linux` | Was private on the org's Free plan, so GitHub Pages wasn't available. **Made public** (decision 2026-10-23) to publish the help center on Pages, matching the API docs. | +| GitHub | Actions | Available. No secrets required. An optional `SYNC_TOKEN` PAT makes the automated sync PRs trigger CI. | +| AWS | needed for this phase? | No. The app and help site add nothing to AWS. | + +## Limits on verification + +| Item | Effect | To unblock | +|---|---|---| +| ⚠️ No test account on the live API | Signed-in screens (Users table, Account, Capabilities from real roles) are built and covered by the stub-server integration tests, but haven't run against production data | A test-mode account per persona (superadmin, support, billing admin, app owner, member), or run the API's devserver (`make up && make run` in the API repo) and use `SUBSTRATAL_API=local` | +| ⚠️ No Secret Service in containers | The app falls back to memory-only sessions there and says so on stderr | Nothing to do: real desktops and the Flatpak (Secret portal) have one | +| ⚠️ Generated API client | Plan 1 §6.1 said to spike a generated Rust client. The skeleton uses hand-written models for the operations it calls, plus a shared middleware (`api.rs`) that already applies every request convention. | Spike `progenitor` (after a 3.1 → 3.0 down-convert) or `openapi-generator` when the screens need many more operations | +| ⚠️ Spec icon names | `mail-inbox-symbolic`, `wallet-symbolic` and `bell-symbolic` (INFORMATION-ARCHITECTURE §5) aren't in Adwaita 46+ | The app bundles small originals under those names. Worth raising with the specs team. | diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..d0ead5e --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["clippy", "rustfmt"] diff --git a/scripts/dev.sh b/scripts/dev.sh new file mode 100755 index 0000000..9e8b336 --- /dev/null +++ b/scripts/dev.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Run a command in the Linux dev container (build-aux/docker/Dockerfile.dev), +# with the cargo registry and target dir cached in Docker volumes. +# +# scripts/dev.sh cargo test --workspace +# scripts/dev.sh scripts/screenshot.sh +set -euo pipefail +cd "$(dirname "$0")/.." +image=substratal-hub-dev +if ! docker image inspect "$image" >/dev/null 2>&1; then + docker build -t "$image" -f build-aux/docker/Dockerfile.dev build-aux/docker +fi +tty=() +[ -t 0 ] && tty=(-it) +exec docker run --rm ${tty[@]+"${tty[@]}"} \ + -v "$PWD":/src -w /src \ + -v substratal-cargo-registry:/usr/local/cargo/registry \ + -v substratal-target:/src/target \ + -e CARGO_TERM_COLOR=always \ + "$image" "$@" diff --git a/scripts/screenshot.sh b/scripts/screenshot.sh new file mode 100755 index 0000000..cb268f8 --- /dev/null +++ b/scripts/screenshot.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Renders the app headlessly (Xvfb) and saves PNG screenshots. Runs inside +# the dev container: scripts/dev.sh scripts/screenshot.sh [OUT_DIR] [ROUTE…] +# +# Uses the developer preview (SUBSTRATAL_PREVIEW), so no account is needed: +# layout only, nothing is loaded from the API. Set PERSONA, SCHEME (light|dark), +# SIZE (WxH) to vary it. +set -euo pipefail +out=${1:-_screens} +shift || true +routes=("$@") +[ ${#routes[@]} -eq 0 ] && routes=(/ /admin/users /settings) +size=${SIZE:-1280x800} +mkdir -p "$out" +cargo build -q -p substratal-hub +export DISPLAY=:99 GDK_BACKEND=x11 GSK_RENDERER=cairo NO_AT_BRIDGE=1 \ + XDG_DATA_DIRS="$PWD/data:/usr/local/share:/usr/share" \ + XDG_CONFIG_HOME="$(mktemp -d)" SUBSTRATAL_PREVIEW="${PERSONA-superadmin}" +Xvfb :99 -screen 0 "${size}x24" -nolisten tcp >/dev/null 2>&1 & +xvfb=$! +trap 'kill $xvfb 2>/dev/null || true' EXIT +sleep 1 +if [ -n "${SCHEME:-}" ]; then + mkdir -p "$XDG_CONFIG_HOME/com.substratalapps.Hub" + w=${size%x*}; h=${size#*x} + printf '{"color_scheme":"%s","width":%s,"height":%s}\n' "$SCHEME" "$w" "$h" > "$XDG_CONFIG_HOME/com.substratalapps.Hub/preferences.json" +else + w=${size%x*}; h=${size#*x} + mkdir -p "$XDG_CONFIG_HOME/com.substratalapps.Hub" + printf '{"width":%s,"height":%s}\n' "$w" "$h" > "$XDG_CONFIG_HOME/com.substratalapps.Hub/preferences.json" +fi +for route in "${routes[@]}"; do + name=$(echo "${route#/}" | tr '/' '-'); name=${name:-home} + SUBSTRATAL_ROUTE="$route" dbus-run-session -- target/debug/substratal-hub >/tmp/hub.log 2>&1 & + app=$! + sleep "${WAIT:-3}" + import -window root "$out/${SCHEME:-light}-$name.png" + kill $app 2>/dev/null || true + wait $app 2>/dev/null || true + echo "screenshot: $out/${SCHEME:-light}-$name.png" +done +grep -iE 'critical|error' /tmp/hub.log | head -5 || true diff --git a/scripts/sync-specs.py b/scripts/sync-specs.py new file mode 100755 index 0000000..975d3b5 --- /dev/null +++ b/scripts/sync-specs.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Sync the Linux app's copy of the client specs. + +Reads the specs package (CompositeCode/substratalapps-applications-specs) and +writes everything this repo derives from it: + + data/spec/screens.json every screen: id, title, route, archetype, phase, + who, purpose, Linux container, loads, elements, + actions, states, notes, platforms + data/spec/status-semantics.json + data/spec/themes.json theme ids, names and swatches (SET-01) + data/spec/SOURCE the specs commit these files came from + crates/substratal-hub/resources/theme/{light,dark}/*.css + data/icons/ the app icon set from linux/brand-assets + crates/substratal-hub/resources/brand/*.svg + +The specs repo is the source of truth (PRODUCT-SPEC: "If this package and the +API spec disagree, the spec wins"), so these files are never edited by hand. +Run it after the specs change; CI runs it with --check. + + scripts/sync-specs.py [--specs PATH] [--check] + +PATH defaults to $SPECS_DIR or ../substratalapps-applications-specs. +""" + +import argparse +import filecmp +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + +HEADER = re.compile(r"^### ([A-Z]{3,4}-\d{2}[a-z]?) — (.+)$") +META = re.compile(r"\*\*(Domain|Route|Archetype|Phase):\*\* ([^·]+?)\s*(?:·|$)") + + +def split_row(line): + cells = line.strip().strip("|").split("|") + return [c.strip().replace("\\|", "|") for c in cells] + + +def parse_tables(lines): + """Yield (header, rows) for every markdown table in lines.""" + i = 0 + while i < len(lines): + if lines[i].startswith("|") and i + 1 < len(lines) and set(lines[i + 1].replace("|", "").strip()) <= set("-: "): + header = split_row(lines[i]) + rows = [] + i += 2 + while i < len(lines) and lines[i].startswith("|"): + rows.append(split_row(lines[i])) + i += 1 + yield header, rows + else: + i += 1 + + +def field(block, name): + for line in block: + m = re.match(rf"\*\*{re.escape(name)}:\*\* (.*)$", line.strip()) + if m: + return m.group(1).strip() + return None + + +def strip_code(s): + return s.strip().strip("`").strip() if s else s + + +def parse_screens(path, platform_control_column): + text = path.read_text(encoding="utf-8").splitlines() + screens = [] + current = None + domain_heading = None + for line in text: + if line.startswith("## ") and not line.startswith("### "): + domain_heading = line[3:].strip() + if current: + screens.append(current) + current = None + continue + m = HEADER.match(line) + if m: + if current: + screens.append(current) + current = {"id": m.group(1), "title": m.group(2).strip(), "section_heading": domain_heading, "_lines": []} + continue + if current is not None: + current["_lines"].append(line) + if current: + screens.append(current) + + out = [] + for s in screens: + block = s.pop("_lines") + meta_line = next((l for l in block if l.startswith("**Domain:**")), "") + meta = {k.lower(): strip_code(v) for k, v in META.findall(meta_line)} + s.update({ + "domain": meta.get("domain", ""), + "route": meta.get("route", ""), + "archetype": meta.get("archetype", ""), + "phase": meta.get("phase", ""), + "who": field(block, "Who") or "", + "purpose": field(block, "Purpose") or "", + "loads": field(block, "Loads") or "", + "states": field(block, "States") or "", + "notes": field(block, "Notes") or "", + "entry_points": field(block, "Entry points") or "", + }) + container = next((l for l in block if "container:**" in l), None) + s["container"] = container.split("container:**", 1)[1].strip() if container else "" + s["elements"] = [] + s["actions"] = [] + for header, rows in parse_tables(block): + h = [x.lower() for x in header] + if h and h[0] == "element": + for r in rows: + r = r + [""] * (len(header) - len(r)) + if platform_control_column: + name, kind, control, data, rules = (r + [""] * 5)[:5] + else: + name, kind, data, rules = (r + [""] * 4)[:4] + control = "" + s["elements"].append({"name": name, "kind": kind, "control": control, "data": data, "rules": rules}) + elif h and h[0] == "action": + for r in rows: + action, call, result = (r + [""] * 3)[:3] + s["actions"].append({"action": action, "call": strip_code(call), "result": result}) + s["platforms"] = ["web"] if "web only" in s["phase"].lower() else ["all"] + out.append(s) + return out + + +def specs_commit(specs): + try: + return subprocess.check_output(["git", "-C", str(specs), "rev-parse", "HEAD"], text=True).strip() + except Exception: + return "unknown" + + +def build(specs, dest): + pkg = specs / "substratal-application-specs" + linux = pkg / "linux" + shared = pkg / "00-shared" + + native = parse_screens(linux / "SCREENS.md", platform_control_column=True) + native_ids = {s["id"] for s in native} + # Screens the native apps skip (OPEN-QUESTIONS #27) only exist in the + # shared file. Keep them so the shared help center can document them. + shared_only = [s for s in parse_screens(shared / "SCREEN-SPECS.md", platform_control_column=False) if s["id"] not in native_ids] + screens = native + shared_only + + (dest / "data/spec").mkdir(parents=True, exist_ok=True) + doc = { + "source": "CompositeCode/substratalapps-applications-specs", + "commit": specs_commit(specs), + "screens": screens, + } + (dest / "data/spec/screens.json").write_text(json.dumps(doc, indent=1, ensure_ascii=False) + "\n", encoding="utf-8") + shutil.copy(shared / "status-semantics.json", dest / "data/spec/status-semantics.json") + themes = json.loads((shared / "themes/themes.json").read_text(encoding="utf-8")) + slim = [{k: t[k] for k in ("id", "name", "mode", "default", "description")} | {"swatch": t["tokens"].get("accent")} for t in themes["themes"]] + (dest / "data/spec/themes.json").write_text(json.dumps({"defaults": themes["defaults"], "themes": slim}, indent=1) + "\n", encoding="utf-8") + (dest / "data/spec/SOURCE").write_text(f"{doc['source']}@{doc['commit']}\n") + + theme_dest = dest / "crates/substratal-hub/resources/theme" + for mode in ("light", "dark"): + (theme_dest / mode).mkdir(parents=True, exist_ok=True) + for css in sorted((linux / "theme" / mode).glob("*.css")): + shutil.copy(css, theme_dest / mode / css.name) + + brand_dest = dest / "crates/substratal-hub/resources/brand" + brand_dest.mkdir(parents=True, exist_ok=True) + for svg in sorted((linux / "brand-assets/svg").glob("*.svg")): + shutil.copy(svg, brand_dest / svg.name) + + icons_dest = dest / "data/icons" + if icons_dest.exists(): + shutil.rmtree(icons_dest) + shutil.copytree(linux / "brand-assets/icons", icons_dest) + return len(native), len(shared_only) + + +def tree_equal(a, b): + cmp = filecmp.dircmp(a, b) + if cmp.left_only or cmp.right_only or cmp.diff_files or cmp.funny_files: + return False + return all(tree_equal(Path(a) / d, Path(b) / d) for d in cmp.common_dirs) + + +OUTPUTS = ["data/spec", "data/icons", "crates/substratal-hub/resources/theme", "crates/substratal-hub/resources/brand"] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--specs", default=os.environ.get("SPECS_DIR", str(ROOT.parent / "substratalapps-applications-specs"))) + ap.add_argument("--check", action="store_true", help="fail if the committed files differ from the specs") + args = ap.parse_args() + specs = Path(args.specs).resolve() + if not (specs / "substratal-application-specs").is_dir(): + sys.exit(f"sync-specs: no specs package at {specs}") + + if args.check: + with tempfile.TemporaryDirectory() as tmp: + build(specs, Path(tmp)) + stale = [o for o in OUTPUTS if not (ROOT / o).exists() or not tree_equal(ROOT / o, Path(tmp) / o)] + if stale: + sys.exit("sync-specs: out of date with the specs: " + ", ".join(stale) + "\nRun scripts/sync-specs.py and commit.") + print("sync-specs: up to date") + return + + native, web = build(specs, ROOT) + print(f"sync-specs: {native} native screens + {web} web-only screens from {specs_commit(specs)[:7]}") + + +if __name__ == "__main__": + main() diff --git a/vendor/openapi.yaml b/vendor/openapi.yaml new file mode 100644 index 0000000..f36006d --- /dev/null +++ b/vendor/openapi.yaml @@ -0,0 +1,4446 @@ +openapi: 3.1.0 +info: + title: Substratal Apps — Platform API + version: "1.0.0-draft.9" + summary: >- + User accounts, auth, roles, per-app entitlements (the on/off access + switch), profiles, settings, organizations, tenancy, subscription billing, + and push notifications for applications built on Substratal Apps. Target contract for an API + that is not yet implemented; the prose specification is authoritative. + description: >- + Full documentation: https://compositecode.github.io/substratalapps.com/ — read + /api-reference/conventions/ first (IDs, pagination, PATCH semantics, + ETag/If-Match, idempotency, error shape). + + Every request must send the API version in a `Substratal-Version` + header (currently `1`, the only version); every response echoes the + version that served it. A missing header is `400 api_version_required`, + and an unknown version is `400 unsupported_api_version`, both with + `details.supported`. Exempt: `GET /health`, `POST /auth/oauth/token` + (called by standard OAuth libraries), the `/.well-known` endpoints, and + SCIM under `/scim/v2` (called by identity providers). + Every versioned operation declares it as the shared `ApiVersion` header + parameter, so generated clients know to send it. See + /api-reference/conventions/#versioning. + + Vendor extensions used here: + `x-substratal-destructive` (`always` | `conditional`) marks operations + annotated destructiveHint for MCP and rejected for API Keys with + restrict_destructive=true. For `conditional`, only requests matching + `x-substratal-destructive-when` are rejected; the rest of the + operation stays callable. + Application and Role ids are derived (app_, role__), + not random; every other id is a prefix plus a ULID. `x-substratal-phase` gives the roadmap phase an operation + ships in. + license: + name: Unlicensed draft — internal specification, not yet released +externalDocs: + description: Substratal Apps Platform API documentation + url: https://compositecode.github.io/substratalapps.com/ +servers: + - url: https://api.substratalapps.com + description: Production + +security: + - bearerAuth: [] + +tags: + - name: Auth + - name: Users + - name: Profiles + - name: Settings + - name: Applications + - name: Entitlements + - name: Roles & Permissions + - name: Organizations + - name: Tenancy + - name: Billing + - name: Audit + - name: Webhooks + - name: Notifications + description: >- + Push notifications (APNs, FCM, Web Push) and the notification inbox. + - name: API Keys + - name: Meta + +paths: + /health: + get: + operationId: health.check + tags: [Meta] + summary: Health check + security: [] + responses: + "200": + description: Service is responding + content: + application/json: + schema: + type: object + properties: + status: { type: string, enum: [ok] } + + # ───────────────────────────── Auth ───────────────────────────── + + /auth/signup: + post: + operationId: auth.signup + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Create an account with email and password + description: "An email held by an invited User is rejected with 409 email_taken (details.reason invitation_pending) and the invitation is re-sent; invitations complete only through invitations/accept." + security: [] + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: { type: string, format: email } + password: { type: string, format: password, minLength: 12, maxLength: 128 } + display_name: { type: string, minLength: 1, maxLength: 100 } + application_id: { type: string, description: "Informational: records which app the signup came from. Grants nothing." } + test_mode: { type: boolean, default: false, description: "Create a test-mode User." } + expires_at: { type: string, format: date-time, description: "Test mode only: when the account stops working. Default 24 hours from now; at least 15 minutes away, at most 30 days. 422 expires_at_out_of_range, or live_user when test_mode is false." } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Account created and signed in + content: + application/json: + schema: { $ref: "#/components/schemas/AuthSession" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /auth/login: + post: + operationId: auth.login + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Exchange email and password for a session (or an MFA challenge) + security: [] + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: { type: string, format: email } + password: { type: string, format: password } + test_mode: { type: boolean, default: false, description: "Sign in the test-mode account with this email." } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A session, or an MFA challenge when mfa_required is true + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/AuthSession" + - $ref: "#/components/schemas/MfaChallenge" + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /auth/mfa/verify: + post: + operationId: auth.verifyMfa + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Complete a login that returned an MFA challenge + security: [] + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [mfa_token] + properties: + mfa_token: { type: string } + code: { type: string, pattern: "^[0-9]{6}$" } + recovery_code: { type: string } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Session issued + content: + application/json: + schema: { $ref: "#/components/schemas/AuthSession" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /auth/token/refresh: + post: + operationId: auth.refreshToken + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Rotate a platform refresh token for a new access token + security: [] + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [refresh_token] + properties: + refresh_token: { type: string } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: New session tokens (the old refresh token is now invalid) + content: + application/json: + schema: { $ref: "#/components/schemas/AuthSession" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + + /auth/logout: + post: + operationId: auth.logout + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Revoke the current session, or every session + x-substratal-phase: mvp + x-substratal-destructive: conditional + x-substratal-destructive-when: "all_sessions is true" + requestBody: + content: + application/json: + schema: + type: object + properties: + all_sessions: { type: boolean, default: false } + responses: + "204": { description: Logged out } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /auth/app-tokens: + post: + operationId: auth.createAppToken + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Mint a per-app JWT from a platform session (embedded login) + description: "Requires a User token. Fails with 403 embedded_login_not_allowed for a self-registered (third-party) Application, which must use the hosted flow; 403 entitlement_required unless the user's resolved access to the Application is active; 409 application_not_available unless it's approved; and 403 tenant_suspended if its Tenant is suspended." + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [application_id] + properties: + application_id: { type: string } + organization_id: { type: string, description: "Attribute org_id to this Organization's grant." } + responses: + "200": + description: App token + content: + application/json: + schema: { $ref: "#/components/schemas/AppToken" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + + /auth/oauth/authorization-codes: + post: + operationId: auth.createAuthorizationCode + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Mint a one-time authorization code for a hosted launch (PKCE) + description: "Called by Substratal's hosted sign-in page on the signed-in user's behalf." + x-substratal-phase: phase3 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [application_id, redirect_uri, code_challenge, code_challenge_method] + properties: + application_id: { type: string } + redirect_uri: { type: string } + code_challenge: { type: string, minLength: 43, maxLength: 128 } + code_challenge_method: { type: string, enum: [S256] } + state: { type: string, maxLength: 512 } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Authorization code + content: + application/json: + schema: + type: object + required: [code, expires_in, redirect_to] + properties: + code: { type: string } + expires_in: { type: integer, example: 60 } + redirect_to: { type: string } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /auth/oauth/token: + post: + operationId: auth.exchangeOAuthToken + tags: [Auth] + summary: Exchange an authorization code or app refresh token for an app token + description: "Public-client OAuth 2.0 token endpoint. Errors use the OAuth error shape ({error, error_description}), not the usual envelope." + security: [] + x-substratal-phase: phase3 + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/OAuthTokenRequest" } + application/x-www-form-urlencoded: + schema: { $ref: "#/components/schemas/OAuthTokenRequest" } + responses: + "200": + description: App token and rotated app refresh token + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/AppToken" + - type: object + properties: + refresh_token: { type: string } + refresh_token_expires_in: { type: integer } + "400": + description: OAuth error + content: + application/json: + schema: { $ref: "#/components/schemas/OAuthError" } + + /auth/email/verify: + post: + operationId: auth.verifyEmail + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Confirm an email address from an emailed token + security: [] + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token] + properties: + token: { type: string } + responses: + "204": { description: Verified } + "400": { $ref: "#/components/responses/BadRequest" } + + /auth/email/verify/resend: + post: + operationId: auth.resendEmailVerification + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Re-send a verification email (always 202) + security: [] + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string, format: email } + test_mode: { type: boolean, default: false } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "202": { description: Accepted } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /auth/password/forgot: + post: + operationId: auth.forgotPassword + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Send a password-reset email (always 202) + security: [] + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string, format: email } + test_mode: { type: boolean, default: false } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "202": { description: Accepted } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /auth/password/reset: + post: + operationId: auth.resetPassword + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Set a new password from a reset token (revokes every session) + security: [] + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token, new_password] + properties: + token: { type: string } + new_password: { type: string, format: password, minLength: 12, maxLength: 128 } + responses: + "204": { description: Password reset } + "400": { $ref: "#/components/responses/BadRequest" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /auth/invitations/accept: + post: + operationId: auth.acceptInvitation + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Set a password on an invited account, activate it, and sign in + security: [] + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token, password] + properties: + token: { type: string } + password: { type: string, format: password, minLength: 12, maxLength: 128 } + display_name: { type: string, maxLength: 100 } + responses: + "200": + description: Session issued + content: + application/json: + schema: { $ref: "#/components/schemas/AuthSession" } + "400": { $ref: "#/components/responses/BadRequest" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /auth/sso/{provider}/callback: + post: + operationId: auth.ssoCallback + tags: [Auth] + summary: Complete an SSO login (reserved — 501 until the broker integration ships) + security: [] + x-substratal-phase: phase3 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - name: provider + in: path + required: true + schema: { type: string } + requestBody: + content: + application/json: + schema: { type: object } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Session issued + content: + application/json: + schema: { $ref: "#/components/schemas/AuthSession" } + "501": + description: not_implemented + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /users/{id}/password: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + post: + operationId: auth.changePassword + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Change password given the current one (self only) + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [current_password, new_password] + properties: + current_password: { type: string, format: password } + new_password: { type: string, format: password, minLength: 12, maxLength: 128 } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Changed; other sessions revoked } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /users/{id}/mfa/totp: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + post: + operationId: auth.enrollMfaTotp + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Begin TOTP enrollment (self only) + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Pending TOTP secret + content: + application/json: + schema: + type: object + properties: + secret: { type: string } + otpauth_uri: { type: string } + expires_in: { type: integer } + "409": { $ref: "#/components/responses/Conflict" } + delete: + operationId: auth.resetMfaTotp + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Support reset of a user's TOTP (requires users.manage) + x-substratal-phase: mvp + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: MFA removed; sessions revoked } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /users/{id}/mfa/totp/confirm: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + post: + operationId: auth.confirmMfaTotp + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Confirm TOTP enrollment with a first code; returns recovery codes once + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: { type: string, pattern: "^[0-9]{6}$" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Enabled + content: + application/json: + schema: + type: object + properties: + mfa_enabled: { type: boolean } + recovery_codes: { type: array, items: { type: string }, minItems: 10, maxItems: 10 } + "401": { $ref: "#/components/responses/Unauthorized" } + "409": { $ref: "#/components/responses/Conflict" } + + /users/{id}/mfa/totp/disable: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + post: + operationId: auth.disableMfaTotp + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Turn TOTP off given a current code or recovery code (self only) + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + description: "Exactly one of code or recovery_code." + properties: + code: { type: string, pattern: "^[0-9]{6}$", description: "A current TOTP code" } + recovery_code: { type: string } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Disabled } + "401": { $ref: "#/components/responses/Unauthorized" } + "409": { $ref: "#/components/responses/Conflict" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /users/{id}/sessions: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: auth.listSessions + tags: [Auth] + summary: List active sessions (self or users.manage) + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of sessions + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/Session" } } + + /users/{id}/sessions/revoke-all: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + post: + operationId: auth.revokeAllSessions + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Revoke every session (self or users.manage) + description: >- + Signs the User out everywhere: each live session is revoked with revoked_reason + revoked_all, which ends its refresh tokens and app refresh tokens and deactivates + the push devices it registered. keep_current keeps the calling session, and only + applies to your own sessions. Writes user.sessions_revoked. + x-substratal-phase: mvp + x-substratal-destructive: always + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + keep_current: { type: boolean, default: false, description: "Self only; 422 self_only otherwise." } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Revoked + content: + application/json: + schema: + type: object + required: [revoked] + properties: + revoked: { type: integer, description: "How many sessions were revoked." } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /users/{id}/sessions/{sessionId}: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + - name: sessionId + in: path + required: true + schema: { type: string } + delete: + operationId: auth.revokeSession + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Revoke one session + x-substratal-phase: mvp + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Revoked } + "404": { $ref: "#/components/responses/NotFound" } + + # ───────────────────────────── Users ───────────────────────────── + + /users: + get: + operationId: users.list + tags: [Users] + summary: List or search users + description: >- + Requires users.list. An app-scoped key with users.list is forced to application_id=, + and any role_id it sends must be one of its own Application's Roles (403 role_scope_mismatch). + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: status, in: query, description: "deleted is allowed for users.manage only", schema: { type: string, enum: [active, invited, suspended, deleted] } } + - { name: email, in: query, schema: { type: string, format: email } } + - { name: q, in: query, schema: { type: string, minLength: 3 } } + - { name: application_id, in: query, schema: { type: string } } + - { name: organization_id, in: query, schema: { type: string } } + - name: role_id + in: query + description: >- + Users holding any of these Roles by explicit assignment. Repeatable. Implicit holders + of an Application's default_app_role aren't listed. + style: form + explode: true + schema: { type: array, items: { type: string }, maxItems: 20 } + - { name: created_after, in: query, schema: { type: string, format: date-time } } + - { name: created_before, in: query, schema: { type: string, format: date-time } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of users + content: + application/json: + schema: { $ref: "#/components/schemas/UserPage" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + operationId: users.create + tags: [Users] + summary: Create (invite) a user + description: "Requires users.manage. Also creates a default Profile and Settings and assigns the member platform role, in one transaction." + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKeyOptional" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string, format: email } + display_name: { type: string, maxLength: 100 } + status: { type: string, enum: [invited, active], default: invited } + send_invitation: { type: boolean, default: true } + expires_at: { type: string, format: date-time, description: "Test-mode keys only: when the User stops working. Default 24 hours from now; at least 15 minutes away, at most 30 days. 422 expires_at_out_of_range, or live_user with a live key." } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: User created + content: + application/json: + schema: { $ref: "#/components/schemas/User" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /users/{id}: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: users.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Users] + summary: Fetch one user + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The user + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/User" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: users.update + tags: [Users] + summary: Update email (self, pending verification) or status (users.manage) + x-substratal-phase: mvp + x-substratal-destructive: conditional + x-substratal-destructive-when: "status becomes suspended" + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + content: + application/json: + schema: + type: object + properties: + email: { type: string, format: email } + status: { type: string, enum: [active, suspended], description: "users.manage only. active on a deleted User restores it." } + expires_at: { type: string, format: date-time, description: "users.manage only, test-mode Users only. At least 15 minutes from now and at most 30 days after created_at. Moving it later on an expired User brings them back and fires access.granted (test_user_extended)." } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated user + content: + application/json: + schema: { $ref: "#/components/schemas/User" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + delete: + operationId: users.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Users] + summary: Soft-delete a user (self or users.manage) + x-substratal-phase: mvp + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /users/{id}/suspend: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + post: + operationId: users.suspend + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Users] + summary: Suspend a user (users.manage) + x-substratal-phase: mvp + x-substratal-destructive: always + requestBody: + content: + application/json: + schema: + type: object + properties: + reason: { type: string, maxLength: 2000 } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: User suspended + content: + application/json: + schema: { $ref: "#/components/schemas/User" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + + /users/{id}/invitation: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + post: + operationId: users.resendInvitation + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Users] + summary: Re-send the invitation email to an invited user (users.manage) + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "202": { description: Sent } + "409": { $ref: "#/components/responses/Conflict" } + + /users/{id}/export: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: users.export + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Users] + summary: Export everything this API holds about this user + description: "Self, or users.manage. GDPR Article 20 / CCPA right-to-know. Covers every table the hard-delete cascade clears, plus global Settings, which the cascade leaves in place." + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Full data export bundle + content: + application/json: + schema: { $ref: "#/components/schemas/UserExport" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /users/{id}/erasure-requests: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + post: + operationId: users.requestErasure + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Users] + summary: Request right-to-erasure (soft-delete now, hard-delete cascade in 7 days) + x-substratal-phase: mvp + x-substratal-destructive: always + requestBody: + content: + application/json: + schema: + type: object + properties: + reason: { type: string, maxLength: 2000 } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "202": + description: Scheduled + content: + application/json: + schema: { $ref: "#/components/schemas/ErasureRequest" } + + /users/{id}/erasure-requests/current: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + delete: + operationId: users.cancelErasure + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Users] + summary: Cancel a scheduled erasure inside its 7-day window (users.manage) + x-substratal-phase: mvp + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Cancelled } + "404": { $ref: "#/components/responses/NotFound" } + + # ─────────────────────────── Profiles & Settings ─────────────────────────── + + /users/{id}/profile: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: profiles.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Profiles] + summary: Fetch global Profile + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The global Profile + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/Profile" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: profiles.update + tags: [Profiles] + summary: Update global Profile (self or users.manage) + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + content: + application/json: + schema: { $ref: "#/components/schemas/ProfileWrite" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated Profile + content: + application/json: + schema: { $ref: "#/components/schemas/Profile" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /users/{id}/settings: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: settings.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Settings] + summary: Fetch global Settings (self, or platform users.list) + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The global Settings + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/Settings" } + patch: + operationId: settings.update + tags: [Settings] + summary: Update global Settings (self or users.manage) + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + content: + application/json: + schema: { $ref: "#/components/schemas/SettingsWrite" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated Settings + content: + application/json: + schema: { $ref: "#/components/schemas/Settings" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /users/{id}/apps/{appId}/profile: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + - $ref: "#/components/parameters/AppId" + get: + operationId: appProfiles.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Profiles] + summary: Fetch per-app AppProfile (self, the app's own key, or platform users.list; defaults if none written yet) + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The AppProfile + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/AppProfile" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: appProfiles.update + tags: [Profiles] + summary: Update per-app AppProfile (custom is merged one level; null removes a key) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + content: + application/json: + schema: + type: object + properties: + display_handle: { type: [string, "null"], minLength: 1, maxLength: 64 } + custom: { type: object } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated AppProfile + content: + application/json: + schema: { $ref: "#/components/schemas/AppProfile" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /users/{id}/apps/{appId}/settings: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + - $ref: "#/components/parameters/AppId" + get: + operationId: appSettings.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Settings] + summary: Fetch fully resolved per-app settings (self, the app's own key, or platform users.list) + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Resolved AppSettings + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/AppSettingsResolved" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: appSettings.update + tags: [Settings] + summary: Write per-app overrides (validated against settings_schema; null clears) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + content: + application/json: + schema: + type: object + required: [overrides] + properties: + overrides: { type: object } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Resolved AppSettings after the write + content: + application/json: + schema: { $ref: "#/components/schemas/AppSettingsResolved" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + # ───────────────────────────── Applications ───────────────────────────── + + /applications: + get: + operationId: applications.list + tags: [Applications] + summary: List the catalog (filtered by visibility rules) + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: visibility, in: query, schema: { $ref: "#/components/schemas/ApplicationVisibility" } } + - { name: review_status, in: query, schema: { $ref: "#/components/schemas/ApplicationReviewStatus" } } + - { name: access_tier, in: query, schema: { $ref: "#/components/schemas/ApplicationAccessTier" } } + - { name: owned, in: query, schema: { type: boolean } } + - { name: tenant_id, in: query, schema: { type: string } } + - { name: owner_user_id, in: query, schema: { type: string } } + - { name: owner_organization_id, in: query, schema: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Applications (trimmed view) + content: + application/json: + schema: { $ref: "#/components/schemas/ApplicationSummaryPage" } + post: + operationId: applications.create + tags: [Applications] + summary: Register an Application (applications.manage, or self-service into review) + description: >- + applications.manage creates any Application, approved by default (concierge onboarding). + Any other User with a verified email may submit their own: it's owned by the caller (or an + Organization they're an org_admin of), starts in pending_review, and can't set visibility, + access_tier, review_status, or review_notes (403 moderation_field_forbidden). Unverified + email is 403 email_not_verified; API Keys without applications.manage are 403 forbidden. + Self-service submissions are limited to 10 per User per day. + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKeyOptional" + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ApplicationWrite" + - type: object + required: [slug, name, launch_url] + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Application created + content: + application/json: + schema: { $ref: "#/components/schemas/Application" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /applications/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: applications.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Applications] + summary: Fetch one Application + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The Application + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/Application" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: applications.update + tags: [Applications] + summary: Update a catalog entry (owner — configuration; applications.manage — anything) + x-substratal-phase: mvp + x-substratal-destructive: conditional + x-substratal-destructive-when: "review_status becomes rejected or suspended, or available_app_roles/permissions shrink" + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + content: + application/json: + schema: { $ref: "#/components/schemas/ApplicationWrite" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated Application + content: + application/json: + schema: { $ref: "#/components/schemas/Application" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + # ───────────────────────────── Entitlements ───────────────────────────── + + /users/{id}/entitlements: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: entitlements.listForUser + tags: [Entitlements] + summary: A user's entitlements, personal and org-sourced + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: application_id, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: "#/components/schemas/EntitlementStatus" } } + - { name: source, in: query, schema: { $ref: "#/components/schemas/EntitlementSource" } } + - { name: include_inactive, in: query, schema: { type: boolean, default: false } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Entitlements + content: + application/json: + schema: { $ref: "#/components/schemas/EntitlementPage" } + post: + operationId: entitlements.grant + tags: [Entitlements] + summary: Grant a personal Entitlement + description: "Requires entitlements.manage (platform, or app-confined to the key's own Application), or the Application's owner." + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [application_id, source] + properties: + application_id: { type: string } + source: { type: string, enum: [purchase, trial, admin_grant] } + order_id: { type: [string, "null"], maxLength: 255 } + starts_at: { type: string, format: date-time } + ends_at: { type: [string, "null"], format: date-time } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Entitlement created + content: + application/json: + schema: { $ref: "#/components/schemas/Entitlement" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /entitlements: + get: + operationId: entitlements.list + tags: [Entitlements] + summary: List entitlements across users (entitlements.manage or the Application's owner; app keys and owners confined) + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: application_id, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: "#/components/schemas/EntitlementStatus" } } + - { name: user_id, in: query, schema: { type: string } } + - { name: organization_id, in: query, schema: { type: string } } + - { name: source, in: query, schema: { $ref: "#/components/schemas/EntitlementSource" } } + - { name: order_id, in: query, schema: { type: string } } + - { name: ends_before, in: query, schema: { type: string, format: date-time } } + - { name: include_inactive, in: query, schema: { type: boolean, default: false } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Entitlements + content: + application/json: + schema: { $ref: "#/components/schemas/EntitlementPage" } + "403": { $ref: "#/components/responses/Forbidden" } + + /entitlements/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: entitlements.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Entitlements] + summary: Fetch one entitlement + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The Entitlement + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/Entitlement" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: entitlements.update + tags: [Entitlements] + summary: "The toggle: change status, term, source (trial→purchase), order_id, or an org grant's member scope" + x-substratal-phase: mvp + x-substratal-destructive: conditional + x-substratal-destructive-when: "status becomes disabled or revoked" + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + status: { $ref: "#/components/schemas/EntitlementStatus" } + disabled_reason: { type: [string, "null"], maxLength: 255 } + ends_at: { type: [string, "null"], format: date-time } + source: { type: string, enum: [purchase] } + order_id: { type: string, maxLength: 255 } + member_scope: { $ref: "#/components/schemas/EntitlementMemberScope" } + member_overrides: { type: array, maxItems: 1000, items: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated Entitlement + content: + application/json: + schema: { $ref: "#/components/schemas/Entitlement" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + delete: + operationId: entitlements.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Entitlements] + summary: Hard-remove a grant made in error (no order_id, under 24 hours old) + x-substratal-phase: mvp + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Removed } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + + # ─────────────────────────── Roles & Permissions ─────────────────────────── + + /permissions: + get: + operationId: permissions.list + tags: [Roles & Permissions] + summary: List every Permission key the caller can see + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: scope, in: query, description: "'platform' or an application_id", schema: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Permissions + content: + application/json: + schema: { $ref: "#/components/schemas/PermissionPage" } + + /roles: + get: + operationId: roles.list + tags: [Roles & Permissions] + summary: List Roles + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: scope, in: query, description: "'platform' or an application_id", schema: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Roles + content: + application/json: + schema: { $ref: "#/components/schemas/RolePage" } + post: + operationId: roles.create + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Roles & Permissions] + summary: Define a Role + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RoleCreate" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Role created + content: + application/json: + schema: { $ref: "#/components/schemas/Role" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /roles/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: roles.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Roles & Permissions] + summary: Fetch one Role + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The Role + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/Role" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: roles.update + tags: [Roles & Permissions] + summary: Change a Role's permissions or description + x-substratal-phase: mvp + x-substratal-destructive: conditional + x-substratal-destructive-when: "permissions shrink" + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + description: { type: [string, "null"], maxLength: 2000 } + permissions: { type: array, items: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated Role + content: + application/json: + schema: { $ref: "#/components/schemas/Role" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + delete: + operationId: roles.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Roles & Permissions] + summary: Delete an unassigned, non-seed Role + x-substratal-phase: mvp + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + + /users/{id}/roles: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: roles.listForUser + tags: [Roles & Permissions] + summary: List a user's Role assignments (including implicit default_app_role) + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: application_id, in: query, schema: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of UserRoleAssignments + content: + application/json: + schema: { $ref: "#/components/schemas/UserRoleAssignmentPage" } + + /users/{id}/roles/{roleId}: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + - { name: roleId, in: path, required: true, schema: { type: string } } + post: + operationId: roles.assign + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Roles & Permissions] + summary: Assign a Role (idempotent — 200 if already held) + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Already assigned + content: + application/json: + schema: { $ref: "#/components/schemas/UserRoleAssignment" } + "201": + description: Role assigned + content: + application/json: + schema: { $ref: "#/components/schemas/UserRoleAssignment" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + operationId: roles.remove + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Roles & Permissions] + summary: Remove a Role assignment (idempotent) + x-substratal-phase: mvp + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Removed } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + + /users/{id}/apps/{appId}/effective-permissions: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + - $ref: "#/components/parameters/AppId" + get: + operationId: permissions.getEffective + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Roles & Permissions] + summary: The live access check (always 200) + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Effective permissions + content: + application/json: + schema: { $ref: "#/components/schemas/EffectivePermissions" } + "404": { $ref: "#/components/responses/NotFound" } + + # ───────────────────────────── Organizations ───────────────────────────── + + /organizations: + get: + operationId: organizations.list + tags: [Organizations] + summary: List your Organizations (all, with organizations.manage) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: status, in: query, schema: { type: string, enum: [active, suspended] } } + - { name: q, in: query, schema: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Organizations + content: + application/json: + schema: { $ref: "#/components/schemas/OrganizationPage" } + post: + operationId: organizations.create + tags: [Organizations] + summary: Create an Organization (any active User; caller becomes org_admin) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKeyOptional" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: { type: string, minLength: 1, maxLength: 100 } + responses: + "201": + description: Organization created + content: + application/json: + schema: { $ref: "#/components/schemas/Organization" } + "400": { $ref: "#/components/responses/BadRequest" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /organizations/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: organizations.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Organizations] + summary: Fetch one Organization + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The Organization + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/Organization" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: organizations.update + tags: [Organizations] + summary: Rename (org admin), or suspend/reactivate (organizations.manage) + x-substratal-phase: phase2 + x-substratal-destructive: conditional + x-substratal-destructive-when: "status becomes suspended" + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + content: + application/json: + schema: + type: object + properties: + name: { type: string, minLength: 1, maxLength: 100 } + status: { type: string, enum: [active, suspended] } + reason: { type: string, maxLength: 2000 } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated Organization + content: + application/json: + schema: { $ref: "#/components/schemas/Organization" } + "403": { $ref: "#/components/responses/Forbidden" } + + /organizations/{id}/members: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: organizations.listMembers + tags: [Organizations] + summary: List members + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: role, in: query, schema: { $ref: "#/components/schemas/OrganizationMemberRole" } } + - { name: membership_status, in: query, schema: { $ref: "#/components/schemas/OrganizationMembershipStatus" } } + - { name: q, in: query, schema: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of members + content: + application/json: + schema: { $ref: "#/components/schemas/OrganizationMemberPage" } + post: + operationId: organizations.addMember + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Organizations] + summary: Invite a member by email (org admin), or add one directly by user_id (organizations.manage) + description: "By email, creates a pending membership (and an invited User if none exists) and emails an invitation; the response is identical whether or not the email already had an account. By user_id (organizations.manage only), the membership is active immediately. Never fails on a plan limit." + x-substratal-phase: phase2 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + user_id: { type: string } + email: { type: string, format: email } + role: { $ref: "#/components/schemas/OrganizationMemberRole" } + oneOf: + - required: [user_id] + - required: [email] + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Member invited (pending) or added (active) + content: + application/json: + schema: { $ref: "#/components/schemas/OrganizationMember" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + + /organizations/{id}/members/me/accept: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + post: + operationId: organizations.acceptMembership + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Organizations] + summary: Accept a pending membership (the invited User's own token) + x-substratal-phase: phase2 + responses: + "200": + description: The member, now active + content: + application/json: + schema: { $ref: "#/components/schemas/OrganizationMember" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /organizations/{id}/members/{userId}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + - { name: userId, in: path, required: true, description: "A usr_ id, or 'me' to leave", schema: { type: string } } + patch: + operationId: organizations.updateMember + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Organizations] + summary: Change a member's role + x-substratal-phase: phase2 + x-substratal-destructive: conditional + x-substratal-destructive-when: "demotes an org_admin" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [role] + properties: + role: { $ref: "#/components/schemas/OrganizationMemberRole" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated member + content: + application/json: + schema: { $ref: "#/components/schemas/OrganizationMember" } + "409": { $ref: "#/components/responses/Conflict" } + delete: + operationId: organizations.removeMember + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Organizations] + summary: Remove a member or withdraw an invitation; or leave or decline (userId = me) + x-substratal-phase: phase2 + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Removed } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + + /organizations/{id}/scim-token: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: organizations.getScimToken + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Organizations] + summary: Show the Organization's SCIM token (never its secret) + description: "org_admin or organizations.manage. 404 scim_token_not_found when SCIM isn't set up." + x-substratal-phase: phase3 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The token + content: + application/json: + schema: { $ref: "#/components/schemas/ScimToken" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + post: + operationId: organizations.createScimToken + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Organizations] + summary: Create (or rotate) the Organization's SCIM token + description: >- + org_admin or organizations.manage, with a User token. Returns the secret once, for the identity + provider's SCIM settings, with base_url. Replaces any existing token at once. Writes + organization.scim_token_created. + x-substratal-phase: phase3 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: The new token, with its secret + content: + application/json: + schema: { $ref: "#/components/schemas/ScimToken" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + operationId: organizations.deleteScimToken + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Organizations] + summary: Turn SCIM off for the Organization + description: "org_admin or organizations.manage. The token stops working at once. Writes organization.scim_token_revoked." + x-substratal-phase: phase3 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Revoked } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /organizations/{id}/entitlements: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: organizations.listEntitlements + tags: [Organizations] + summary: List org-wide (seat) grants + description: Same filters as GET /entitlements, scoped to this Organization. + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: application_id, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: "#/components/schemas/EntitlementStatus" } } + - { name: order_id, in: query, schema: { type: string } } + - { name: ends_before, in: query, schema: { type: string, format: date-time } } + - { name: include_inactive, in: query, schema: { type: boolean } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Entitlements + content: + application/json: + schema: { $ref: "#/components/schemas/EntitlementPage" } + post: + operationId: organizations.grantEntitlement + tags: [Organizations] + summary: Grant an app to the whole Organization + description: "Requires entitlements.manage (platform, or the Application's own confined key) or the Application's owner. Never the org's own admin." + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [application_id] + properties: + application_id: { type: string } + order_id: { type: [string, "null"], maxLength: 255 } + starts_at: { type: string, format: date-time } + ends_at: { type: [string, "null"], format: date-time } + member_scope: { $ref: "#/components/schemas/EntitlementMemberScope" } + member_overrides: { type: array, maxItems: 1000, items: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Org-wide Entitlement created + content: + application/json: + schema: { $ref: "#/components/schemas/Entitlement" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + # ───────────────────────────── Tenancy ───────────────────────────── + + /tenants: + get: + operationId: tenants.list + tags: [Tenancy] + summary: List Tenants (your own; all with tenants.manage) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: tier, in: query, schema: { $ref: "#/components/schemas/TenantTier" } } + - { name: status, in: query, schema: { $ref: "#/components/schemas/TenantStatus" } } + - { name: owner_user_id, in: query, schema: { type: string } } + - { name: owner_organization_id, in: query, schema: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Tenants + content: + application/json: + schema: { $ref: "#/components/schemas/TenantPage" } + + /tenants/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: tenants.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Tenancy] + summary: Fetch one Tenant + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A Tenant + content: + application/json: + schema: { $ref: "#/components/schemas/Tenant" } + "404": { $ref: "#/components/responses/NotFound" } + + /tenants/{id}/tier-change-requests: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: tenants.listTierChangeRequests + tags: [Tenancy] + summary: List tier-change requests for a Tenant + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of tier-change requests + content: + application/json: + schema: { $ref: "#/components/schemas/TenantTierChangeRequestPage" } + post: + operationId: tenants.requestTierChange + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Tenancy] + summary: Open a tier-change request on a customer's behalf (tenants.manage) + x-substratal-phase: phase2 + x-substratal-destructive: always + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [requested_tier, reason] + properties: + requested_tier: { type: string, enum: [isolated, dedicated_region] } + requested_region: { $ref: "#/components/schemas/Region" } + reason: { type: string, maxLength: 2000 } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Tier-change request created + content: + application/json: + schema: { $ref: "#/components/schemas/TenantTierChangeRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /tenants/{id}/tier-change-requests/{requestId}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + - { name: requestId, in: path, required: true, schema: { type: string } } + get: + operationId: tenants.getTierChangeRequest + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Tenancy] + summary: Fetch one tier-change request + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The request + content: + application/json: + schema: { $ref: "#/components/schemas/TenantTierChangeRequest" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: tenants.updateTierChangeRequest + tags: [Tenancy] + summary: Schedule, start, complete, or cancel a tier-change request (tenants.manage) + x-substratal-phase: phase2 + x-substratal-destructive: always + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [status] + properties: + status: { type: string, enum: [scheduled, in_progress, completed, cancelled] } + scheduled_for: { type: string, format: date-time } + notes: { type: string, maxLength: 2000 } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated request + content: + application/json: + schema: { $ref: "#/components/schemas/TenantTierChangeRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + + # ───────────────────────────── Billing ───────────────────────────── + + /users/{id}/subscription: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: billing.getUserSubscription + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Billing] + summary: A User's plan, what covers it, and their own subscription (self or billing.manage) + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The User's plan and individual subscription + content: + application/json: + schema: { $ref: "#/components/schemas/UserSubscription" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /users/me/billing/checkout-sessions: + post: + operationId: billing.createUserCheckoutSession + tags: [Billing] + summary: Start a Stripe Checkout to subscribe yourself to Standard (User token) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CheckoutSessionCreate" } + responses: + "201": + description: Checkout URL + content: + application/json: + schema: { $ref: "#/components/schemas/RedirectSession" } + "400": { $ref: "#/components/responses/BadRequest" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + "503": { $ref: "#/components/responses/ServiceUnavailable" } + + /users/me/billing/portal-sessions: + post: + operationId: billing.createUserPortalSession + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Billing] + summary: Open the Stripe Customer Portal for your own subscription (User token) + x-substratal-phase: phase2 + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PortalSessionCreate" } + responses: + "201": + description: Portal URL + content: + application/json: + schema: { $ref: "#/components/schemas/RedirectSession" } + "400": { $ref: "#/components/responses/BadRequest" } + "409": { $ref: "#/components/responses/Conflict" } + "503": { $ref: "#/components/responses/ServiceUnavailable" } + + /organizations/{id}/subscription: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: billing.getOrganizationSubscription + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Billing] + summary: An Organization's plan and Standard subscription (org admin or billing.manage) + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The Organization's plan and subscription + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/OrganizationSubscription" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: billing.updateOrganizationSubscription + tags: [Billing] + summary: Choose which members the Organization's subscription covers (org admin) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + member_scope: { $ref: "#/components/schemas/EntitlementMemberScope" } + member_overrides: { type: [array, "null"], maxItems: 1000, items: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/OrganizationSubscription" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /organizations/{id}/billing/checkout-sessions: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + post: + operationId: billing.createOrganizationCheckoutSession + tags: [Billing] + summary: Start a Stripe Checkout to subscribe the Organization to Standard (org admin, User token) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CheckoutSessionCreate" + - type: object + properties: + member_scope: { $ref: "#/components/schemas/EntitlementMemberScope" } + member_overrides: { type: array, maxItems: 1000, items: { type: string } } + responses: + "201": + description: Checkout URL + content: + application/json: + schema: { $ref: "#/components/schemas/RedirectSession" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + "503": { $ref: "#/components/responses/ServiceUnavailable" } + + /organizations/{id}/billing/portal-sessions: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + post: + operationId: billing.createOrganizationPortalSession + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Billing] + summary: Open the Stripe Customer Portal for the Organization's subscription (org admin or billing.manage, User token) + x-substratal-phase: phase2 + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PortalSessionCreate" } + responses: + "201": + description: Portal URL + content: + application/json: + schema: { $ref: "#/components/schemas/RedirectSession" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "503": { $ref: "#/components/responses/ServiceUnavailable" } + + # ───────────────────────────── Audit ───────────────────────────── + + /audit-events: + get: + operationId: audit.list + tags: [Audit] + summary: Query the audit log (hot window only) + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: target_user_id, in: query, schema: { type: string } } + - { name: target_type, in: query, schema: { $ref: "#/components/schemas/AuditTargetType" } } + - { name: target_id, in: query, schema: { type: string } } + - { name: actor_id, in: query, schema: { type: string } } + - { name: actor_type, in: query, schema: { type: string, enum: [user, api_key, system] } } + - name: action + in: query + style: form + explode: true + schema: { type: array, items: { $ref: "#/components/schemas/AuditAction" } } + - { name: application_id, in: query, schema: { type: string } } + - { name: organization_id, in: query, schema: { type: string } } + - { name: tenant_id, in: query, schema: { type: string } } + - { name: since, in: query, schema: { type: string, format: date-time } } + - { name: until, in: query, schema: { type: string, format: date-time } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Audit Events + headers: + Audit-Hot-Window-Start: + description: Earliest timestamp available to this caller's scope + schema: { type: string, format: date-time } + content: + application/json: + schema: { $ref: "#/components/schemas/AuditEventPage" } + "403": { $ref: "#/components/responses/Forbidden" } + + /audit-events/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: audit.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Audit] + summary: Fetch one Audit Event + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The event + content: + application/json: + schema: { $ref: "#/components/schemas/AuditEvent" } + "404": { $ref: "#/components/responses/NotFound" } + + /audit-exports: + post: + operationId: audit.createExport + tags: [Audit] + summary: Export Audit Events to a file (asynchronous) + description: >- + Exports every Audit Event matching the same filters as GET /audit-events, in the caller's + scope there, as NDJSON or CSV. Hot window only. Returns 202 at once; a job writes the file, + usually within a minute. Poll GET /audit-exports/{id} until status is ready, then download + from download_url. Exports are kept 7 days. Writes audit.exported when the file is ready. + x-substratal-phase: phase3 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AuditExportRequest" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "202": + description: Export queued + content: + application/json: + schema: { $ref: "#/components/schemas/AuditExport" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /audit-exports/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: audit.getExport + tags: [Audit] + summary: Fetch an export's status, and its download link once ready + description: "Visible to whoever requested it, and to audit.view. download_url is freshly signed on every call and valid for 15 minutes." + x-substratal-phase: phase3 + parameters: + - $ref: "#/components/parameters/ApiVersion" + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The export + content: + application/json: + schema: { $ref: "#/components/schemas/AuditExport" } + "404": { $ref: "#/components/responses/NotFound" } + + # ───────────────────────────── Webhooks ───────────────────────────── + + /webhooks: + get: + operationId: webhooks.list + tags: [Webhooks] + summary: List subscriptions visible to the caller + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: application_id, in: query, schema: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Webhook subscriptions + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookPage" } + post: + operationId: webhooks.create + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Webhooks] + summary: Subscribe a URL to event types + x-substratal-phase: phase2 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [url, events] + properties: + url: { type: string, format: uri } + events: + type: array + minItems: 1 + items: + oneOf: + - $ref: "#/components/schemas/WebhookEvent" + - { type: string, const: "*" } + description: { type: string, maxLength: 255 } + scope: { type: string, description: "An application_id, or 'platform' (webhooks.manage only). Required for an owner's User token or a webhooks.manage caller; optional for an app key, where it defaults to (and must equal) the key's own Application." } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Webhook created — includes the secret once + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookWithSecret" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /webhooks/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: webhooks.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Webhooks] + summary: Fetch one subscription + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The subscription + content: + application/json: + schema: { $ref: "#/components/schemas/Webhook" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: webhooks.update + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Webhooks] + summary: Change url, events, description, api_version, or re-enable + x-substratal-phase: phase2 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + url: { type: string, format: uri } + events: { type: array, items: { type: string } } + description: { type: string, maxLength: 255 } + api_version: { type: string } + status: { type: string, enum: [healthy] } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated subscription + content: + application/json: + schema: { $ref: "#/components/schemas/Webhook" } + "422": { $ref: "#/components/responses/Unprocessable" } + delete: + operationId: webhooks.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Webhooks] + summary: Unsubscribe + x-substratal-phase: phase2 + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Unsubscribed } + "404": { $ref: "#/components/responses/NotFound" } + + /webhooks/{id}/rotate-secret: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + post: + operationId: webhooks.rotateSecret + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Webhooks] + summary: Issue a new signing secret (24-hour overlap) + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: New secret + content: + application/json: + schema: + type: object + properties: + id: { type: string } + signing_secret: { type: string } + previous_secret_expires_at: { type: string, format: date-time } + + /webhooks/{id}/test: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + post: + operationId: webhooks.sendTest + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Webhooks] + summary: Send a webhook.test event now + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "202": + description: Enqueued + content: + application/json: + schema: + type: object + properties: + delivery_id: { type: string } + + /webhooks/{id}/deliveries: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: webhooks.listDeliveries + tags: [Webhooks] + summary: Recent delivery attempts (30-day log) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: event_id, in: query, schema: { type: string } } + - { name: status, in: query, schema: { type: string, enum: [pending, succeeded, failed] } } + - { name: since, in: query, schema: { type: string, format: date-time } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of deliveries + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/WebhookDelivery" } } + + /webhooks/{id}/deliveries/{deliveryId}/redeliver: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + - { name: deliveryId, in: path, required: true, schema: { type: string } } + post: + operationId: webhooks.redeliver + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Webhooks] + summary: Re-send one past event (same event id) + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "202": { description: Enqueued } + "404": { $ref: "#/components/responses/NotFound" } + + # ───────────────────────────── Notifications ───────────────────────────── + + /applications/{id}/push-credentials: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: pushCredentials.list + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Notifications] + summary: List the push providers an Application has configured (the owner, the app's own key, or applications.manage) + description: "Never returns key material. At most one credential per provider, so the list isn't paginated." + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Every configured provider + content: + application/json: + schema: { $ref: "#/components/schemas/PushCredentialList" } + "404": { $ref: "#/components/responses/NotFound" } + post: + operationId: pushCredentials.create + tags: [Notifications] + summary: Configure one push provider (the owner or applications.manage, User token only) + description: "private_key (apns) and service_account (fcm) are write-only and stored KMS-encrypted. For web_push the platform generates the VAPID key pair and returns its public half. An API Key gets 400 user_token_required. A second credential for the same provider is 409 push_credential_exists; material that doesn't parse is 422 invalid_push_credential." + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKeyOptional" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PushCredentialCreate" } + responses: + "201": + description: Credential configured (never echoes the secret) + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/PushCredential" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /applications/{id}/push-credentials/{provider}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + - name: provider + in: path + required: true + schema: { $ref: "#/components/schemas/PushProvider" } + get: + operationId: pushCredentials.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Notifications] + summary: Fetch one provider's configuration + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The credential + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/PushCredential" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: pushCredentials.update + tags: [Notifications] + summary: Replace key material, change non-secret fields, or re-enable a failed credential (User token only) + description: "Writable fields are the provider's own create fields, plus status (only failed → active). Any change to key material also returns a failed credential to active. The VAPID key pair can't be rotated in place: delete and recreate the web_push credential." + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PushCredentialUpdate" } + responses: + "200": + description: The updated credential + content: + application/json: + schema: { $ref: "#/components/schemas/PushCredential" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + delete: + operationId: pushCredentials.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Notifications] + summary: Remove a push provider (User token only) + description: "Pending deliveries to that provider are skipped. Removing web_push deactivates every web_push device of the Application (inactive_reason credential_removed), because browser subscriptions are bound to the VAPID key." + x-substratal-phase: phase2 + x-substratal-destructive: always + responses: + "204": { description: Removed } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + + /users/{id}/devices: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: devices.list + tags: [Notifications] + summary: List a User's push devices (self, the app's own key, or users.list) + description: "Only active devices unless status or include_inactive=true is passed. An app key sees only its own Application's devices." + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: application_id, in: query, schema: { type: string } } + - { name: provider, in: query, schema: { $ref: "#/components/schemas/PushProvider" } } + - { name: status, in: query, schema: { $ref: "#/components/schemas/DeviceStatus" } } + - { name: include_inactive, in: query, schema: { type: boolean, default: false } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Devices + content: + application/json: + schema: { $ref: "#/components/schemas/DevicePage" } + "404": { $ref: "#/components/responses/NotFound" } + post: + operationId: devices.register + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Notifications] + summary: Register a push device, or refresh an existing registration (self or the app's own key) + description: "An idempotent upsert keyed on (application_id, provider, token). 201 for a new Device, 200 for a refresh of the caller's own. A token registered to a different User is moved: the old Device is deleted and a new one created (201). The User must have active access to application_id (403 entitlement_required). A User token ties the device to its session; an app key's registration has session_id null. At most 20 active devices per User per Application: a 21st deactivates the oldest (inactive_reason limit)." + x-substratal-phase: phase2 + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DeviceRegistration" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Existing registration refreshed (and reactivated if it was inactive) + content: + application/json: + schema: { $ref: "#/components/schemas/Device" } + "201": + description: Device registered + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/Device" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /users/{id}/devices/{deviceId}: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + - { name: deviceId, in: path, required: true, schema: { type: string } } + get: + operationId: devices.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Notifications] + summary: Fetch one device + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The Device + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/Device" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: devices.update + tags: [Notifications] + summary: Update a device's name, app version, environment, or rotated token (self or the app's own key) + description: "Writing a token reactivates an inactive device. If the new token is already another Device of the same User and Application, that other registration is deleted." + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DeviceUpdate" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The updated Device + content: + application/json: + schema: { $ref: "#/components/schemas/Device" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + delete: + operationId: devices.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Notifications] + summary: Unregister a device (self, the app's own key, or users.manage) + description: "Hard delete. Pending deliveries to it are dropped. A users.manage delete of someone else's device writes the device.deleted Audit Event." + x-substratal-phase: phase2 + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Unregistered } + "404": { $ref: "#/components/responses/NotFound" } + + /users/{id}/devices/{deviceId}/test: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + - { name: deviceId, in: path, required: true, schema: { type: string } } + post: + operationId: devices.sendTest + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Notifications] + summary: Send a test push to one device and wait for the provider's answer (self or the app's own key) + description: "Synchronous, up to 10 seconds. Ignores the push preference, works on inactive devices, isn't stored in the inbox, and is limited to 10 per device per hour. 409 push_not_configured if the Application has no credential for the device's provider." + x-substratal-phase: phase2 + requestBody: + content: + application/json: + schema: + type: object + properties: + title: { type: string, minLength: 1, maxLength: 120 } + body: { type: [string, "null"], maxLength: 1000 } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The provider's answer + content: + application/json: + schema: { $ref: "#/components/schemas/DeviceTestResult" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /users/{id}/notifications: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: notifications.list + tags: [Notifications] + summary: List a User's notification inbox (self, the app's own key, or users.list) + description: "Newest first. Archived notifications are excluded unless state=archived or include_inactive=true. An app key sees only its own Application's notifications, never platform alerts." + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: state, in: query, schema: { $ref: "#/components/schemas/NotificationState" } } + - { name: application_id, in: query, schema: { type: string } } + - { name: source, in: query, schema: { $ref: "#/components/schemas/NotificationSource" } } + - { name: category, in: query, schema: { type: string } } + - { name: priority, in: query, schema: { $ref: "#/components/schemas/NotificationPriority" } } + - { name: created_after, in: query, schema: { type: string, format: date-time } } + - { name: created_before, in: query, schema: { type: string, format: date-time } } + - { name: include_inactive, in: query, schema: { type: boolean, default: false } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Notifications (without deliveries) + content: + application/json: + schema: { $ref: "#/components/schemas/NotificationPage" } + "404": { $ref: "#/components/responses/NotFound" } + post: + operationId: notifications.send + tags: [Notifications] + summary: Send one notification to one User (the app's own key with notifications.send, or platform notifications.send) + description: "Always creates the notification in the User's inbox; push.status says what happened to the push. The recipient must have active access to the Application (403 entitlement_required). The whole push payload must fit in 4 KB (422 notification_too_large). Categories under security., account., and billing. are reserved for platform alerts (422 reserved_category). Over 60 notifications per User per Application per hour is 429 rate_limited with details.scope recipient." + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKeyOptional" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/NotificationCreate" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Notification created; push queued or skipped + content: + application/json: + schema: { $ref: "#/components/schemas/Notification" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /users/{id}/notifications/unread-count: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: notifications.unreadCount + tags: [Notifications] + summary: Count unread notifications, in total and per Application (self or the app's own key) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - { name: application_id, in: query, schema: { type: string } } + - { name: source, in: query, schema: { $ref: "#/components/schemas/NotificationSource" } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Unread, unarchived counts + content: + application/json: + schema: { $ref: "#/components/schemas/NotificationUnreadCount" } + "404": { $ref: "#/components/responses/NotFound" } + + /users/{id}/notifications/mark-read: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + post: + operationId: notifications.markRead + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Notifications] + summary: Mark every unread notification matching the filters as read (self or the app's own key) + description: "Every filter is optional; an empty body marks everything the caller can see. before limits it to notifications created at or before that time. An app key's application_id is forced to its own." + x-substratal-phase: phase2 + requestBody: + content: + application/json: + schema: + type: object + properties: + application_id: { type: string } + source: { $ref: "#/components/schemas/NotificationSource" } + category: { type: string } + before: { type: string, format: date-time } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: How many were marked read + content: + application/json: + schema: + type: object + required: [updated_count] + properties: + updated_count: { type: integer, minimum: 0 } + "404": { $ref: "#/components/responses/NotFound" } + + /users/{id}/notifications/{notificationId}: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + - { name: notificationId, in: path, required: true, schema: { type: string } } + get: + operationId: notifications.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Notifications] + summary: Fetch one notification, with its per-device deliveries + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The Notification with deliveries (empty after 30 days) + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/NotificationWithDeliveries" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: notifications.update + tags: [Notifications] + summary: Mark one notification read, unread, or archived (self or the app's own key) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [state] + properties: + state: { $ref: "#/components/schemas/NotificationState" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The updated Notification + content: + application/json: + schema: { $ref: "#/components/schemas/Notification" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + # ───────────────────────────── API Keys ───────────────────────────── + + /api-keys: + get: + operationId: apiKeys.list + tags: [API Keys] + summary: List keys the caller may manage + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: scope, in: query, schema: { type: string } } + - { name: mode, in: query, schema: { type: string, enum: [live, test] } } + - { name: intended_use, in: query, schema: { type: string, enum: [service, agent] } } + - { name: include_inactive, in: query, description: "Include revoked and expired keys", schema: { type: boolean, default: false } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of API Keys + content: + application/json: + schema: { $ref: "#/components/schemas/ApiKeyPage" } + post: + operationId: apiKeys.create + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [API Keys] + summary: Create a key (api_keys.manage, or the Application's owner for an app-scoped key) + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ApiKeyWrite" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Key created — includes the secret once + content: + application/json: + schema: { $ref: "#/components/schemas/ApiKeyWithSecret" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /api-keys/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: apiKeys.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [API Keys] + summary: Fetch one key (never the secret) + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The key + content: + application/json: + schema: { $ref: "#/components/schemas/ApiKey" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: apiKeys.update + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [API Keys] + summary: Change name, permissions, or restrict_destructive + x-substratal-phase: mvp + x-substratal-destructive: conditional + x-substratal-destructive-when: "restrict_destructive set to false" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string, maxLength: 100 } + permissions: { type: array, items: { type: string } } + restrict_destructive: { type: boolean } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated key + content: + application/json: + schema: { $ref: "#/components/schemas/ApiKey" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/Unprocessable" } + delete: + operationId: apiKeys.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [API Keys] + summary: Revoke permanently + x-substratal-phase: mvp + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Revoked } + "404": { $ref: "#/components/responses/NotFound" } + + /api-keys/{id}/rotate: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + post: + operationId: apiKeys.rotate + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [API Keys] + summary: Issue a new secret, invalidating the old one immediately (no overlap) + x-substratal-phase: mvp + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: New secret issued + content: + application/json: + schema: { $ref: "#/components/schemas/ApiKeyWithSecret" } + "404": { $ref: "#/components/responses/NotFound" } + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: >- + A platform access token (JWT, from /auth/login, /auth/signup, + /auth/mfa/verify, /auth/token/refresh) or an API Key secret + (satk_live_... / satk_test_...). See Conventions → Authentication. + + headers: + ETag: + description: Weak validator over the row's version, e.g. W/"7". Send it back as If-Match. + schema: { type: string } + + parameters: + ApiVersion: + name: Substratal-Version + in: header + required: true + description: >- + The API version this request is written against (Conventions → Versioning). + Required on every operation except GET /health and POST /auth/oauth/token; + a missing value returns 400 api_version_required, an unknown one 400 + unsupported_api_version. Every versioned response echoes it. + schema: { type: string, enum: ["1"] } + Limit: + name: limit + in: query + schema: { type: integer, minimum: 1, default: 25, maximum: 100 } + Cursor: + name: cursor + in: query + schema: { type: string } + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + description: Client-generated, 1–255 characters; a UUID v4 is recommended. Remembered 24 hours per caller. + schema: { type: string, minLength: 1, maxLength: 255 } + IdempotencyKeyOptional: + name: Idempotency-Key + in: header + required: false + schema: { type: string, minLength: 1, maxLength: 255 } + IfMatch: + name: If-Match + in: header + required: false + description: The ETag from a prior read. A mismatch returns 409 version_conflict. + schema: { type: string } + UserIdOrMe: + name: id + in: path + required: true + description: "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys)." + schema: { type: string } + AppId: + name: appId + in: path + required: true + schema: { type: string } + + responses: + BadRequest: + description: "Malformed request (invalid_request, invalid_cursor, user_token_required, idempotency_key_required, api_version_required, unsupported_api_version, token_invalid)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Unauthorized: + description: "Missing or invalid credentials (unauthenticated, session_revoked, invalid_credentials, invalid_mfa_code, …)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Forbidden: + description: "Authenticated but not permitted (forbidden, destructive_operation_restricted, entitlement_required, role_escalation_forbidden, …)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + NotFound: + description: Resource does not exist or is not visible to the caller + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Conflict: + description: "State conflict (version_conflict, idempotency_key_reused, entitlement_already_exists, entitlement_platform_managed, subscription_exists, user_protected, invalid_status_transition, …)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Unprocessable: + description: "Semantically invalid (validation_failed with details.fields, settings_schema_violation, weak_password, …)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + TooManyRequests: + description: "rate_limited / too_many_attempts — see Retry-After" + headers: + Retry-After: { schema: { type: integer } } + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + ServiceUnavailable: + description: "service_unavailable / billing_unavailable — see Retry-After" + headers: + Retry-After: { schema: { type: integer } } + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + schemas: + Error: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: { type: string, example: entitlement_required } + message: { type: string } + details: + type: object + properties: + fields: + type: array + items: + type: object + required: [field, code] + properties: + field: { type: string } + code: { type: string } + additionalProperties: true + + OAuthError: + type: object + required: [error] + properties: + error: { type: string, enum: [invalid_request, invalid_grant, invalid_client, unsupported_grant_type, access_denied] } + error_description: { type: string } + + PageInfo: + type: object + required: [next_cursor, has_more] + properties: + next_cursor: { type: [string, "null"] } + has_more: { type: boolean } + + PageEnvelope: + type: object + required: [data, page] + properties: + page: { $ref: "#/components/schemas/PageInfo" } + + Actor: + type: object + required: [type, id] + properties: + type: { type: string, enum: [user, api_key, system] } + id: { type: string } + + Region: + type: string + enum: [us-east-1, us-west-2, ca-central-1, eu-west-1, eu-central-1, ap-southeast-2] + + # ── Auth ── + AuthSession: + type: object + required: [token_type, access_token, expires_in, refresh_token, session_id, user_id] + properties: + token_type: { type: string, enum: [Bearer] } + access_token: { type: string } + expires_in: { type: integer, example: 900 } + refresh_token: { type: string } + refresh_token_expires_in: { type: integer, example: 2592000 } + session_id: { type: string } + user_id: { type: string } + email_verified: { type: boolean } + + MfaChallenge: + type: object + required: [mfa_required, mfa_token, methods, expires_in] + properties: + mfa_required: { type: boolean, const: true } + mfa_token: { type: string } + methods: { type: array, items: { type: string, enum: [totp, recovery_code] } } + expires_in: { type: integer, example: 300 } + + AppToken: + type: object + required: [token_type, app_token, expires_in, application_id] + properties: + token_type: { type: string, enum: [Bearer] } + app_token: { type: string, description: "RS256 JWT; aud = application_id; 5-minute TTL. Claims: see AppTokenClaims." } + expires_in: { type: integer, example: 300 } + application_id: { type: string } + + AppTokenClaims: + type: object + description: Decoded payload of an app token, for Application implementers. + properties: + iss: { type: string, const: "https://api.substratalapps.com" } + aud: { type: string } + sub: { type: string } + sid: { type: string } + org_id: { type: [string, "null"] } + email: { type: string } + email_verified: { type: boolean } + entitlement_status: { $ref: "#/components/schemas/ResolvedEntitlementStatus" } + effective_permissions: { type: array, items: { type: string } } + test_mode: { type: boolean } + iat: { type: integer } + exp: { type: integer } + jti: { type: string } + + OAuthTokenRequest: + type: object + required: [grant_type, client_id] + properties: + grant_type: { type: string, enum: [authorization_code, refresh_token] } + client_id: { type: string, description: The application_id } + code: { type: string } + code_verifier: { type: string } + redirect_uri: { type: string } + refresh_token: { type: string } + + Session: + type: object + properties: + id: { type: string } + created_at: { type: string, format: date-time } + last_seen_at: { type: string, format: date-time } + expires_at: { type: string, format: date-time, description: "The earlier of the idle expiry (30 days after the last refresh) and the absolute expiry (90 days after creation)." } + ip_address: { type: string } + user_agent: { type: string } + amr: { type: array, items: { type: string } } + current: { type: boolean } + + # ── Users ── + UserStatus: + type: string + enum: [active, invited, suspended, deleted] + + User: + type: object + properties: + id: { type: string } + email: { type: string, format: email } + email_verified: { type: boolean } + pending_email: { type: [string, "null"], format: email } + status: { $ref: "#/components/schemas/UserStatus" } + mfa_enabled: { type: boolean } + signup_application_id: { type: [string, "null"] } + test_mode: { type: boolean } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + last_login_at: { type: [string, "null"], format: date-time } + expires_at: { type: [string, "null"], format: date-time, description: "When a test-mode User stops working (Auth → Test-mode Users → Lifetime). Always null for live Users." } + plan: { $ref: "#/components/schemas/Plan" } + plan_sources: { type: array, items: { $ref: "#/components/schemas/PlanSource" }, description: "What gives this User Standard: their own subscription, an Organization's, or both. Empty on Free." } + + UserPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/User" } } + + ErasureRequest: + type: object + properties: + user_id: { type: string } + status: { type: string, enum: [scheduled, cancelled, completed] } + requested_at: { type: string, format: date-time } + scheduled_for: { type: string, format: date-time } + + UserExport: + type: object + description: "Reads from the same tables the hard-delete cascade writes to — deliberately symmetric with it." + properties: + exported_at: { type: string, format: date-time } + user: { $ref: "#/components/schemas/User" } + profile: { $ref: "#/components/schemas/Profile" } + settings: { $ref: "#/components/schemas/Settings" } + identities: + type: array + description: "Never includes password_hash or mfa_secret." + items: + type: object + properties: + method: { type: string, enum: [password, sso] } + mfa_enabled: { type: boolean } + last_used_at: { type: [string, "null"], format: date-time } + sessions: { type: array, items: { $ref: "#/components/schemas/Session" } } + organizations: + type: array + items: + type: object + properties: + organization_id: { type: string } + role: { $ref: "#/components/schemas/OrganizationMemberRole" } + joined_at: { type: string, format: date-time } + roles: { type: array, items: { $ref: "#/components/schemas/UserRoleAssignment" } } + applications: + type: array + items: + type: object + properties: + application_id: { type: string } + entitlement: { $ref: "#/components/schemas/Entitlement" } + app_profile: { $ref: "#/components/schemas/AppProfile" } + app_settings: + type: object + properties: + overrides: { type: object } + audit_events: + type: array + description: "This user's own trail as target_user_id (hot window)." + items: { $ref: "#/components/schemas/AuditEvent" } + devices: + type: array + description: "Push devices, active and inactive. Never includes the token." + items: { $ref: "#/components/schemas/Device" } + notifications: + type: array + description: "Every notification still retained (90 days), without deliveries." + items: { $ref: "#/components/schemas/Notification" } + + # ── Profiles & Settings ── + ProfileWrite: + type: object + properties: + display_name: { type: string, minLength: 1, maxLength: 100 } + avatar_url: { type: [string, "null"], format: uri, maxLength: 2048 } + contact_email: { type: [string, "null"], format: email } + contact_phone: { type: [string, "null"], pattern: "^\\+[1-9][0-9]{7,14}$" } + + Profile: + allOf: + - $ref: "#/components/schemas/ProfileWrite" + - type: object + properties: + user_id: { type: string } + locale: { type: string, readOnly: true, example: en-US, description: "Mirror of the global Settings locale; write it there." } + updated_at: { type: string, format: date-time } + + AppProfile: + type: object + properties: + user_id: { type: string } + application_id: { type: string } + display_handle: { type: [string, "null"] } + effective_display_name: { type: string, readOnly: true } + custom: { type: object } + updated_at: { type: [string, "null"], format: date-time } + + SettingsWrite: + type: object + properties: + locale: { type: string } + timezone: { type: string, example: America/Denver } + theme: { type: string, enum: [light, dark, system] } + notifications: + type: object + additionalProperties: { type: [boolean, "null"] } + + Settings: + allOf: + - $ref: "#/components/schemas/SettingsWrite" + - type: object + properties: + user_id: { type: string } + updated_at: { type: string, format: date-time } + + AppSettingsResolved: + type: object + properties: + user_id: { type: string } + application_id: { type: string } + resolved: { type: object } + sources: + type: object + additionalProperties: { type: string, enum: [app_override, global, app_default] } + overrides: { type: object } + stale_overrides: { type: array, items: { type: string } } + updated_at: { type: [string, "null"], format: date-time } + + # ── Applications ── + ApplicationVisibility: + type: string + enum: [public, invite_only, internal] + + ApplicationReviewStatus: + type: string + enum: [approved, pending_review, rejected, suspended] + + ApplicationPermission: + type: object + required: [key] + properties: + key: { type: string, pattern: "^app\\.[a-z0-9_-]+\\.[a-z0-9_.]{1,64}$" } + description: { type: string, maxLength: 255 } + + ApplicationAccessTier: + type: string + enum: [free, standard] + description: "Which plan unlocks the Application: free for every User, standard for Users covered by a Standard subscription. Set by applications.manage; defaults to standard." + + ApplicationSummary: + type: object + properties: + id: { type: string } + slug: { type: string } + name: { type: string } + description: { type: [string, "null"] } + icon_url: { type: [string, "null"] } + visibility: { $ref: "#/components/schemas/ApplicationVisibility" } + access_tier: { $ref: "#/components/schemas/ApplicationAccessTier" } + review_status: { $ref: "#/components/schemas/ApplicationReviewStatus" } + + ApplicationSummaryPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/ApplicationSummary" } } + + ApplicationWrite: + type: object + properties: + slug: { type: string, pattern: "^[a-z][a-z0-9-]{1,63}$", description: "Create only; immutable. 'platform' is reserved (rejected with validation_failed, code unknown_value). The Application's id is app_." } + name: { type: string, minLength: 1, maxLength: 100 } + description: { type: [string, "null"], maxLength: 2000 } + icon_url: { type: [string, "null"], format: uri } + launch_url: { type: string, format: uri } + redirect_uris: { type: array, maxItems: 10, items: { type: string } } + support_url: { type: [string, "null"], format: uri } + email_from_name: { type: [string, "null"], maxLength: 50 } + settings_schema: { type: object, description: "JSON Schema 2020-12 subset — see Domain Model → Settings → settings_schema rules" } + permissions: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/ApplicationPermission" } } + available_app_roles: { type: array, maxItems: 20, items: { type: string, pattern: "^[a-z][a-z0-9_]{1,40}$" } } + default_app_role: { type: [string, "null"] } + visibility: { $ref: "#/components/schemas/ApplicationVisibility" } + access_tier: { $ref: "#/components/schemas/ApplicationAccessTier" } + owner_user_id: { type: [string, "null"] } + owner_organization_id: { type: [string, "null"] } + review_status: { $ref: "#/components/schemas/ApplicationReviewStatus" } + review_notes: { type: [string, "null"], maxLength: 2000 } + receives_platform_notifications: { type: boolean, default: false, description: "When true, this Application's devices also receive pushes for platform alerts (security and account notices). A configuration field the owner may set." } + + Application: + allOf: + - $ref: "#/components/schemas/ApplicationWrite" + - type: object + properties: + id: { type: string } + tenant_id: { type: string, readOnly: true } + settings_schema_stats: + type: object + readOnly: true + properties: + stale_override_counts: { type: object, additionalProperties: { type: integer } } + self_registered: { type: boolean, readOnly: true, description: "true when a developer submitted it themselves, not staff. A self-registered Application must use the hosted sign-in flow (POST /auth/app-tokens returns 403 embedded_login_not_allowed), and only applications.manage can turn on receives_platform_notifications for it." } + test_mode: { type: boolean } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + # ── Entitlements ── + EntitlementStatus: + type: string + enum: [active, disabled, expired, revoked] + + ResolvedEntitlementStatus: + type: string + enum: [active, scheduled, disabled, expired, revoked, none] + description: "scheduled: the only live path is a grant whose starts_at is in the future." + + EntitlementSource: + type: string + enum: [purchase, trial, admin_grant, org_seat, free, subscription] + description: "free and subscription rows are platform-managed: granted and ended by the platform from the Application's access_tier and the holder's subscription. An admin may disable and re-enable one; nothing else about it can be changed." + + EntitlementMemberScope: + type: string + enum: [all_members, allowlist, denylist] + + EntitlementGrantedVia: + type: object + description: "Computed per member at read time; present only when reading a specific member's view of an org-wide grant." + properties: + organization_id: { type: string } + member_decision: { type: string, enum: [included, excluded] } + + Entitlement: + type: object + properties: + id: { type: string } + user_id: { type: [string, "null"] } + organization_id: { type: [string, "null"] } + application_id: { type: string } + status: { $ref: "#/components/schemas/EntitlementStatus" } + source: { $ref: "#/components/schemas/EntitlementSource" } + order_id: { type: [string, "null"], maxLength: 255, description: "Opaque developer-supplied billing reference" } + subscription_id: { type: [string, "null"], readOnly: true, description: "The subscription a source: subscription row comes from." } + starts_at: { type: string, format: date-time } + ends_at: { type: [string, "null"], format: date-time } + disabled_reason: { type: [string, "null"] } + member_scope: { oneOf: [ { $ref: "#/components/schemas/EntitlementMemberScope" }, { type: "null" } ] } + member_overrides: { type: [array, "null"], items: { type: string } } + included_member_count: { type: integer, readOnly: true } + granted_via: { $ref: "#/components/schemas/EntitlementGrantedVia" } + test_mode: { type: boolean } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + EntitlementPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/Entitlement" } } + + # ── Roles & Permissions ── + Permission: + type: object + properties: + key: { type: string } + scope: { type: string } + description: { type: string } + + PermissionPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/Permission" } } + + RoleCreate: + type: object + required: [name, scope, permissions] + properties: + name: { type: string, pattern: "^[a-z][a-z0-9_]{1,40}$" } + scope: { type: string, description: "'platform' or an application_id" } + description: { type: [string, "null"], maxLength: 2000 } + permissions: { type: array, items: { type: string } } + + Role: + allOf: + - $ref: "#/components/schemas/RoleCreate" + - type: object + properties: + id: { type: string, description: "role_platform_ or role__" } + seed: { type: boolean } + assignment_count: { type: integer } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + RolePage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/Role" } } + + UserRoleAssignment: + type: object + properties: + user_id: { type: string } + role_id: { type: string } + application_id: { type: [string, "null"] } + assigned_at: { type: [string, "null"], format: date-time } + assigned_by: { oneOf: [ { $ref: "#/components/schemas/Actor" }, { type: "null" } ] } + implicit: { type: boolean } + + UserRoleAssignmentPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/UserRoleAssignment" } } + + EffectivePermissions: + type: object + required: [user_id, application_id, allowed, user_status, entitlement_status, effective_permissions] + properties: + user_id: { type: string } + application_id: { type: string } + allowed: { type: boolean } + user_status: { $ref: "#/components/schemas/UserStatus" } + entitlement_status: { $ref: "#/components/schemas/ResolvedEntitlementStatus" } + access_paths: + type: array + items: + type: object + properties: + source: { $ref: "#/components/schemas/EntitlementSource" } + entitlement_id: { type: string } + status: { $ref: "#/components/schemas/EntitlementStatus" } + organization_id: { type: [string, "null"] } + member_decision: { type: [string, "null"], enum: [included, excluded, null] } + roles: { type: array, items: { type: string } } + effective_permissions: { type: array, items: { type: string } } + computed_at: { type: string, format: date-time } + + # ── Organizations ── + Organization: + type: object + properties: + id: { type: string } + name: { type: string } + status: { type: string, enum: [active, suspended] } + member_count: { type: integer, description: "Active members only" } + my_role: { $ref: "#/components/schemas/OrganizationMemberRole" } + my_membership_status: { $ref: "#/components/schemas/OrganizationMembershipStatus" } + created_by: { type: string } + test_mode: { type: boolean } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + OrganizationPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/Organization" } } + + OrganizationMemberRole: + type: string + enum: [org_admin, member] + + OrganizationMember: + type: object + properties: + user_id: { type: string } + email: { type: string, format: email } + display_name: { type: [string, "null"], description: "null while the membership is pending" } + membership_status: { $ref: "#/components/schemas/OrganizationMembershipStatus" } + role: { $ref: "#/components/schemas/OrganizationMemberRole" } + invited_at: { type: string, format: date-time } + joined_at: { type: [string, "null"], format: date-time } + + OrganizationMembershipStatus: + type: string + enum: [pending, active] + + OrganizationMemberPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/OrganizationMember" } } + + # ── Tenancy & Billing ── + TenantOwnerType: + type: string + enum: [user, organization] + + TenantTier: + type: string + enum: [shared, isolated, dedicated_region] + + TenantStatus: + type: string + enum: [active, migrating, suspended] + + SubscriptionStatus: + type: string + enum: [active, trialing, past_due, canceled, unpaid, incomplete, incomplete_expired, paused] + description: "Mirrors Stripe. active, trialing, and past_due cover their users with Standard; the rest don't." + + Tenant: + type: object + properties: + id: { type: string } + owner_type: { $ref: "#/components/schemas/TenantOwnerType" } + owner_user_id: { type: [string, "null"] } + owner_organization_id: { type: [string, "null"] } + tier: { $ref: "#/components/schemas/TenantTier" } + region: { $ref: "#/components/schemas/Region" } + status: { $ref: "#/components/schemas/TenantStatus" } + application_count: { type: integer } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + TenantPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/Tenant" } } + + TenantTierChangeRequestStatus: + type: string + enum: [pending, scheduled, in_progress, completed, cancelled] + + TenantTierChangeRequest: + type: object + properties: + id: { type: string } + tenant_id: { type: string } + from_tier: { $ref: "#/components/schemas/TenantTier" } + from_region: { $ref: "#/components/schemas/Region" } + requested_tier: { $ref: "#/components/schemas/TenantTier" } + requested_region: { oneOf: [ { $ref: "#/components/schemas/Region" }, { type: "null" } ] } + status: { $ref: "#/components/schemas/TenantTierChangeRequestStatus" } + reason: { type: string } + notes: { type: [string, "null"] } + scheduled_for: { type: [string, "null"], format: date-time } + requested_by: { type: string } + created_at: { type: string, format: date-time } + started_at: { type: [string, "null"], format: date-time } + completed_at: { type: [string, "null"], format: date-time } + + TenantTierChangeRequestPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/TenantTierChangeRequest" } } + + Plan: + type: string + enum: [free, standard] + description: "Business and enterprise plans come later." + + PlanSource: + type: object + required: [type, id] + properties: + type: { type: string, enum: [user, organization] } + id: { type: string, description: "The User (an individual subscription) or the Organization whose subscription covers this User." } + + Subscription: + type: object + properties: + id: { type: string, description: "sbs_ prefix" } + subscriber_type: { type: string, enum: [user, organization] } + user_id: { type: [string, "null"] } + organization_id: { type: [string, "null"] } + plan: { type: string, enum: [standard] } + status: { $ref: "#/components/schemas/SubscriptionStatus" } + current_period_start: { type: [string, "null"], format: date-time } + current_period_end: { type: [string, "null"], format: date-time } + cancel_at_period_end: { type: boolean } + member_scope: { oneOf: [ { $ref: "#/components/schemas/EntitlementMemberScope" }, { type: "null" } ], description: "Organization subscriptions only." } + member_overrides: { type: [array, "null"], items: { type: string } } + quantity: + type: object + description: "Users billed. Always 1 for an individual subscription; for an Organization, covered members." + properties: + current: { type: integer } + last_synced: { type: [integer, "null"] } + last_synced_at: { type: [string, "null"], format: date-time } + unit_amount: { type: integer, description: "Cents per user per month (600)." } + currency: { type: string, const: usd } + test_mode: { type: boolean } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + UserSubscription: + type: object + properties: + user_id: { type: string } + plan: { $ref: "#/components/schemas/Plan" } + plan_sources: { type: array, items: { $ref: "#/components/schemas/PlanSource" } } + subscription: { oneOf: [ { $ref: "#/components/schemas/Subscription" }, { type: "null" } ], description: "The User's own individual subscription, if they've ever started one." } + + OrganizationSubscription: + type: object + properties: + organization_id: { type: string } + plan: { $ref: "#/components/schemas/Plan" } + subscription: { oneOf: [ { $ref: "#/components/schemas/Subscription" }, { type: "null" } ] } + + CheckoutSessionCreate: + type: object + required: [plan, success_url, cancel_url] + properties: + plan: { type: string, enum: [standard, business, enterprise], description: "Only standard is self-service; business and enterprise return 422 plan_requires_sales." } + success_url: { type: string, format: uri } + cancel_url: { type: string, format: uri } + + PortalSessionCreate: + type: object + required: [return_url] + properties: + return_url: { type: string, format: uri } + + RedirectSession: + type: object + required: [url, expires_at] + properties: + url: { type: string, format: uri } + expires_at: { type: string, format: date-time } + + # ── Audit ── + AuditAction: + type: string + enum: + - user.created + - user.updated + - user.email_changed + - user.suspended + - user.reactivated + - user.deleted + - user.expired + - user.erasure_requested + - user.erasure_cancelled + - user.erased + - user.password_changed + - user.password_reset + - user.mfa_enabled + - user.mfa_disabled + - user.mfa_reset + - user.sessions_revoked + - audit.exported + - entitlement.granted + - entitlement.disabled + - entitlement.revoked + - entitlement.expired + - entitlement.updated + - entitlement.member_scope_changed + - entitlement.deleted + - role.created + - role.updated + - role.deleted + - role.assigned + - role.removed + - profile.updated + - settings.updated + - application.created + - application.updated + - application.review_status_changed + - organization.created + - organization.updated + - organization.member_invited + - organization.member_added + - organization.member_removed + - organization.member_role_changed + - organization.scim_token_created + - organization.scim_token_revoked + - subscription.created + - subscription.status_changed + - subscription.members_changed + - tenant.tier_change_requested + - tenant.tier_change_request_updated + - tenant.tier_changed + - api_key.created + - api_key.updated + - api_key.rotated + - api_key.revoked + - api_key.restrict_destructive_disabled + - webhook.created + - webhook.updated + - webhook.deleted + - webhook.secret_rotated + - push_credential.created + - push_credential.updated + - push_credential.deleted + - device.deleted + + AuditTargetType: + type: string + enum: [user, entitlement, role, role_assignment, application, organization, subscription, tenant, tier_change_request, api_key, webhook, push_credential, device, audit_export] + + AuditExportRequest: + type: object + description: "The same filters as GET /audit-events, as body fields, plus format." + properties: + format: { type: string, enum: [ndjson, csv], default: ndjson } + target_user_id: { type: string, description: "A User id, or me." } + target_type: { $ref: "#/components/schemas/AuditTargetType" } + target_id: { type: string } + actor_id: { type: string } + actor_type: { type: string, enum: [user, api_key, system] } + action: { type: array, items: { $ref: "#/components/schemas/AuditAction" }, maxItems: 100 } + application_id: { type: string } + organization_id: { type: string } + tenant_id: { type: string } + since: { type: string, format: date-time } + until: { type: string, format: date-time } + + AuditExport: + type: object + properties: + id: { type: string } + status: { type: string, enum: [pending, running, ready, failed] } + format: { type: string, enum: [ndjson, csv] } + filters: { type: object, description: "The filters as requested, with target_user_id me resolved to the caller's id." } + row_count: { type: [integer, "null"], description: "Set once ready." } + download_url: { type: [string, "null"], format: uri, description: "Set once ready. Signed, valid for 15 minutes; fetch the export again for a fresh one." } + download_url_expires_at: { type: [string, "null"], format: date-time } + error: { type: [string, "null"], description: "Set when failed: too_many_rows (over 200,000), too_large (over 400 MB), or internal_error." } + created_at: { type: string, format: date-time } + completed_at: { type: [string, "null"], format: date-time } + expires_at: { type: string, format: date-time, description: "When the file and this record are deleted: 7 days after the request." } + test_mode: { type: boolean } + + ScimToken: + type: object + properties: + id: { type: string } + organization_id: { type: string } + token: { type: string, description: "The secret (scim_…). Only in the response that creates it." } + base_url: { type: string, format: uri, description: "The SCIM 2.0 base URL to give the identity provider." } + created_by: { type: string } + created_at: { type: string, format: date-time } + last_used_at: { type: [string, "null"], format: date-time } + test_mode: { type: boolean } + + AuditEvent: + type: object + properties: + id: { type: string } + action: { $ref: "#/components/schemas/AuditAction" } + actor: { $ref: "#/components/schemas/Actor" } + target: + type: object + properties: + type: { $ref: "#/components/schemas/AuditTargetType" } + id: { type: string } + target_user_id: { type: [string, "null"] } + application_id: { type: [string, "null"] } + organization_id: { type: [string, "null"] } + tenant_id: { type: [string, "null"] } + before: { type: [object, "null"] } + after: { type: [object, "null"] } + request_id: { type: [string, "null"] } + timestamp: { type: string, format: date-time } + + AuditEventPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/AuditEvent" } } + + # ── Webhooks ── + WebhookEvent: + type: string + enum: + - access.granted + - access.revoked + - entitlement.granted + - entitlement.disabled + - entitlement.revoked + - entitlement.expired + - entitlement.updated + - entitlement.member_scope_changed + - entitlement.deleted + - role.assigned + - role.removed + - user.suspended + - user.reactivated + - user.deleted + - user.expired + - organization.member_added + - organization.member_removed + - application.review_status_changed + - device.registered + - device.deactivated + - device.deleted + - notification.push_failed + - push_credential.failed + - webhook.test + + AccessEventReason: + type: string + enum: + - entitlement_granted + - entitlement_started + - entitlement_enabled + - entitlement_disabled + - entitlement_revoked + - entitlement_expired + - entitlement_deleted + - org_grant_changed + - org_member_added + - org_member_removed + - user_suspended + - user_reactivated + - user_deleted + - test_user_expired + - test_user_extended + + WebhookEventEnvelope: + type: object + description: "Body POSTed to a subscriber. Signed with Substratal-Signature: t=,v1=." + required: [id, type, api_version, created_at, test_mode, data] + properties: + id: { type: string, description: "wev_… — stable across retries; dedupe on it" } + type: { $ref: "#/components/schemas/WebhookEvent" } + api_version: { type: string, example: "2026-10-05" } + created_at: { type: string, format: date-time } + application_id: { type: [string, "null"] } + test_mode: { type: boolean } + data: { type: object } + + Webhook: + type: object + properties: + id: { type: string } + scope: { type: string, description: "'platform' or an application_id" } + url: { type: string, format: uri } + events: { type: array, items: { type: string } } + description: { type: [string, "null"] } + status: { type: string, enum: [healthy, unhealthy, disabled] } + api_version: { type: string } + test_mode: { type: boolean } + consecutive_failures: { type: integer } + last_delivery_at: { type: [string, "null"], format: date-time } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + WebhookWithSecret: + allOf: + - $ref: "#/components/schemas/Webhook" + - type: object + properties: + signing_secret: { type: string } + + WebhookPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/Webhook" } } + + WebhookDelivery: + type: object + properties: + id: { type: string } + event_id: { type: string } + event_type: { $ref: "#/components/schemas/WebhookEvent" } + attempt: { type: integer, minimum: 1, maximum: 6 } + status: { type: string, enum: [pending, succeeded, failed] } + response_status: { type: [integer, "null"] } + duration_ms: { type: [integer, "null"] } + error: { type: [string, "null"] } + next_retry_at: { type: [string, "null"], format: date-time } + created_at: { type: string, format: date-time } + + # ── Notifications ── + PushProvider: + type: string + enum: [apns, fcm, web_push] + + PushCredentialCreate: + type: object + description: "Fields depend on provider. apns: team_id, key_id, topic, private_key. fcm: service_account. web_push: subject (the VAPID key pair is generated)." + required: [provider] + properties: + provider: { $ref: "#/components/schemas/PushProvider" } + team_id: { type: string, minLength: 10, maxLength: 10, description: "apns" } + key_id: { type: string, minLength: 10, maxLength: 10, description: "apns" } + topic: { type: string, maxLength: 255, description: "apns: the app's bundle id" } + private_key: { type: string, writeOnly: true, description: "apns: the .p8 file's contents (PEM PKCS#8, P-256). Write-only." } + service_account: { type: object, writeOnly: true, description: "fcm: the service-account JSON key (type, project_id, private_key, client_email). Write-only." } + subject: { type: string, maxLength: 255, description: "web_push: a mailto: or https: contact URI (RFC 8292)" } + + PushCredentialUpdate: + type: object + properties: + team_id: { type: string, minLength: 10, maxLength: 10 } + key_id: { type: string, minLength: 10, maxLength: 10 } + topic: { type: string, maxLength: 255 } + private_key: { type: string, writeOnly: true } + service_account: { type: object, writeOnly: true } + subject: { type: string, maxLength: 255 } + status: { type: string, enum: [active], description: "Only failed → active." } + + PushCredential: + type: object + required: [id, application_id, provider, status] + properties: + id: { type: string, description: "pcr_…" } + application_id: { type: string } + provider: { $ref: "#/components/schemas/PushProvider" } + status: { type: string, enum: [active, failed] } + team_id: { type: string, description: "apns" } + key_id: { type: string, description: "apns" } + topic: { type: string, description: "apns" } + project_id: { type: string, description: "fcm" } + client_email: { type: string, description: "fcm" } + subject: { type: string, description: "web_push" } + vapid_public_key: { type: string, description: "web_push: base64url uncompressed P-256 point, for PushManager.subscribe({applicationServerKey})" } + failure_reason: { type: [string, "null"] } + failed_at: { type: [string, "null"], format: date-time } + last_used_at: { type: [string, "null"], format: date-time } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + PushCredentialList: + type: object + required: [data] + properties: + data: { type: array, maxItems: 3, items: { $ref: "#/components/schemas/PushCredential" } } + + DeviceStatus: + type: string + enum: [active, inactive] + + DeviceInactiveReason: + type: string + enum: [unregistered, session_revoked, user_suspended, user_deleted, test_user_expired, stale, limit, credential_removed] + + WebPushSubscription: + type: object + description: "The PushSubscription from PushManager.subscribe(), as JSON." + required: [endpoint, keys] + properties: + endpoint: { type: string, format: uri, maxLength: 2048 } + keys: + type: object + required: [p256dh, auth] + properties: + p256dh: { type: string } + auth: { type: string } + + DeviceRegistration: + type: object + required: [application_id, provider] + properties: + application_id: { type: string } + provider: { $ref: "#/components/schemas/PushProvider" } + token: { type: string, minLength: 1, maxLength: 4096, writeOnly: true, description: "apns (hex, 64–200 characters) and fcm only" } + subscription: { $ref: "#/components/schemas/WebPushSubscription" } + environment: { type: string, enum: [production, sandbox], description: "apns only, required for it" } + name: { type: [string, "null"], maxLength: 100 } + app_version: { type: [string, "null"], maxLength: 50 } + + DeviceUpdate: + type: object + properties: + token: { type: string, minLength: 1, maxLength: 4096, writeOnly: true } + subscription: { $ref: "#/components/schemas/WebPushSubscription" } + environment: { type: string, enum: [production, sandbox] } + name: { type: [string, "null"], maxLength: 100 } + app_version: { type: [string, "null"], maxLength: 50 } + + Device: + type: object + required: [id, user_id, application_id, provider, token_hint, status] + properties: + id: { type: string, description: "dvc_…" } + user_id: { type: string } + application_id: { type: string } + provider: { $ref: "#/components/schemas/PushProvider" } + environment: { type: [string, "null"], enum: [production, sandbox, null] } + token_hint: { type: string, example: "…9f3a", description: "The token itself is never returned." } + name: { type: [string, "null"] } + app_version: { type: [string, "null"] } + session_id: { type: [string, "null"], description: "The registering session; null when an app key registered it." } + status: { $ref: "#/components/schemas/DeviceStatus" } + inactive_reason: + oneOf: + - $ref: "#/components/schemas/DeviceInactiveReason" + - type: "null" + last_registered_at: { type: string, format: date-time } + last_delivery_at: { type: [string, "null"], format: date-time } + test_mode: { type: boolean } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + DevicePage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/Device" } } + + DeviceTestResult: + type: object + required: [device_id, status] + properties: + device_id: { type: string } + status: { type: string, enum: [sent, failed] } + provider_status: { type: [integer, "null"], description: "The provider's HTTP status; null on a timeout or connection error." } + error: { type: [string, "null"], description: "The provider's reason code, e.g. BadDeviceToken, UNREGISTERED, InvalidProviderToken, or timeout." } + duration_ms: { type: integer } + + NotificationPriority: + type: string + enum: [normal, high] + + NotificationSource: + type: string + enum: [application, platform] + + NotificationState: + type: string + enum: [unread, read, archived] + + NotificationPushStatus: + type: string + enum: [queued, sent, failed, skipped] + + NotificationSkipReason: + type: string + enum: [push_not_requested, push_disabled, no_devices, no_credentials, credential_failed, expired] + + NotificationCreate: + type: object + required: [title, category] + properties: + title: { type: string, minLength: 1, maxLength: 120 } + body: { type: [string, "null"], maxLength: 1000 } + category: { type: string, maxLength: 64, pattern: "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)*$", description: "Sender-defined. security., account., and billing. are reserved for platform alerts." } + priority: { $ref: "#/components/schemas/NotificationPriority" } + push: { type: boolean, default: true, description: "false writes to the inbox without a push." } + data: + type: object + maxProperties: 20 + additionalProperties: { type: string } + description: "String values only (FCM's data payload carries only strings); 2 KB serialized; the key substratal is reserved." + action_url: { type: [string, "null"], maxLength: 2048 } + badge: { type: [integer, "null"], minimum: 0, maximum: 99999 } + sound: { type: [string, "null"], enum: [default, null], description: "Defaults to default for high priority, null (silent) for normal." } + collapse_key: { type: [string, "null"], maxLength: 64, pattern: "^[A-Za-z0-9._-]+$" } + ttl_seconds: { type: integer, minimum: 0, maximum: 2419200, default: 86400 } + application_id: { type: string, description: "Only for a platform notifications.send caller, and required for one." } + + NotificationPush: + type: object + required: [requested, status] + properties: + requested: { type: boolean } + status: { $ref: "#/components/schemas/NotificationPushStatus" } + devices_targeted: { type: integer, minimum: 0 } + devices_sent: { type: integer, minimum: 0 } + devices_failed: { type: integer, minimum: 0 } + skip_reason: + oneOf: + - $ref: "#/components/schemas/NotificationSkipReason" + - type: "null" + + Notification: + type: object + required: [id, user_id, source, category, priority, title, push, state, created_at] + properties: + id: { type: string, description: "ntf_…" } + user_id: { type: string } + application_id: { type: [string, "null"], description: "null for a platform alert" } + source: { $ref: "#/components/schemas/NotificationSource" } + category: { type: string } + priority: { $ref: "#/components/schemas/NotificationPriority" } + title: { type: string } + body: { type: [string, "null"] } + data: { type: object, additionalProperties: { type: string } } + action_url: { type: [string, "null"] } + badge: { type: [integer, "null"] } + sound: { type: [string, "null"] } + collapse_key: { type: [string, "null"] } + ttl_seconds: { type: integer } + push: { $ref: "#/components/schemas/NotificationPush" } + state: { $ref: "#/components/schemas/NotificationState" } + read_at: { type: [string, "null"], format: date-time } + archived_at: { type: [string, "null"], format: date-time } + sender: + type: object + required: [type, id] + properties: + type: { type: string, enum: [api_key, user, system] } + id: { type: string } + test_mode: { type: boolean } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + NotificationDelivery: + type: object + properties: + id: { type: string, description: "ndl_…" } + device_id: { type: [string, "null"], description: "null once the Device is deleted" } + provider: { $ref: "#/components/schemas/PushProvider" } + status: { type: string, enum: [pending, sent, failed, skipped] } + attempts: { type: integer, minimum: 0, maximum: 4 } + error: { type: [string, "null"] } + sent_at: { type: [string, "null"], format: date-time } + + NotificationWithDeliveries: + allOf: + - $ref: "#/components/schemas/Notification" + - type: object + properties: + deliveries: { type: array, items: { $ref: "#/components/schemas/NotificationDelivery" } } + + NotificationPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/Notification" } } + + NotificationUnreadCount: + type: object + required: [unread_count, applications] + properties: + unread_count: { type: integer, minimum: 0 } + applications: + type: array + items: + type: object + required: [application_id, unread_count] + properties: + application_id: { type: [string, "null"], description: "null for platform alerts" } + unread_count: { type: integer, minimum: 0 } + + # ── API Keys ── + ApiKeyWrite: + type: object + required: [name, scope, permissions, mode] + properties: + name: { type: string, maxLength: 100 } + scope: { type: string, description: "'platform' or an application_id" } + permissions: { type: array, items: { type: string } } + mode: + type: string + enum: [live, test] + description: Fixed at creation. + intended_use: + type: string + enum: [service, agent] + default: service + description: Sets the default for restrict_destructive (true for agent, false for service). + restrict_destructive: + type: boolean + description: "When true, every operation marked x-substratal-destructive is rejected with 403 destructive_operation_restricted." + expires_at: { type: [string, "null"], format: date-time } + + ApiKey: + allOf: + - $ref: "#/components/schemas/ApiKeyWrite" + - type: object + properties: + id: { type: string } + secret_hint: { type: string, example: "…6c8e" } + created_by: + type: object + description: "Always a User: API Keys can't create API Keys." + properties: + type: { type: string, enum: [user] } + id: { type: string } + last_used_at: { type: [string, "null"], format: date-time } + created_at: { type: string, format: date-time } + revoked_at: { type: [string, "null"], format: date-time } + + ApiKeyWithSecret: + allOf: + - $ref: "#/components/schemas/ApiKey" + - type: object + properties: + secret: { type: string } + + ApiKeyPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/ApiKey" } }