From abb2344e05767b68709b3245317099c7181db6d5 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 23 Oct 2026 09:19:04 -0700 Subject: [PATCH 1/4] Build the Linux Hub skeleton, CLI and versioned help center App (GTK 4 + libadwaita, Rust): - substratal-core: API client that sends Substratal-Version: 1 to the host root (no /v1), with single-flight refresh, idempotency keys, If-Match, retries, error-envelope decoding, and Deprecation/Sunset -> "Update available"/"Update required"; Secret Service storage; Capabilities; the screen registry (all 75 Hub screens plus the 12 web-only Console screens) generated from the specs; navigation and visibility rules; shortcuts. - substratal-hub: window shell with gated sidebar, router and substratal:// links, Blueprint sign-in with 2FA, themes (5 light, 5 dark), command palette, shortcuts dialog, F1 help per screen, spec-driven scaffolds for every screen, and live Users, Account and Settings > Appearance screens. - substratalctl: CLI on the same core. Help center (docs/, Jekyll + just-the-docs) for every Hub app: guides, tutorials, FAQ, a page per screen, API versioning and auth, Swagger UI with a Substratal-Version picker, every API operation cross-linked to the screens using it, and versioned snapshots like the API docs. Reference pages are generated from the code by `cargo xtask gen-docs`. Automation: CI (fmt, clippy, tests, generated-docs and spec checks), docs build + GitHub Pages deploy on every change to main, API spec sync (oasdiff changelog, PR) and client specs sync, Flatpak builds. Plans moved to planning/, with a prerequisites check and dated updates for header versioning and the web-only Console (specs OPEN-QUESTIONS #27). Co-Authored-By: Claude Opus 5.5 --- .cargo/config.toml | 2 + .github/workflows/api-spec-sync.yml | 114 + .github/workflows/ci.yml | 67 + .github/workflows/docs.yml | 130 + .github/workflows/flatpak.yml | 35 + .github/workflows/specs-sync.yml | 52 + .gitignore | 12 + Cargo.lock | 3100 ++++++++++ Cargo.toml | 31 + README.md | 63 + build-aux/docker/Dockerfile.dev | 23 + build-aux/flatpak/com.substratalapps.Hub.json | 70 + crates/substratal-core/Cargo.toml | 30 + crates/substratal-core/src/api.rs | 553 ++ crates/substratal-core/src/capabilities.rs | 225 + crates/substratal-core/src/config.rs | 121 + crates/substratal-core/src/error.rs | 225 + crates/substratal-core/src/lib.rs | 23 + crates/substratal-core/src/models.rs | 165 + crates/substratal-core/src/nav.rs | 345 ++ crates/substratal-core/src/screens.rs | 242 + crates/substratal-core/src/secrets.rs | 138 + crates/substratal-core/src/session.rs | 209 + crates/substratal-core/src/shortcuts.rs | 183 + crates/substratal-core/src/version.rs | 89 + crates/substratal-core/tests/api_client.rs | 316 ++ crates/substratal-hub/Cargo.toml | 24 + crates/substratal-hub/build.rs | 106 + .../brand/lockup-horizontal-on-dark.svg | 1 + .../resources/brand/lockup-horizontal.svg | 1 + .../resources/brand/mark-on-dark.svg | 1 + .../substratal-hub/resources/brand/mark.svg | 1 + .../icons/scalable/actions/bell-symbolic.svg | 1 + .../scalable/actions/mail-inbox-symbolic.svg | 1 + .../scalable/actions/wallet-symbolic.svg | 1 + crates/substratal-hub/resources/style.css | 42 + .../resources/theme/dark/deep-teal.css | 43 + .../resources/theme/dark/ember-clay.css | 43 + .../resources/theme/dark/forest-floor.css | 43 + .../resources/theme/dark/graphite-spore.css | 43 + .../resources/theme/dark/night-soil.css | 43 + .../resources/theme/light/coral-clay.css | 43 + .../resources/theme/light/indigo-root.css | 43 + .../resources/theme/light/moss-paper.css | 43 + .../resources/theme/light/spore-pink.css | 43 + .../resources/theme/light/teal-ledger.css | 43 + .../substratal-hub/resources/ui/sign-in.blp | 167 + crates/substratal-hub/src/context.rs | 67 + crates/substratal-hub/src/dialogs.rs | 168 + crates/substratal-hub/src/main.rs | 176 + crates/substratal-hub/src/pages/account.rs | 56 + crates/substratal-hub/src/pages/mod.rs | 30 + crates/substratal-hub/src/pages/scaffold.rs | 475 ++ crates/substratal-hub/src/pages/settings.rs | 160 + crates/substratal-hub/src/pages/users.rs | 206 + crates/substratal-hub/src/prefs.rs | 93 + crates/substratal-hub/src/runtime.rs | 42 + crates/substratal-hub/src/shell.rs | 484 ++ crates/substratal-hub/src/sign_in.rs | 263 + crates/substratal-hub/src/theme.rs | 106 + crates/substratal-hub/src/widgets.rs | 97 + crates/substratalctl/Cargo.toml | 26 + crates/substratalctl/src/lib.rs | 252 + crates/substratalctl/src/main.rs | 16 + crates/xtask/Cargo.toml | 15 + crates/xtask/src/main.rs | 569 ++ data/com.substratalapps.Hub.desktop | 12 + data/com.substratalapps.Hub.metainfo.xml | 45 + data/com.substratalapps.Hub.service | 3 + .../128x128/apps/com.substratalapps.Hub.png | Bin 0 -> 4342 bytes .../16x16/apps/com.substratalapps.Hub.png | Bin 0 -> 448 bytes .../24x24/apps/com.substratalapps.Hub.png | Bin 0 -> 696 bytes .../256x256/apps/com.substratalapps.Hub.png | Bin 0 -> 8589 bytes .../32x32/apps/com.substratalapps.Hub.png | Bin 0 -> 900 bytes .../48x48/apps/com.substratalapps.Hub.png | Bin 0 -> 1571 bytes .../512x512/apps/com.substratalapps.Hub.png | Bin 0 -> 17722 bytes .../64x64/apps/com.substratalapps.Hub.png | Bin 0 -> 2095 bytes .../scalable/apps/com.substratalapps.Hub.svg | 1 + .../apps/com.substratalapps.Hub-symbolic.svg | 1 + data/spec/SOURCE | 1 + data/spec/screens.json | 4981 +++++++++++++++++ data/spec/status-semantics.json | 252 + data/spec/themes.json | 88 + docs/Gemfile | 7 + docs/Gemfile.lock | 294 + docs/_config.yml | 69 + docs/_data/api.yml | 10 + docs/_includes/header_custom.html | 24 + docs/_sass/color_schemes/substratal.scss | 21 + docs/_sass/custom/custom.scss | 144 + docs/api/authentication.md | 47 + docs/api/changelog.md | 27 + docs/api/index.md | 48 + docs/api/operations.md | 217 + docs/api/reference.md | 78 + docs/api/v1/openapi.yaml | 4446 +++++++++++++++ docs/api/versioning.md | 74 + docs/assets/images/APP-01-home.png | Bin 0 -> 265933 bytes docs/assets/images/mark.svg | 1 + docs/changelog.md | 15 + docs/contributing.md | 62 + docs/faq.md | 56 + docs/getting-started/index.md | 17 + docs/getting-started/install.md | 42 + docs/getting-started/sign-in.md | 30 + docs/getting-started/tour.md | 40 + docs/guides/administration.md | 46 + docs/guides/console.md | 29 + docs/guides/developer.md | 50 + docs/guides/index.md | 19 + docs/guides/personal.md | 40 + docs/index.md | 40 + docs/reference/cli.md | 156 + docs/reference/errors.md | 30 + docs/reference/index.md | 13 + docs/reference/linking.md | 35 + docs/reference/navigation.md | 51 + docs/reference/shortcuts.md | 49 + docs/reference/statuses.md | 28 + docs/screens.json | 1133 ++++ docs/screens/acc-01.md | 56 + docs/screens/acc-02.md | 38 + docs/screens/acc-03.md | 39 + docs/screens/acc-04.md | 53 + docs/screens/acc-05.md | 44 + docs/screens/acc-06.md | 38 + docs/screens/acc-07.md | 38 + docs/screens/acc-08.md | 44 + docs/screens/acx-01.md | 53 + docs/screens/acx-02.md | 41 + docs/screens/acx-03.md | 48 + docs/screens/acx-04.md | 42 + docs/screens/acx-05.md | 48 + docs/screens/apl-01.md | 41 + docs/screens/apl-02.md | 46 + docs/screens/apl-03.md | 50 + docs/screens/apl-04.md | 40 + docs/screens/apl-05.md | 39 + docs/screens/apl-06.md | 38 + docs/screens/apl-07.md | 56 + docs/screens/app-01.md | 57 + docs/screens/app-02.md | 46 + docs/screens/app-03.md | 42 + docs/screens/app-04.md | 46 + docs/screens/app-05.md | 39 + docs/screens/aud-01.md | 43 + docs/screens/aud-02.md | 41 + docs/screens/auth-01.md | 50 + docs/screens/auth-02.md | 46 + docs/screens/auth-03.md | 46 + docs/screens/auth-04.md | 42 + docs/screens/auth-05.md | 37 + docs/screens/auth-06.md | 41 + docs/screens/auth-07.md | 40 + docs/screens/bil-01.md | 47 + docs/screens/bil-02.md | 52 + docs/screens/bil-03.md | 39 + docs/screens/bil-04.md | 50 + docs/screens/cfg-01.md | 43 + docs/screens/cfg-02.md | 38 + docs/screens/glb-01.md | 30 + docs/screens/glb-02.md | 31 + docs/screens/glb-03.md | 48 + docs/screens/index.md | 179 + docs/screens/int-01.md | 43 + docs/screens/key-01.md | 42 + docs/screens/key-02.md | 43 + docs/screens/key-03.md | 31 + docs/screens/key-04.md | 51 + docs/screens/ntf-01.md | 56 + docs/screens/ntf-02.md | 51 + docs/screens/ntf-03.md | 53 + docs/screens/ntf-04.md | 47 + docs/screens/ntf-05.md | 43 + docs/screens/ntf-06.md | 47 + docs/screens/ops-01.md | 47 + docs/screens/ops-02.md | 44 + docs/screens/ops-03.md | 44 + docs/screens/ops-04.md | 40 + docs/screens/ops-05.md | 39 + docs/screens/ops-06.md | 40 + docs/screens/ops-07.md | 44 + docs/screens/org-01.md | 43 + docs/screens/org-02.md | 37 + docs/screens/org-03.md | 50 + docs/screens/org-04.md | 43 + docs/screens/org-05.md | 39 + docs/screens/org-06.md | 48 + docs/screens/org-07.md | 42 + docs/screens/org-08.md | 50 + docs/screens/org-09.md | 42 + docs/screens/rol-01.md | 42 + docs/screens/rol-02.md | 51 + docs/screens/rol-03.md | 40 + docs/screens/rol-04.md | 48 + docs/screens/rpt-01.md | 45 + docs/screens/sec-01.md | 44 + docs/screens/set-01.md | 56 + docs/screens/set-02.md | 50 + docs/screens/usr-01.md | 52 + docs/screens/usr-02.md | 40 + docs/screens/usr-03.md | 55 + docs/screens/usr-04.md | 47 + docs/screens/usr-05.md | 41 + docs/screens/whk-01.md | 41 + docs/screens/whk-02.md | 43 + docs/screens/whk-03.md | 51 + docs/screens/whk-04.md | 32 + docs/tutorials/api-keys.md | 38 + docs/tutorials/applications.md | 28 + docs/tutorials/cli.md | 49 + docs/tutorials/index.md | 13 + docs/tutorials/invite-and-grant.md | 38 + docs/tutorials/request-id.md | 24 + docs/versions/index.md | 29 + {docs => planning}/PLAN-api-gaps.md | 5 + {docs => planning}/PLAN-management-app.md | 7 + planning/PREREQUISITES.md | 51 + rust-toolchain.toml | 3 + scripts/dev.sh | 20 + scripts/docs-build.rb | 130 + scripts/screenshot.sh | 42 + scripts/sync-specs.py | 223 + vendor/openapi.yaml | 4446 +++++++++++++++ 224 files changed, 32700 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 .github/workflows/api-spec-sync.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/flatpak.yml create mode 100644 .github/workflows/specs-sync.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 build-aux/docker/Dockerfile.dev create mode 100644 build-aux/flatpak/com.substratalapps.Hub.json create mode 100644 crates/substratal-core/Cargo.toml create mode 100644 crates/substratal-core/src/api.rs create mode 100644 crates/substratal-core/src/capabilities.rs create mode 100644 crates/substratal-core/src/config.rs create mode 100644 crates/substratal-core/src/error.rs create mode 100644 crates/substratal-core/src/lib.rs create mode 100644 crates/substratal-core/src/models.rs create mode 100644 crates/substratal-core/src/nav.rs create mode 100644 crates/substratal-core/src/screens.rs create mode 100644 crates/substratal-core/src/secrets.rs create mode 100644 crates/substratal-core/src/session.rs create mode 100644 crates/substratal-core/src/shortcuts.rs create mode 100644 crates/substratal-core/src/version.rs create mode 100644 crates/substratal-core/tests/api_client.rs create mode 100644 crates/substratal-hub/Cargo.toml create mode 100644 crates/substratal-hub/build.rs create mode 100644 crates/substratal-hub/resources/brand/lockup-horizontal-on-dark.svg create mode 100644 crates/substratal-hub/resources/brand/lockup-horizontal.svg create mode 100644 crates/substratal-hub/resources/brand/mark-on-dark.svg create mode 100644 crates/substratal-hub/resources/brand/mark.svg create mode 100644 crates/substratal-hub/resources/icons/scalable/actions/bell-symbolic.svg create mode 100644 crates/substratal-hub/resources/icons/scalable/actions/mail-inbox-symbolic.svg create mode 100644 crates/substratal-hub/resources/icons/scalable/actions/wallet-symbolic.svg create mode 100644 crates/substratal-hub/resources/style.css create mode 100644 crates/substratal-hub/resources/theme/dark/deep-teal.css create mode 100644 crates/substratal-hub/resources/theme/dark/ember-clay.css create mode 100644 crates/substratal-hub/resources/theme/dark/forest-floor.css create mode 100644 crates/substratal-hub/resources/theme/dark/graphite-spore.css create mode 100644 crates/substratal-hub/resources/theme/dark/night-soil.css create mode 100644 crates/substratal-hub/resources/theme/light/coral-clay.css create mode 100644 crates/substratal-hub/resources/theme/light/indigo-root.css create mode 100644 crates/substratal-hub/resources/theme/light/moss-paper.css create mode 100644 crates/substratal-hub/resources/theme/light/spore-pink.css create mode 100644 crates/substratal-hub/resources/theme/light/teal-ledger.css create mode 100644 crates/substratal-hub/resources/ui/sign-in.blp create mode 100644 crates/substratal-hub/src/context.rs create mode 100644 crates/substratal-hub/src/dialogs.rs create mode 100644 crates/substratal-hub/src/main.rs create mode 100644 crates/substratal-hub/src/pages/account.rs create mode 100644 crates/substratal-hub/src/pages/mod.rs create mode 100644 crates/substratal-hub/src/pages/scaffold.rs create mode 100644 crates/substratal-hub/src/pages/settings.rs create mode 100644 crates/substratal-hub/src/pages/users.rs create mode 100644 crates/substratal-hub/src/prefs.rs create mode 100644 crates/substratal-hub/src/runtime.rs create mode 100644 crates/substratal-hub/src/shell.rs create mode 100644 crates/substratal-hub/src/sign_in.rs create mode 100644 crates/substratal-hub/src/theme.rs create mode 100644 crates/substratal-hub/src/widgets.rs create mode 100644 crates/substratalctl/Cargo.toml create mode 100644 crates/substratalctl/src/lib.rs create mode 100644 crates/substratalctl/src/main.rs create mode 100644 crates/xtask/Cargo.toml create mode 100644 crates/xtask/src/main.rs create mode 100644 data/com.substratalapps.Hub.desktop create mode 100644 data/com.substratalapps.Hub.metainfo.xml create mode 100644 data/com.substratalapps.Hub.service create mode 100644 data/icons/hicolor/128x128/apps/com.substratalapps.Hub.png create mode 100644 data/icons/hicolor/16x16/apps/com.substratalapps.Hub.png create mode 100644 data/icons/hicolor/24x24/apps/com.substratalapps.Hub.png create mode 100644 data/icons/hicolor/256x256/apps/com.substratalapps.Hub.png create mode 100644 data/icons/hicolor/32x32/apps/com.substratalapps.Hub.png create mode 100644 data/icons/hicolor/48x48/apps/com.substratalapps.Hub.png create mode 100644 data/icons/hicolor/512x512/apps/com.substratalapps.Hub.png create mode 100644 data/icons/hicolor/64x64/apps/com.substratalapps.Hub.png create mode 100644 data/icons/hicolor/scalable/apps/com.substratalapps.Hub.svg create mode 100644 data/icons/hicolor/symbolic/apps/com.substratalapps.Hub-symbolic.svg create mode 100644 data/spec/SOURCE create mode 100644 data/spec/screens.json create mode 100644 data/spec/status-semantics.json create mode 100644 data/spec/themes.json create mode 100644 docs/Gemfile create mode 100644 docs/Gemfile.lock create mode 100644 docs/_config.yml create mode 100644 docs/_data/api.yml create mode 100644 docs/_includes/header_custom.html create mode 100644 docs/_sass/color_schemes/substratal.scss create mode 100644 docs/_sass/custom/custom.scss create mode 100644 docs/api/authentication.md create mode 100644 docs/api/changelog.md create mode 100644 docs/api/index.md create mode 100644 docs/api/operations.md create mode 100644 docs/api/reference.md create mode 100644 docs/api/v1/openapi.yaml create mode 100644 docs/api/versioning.md create mode 100644 docs/assets/images/APP-01-home.png create mode 100644 docs/assets/images/mark.svg create mode 100644 docs/changelog.md create mode 100644 docs/contributing.md create mode 100644 docs/faq.md create mode 100644 docs/getting-started/index.md create mode 100644 docs/getting-started/install.md create mode 100644 docs/getting-started/sign-in.md create mode 100644 docs/getting-started/tour.md create mode 100644 docs/guides/administration.md create mode 100644 docs/guides/console.md create mode 100644 docs/guides/developer.md create mode 100644 docs/guides/index.md create mode 100644 docs/guides/personal.md create mode 100644 docs/index.md create mode 100644 docs/reference/cli.md create mode 100644 docs/reference/errors.md create mode 100644 docs/reference/index.md create mode 100644 docs/reference/linking.md create mode 100644 docs/reference/navigation.md create mode 100644 docs/reference/shortcuts.md create mode 100644 docs/reference/statuses.md create mode 100644 docs/screens.json create mode 100644 docs/screens/acc-01.md create mode 100644 docs/screens/acc-02.md create mode 100644 docs/screens/acc-03.md create mode 100644 docs/screens/acc-04.md create mode 100644 docs/screens/acc-05.md create mode 100644 docs/screens/acc-06.md create mode 100644 docs/screens/acc-07.md create mode 100644 docs/screens/acc-08.md create mode 100644 docs/screens/acx-01.md create mode 100644 docs/screens/acx-02.md create mode 100644 docs/screens/acx-03.md create mode 100644 docs/screens/acx-04.md create mode 100644 docs/screens/acx-05.md create mode 100644 docs/screens/apl-01.md create mode 100644 docs/screens/apl-02.md create mode 100644 docs/screens/apl-03.md create mode 100644 docs/screens/apl-04.md create mode 100644 docs/screens/apl-05.md create mode 100644 docs/screens/apl-06.md create mode 100644 docs/screens/apl-07.md create mode 100644 docs/screens/app-01.md create mode 100644 docs/screens/app-02.md create mode 100644 docs/screens/app-03.md create mode 100644 docs/screens/app-04.md create mode 100644 docs/screens/app-05.md create mode 100644 docs/screens/aud-01.md create mode 100644 docs/screens/aud-02.md create mode 100644 docs/screens/auth-01.md create mode 100644 docs/screens/auth-02.md create mode 100644 docs/screens/auth-03.md create mode 100644 docs/screens/auth-04.md create mode 100644 docs/screens/auth-05.md create mode 100644 docs/screens/auth-06.md create mode 100644 docs/screens/auth-07.md create mode 100644 docs/screens/bil-01.md create mode 100644 docs/screens/bil-02.md create mode 100644 docs/screens/bil-03.md create mode 100644 docs/screens/bil-04.md create mode 100644 docs/screens/cfg-01.md create mode 100644 docs/screens/cfg-02.md create mode 100644 docs/screens/glb-01.md create mode 100644 docs/screens/glb-02.md create mode 100644 docs/screens/glb-03.md create mode 100644 docs/screens/index.md create mode 100644 docs/screens/int-01.md create mode 100644 docs/screens/key-01.md create mode 100644 docs/screens/key-02.md create mode 100644 docs/screens/key-03.md create mode 100644 docs/screens/key-04.md create mode 100644 docs/screens/ntf-01.md create mode 100644 docs/screens/ntf-02.md create mode 100644 docs/screens/ntf-03.md create mode 100644 docs/screens/ntf-04.md create mode 100644 docs/screens/ntf-05.md create mode 100644 docs/screens/ntf-06.md create mode 100644 docs/screens/ops-01.md create mode 100644 docs/screens/ops-02.md create mode 100644 docs/screens/ops-03.md create mode 100644 docs/screens/ops-04.md create mode 100644 docs/screens/ops-05.md create mode 100644 docs/screens/ops-06.md create mode 100644 docs/screens/ops-07.md create mode 100644 docs/screens/org-01.md create mode 100644 docs/screens/org-02.md create mode 100644 docs/screens/org-03.md create mode 100644 docs/screens/org-04.md create mode 100644 docs/screens/org-05.md create mode 100644 docs/screens/org-06.md create mode 100644 docs/screens/org-07.md create mode 100644 docs/screens/org-08.md create mode 100644 docs/screens/org-09.md create mode 100644 docs/screens/rol-01.md create mode 100644 docs/screens/rol-02.md create mode 100644 docs/screens/rol-03.md create mode 100644 docs/screens/rol-04.md create mode 100644 docs/screens/rpt-01.md create mode 100644 docs/screens/sec-01.md create mode 100644 docs/screens/set-01.md create mode 100644 docs/screens/set-02.md create mode 100644 docs/screens/usr-01.md create mode 100644 docs/screens/usr-02.md create mode 100644 docs/screens/usr-03.md create mode 100644 docs/screens/usr-04.md create mode 100644 docs/screens/usr-05.md create mode 100644 docs/screens/whk-01.md create mode 100644 docs/screens/whk-02.md create mode 100644 docs/screens/whk-03.md create mode 100644 docs/screens/whk-04.md create mode 100644 docs/tutorials/api-keys.md create mode 100644 docs/tutorials/applications.md create mode 100644 docs/tutorials/cli.md create mode 100644 docs/tutorials/index.md create mode 100644 docs/tutorials/invite-and-grant.md create mode 100644 docs/tutorials/request-id.md create mode 100644 docs/versions/index.md rename {docs => planning}/PLAN-api-gaps.md (97%) rename {docs => planning}/PLAN-management-app.md (98%) create mode 100644 planning/PREREQUISITES.md create mode 100644 rust-toolchain.toml create mode 100755 scripts/dev.sh create mode 100755 scripts/docs-build.rb create mode 100755 scripts/screenshot.sh create mode 100755 scripts/sync-specs.py create mode 100644 vendor/openapi.yaml 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..932e816 --- /dev/null +++ b/.github/workflows/api-spec-sync.yml @@ -0,0 +1,114 @@ +# Keeps the vendored Platform API spec (vendor/openapi.yaml) and the help +# center's API pages current with the API repo. +# +# Runs every 6 hours, on demand, and when the API repo sends a +# `repository_dispatch` of type `api-spec-updated` (see docs/contributing.md +# for the one-step snippet to add there). If openapi.yaml changed, it: +# 1. diffs old vs new with oasdiff (changelog + breaking-change check), +# 2. adds an entry to docs/api/changelog.md, +# 3. updates vendor/openapi.yaml and regenerates the API pages, +# 4. opens (or updates) a pull request, 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: Add an API changelog entry + if: steps.fetch.outputs.changed == 'true' + env: + VERSION: ${{ steps.fetch.outputs.version }} + BREAKING: ${{ steps.diff.outputs.breaking }} + run: | + python3 - <<'PY' + import datetime, os, pathlib + path = pathlib.Path("docs/api/changelog.md") + body = pathlib.Path("_changelog.md").read_text().strip() + # oasdiff starts with its own H1/H2 headings; demote them under ours. + body = "\n".join(("###" + l.lstrip("#")) if l.startswith("#") else l for l in body.splitlines()) + warn = "" + if os.environ["BREAKING"] == "true": + warn = "\n**Breaking for clients of this API version** (oasdiff). Check the app before merging.\n{: .important }\n" + entry = f"## {os.environ['VERSION']} — {datetime.date.today().isoformat()}\n{warn}\n{body or 'No API-visible changes (descriptions or examples only).'}\n" + marker = "" + text = path.read_text() + path.write_text(text.replace(marker, marker + "\n\n" + entry, 1)) + PY + + - uses: dtolnay/rust-toolchain@stable + if: steps.fetch.outputs.changed == 'true' + - uses: Swatinem/rust-cache@v2 + if: steps.fetch.outputs.changed == 'true' + + - name: Update vendored spec and regenerate API pages + if: steps.fetch.outputs.changed == 'true' + run: | + mv _new.yaml vendor/openapi.yaml + rm -f oasdiff _changelog.md _breaking.txt + cargo xtask gen-docs + + - 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`, regenerates the help center's API pages (`cargo xtask gen-docs`) and adds an entry to `docs/api/changelog.md`. + + Breaking changes found by oasdiff: **${{ steps.diff.outputs.breaking }}** + + 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..df25f95 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +# Build, lint and test the app; check that generated files match their +# sources (specs sync and generated docs); check the help site builds. +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: Generated docs are current + run: cargo xtask gen-docs --check + - 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." + + docs: + name: Help site builds + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.2" + bundler-cache: true + working-directory: docs + - run: bundle exec jekyll build --strict_front_matter + working-directory: docs diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..42794f8 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,130 @@ +# Versioned build + deploy of the help center (docs/) to GitHub Pages. +# +# Runs on every push to main that changes the app, the docs, the vendored +# API spec or the spec-derived data, so the published help always matches +# main. Generated reference pages are rebuilt from the code first +# (cargo xtask gen-docs), then the site is built and versioned exactly like +# the Platform API's docs: patch bump by default, [docs:minor] or +# [docs:major] in a commit subject for more. Each version's frozen snapshot +# goes to the docs-versions branch and is served at /versions//. +name: Docs + +on: + push: + branches: [main] + paths: + - "docs/**" + - "crates/**" + - "data/**" + - "vendor/**" + - "scripts/docs-build.rb" + - ".github/workflows/docs.yml" + workflow_dispatch: + inputs: + bump: + description: Version bump for this release + type: choice + options: [patch, minor, major] + default: patch + +permissions: + contents: write # push snapshots to docs-versions + pages: write + id-token: write + +# One release at a time, never cancelled midway: each run reads the archive +# the previous run wrote, so overlapping runs would race on version numbers. +concurrency: + group: docs-pages + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-24.04 + environment: + name: github-pages + url: ${{ steps.deploy.outputs.page_url }} + steps: + - uses: actions/checkout@v7 + with: + fetch-depth: 0 # bump markers are read from every commit since the last version + + # The generator only needs the GTK-free crates (core and the CLI). + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Regenerate reference pages from the code + run: cargo xtask gen-docs + + - name: Check out version archive + run: | + if git ls-remote --exit-code --heads origin docs-versions >/dev/null; then + git fetch --depth=1 origin +docs-versions:refs/remotes/origin/docs-versions + git worktree add _archive -B docs-versions origin/docs-versions + else + echo "No docs-versions branch yet; starting the archive at 1.0.0." + git worktree add --orphan -b docs-versions _archive + fi + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.2" + bundler-cache: true + working-directory: docs + + - name: Pick version bump + id: bump + env: + INPUT_BUMP: ${{ inputs.bump }} + run: | + bump="${INPUT_BUMP:-patch}" + last=$(ruby -ryaml -e 'm = File.exist?("_archive/manifest.yml") ? YAML.load_file("_archive/manifest.yml") : nil + v = ((m || {})["versions"] || []).first + print v ? v["commit"].to_s : ""') + if [ -n "$last" ] && git cat-file -e "$last^{commit}" 2>/dev/null; then + msgs=$(git log --format=%s "$last..HEAD") + else + msgs=$(git log -1 --format=%s) + fi + if grep -q '\[docs:major\]' <<<"$msgs"; then bump=major + elif [ "$bump" != major ] && grep -q '\[docs:minor\]' <<<"$msgs"; then bump=minor; fi + echo "level=$bump" >> "$GITHUB_OUTPUT" + + - name: Build versioned site + id: build + run: | + ruby scripts/docs-build.rb \ + --archive _archive \ + --out _site \ + --bump "${{ steps.bump.outputs.level }}" \ + --commit "$GITHUB_SHA" \ + --summary "$(git log -1 --format=%s)" + + - name: Commit snapshot to docs-versions + working-directory: _archive + env: + VERSION: ${{ steps.build.outputs.version }} + run: | + if [ ! -f README.md ]; then + cat > README.md <<'README' + # docs-versions + + Archive of every released version of the help center, written by + `.github/workflows/docs.yml` on `main`. Don't edit by hand. + + - `manifest.yml`: every version, newest first. + - `/`: that version's frozen static build, served at + https://compositecode.github.io/substratalapps-linux/versions// + README + fi + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add -A + git commit -m "Archive docs v$VERSION from ${GITHUB_SHA::7}" + git push origin docs-versions + + - uses: actions/configure-pages@v6 + - uses: actions/upload-pages-artifact@v5 + with: + path: _site + - id: deploy + uses: actions/deploy-pages@v5 diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml new file mode 100644 index 0000000..5aaa25e --- /dev/null +++ b/.github/workflows/flatpak.yml @@ -0,0 +1,35 @@ +# 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"] + 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..20d5ae4 --- /dev/null +++ b/.github/workflows/specs-sync.yml @@ -0,0 +1,52 @@ +# Keeps the spec-derived files (screen registry, themes, icons, status +# semantics) and the help center's screen pages 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: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Regenerate screen pages + run: cargo xtask gen-docs + - 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/`, the bundled themes and icons, and the help center's screen pages. + + 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..0f6aaf0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/target +/_site +/docs/_site +/docs/.jekyll-cache +/docs/.bundle +/docs/vendor +/docs/_data/versions.yml +/_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..3c10888 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3100 @@ +# 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 = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[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 = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[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 = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[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", + "serde_json", + "serde_yaml", + "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..a4c04f3 --- /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" } +substratalctl = { path = "crates/substratalctl" } +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..b9c699c --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# 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 center:** https://compositecode.github.io/substratalapps-linux/. It's built from `docs/` in this repo and serves every Hub app (Linux, macOS, Windows, iOS/iPadOS, Android, Web). + +> **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-docs`: generates the help center's reference pages from the code, the specs and the API spec | +| `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 | +| `docs/` | The help center (Jekyll + just-the-docs), versioned and deployed to GitHub Pages | +| `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 +cargo xtask gen-docs --check # generated help pages match the code +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, generated-docs and spec checks, desktop/AppStream validation, site build | +| `docs.yml` | Pushes to `main` touching app, docs, data or spec | Regenerates reference pages, builds a new docs version, deploys to GitHub Pages | +| `api-spec-sync.yml` | Every 6 h, on demand, `repository_dispatch: api-spec-updated` | Pulls the API's `openapi.yaml`, diffs it with oasdiff, updates the API changelog and pages, opens a PR (labelled when breaking) | +| `specs-sync.yml` | Daily, on demand | Pulls the client specs, regenerates screens, themes and help pages, 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..7ff22f3 --- /dev/null +++ b/crates/substratal-core/src/config.rs @@ -0,0 +1,121 @@ +//! 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 (this repo's `docs/`, on GitHub Pages). +pub const HELP_BASE: &str = "https://compositecode.github.io/substratalapps-linux/"; +/// 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. `…/screens/usr-01/`. 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/{}/", 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-linux/screens/usr-01/" + ); + } +} 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..184e78b --- /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 +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..9cfe437 --- /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, default-features = false } +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..3265bb3 --- /dev/null +++ b/crates/xtask/Cargo.toml @@ -0,0 +1,15 @@ +[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, default-features = false } +substratalctl = { workspace = true, default-features = false } +anyhow.workspace = true +clap.workspace = true +clap-markdown = "0.1" +serde_json.workspace = true +serde_yaml.workspace = true diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs new file mode 100644 index 0000000..94bbf79 --- /dev/null +++ b/crates/xtask/src/main.rs @@ -0,0 +1,569 @@ +//! Repo automation. +//! +//! cargo xtask gen-docs regenerate the help center's generated pages +//! cargo xtask gen-docs --check fail if they're out of date (CI) +//! +//! Generated pages come from the code and the specs, never by hand, so the +//! help center can't drift from what the app does: +//! +//! | Page | Source | +//! |------------------------------|------------------------------------------------| +//! | screens/index.md, screens/* | data/spec/screens.json (the specs package) | +//! | reference/shortcuts.md | substratal_core::shortcuts::SHORTCUTS | +//! | reference/navigation.md | substratal_core::nav + capabilities::personas | +//! | reference/statuses.md | data/spec/status-semantics.json | +//! | reference/cli.md | substratalctl's clap definition | +//! | api/operations.md | vendor/openapi.yaml + screens' API calls | +//! | api/v1/openapi.yaml | vendor/openapi.yaml | +//! | _data/api.yml | vendor/openapi.yaml + core::version | + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, bail}; +use clap::Parser; +use serde_json::Value as Json; +use serde_yaml::Value as Yaml; +use substratal_core::capabilities::personas; +use substratal_core::screens::{self, Screen}; +use substratal_core::{config, nav, shortcuts, version}; + +const GENERATED: &str = "GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate."; + +#[derive(Parser)] +enum Cmd { + /// Regenerate the help center's generated pages. + GenDocs { + /// Fail instead of writing when a page is out of date. + #[arg(long)] + check: bool, + }, +} + +fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .unwrap() + .to_path_buf() +} + +fn main() -> Result<()> { + match Cmd::parse() { + Cmd::GenDocs { check } => gen_docs(check), + } +} + +struct Out { + files: BTreeMap, +} + +impl Out { + fn put(&mut self, rel: &str, body: String) { + self.files.insert(root().join("docs").join(rel), body); + } +} + +fn gen_docs(check: bool) -> Result<()> { + let spec_text = std::fs::read_to_string(root().join("vendor/openapi.yaml")) + .context("vendor/openapi.yaml")?; + let spec: Yaml = serde_yaml::from_str(&spec_text)?; + let mut out = Out { + files: BTreeMap::new(), + }; + + screens_pages(&mut out); + shortcuts_page(&mut out); + navigation_page(&mut out); + statuses_page(&mut out)?; + cli_page(&mut out); + operations_page(&mut out, &spec); + out.put("api/v1/openapi.yaml", spec_text.clone()); + api_data(&mut out, &spec); + + // Stale generated screen pages (a screen removed from the specs). + let screens_dir = root().join("docs/screens"); + let mut stale = Vec::new(); + if let Ok(rd) = std::fs::read_dir(&screens_dir) { + for e in rd.flatten() { + let p = e.path(); + if p.extension().is_some_and(|x| x == "md") && !out.files.contains_key(&p) { + stale.push(p); + } + } + } + + let mut changed = Vec::new(); + for (path, body) in &out.files { + if std::fs::read_to_string(path).ok().as_deref() != Some(body.as_str()) { + changed.push(path.clone()); + if !check { + std::fs::create_dir_all(path.parent().unwrap())?; + std::fs::write(path, body)?; + } + } + } + if !check { + for p in &stale { + std::fs::remove_file(p)?; + } + } + let rel = |p: &PathBuf| p.strip_prefix(root()).unwrap().display().to_string(); + if check { + if !changed.is_empty() || !stale.is_empty() { + let list: Vec<_> = changed.iter().chain(&stale).map(rel).collect(); + bail!( + "generated docs are out of date:\n {}\nRun `cargo xtask gen-docs` and commit.", + list.join("\n ") + ); + } + println!("gen-docs: {} generated pages up to date", out.files.len()); + } else { + println!( + "gen-docs: wrote {} of {} pages, removed {}", + changed.len(), + out.files.len(), + stale.len() + ); + } + Ok(()) +} + +fn md_cell(s: &str) -> String { + s.replace('|', "\\|").replace('\n', " ") +} + +/// Liquid would try to render `{{ … }}` and `{% … %}` that appear in spec text. +fn raw(s: &str) -> String { + if s.contains("{{") || s.contains("{%") { + format!("{{% raw %}}{s}{{% endraw %}}") + } else { + s.to_string() + } +} + +fn front(title: &str, extra: &[(&str, String)]) -> String { + let mut f = format!( + "---\n# {GENERATED}\nlayout: default\ntitle: \"{}\"\n", + title.replace('"', "\\\"") + ); + for (k, v) in extra { + let _ = writeln!(f, "{k}: {v}"); + } + f.push_str("---\n\n"); + f +} + +fn platform_label(s: &Screen) -> &'static str { + if s.on_linux() { + "All Hub apps" + } else { + "Web Hub only" + } +} + +fn nav_path(s: &Screen) -> Option { + nav::item_for_route(&s.route) + .or_else(|| nav::TREE.iter().find(|i| i.screen_id == s.id)) + .map(|i| format!("{} › {}", i.section.title(), i.title)) +} + +fn screens_pages(out: &mut Out) { + let mut index = front( + "Screens", + &[ + ("nav_order", "5".into()), + ("has_children", "false".into()), + ("permalink", "/screens/".into()), + ], + ); + let _ = write!( + index, + "# Screens\n{{: .no_toc }}\n\nEvery screen in the Substratal Hub, with the same ID, route and behavior in all six apps (Linux, macOS, Windows, iOS/iPadOS, Android and Web). Each has its own help page at `/screens//`, which every app links to from its Help button or **F1**.\n{{: .fs-6 .fw-300 }}\n\nGenerated from the [specs package](https://github.com/CompositeCode/substratalapps-applications-specs) at `{}`. A machine-readable list is at [`screens.json`]({{{{ '/screens.json' | relative_url }}}}).\n\n", + &screens::spec_commit()[..7] + ); + let mut current_heading = String::new(); + for s in screens::all() { + let heading = s.section_heading.clone().unwrap_or_default(); + if heading != current_heading { + let _ = write!( + index, + "\n## {heading}\n\n| ID | Screen | Route | Who | Apps |\n|---|---|---|---|---|\n" + ); + current_heading = heading; + } + let _ = writeln!( + index, + "| [{id}]({{{{ '/screens/{lid}/' | relative_url }}}}) | {title} | `{route}` | {who} | {plat} |", + id = s.id, + lid = s.id.to_lowercase(), + title = md_cell(&s.title), + route = md_cell(&s.route), + who = md_cell(&raw(&s.who)), + plat = platform_label(s) + ); + } + out.put("screens/index.md", index); + + let mut json = Vec::new(); + for s in screens::all() { + json.push(serde_json::json!({ + "id": s.id, "title": s.title, "route": s.route, "archetype": s.archetype, "phase": s.phase, + "who": s.who, "purpose": s.purpose, "platforms": s.platforms, + "help": config::help_url(&s.id), + })); + } + out.put( + "screens.json", + serde_json::to_string_pretty(&json).unwrap() + "\n", + ); + + for s in screens::all() { + out.put( + &format!("screens/{}.md", s.id.to_lowercase()), + screen_page(s), + ); + } +} + +fn screen_page(s: &Screen) -> String { + let mut p = front( + &format!("{} {}", s.id, s.title), + &[ + ("permalink", format!("/screens/{}/", s.id.to_lowercase())), + ("nav_exclude", "true".into()), + ("screen_id", s.id.clone()), + ], + ); + let _ = write!(p, "# {}\n{{: .no_toc }}\n\n", s.title); + if !s.purpose.is_empty() { + let _ = write!(p, "{}\n{{: .fs-6 .fw-300 }}\n\n", raw(&s.purpose)); + } + if !s.on_linux() { + p.push_str("This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27).\n{: .note }\n\n"); + } + let _ = writeln!(p, "| | |\n|---|---|"); + let _ = writeln!(p, "| **Screen ID** | `{}` |", s.id); + if s.route.starts_with('/') { + let deep = if s.on_linux() { + format!(" · deep link `substratal:/{}`", s.route) + } else { + String::new() + }; + let _ = writeln!(p, "| **Route** | `{}`{} |", md_cell(&s.route), deep); + } + if let Some(n) = nav_path(s) { + let _ = writeln!(p, "| **Where** | {} |", n); + } + let _ = writeln!( + p, + "| **Who can use it** | {} |", + md_cell(&raw(if s.who.is_empty() { "—" } else { &s.who })) + ); + let _ = writeln!(p, "| **Kind** | {} |", s.archetype); + let _ = writeln!( + p, + "| **Available in** | {} ({}) |", + platform_label(s), + md_cell(&s.phase) + ); + if !s.container.is_empty() { + let _ = writeln!(p, "| **On Linux** | {} |", md_cell(&raw(&s.container))); + } + p.push('\n'); + if !s.entry_points.is_empty() { + let _ = write!(p, "**How you get here:** {}\n\n", raw(&s.entry_points)); + } + if !s.elements.is_empty() { + let linux = s.elements.iter().any(|e| !e.control.is_empty()); + p.push_str("## What's on this screen\n\n"); + if linux { + p.push_str("| Element | What it is | Shows / does | Rules | On Linux |\n|---|---|---|---|---|\n"); + } else { + p.push_str("| Element | What it is | Shows / does | Rules |\n|---|---|---|---|\n"); + } + for e in &s.elements { + let _ = write!( + p, + "| {} | {} | {} | {} |", + md_cell(&raw(&e.name)), + md_cell(&e.kind), + md_cell(&raw(&e.data)), + md_cell(&raw(&e.rules)) + ); + if linux { + let _ = write!(p, " {} |", md_cell(&raw(&e.control))); + } + p.push('\n'); + } + p.push('\n'); + } + if !s.actions.is_empty() { + p.push_str( + "## What happens when you act\n\n| Action | API call | Result |\n|---|---|---|\n", + ); + for a in &s.actions { + let call = if a.call.is_empty() { + String::new() + } else { + format!("`{}`", md_cell(&a.call)) + }; + let _ = writeln!( + p, + "| {} | {} | {} |", + md_cell(&raw(&a.action)), + call, + md_cell(&raw(&a.result)) + ); + } + p.push('\n'); + } + if !s.states.is_empty() || !s.notes.is_empty() { + p.push_str("## Good to know\n\n"); + if !s.states.is_empty() { + let _ = writeln!( + p, + "- **Loading, empty and error states:** {}", + raw(&s.states) + ); + } + if !s.notes.is_empty() { + let _ = writeln!(p, "- {}", raw(&s.notes)); + } + p.push('\n'); + } + if !s.loads.is_empty() { + let _ = write!( + p, + "## For developers\n\nLoads: {}\n\nEvery call sends `Substratal-Version: {}` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{{{ '/api/versioning/' | relative_url }}}}) and the [API reference]({{{{ '/api/reference/' | relative_url }}}}).\n", + raw(&s.loads), + version::API_VERSION + ); + } + p +} + +fn shortcuts_page(out: &mut Out) { + let mut p = front( + "Keyboard shortcuts", + &[("parent", "Reference".into()), ("nav_order", "1".into())], + ); + p.push_str("# Keyboard shortcuts\n{: .no_toc }\n\nShortcuts in Substratal for Linux. Every shortcut has a visible equivalent: a button, menu item or the command palette. Press **Ctrl+?** in the app to see this list.\n{: .fs-6 .fw-300 }\n\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); + } + out.put("reference/shortcuts.md", p); +} + +fn navigation_page(out: &mut Out) { + let mut p = front( + "Navigation and permissions", + &[("parent", "Reference".into()), ("nav_order", "2".into())], + ); + p.push_str("# Navigation and permissions\n{: .no_toc }\n\nWhat each kind of person sees in the sidebar. The app hides what you can never use, and the server checks every action again, so this is a guide, not a guarantee.\n{: .fs-6 .fw-300 }\n\n"); + let ps = personas::all(); + p.push_str("| Section › Item | Screen |"); + for (name, _) in &ps { + let _ = write!(p, " {name} |"); + } + p.push_str("\n|---|---|"); + for _ in &ps { + p.push_str(":-:|"); + } + p.push('\n'); + for item in nav::TREE.iter().filter(|i| !i.web_only) { + let _ = write!( + p, + "| {} › {} | [{}]({{{{ '/screens/{}/' | relative_url }}}}) |", + item.section.title(), + item.title, + item.screen_id, + item.screen_id.to_lowercase() + ); + for (_, caps) in &ps { + let visible = nav::visible_sections(caps) + .iter() + .any(|(_, items)| items.iter().any(|i| i.screen_id == item.screen_id)); + p.push_str(if visible { " ✓ |" } else { " |" }); + } + p.push('\n'); + } + p.push_str("\nThe web Hub also has an **Operations** section (the Console) and **Reports** and **Integrations** items. They're web-only for now; see [Screens]({{ '/screens/' | relative_url }}).\n\n## Platform permissions\n\n| Permission | Typical holder |\n|---|---|\n"); + for perm in substratal_core::capabilities::perm::ALL { + let holders: Vec<_> = ps + .iter() + .filter(|(_, c)| c.has(perm)) + .map(|(n, _)| *n) + .collect(); + let _ = writeln!(p, "| `{perm}` | {} |", holders.join(", ")); + } + out.put("reference/navigation.md", p); +} + +fn statuses_page(out: &mut Out) -> Result<()> { + let v: Json = serde_json::from_str(&std::fs::read_to_string( + root().join("data/spec/status-semantics.json"), + )?)?; + let mut p = front( + "Status meanings", + &[("parent", "Reference".into()), ("nav_order", "3".into())], + ); + p.push_str("# Status meanings\n{: .no_toc }\n\nEvery status badge in every Hub app shows an icon and a label, never colour alone. They mean the same thing everywhere.\n{: .fs-6 .fw-300 }\n\n| Status | Label | Kind |\n|---|---|---|\n"); + if let Some(m) = v.get("statuses").and_then(Json::as_object) { + for (k, s) in m { + let _ = writeln!( + p, + "| `{k}` | {} | {} |", + s.get("label").and_then(Json::as_str).unwrap_or(k), + s.get("role").and_then(Json::as_str).unwrap_or("") + ); + } + } + out.put("reference/statuses.md", p); + Ok(()) +} + +fn cli_page(out: &mut Out) { + let mut p = front( + "substratalctl", + &[("parent", "Reference".into()), ("nav_order", "4".into())], + ); + p.push_str("# substratalctl command reference\n{: .no_toc }\n\n`substratalctl` is the Hub's command-line companion. It shares the Linux app's core, so it sends the same `Substratal-Version` header, signs in the same way and gets the same permission errors. See the [CLI tutorial]({{ '/tutorials/cli/' | relative_url }}).\n{: .fs-6 .fw-300 }\n\n"); + let md = clap_markdown::help_markdown_command(&substratalctl::command()); + // clap-markdown starts with its own H1; the page has one already. + let body: String = md + .lines() + .skip_while(|l| !l.starts_with("## ") && !l.starts_with("**Usage")) + .collect::>() + .join("\n"); + p.push_str(&raw(&body)); + p.push('\n'); + out.put("reference/cli.md", p); +} + +fn op_screens(method: &str, path: &str) -> Vec<&'static str> { + // Matches "GET /users" or "GET /users?…" in a screen's loads and actions. + let needle = format!("{method} {path}"); + let hit = |text: &str| { + text.match_indices(&needle).any(|(i, _)| { + let next = text[i + needle.len()..].chars().next(); + matches!( + next, + None | Some(' ' | '?' | '`' | ',' | ')' | ';' | '·' | '\n') + ) + }) + }; + screens::all() + .iter() + .filter(|s| { + hit(&s.loads) + || s.actions.iter().any(|a| hit(&a.call)) + || s.elements.iter().any(|e| hit(&e.data)) + }) + .map(|s| s.id.as_str()) + .collect() +} + +fn operations_page(out: &mut Out, spec: &Yaml) { + let info_version = spec["info"]["version"].as_str().unwrap_or("?"); + // tag → (method, path, summary, versioned, public) + type Op = (String, String, String, bool, bool); + let mut by_tag: BTreeMap> = BTreeMap::new(); + let mut total = 0; + if let Some(paths) = spec["paths"].as_mapping() { + for (path, item) in paths { + let path = path.as_str().unwrap_or_default(); + for method in ["get", "post", "put", "patch", "delete"] { + let op = &item[method]; + if op.is_null() { + continue; + } + total += 1; + let tag = op["tags"][0].as_str().unwrap_or("Other").to_string(); + let summary = op["summary"].as_str().unwrap_or("").to_string(); + let versioned = op["parameters"].as_sequence().is_some_and(|ps| { + ps.iter() + .any(|p| p["$ref"].as_str() == Some("#/components/parameters/ApiVersion")) + }); + let public = op["security"].as_sequence().is_some_and(|s| s.is_empty()); + by_tag.entry(tag).or_default().push(( + method.to_uppercase(), + path.to_string(), + summary, + versioned, + public, + )); + } + } + } + let mut p = front( + "Operations", + &[("parent", "API".into()), ("nav_order", "4".into())], + ); + let _ = write!( + p, + "# API operations\n{{: .no_toc }}\n\nAll {total} operations in `openapi.yaml` **{info_version}** (API version `{}`), and which Hub screens use each one. Full request and response shapes are in the [interactive reference]({{{{ '/api/reference/' | relative_url }}}}).\n{{: .fs-6 .fw-300 }}\n\n**Header** ✓ means the operation requires `Substratal-Version`; the few exempt ones are noted in [versioning]({{{{ '/api/versioning/' | relative_url }}}}). **Auth** — means no sign-in needed.\n\n1. TOC\n{{:toc}}\n", + version::API_VERSION + ); + for (tag, ops) in &by_tag { + let _ = write!( + p, + "\n## {tag}\n\n| Method | Path | Summary | Header | Auth | Used by |\n|---|---|---|:-:|:-:|---|\n" + ); + for (m, path, summary, versioned, public) in ops { + let used: Vec<_> = op_screens(m, path) + .into_iter() + .map(|id| { + format!( + "[{id}]({{{{ '/screens/{}/' | relative_url }}}})", + id.to_lowercase() + ) + }) + .collect(); + let _ = writeln!( + p, + "| `{m}` | `{}` | {} | {} | {} | {} |", + md_cell(path), + md_cell(&raw(summary)), + if *versioned { "✓" } else { "exempt" }, + if *public { "—" } else { "✓" }, + used.join(" ") + ); + } + } + out.put("api/operations.md", p); +} + +fn api_data(out: &mut Out, spec: &Yaml) { + let ops: usize = spec["paths"] + .as_mapping() + .map(|m| { + m.values() + .map(|i| { + ["get", "post", "put", "patch", "delete"] + .iter() + .filter(|k| !i[**k].is_null()) + .count() + }) + .sum() + }) + .unwrap_or(0); + let body = format!( + "# {GENERATED}\n# The API version(s) this help center documents. One entry per Substratal-Version value.\ncurrent: \"{v}\"\nversions:\n - version: \"{v}\"\n status: current\n spec_version: \"{sv}\"\n operations: {ops}\n openapi: /api/v{v}/openapi.yaml\n base_url: \"{base}\"\n", + v = version::API_VERSION, + sv = spec["info"]["version"].as_str().unwrap_or("?"), + base = config::PRODUCTION_API, + ); + out.put("_data/api.yml", body); +} 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..4ddfa64 --- /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-linux/ + https://compositecode.github.io/substratalapps-linux/ + 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 0000000000000000000000000000000000000000..0f0348dd3a5b9ad48729a130414307d595f1b0c7 GIT binary patch literal 4342 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..a914780a4bd8c683786fbc316f78fe3fd973cc40 GIT binary patch literal 448 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νSCm&NtqW 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 aR09At@)(8K9xJr~0000NiXAqHX) zLX_ZQln_B;_(NVIhM?eJ0^0}-HdokK+tJ;w?fUM$*VE38c3rpI$6QfA$;o;B&h77a z9`~Hzxs~DxM~Ww$f4>1bovVZpZYe-2Xx0~sF}k$UP)C2SPAVm5)N1+d;e<)J3X4Th z+wIO>G`W@r24e$H!y$ztc}sdaVTTeJ(g1_OwL%d1TAEBjVqhVJS(}q1KQXu>#DMN@ zd%nj*e8Z+hVqufXgc6l%+=IZvkf}kHKZ?Xt-iOldCPxMr1{t8&+c(naIV3KXI-T>~ zz=9wH1c9tafQ=5d9fpivqU970GI)pelb2NEpnP!F>$h(_pj zfID{${2$E_x_bJ4G&=)K`E*FC%Ry2Uh!v`I1sQb%*tSb1p>rKm^tb?~$JnPvU9-cjrM|WFjH1r_tp7 z8-w!honO;#Cy>4WI!25CmOpU%)Op;$-!597w;KF0bFfx)o1)@E)YL2zEgz8qJGSqE z-EPNdl9HkvJp;Zwei)sdx)J3J1}6^eZ^rT!1(4^TLyz+wW~$#rs%+wjZS!&`AQB=n zAU8M1AHy`O< zXX|g6TdqQ7RSss)$wa(}%m9lGfV zK#B7|8)(p@xHuI%ca?$qyg|k?D1JK%jBBx!&FF!4Fq$ID4rF?`@cY${BJ9kNVC_eETE1OP}?pWt&!mDGh&j zKs~cb{hpcV5#TO0(6a*PPkU;A^dCq<9{hLg4)csu8s2#2c^J#H(Ar|feA7L6k6j3n z;~QItH6Ret21u&rfWOt|JM|%H`2vvK$0Ok4EVTC@(s^$ILz0lAEF1C}r!N?B@{|+Z zW)?MDav>>HgPGblT6{+rxDyxsBgo920Ue(x+VwHN}U)t;U=1lhtW=_*r~SZsF<)B zS;?~y58^TcH?Fhzct;}&3sbOgaW*O{)X2^b_Pm^479G78{rd##XvV^cyO5vu0-_=A zQ|O~@4RAWW*!fABDEZ@etLZOGuyX1dL_?qfF*`I?s|Sq@CKMH=if)six`e)w9sb=q zR-!V%w>WLRUfSy%KHt~uFRQG~7TtbQS~WPqh0C4W;N@L@L!2CoXoz?b*VlLB)7`hg z(|hBzv?TQOxUqUo5lUu?choh?c_>w{N2BpeoNRfODsQ6uB7?>Gdud$-B4JRDDuce@>0S{42390ll&v{5csOi`(bIj}rrBl8ImB6%AL z1nqez)oI;e<$Z*`Pp4NSlj)brZ>h*L7*mAb<#MtrGgEPYsM=va2+(0;^m@l8Dz=?^ zkaGNk_zb_b+&l_m_d$``TCSYpr#zbFDRDFI44esF4}-rb8Q3Hmq_Iq2bY;~5{0y|59m*=YoCp9Zrd3sU z7bk1-BomIE^*Q5`ZJK^o$LCUE&2m3GF#8oNf^7kNw4?B01g~5Fve03KC-#oYCk6BH z<3fkUN%}BrI}Z>0H*}O_fYsJ|S+cF+k=jG2KjMpREAbX7=P=0+z*U9=M|4f9>DQ5| zaFIm6fwukq9gAQT9gym~yZfWjkAXc$L!zyrGj)v&8xnpVNd25jpq#wRJQ0GTfH{nNmWXqrj8+ z=7Lfl-2nDJi8v{qE$$E)nRz@!AG(~+Z{QO8zBGUP)`McmHf z;?ET2Y$7lAZ_?riO7MfjqO3VZGP{uKr_-zVruLUWyMj`37Sp{z;9aQ7 zKo)oIE2oKQ=G_nC{3s}h^Hb0Vt3t8H~0^`OyqBm#t93Tt32+b z1FkuBKBg`L;_mM_Y>e)9;Q+rz-*yY@olnc6wzgC{=k=hT1gv6iVz;lwn40ha*>+{^ z*)nS_6I5XsoXwmD#-_x4ok6gtc15jssFDo)mS7;;6nR3LFf!UMa^cz?U~b)iv;G17 zbN?BCv%uhof#5ff8)`%JaI#p1^6100*D93Ra^Sb@lP6mSJxWJB*j_O`fP82c+!Cd5 zcXwxU@(28U_G57M9keIw=7UY6Lb&`3U^eem`Q9uk^@WHVFjF$->l?4pn?!hb1>9Dq zhY7TJ@Nu)?JUJOpci*1ZLGg2wAz#^AD>TR{;(y%jwS@tx-;Q55exI(bnblrEz29F! zwbX3j+S_Ngl8uQDLIf~tq0{Jw5;=FU}Z0z%hh?i-Uuo6D5Vri4!^PbtHhS(`}%@LmfKHjUVNJ_aig2 zfA%qSY3j!fVWXAIQ+LVUeBcSWHpZ; z#KKI8%EX@;%HqrZFz~pC;O5|fqh}yFVHC)^09aT&wVj+W(~IGaS^y|SU%C9V4^>nl zart%@6(}uj)I4{t#IJWqj|9w;TZ)+mJPHap_~!Dg{xW@56505KO|)Pn5&va}0yMm> zBzDhcR`=L&LH7#4`h*$Nv)cjDUrYJGtg}7Z*g|hdb8WTcCb{7(7g!8WVhX$Kn1A*l zeRstbrLbl(hfosUKd=^mSZRBE6DB3!iV`6IiVsz>G#`- zs#u(C95+vcNVvbkT5@W+xRqGx0mwF`0tv!Gdx45D#^1YgW@cN>Nr*`&av&R`Y_+>l zUA?o$$RBh8(9n=}=2|O#@BMulg|hf?LQMk@_DMA&!?K;egF= zSEF#Ahlj_rkmT+Q7~3ZWT;FBk`&0nAo*b`yfLDakCxSoYiAM$3^2OBvn18$99uo31 zl#wndk@U~7(APgTF>1;A$0UGF-rj&TZ_8gfAmFdtt~f>)q&~bhbcErA-n48~lA>FX zMXsYuO=3d-u?$Fjn1o3TwIKnf_B~Ng>ESW`#}YtD1hJ-YEb$0km zQe;*R-Nc|%#$TtOv(-BXT0|iY{93RJ(!jg7Fgn23mmWh8t)xXdHy|L)sVm=K4`yw< zD&1LA8cGh3-S(2ch5-SsF}v3=+Q1&Gn6EL~4_&8tIr_&VP)cIl3zk_}NEGNEG=zcK z^P>Zik@zMw+i%^lcVP$sqIaj?%9?2ZXOt^vqxdPRQ0#LazjxvU$~aQsotAN*>?Dum zqvDDm31}Jm+@yD9IUM|%n)3T#p{E256si}m>STRYOh;xm9GLnNGa$PrW2*NgBaYTP z@S?1e(rZ-#8x#`&1XKC!QQYckVm_L;Ul%CP5q+Q;vBulf^Coood?)!c_*zZjJ_EXU z2(qQKtWSJ=%5CO44C)uu)62l!n1H@<_}K)VcF$3q!f(-H?a4D6r_0vYl-6swe?b%J zAFnz2w=@3p168rg&G`u`qzw)(H%AM$x$NoZ9ug z$w}`<3%wet_83Ow;v%8a8V3Ues}T`lafjqXZvl{_aeyWRvLLajh8-;g?JV~3ubwu8 zvr8}IXUr!>`g?7#hJfFU=p@9aqYMDq6B{!#%)8yIv^s#hiwKai4_OTQs~yty$!AOO zYIo^*7HuF|CF?NdzEZqDm=4m5z+HO0>f7r?1IlPPjz~-w*K>9Tj zYr9IWlT(KDDc(x}Y5%^%Y_6-jRL2Kt#R>5EfTi;Q36$=hGrkwS{f7>@ki-P-$CQF^ zUMY4^y&f`a(A`|_{wj}j=unSak0t(4h=IU|7^;VK`zIaJ;+`=N1WPhes4YVuBs&Sx zA$$iq)C%5aeJQ#mNcLz&GQdV5^^mq@ACi@E(di1IXArJwD_OLxSLJn+28r!NEf}D? zU8t3@rVYjtV9C2gNL$fyj*`^%>{B5f&~DI%z4y-AaBCzn)w==#B05gXBlLbvKPe;V zy#nE-*XU8hx}Fj6=g%L}-ki+GXWglyYn@EC%&v^GAOwO|SqrOoy=xO-s&r8A<{_;{4y4l;!97Tj z-*R`mR@1oq&}SNPLd)))7WuOw4j^PEt5AnN_gx3Z-$;CKQ78*7QM24|ULP#4YrTWE zt33Y7VF=9dI*CFqeJl~iG;Y+Wq4wC*AfY6y+ipP zxWqj$5u;!GcMw>mH6fQ(j88;If{4M{j~Xe55B71ML)$5KLyk74FOn1}$d+s!HFq;v z5j#GQnr>IJa|j?-33exB9B@vM&FJF5kr@taIhIG0@GGmnX?7VSv=e0Cz!QfP1z>mf zr$+bU4nE78suYX1Q8_yCksLi2@Q1O{s~dl~1ZKDG(Ly!oSpVf~_cHXYiryvdZ&z;S z24(V_445r(_;w9^xbcuDV37m)L@Bo<(eSYr1!Tm>YnulU-jVA8X}dHGFFw3nEx_ zqe;*GxL;=%7N1e02 z*8uz;6gE`GQ_6ZmtKn9fY5vd-4-X0S&^`G?2h5qC0NE?KHw{vZ(NWy4A{Pa2u^lhF zk$jYf#9V2Rfe`3#w_UsE@y17GD22WJGs$meXlU|6BOYd4Yj5A*na5FyW1h32wZCUZ zxIxk#0IpRqyg9v&usmHdCYnl+S@Uy%q+8Hs=96jZiofr#>%H|u zCZr%6>Oih_b6tCPaUr-qq-U&Ub2gRSREQF^JfNO#Q1;YC%?e2ej-hL4J#XG=QSuYWeSj`L708{JRy#lQxdB_g zH+HUN8``@|3NrQDo(B1|Jf3#CCw8Q!N6<)=IXY!b4Phu6egSBX-|P(v8B^t!v;^3Zdgs1Ngg290%fy>TNLzFkcc5_;?G zTY6wC>ybXJ4ce8E?1V;Qz#9}Jgs!1mFNs^MA~10E3N#z)PlX;`eshJpDZ}kXT~Eyq z?jEa{Yea$uf&bh*)*MQ}+?^pXT6~$^RU&6g{C~@9WqT@OKVDMZUpj>CcN&i8LH9jHUmS zvHf2zfKjb9aOZfwlVSfCUw~Zr6;=*1gQt`wO-(WZcstC9-wZwjayddQminew{MtoP z-{r!Z0&jgQ0A%9`#4cig`HmVDkX!X?Zq$?XTpy$@I}V{!)&eA}YN8kjaIoE>h3|bM z_^+`%=D!3;3SOzjlo1wb$wAWh-RtldZf@apQe;SQGL+${dHYNpyXq3AcG-)-qn|gB zK)=~B2Cx~7qL$L>pF+)CM^8y*Yv@35*exV-Wp≦i9aLc=vlhKfiaTw*Z;4i_527 zEXO+do&K`q#j|J69)G0+B=;fTyDxQu1ZkfnXqES$@scH@MY)jcNs`g3&*-3eE%!P# z@f$(FLHSv>x{-h9^|!NGag>=p*rnyPE#G3mrnwb>$xRk_OFnxXc`_S1lgptAA!j+e zlZZlJ%qiv;83T+hlZtA0KW!^e7=P{@@{{X*Lcpn{iMp||7Ug{sS)_O*am%TpFLryT zJr#hqe-X#cTX?4-C�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
Q{rz(EJKf8Zkw`fx{B)3@#VONG zhUDrEeM17ggRiZzI&b#&Nhh}>jtW4;j|*<3f#&Jy8~~ZJDQJdgwJbr*Q-W*Oj++QK zfh1hiHLK*ipe4~|bYK4Vr|7ZKD>K{?R8PRQf%f= zhAN?Cql83hxmE9n%2*AqUjOyt0*KJ+s@X`%9%w*~4PP#yuZRL4&3b#o^q?@nJD(Fz zw}lJ9S)uQ0bKw_)1bul~Nnw}+y=w^ImxDsxX+mNJ-MvbLi`Z9508p$6^%DCb3Dh1L z(RuUH?LWUhpige;JsYgWc`Sn6Z63Wl{TL#^)CbCtjbQ!O3vi4mOIK<6F(~MaybZSF9o;(g9dB;MeSw66%kYjj zGpr>PI=reXxr1)!qJ*5j`P{$L^RShT7hEN^EU4(OSf|N5uV*D=$PhN(ZD;%8CcXbV zC{0H-F0)bkFZK0Aem1uWtTJ2T(pysOy)p(6@nL#aXm_!xVy2?R1Nu{z*4*6sXM|j7u$FBBKYe*mbU6u(GTd6LP^$~d zS=>E|d0r1^uBP}hphf)i!ap-p7SG7IDoW#@L1#?{frrNrpaSu;D8c`Gzv%%1o&Wx{ s$TE=>03wmL#}CqL;M|!iBL!vu-+sOcyU4(*1{7iNboFyt=akR{0P5b+C;$Ke literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..fd3bec26c0472e399389966663073e7a33f74104 GIT binary patch literal 2095 zcmV+~2+;S5P);F6Ou5Jl4d{#+Sb4rFCs%LpbgMii^il0wy~{HX_Jt~s;!&W zp>17PsbYQE)S<5Z1C{Zns$pY;hSHL<66o3_Eu{^C6zZ2a2{v(TW8Yn$JJU3doy5NQ zoW$^vEZ_Zpzw`Nd?)g2x=Xvf^8`&18 z0Gca{kiEpo7NY=KI60d8mB?PI<=|j=LyQ7E9uy-}h!Vt)J<3Pk&J<23iiYoxNQOdr z&={%nD#8a(LA>}8oh}@0c(RDk=|GBDLMp(T2KsDQqDhW3YdQs_h$q0QBhdcBD3 zZ-&s)9^Xld5Zf*xL?3p^d~o0S8elO)3!*Z9h7K7b@~{6T7JdZ6={AI4I*m+`5e3NR zE*yI^>GASdWr}2g%qXD!5>iFYF>1(2NlbUo=aEx4E3Q4fw@LZtwyupoD9?mUkQoKc zOuV{E&c0}WT@@yaJ4I2bBp3+c$dM*^y*{W#utwtm_L=9Pmiz)M)~#CugMo({R4d@k zv*+;WANL?Fygsqyv#iXHrAyVNFsfI9ndRlMS}mAtWDBUMD2G~N5(AGXR2PYe??Pi9 z`0*La%^Cdi;d+EjEqMLfLojo8tgkr&tkE(IMxTVAm`Hx2Z91jPGVQ0+DFyEnodAOCxwT zf6U(<7zpF(C&@ot8Gzdr#OgKW*!umN`19{O4&mq-Wt&}E4{qBxkqqfk00lw82Og&! zSBUOQw`vG!wAap`@4#<<{Wi>IJ)V1h2|`={0Ew>l(D{a+OV5ITdM6kIH}3C_RDk+y zkWQ)qZ}R3o3HZM_nODof9$10$jdEC zHA%m^fd=G24Vni+eZdPjb#)i~VTZCW3qQZ;2PicygoUrdwQh+VpjY{{w)PtLrhU9vzG8TUGT}nkEa73S&A!Ns>gQ$jOt=n0=F(#qQrPz>Wv+p0MyTJZr&{ zvfnDgnmJocpEuNnR3RB-vZfdXh$4R-DYE&yvyqz{O+#6h!Oop?F=vhy=|;ZN_fYAK zx%HQM){06?W6Hl1iST{H_u~?Hr!ycT#V14MKu3=47c4Bmj2Y@~P$Y}IBs%?P$Ul6Y zmP-RtM6%h8F&9070;qBcCVipT6oRb(yxne!sl~-DgAAklH?kB;vEyV*@SLEOwF=Pb z5GPy0x$bZbo}Di51|$oa|2Z=MIGGYgf(*lMfmTmTO%7CMV!xNDBcTFpHojewm~47v zcAMSKcO>cDxP6e=@Jktfi9ki%=dppH|$UJJ9!)lo-Zz z>f>gEWC=|iC)JTA4`BMRpJffT;|VX5QpD$S4St2Pzs \ 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/Gemfile b/docs/Gemfile new file mode 100644 index 0000000..6563f8c --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,7 @@ +source "https://rubygems.org" + +# Pinned to the exact version GitHub Pages' classic build currently runs +# (checked against https://rubygems.org/api/v1/gems/github-pages.json). +# Keep this in sync if GitHub bumps their build image, so local builds +# never diverge from what actually gets deployed. +gem "github-pages", "232", group: :jekyll_plugins diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock new file mode 100644 index 0000000..3f7b4c1 --- /dev/null +++ b/docs/Gemfile.lock @@ -0,0 +1,294 @@ +GEM + remote: https://rubygems.org/ + specs: + activesupport (8.1.4) + base64 + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + json + logger (>= 1.4.2) + minitest (>= 5.1) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + base64 (0.3.0) + bigdecimal (4.1.3) + coffee-script (2.4.1) + coffee-script-source + execjs + coffee-script-source (1.12.2) + colorator (1.1.0) + commonmarker (0.23.12) + concurrent-ruby (1.3.8) + connection_pool (3.0.2) + csv (3.3.6) + dnsruby (1.74.0) + base64 (>= 0.2) + logger (~> 1.6) + simpleidn (~> 0.2.1) + drb (2.2.3) + em-websocket (0.5.3) + eventmachine (>= 0.12.9) + http_parser.rb (~> 0) + ethon (0.18.0) + ffi (>= 1.15.0) + logger + eventmachine (1.2.7) + execjs (2.10.2) + json (>= 2) + faraday (2.15.0) + faraday-net_http (>= 2.0, < 3.6) + json + logger + faraday-net_http (3.5.0) + net-http (~> 0.5) + ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x86_64-linux-gnu) + forwardable-extended (2.6.0) + gemoji (4.1.0) + github-pages (232) + github-pages-health-check (= 1.18.2) + jekyll (= 3.10.0) + jekyll-avatar (= 0.8.0) + jekyll-coffeescript (= 1.2.2) + jekyll-commonmark-ghpages (= 0.5.1) + jekyll-default-layout (= 0.1.5) + jekyll-feed (= 0.17.0) + jekyll-gist (= 1.5.0) + jekyll-github-metadata (= 2.16.1) + jekyll-include-cache (= 0.2.1) + jekyll-mentions (= 1.6.0) + jekyll-optional-front-matter (= 0.3.2) + jekyll-paginate (= 1.1.0) + jekyll-readme-index (= 0.3.0) + jekyll-redirect-from (= 0.16.0) + jekyll-relative-links (= 0.6.1) + jekyll-remote-theme (= 0.4.3) + jekyll-sass-converter (= 1.5.2) + jekyll-seo-tag (= 2.8.0) + jekyll-sitemap (= 1.4.0) + jekyll-swiss (= 1.0.0) + jekyll-theme-architect (= 0.2.0) + jekyll-theme-cayman (= 0.2.0) + jekyll-theme-dinky (= 0.2.0) + jekyll-theme-hacker (= 0.2.0) + jekyll-theme-leap-day (= 0.2.0) + jekyll-theme-merlot (= 0.2.0) + jekyll-theme-midnight (= 0.2.0) + jekyll-theme-minimal (= 0.2.0) + jekyll-theme-modernist (= 0.2.0) + jekyll-theme-primer (= 0.6.0) + jekyll-theme-slate (= 0.2.0) + jekyll-theme-tactile (= 0.2.0) + jekyll-theme-time-machine (= 0.2.0) + jekyll-titles-from-headings (= 0.5.3) + jemoji (= 0.13.0) + kramdown (= 2.4.0) + kramdown-parser-gfm (= 1.1.0) + liquid (= 4.0.4) + mercenary (~> 0.3) + minima (= 2.5.1) + nokogiri (>= 1.16.2, < 2.0) + rouge (= 3.30.0) + terminal-table (~> 1.4) + webrick (~> 1.8) + github-pages-health-check (1.18.2) + addressable (~> 2.3) + dnsruby (~> 1.60) + octokit (>= 4, < 8) + public_suffix (>= 3.0, < 6.0) + typhoeus (~> 1.3) + html-pipeline (2.14.3) + activesupport (>= 2) + nokogiri (>= 1.4) + http_parser.rb (0.8.1) + i18n (1.15.2) + concurrent-ruby (~> 1.0) + jekyll (3.10.0) + addressable (~> 2.4) + colorator (~> 1.0) + csv (~> 3.0) + em-websocket (~> 0.5) + i18n (>= 0.7, < 2) + jekyll-sass-converter (~> 1.0) + jekyll-watch (~> 2.0) + kramdown (>= 1.17, < 3) + liquid (~> 4.0) + mercenary (~> 0.3.3) + pathutil (~> 0.9) + rouge (>= 1.7, < 4) + safe_yaml (~> 1.0) + webrick (>= 1.0) + jekyll-avatar (0.8.0) + jekyll (>= 3.0, < 5.0) + jekyll-coffeescript (1.2.2) + coffee-script (~> 2.2) + coffee-script-source (~> 1.12) + jekyll-commonmark (1.4.0) + commonmarker (~> 0.22) + jekyll-commonmark-ghpages (0.5.1) + commonmarker (>= 0.23.7, < 1.1.0) + jekyll (>= 3.9, < 4.0) + jekyll-commonmark (~> 1.4.0) + rouge (>= 2.0, < 5.0) + jekyll-default-layout (0.1.5) + jekyll (>= 3.0, < 5.0) + jekyll-feed (0.17.0) + jekyll (>= 3.7, < 5.0) + jekyll-gist (1.5.0) + octokit (~> 4.2) + jekyll-github-metadata (2.16.1) + jekyll (>= 3.4, < 5.0) + octokit (>= 4, < 7, != 4.4.0) + jekyll-include-cache (0.2.1) + jekyll (>= 3.7, < 5.0) + jekyll-mentions (1.6.0) + html-pipeline (~> 2.3) + jekyll (>= 3.7, < 5.0) + jekyll-optional-front-matter (0.3.2) + jekyll (>= 3.0, < 5.0) + jekyll-paginate (1.1.0) + jekyll-readme-index (0.3.0) + jekyll (>= 3.0, < 5.0) + jekyll-redirect-from (0.16.0) + jekyll (>= 3.3, < 5.0) + jekyll-relative-links (0.6.1) + jekyll (>= 3.3, < 5.0) + jekyll-remote-theme (0.4.3) + addressable (~> 2.0) + jekyll (>= 3.5, < 5.0) + jekyll-sass-converter (>= 1.0, <= 3.0.0, != 2.0.0) + rubyzip (>= 1.3.0, < 3.0) + jekyll-sass-converter (1.5.2) + sass (~> 3.4) + jekyll-seo-tag (2.8.0) + jekyll (>= 3.8, < 5.0) + jekyll-sitemap (1.4.0) + jekyll (>= 3.7, < 5.0) + jekyll-swiss (1.0.0) + jekyll-theme-architect (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-cayman (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-dinky (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-hacker (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-leap-day (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-merlot (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-midnight (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-minimal (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-modernist (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-primer (0.6.0) + jekyll (> 3.5, < 5.0) + jekyll-github-metadata (~> 2.9) + jekyll-seo-tag (~> 2.0) + jekyll-theme-slate (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-tactile (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-time-machine (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-titles-from-headings (0.5.3) + jekyll (>= 3.3, < 5.0) + jekyll-watch (2.2.1) + listen (~> 3.0) + jemoji (0.13.0) + gemoji (>= 3, < 5) + html-pipeline (~> 2.2) + jekyll (>= 3.0, < 5.0) + json (3.1.0) + kramdown (2.4.0) + rexml + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) + liquid (4.0.4) + listen (3.10.1) + logger + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + logger (1.7.0) + mercenary (0.3.6) + minima (2.5.1) + jekyll (>= 3.5, < 5.0) + jekyll-feed (~> 0.9) + jekyll-seo-tag (~> 2.1) + minitest (6.0.6) + drb (~> 2.0) + prism (~> 1.5) + net-http (0.10.0) + net-protocol (>= 0.3.0) + uri (>= 0.11.1) + net-protocol (0.4.0) + timeout + nokogiri (1.19.4-arm64-darwin) + racc (~> 1.4) + nokogiri (1.19.4-x86_64-linux-gnu) + racc (~> 1.4) + octokit (4.25.1) + faraday (>= 1, < 3) + sawyer (~> 0.9) + pathutil (0.16.2) + forwardable-extended (~> 2.6) + prism (1.9.0) + public_suffix (5.1.1) + racc (1.8.1) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + rexml (3.4.4) + rouge (3.30.0) + rubyzip (2.4.1) + safe_yaml (1.0.5) + sass (3.7.4) + sass-listen (~> 4.0.0) + sass-listen (4.0.0) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) + sawyer (0.9.3) + addressable (>= 2.3.5) + faraday (>= 0.17.3, < 3) + securerandom (0.4.1) + simpleidn (0.2.3) + terminal-table (1.8.0) + unicode-display_width (~> 1.1, >= 1.1.1) + timeout (0.6.1) + typhoeus (1.6.0) + ethon (>= 0.18.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + unicode-display_width (1.8.0) + uri (1.1.2) + webrick (1.9.2) + +PLATFORMS + arm64-darwin-23 + x86_64-linux + +DEPENDENCIES + github-pages (= 232) + +BUNDLED WITH + 2.4.10 diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..4528a0c --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,69 @@ +title: Substratal Hub Help +description: >- + Help center for the Substratal Hub apps (Linux, macOS, Windows, iOS/iPadOS, + Android and Web): guides, tutorials, FAQs, a page for every screen, and the + versioned Platform API reference. + +# Project page on the default github.io URL. If a custom domain is added +# later (for example help.substratalapps.com), update both of these, add a +# CNAME file, and change HELP_BASE in crates/substratal-core/src/config.rs. +url: "https://compositecode.github.io" +baseurl: "/substratalapps-linux" + +permalink: pretty + +remote_theme: just-the-docs/just-the-docs@v0.12.0 +plugins: + - jekyll-remote-theme + - jekyll-seo-tag + - jekyll-sitemap + +color_scheme: substratal +logo: "/assets/images/mark.svg" +search_enabled: true +search: + heading_level: 3 + previews: 2 + preview_words_before: 4 + preview_words_after: 8 + tokenizer_separator: /[\s/-]+/ + rel_url: true + button: true +heading_anchors: true +back_to_top: true +back_to_top_text: "Back to top" + +aux_links: + "Platform API spec": "https://compositecode.github.io/substratalapps.com/" + "GitHub": "https://github.com/CompositeCode/substratalapps-linux" +aux_links_new_tab: true + +gh_edit_link: true +gh_edit_link_text: "Edit this page on GitHub" +gh_edit_repository: "https://github.com/CompositeCode/substratalapps-linux" +gh_edit_branch: "main" +gh_edit_source: docs +gh_edit_view_mode: "edit" + +callouts_level: quiet +callouts: + note: + title: Note + color: blue + important: + title: Important + color: red + tip: + title: Tip + color: green + +footer_content: >- + Substratal Hub help center. Reference pages are generated from the app's + code, the client specs and the Platform API's openapi.yaml on every change. + +exclude: + - Gemfile + - Gemfile.lock + - vendor + - node_modules + - README.md diff --git a/docs/_data/api.yml b/docs/_data/api.yml new file mode 100644 index 0000000..e819a08 --- /dev/null +++ b/docs/_data/api.yml @@ -0,0 +1,10 @@ +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +# The API version(s) this help center documents. One entry per Substratal-Version value. +current: "1" +versions: + - version: "1" + status: current + spec_version: "1.0.0-draft.9" + operations: 124 + openapi: /api/v1/openapi.yaml + base_url: "https://api.substratalapps.com" diff --git a/docs/_includes/header_custom.html b/docs/_includes/header_custom.html new file mode 100644 index 0000000..d913059 --- /dev/null +++ b/docs/_includes/header_custom.html @@ -0,0 +1,24 @@ +{%- comment -%} + Docs version badge, top right of every page's header. Version data is + _data/versions.yml, generated by scripts/docs-build.rb on each GitHub + build, so a plain local `jekyll build` has none and shows "local". + `version_archive_build` is set only when that script freezes a snapshot + into /versions//, so an archived copy labels itself and links to latest. +{%- endcomment -%} +{%- assign current = site.data.versions.current -%} +{%- if site.version_archive_build -%} + {%- assign latest_baseurl = site.baseurl | split: "/versions/" | first -%} +
+ v{{ current.version }} · archived + View latest → + All versions +
+{%- elsif current -%} + +{%- else -%} +
+ local +
+{%- endif -%} diff --git a/docs/_sass/color_schemes/substratal.scss b/docs/_sass/color_schemes/substratal.scss new file mode 100644 index 0000000..32dc859 --- /dev/null +++ b/docs/_sass/color_schemes/substratal.scss @@ -0,0 +1,21 @@ +// Substratal color scheme for just-the-docs. +// Cool stone neutrals + a verdigris (oxidized copper) accent — a nod to +// "strata": layered bedrock, not another blue-SaaS or cream-and-terracotta +// doc site. + +$body-background-color: #f2f3f0; +$sidebar-color: #e9ebe4; +$search-background-color: #ffffff; +$table-background-color: #ebede6; +$code-background-color: #e9ebe4; +$feedback-color: $sidebar-color; + +$body-text-color: #20242a; +$body-heading-color: #171a1e; +$search-result-preview-color: #5b6168; +$nav-child-link-color: #4b5159; + +$link-color: #2e6f62; +$btn-primary-color: #2e6f62; +$base-button-color: #e9ebe4; +$border-color: #d7dad2; diff --git a/docs/_sass/custom/custom.scss b/docs/_sass/custom/custom.scss new file mode 100644 index 0000000..7ece77b --- /dev/null +++ b/docs/_sass/custom/custom.scss @@ -0,0 +1,144 @@ +// Loaded last by just-the-docs — safe place to win the cascade without !important. + +@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Serif:ital,wght@0,400;0,500;0,600;1,400&display=swap"); + +$accent: #2e6f62; + +body { + font-family: "IBM Plex Serif", Georgia, "Times New Roman", serif; + font-size: 17px; + line-height: 1.65; +} + +.site-title, +.site-nav, +.nav-list, +.search-input, +.label, +.btn, +.main-content h1, +.main-content h2, +.main-content h3, +.main-content h4 { + font-family: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif; +} + +.main-content h1 { + font-weight: 700; + letter-spacing: -0.01em; +} + +.main-content h2 { + font-weight: 600; + margin-top: 2.2em; + padding-top: 0.9em; + border-top: 1px solid $border-color; +} + +.main-content h3 { + font-weight: 600; +} + +code, +pre, +.highlight, +kbd, +.code-example, +.language-plaintext { + font-family: "IBM Plex Mono", SFMono-Regular, Consolas, monospace; +} + +.site-nav .nav-list .nav-list-item .nav-list-link.active { + color: $accent; + font-weight: 600; +} + +.site-title { + font-weight: 700; +} + +// Thin layered-strata motif on the header — a quiet nod to "Substratal." +.site-header { + border-bottom: 4px solid transparent; + border-image: linear-gradient(to right, #8a7a63, $accent, #4b5159) 1; +} + +// Docs version badge (_includes/header_custom.html), top right of the header. +.docs-version { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0 1rem; + margin-left: auto; + font-family: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif; + font-size: 0.8125rem; + white-space: nowrap; +} + +.docs-version__badge { + display: inline-block; + padding: 0.15rem 0.6rem; + border: 1px solid $accent; + border-radius: 999px; + color: $accent; + font-family: "IBM Plex Mono", SFMono-Regular, Consolas, monospace; + font-weight: 500; + text-decoration: none; + + &:hover { + background-color: $accent; + color: #fff; + text-decoration: none; + } +} + +.docs-version--archived .docs-version__badge { + border-color: #b7791f; + background-color: #fdf3e1; + color: #8a5a12; + + &:hover { + background-color: #fdf3e1; + color: #8a5a12; + } +} + +.docs-version__link { + color: $accent; +} + +// Interactive Reference (docs/openapi/reference.md): give Swagger UI the +// full content width and undo just-the-docs' table/code styling inside it. +.swagger-host { + margin-top: 1.5rem; + + .swagger-ui { + font-size: 14px; + + table { display: table; min-width: 0; border: 0; box-shadow: none; } + th, td { border: 0; background: transparent; padding: 8px 10px; } + code { background: transparent; border: 0; padding: 0; } + pre { margin: 0; } + .wrapper { padding: 0; max-width: none; } + .information-container .info { margin: 0 0 1rem; } + h2, h3, h4, h5 { border-top: 0; margin-top: 0; padding-top: 0; font-family: sans-serif; } + } +} + +// Keyboard keys in the shortcuts reference. +kbd { + display: inline-block; + padding: 0.05rem 0.4rem; + border: 1px solid $border-color; + border-bottom-width: 2px; + border-radius: 4px; + font-size: 0.85em; +} + +// API version picker on the interactive reference. +.api-version-picker { + display: flex; + gap: 0.75rem; + align-items: center; + margin: 1rem 0; +} diff --git a/docs/api/authentication.md b/docs/api/authentication.md new file mode 100644 index 0000000..0267e99 --- /dev/null +++ b/docs/api/authentication.md @@ -0,0 +1,47 @@ +--- +layout: default +title: Authentication +parent: API +nav_order: 2 +permalink: /api/authentication/ +--- + +# Authentication +{: .no_toc } + +Every request except a few public ones (`/health`, sign-in, sign-up, password reset) sends a bearer token: + +```http +Authorization: Bearer +``` + +1. TOC +{:toc} + +## People: platform sessions + +The Hub signs people in with email and password: + +1. `POST /auth/login` with `{email, password}` returns either a session or, when two-factor is on, an MFA challenge (`mfa_required: true` and an `mfa_token`). +2. `POST /auth/mfa/verify` with the `mfa_token` and a 6-digit `code` (or a `recovery_code`) returns the session. +3. The session has a 15-minute **access token** (a JWT) and a long-lived, single-use **refresh token**. +4. `POST /auth/token/refresh` swaps the refresh token for a new pair. Each refresh token works **once**: reusing one revokes the whole session and raises a security alert. Refresh one request at a time and save the new refresh token before using the new access token. +5. `POST /auth/logout` ends the session. + +## Apps: app tokens + +A launched app gets a 5-minute **app token** for one Application: from `POST /auth/app-tokens` (embedded launch from the Hub) or through the hosted sign-in with PKCE (`POST /auth/oauth/authorization-codes` and `POST /auth/oauth/token`). + +## Servers and agents: API keys + +An API key (`satk_live_…` or `satk_test_…`) is a bearer token for your backend or an AI agent. It holds only the permissions it was created with, and an agent key can be restricted from destructive actions. Keys: + +- never work from a browser (`400 api_key_in_browser`); +- can't act as a person (endpoints using `me`, or `app-tokens`, return `400 user_token_required`); +- can't manage other keys. + +Create and rotate them in the Hub; see [API keys]({{ '/tutorials/api-keys/' | relative_url }}). + +## Permissions + +What a token can do comes from its holder's roles, or the key's permissions. A `403 forbidden` names the missing permission in `details.required_permission`. See [Navigation and permissions]({{ '/reference/navigation/' | relative_url }}) for the platform permissions. diff --git a/docs/api/changelog.md b/docs/api/changelog.md new file mode 100644 index 0000000..5d1b234 --- /dev/null +++ b/docs/api/changelog.md @@ -0,0 +1,27 @@ +--- +layout: default +title: API changelog +parent: API +nav_order: 5 +permalink: /api/changelog/ +--- + +# API changelog +{: .no_toc } + +Changes to the Platform API specification this help center documents, newest first. Entries are added automatically by the **API spec sync** workflow when the API's `openapi.yaml` changes; it compares the old and new specification with [oasdiff](https://github.com/oasdiff/oasdiff) and flags anything breaking. The API team's own record is the [spec changelog](https://compositecode.github.io/substratalapps.com/changelog/). +{: .fs-6 .fw-300 } + + + +## 1.0.0-draft.9 (API v1.8.0) — 2026-10-22 + +Baseline for this help center: 124 operations under `Substratal-Version: 1`. + +- **Versioning moved to a header** (draft.4 to draft.5, API v1.7.0 to v1.7.3): `/v1` dropped from every path; `Substratal-Version` required; `Deprecation` and `Sunset` on deprecated versions; `405 method_not_allowed` for a wrong method on a known path. +- **SCIM 2.0 provisioning** per organization: `GET`/`POST`/`DELETE /organizations/{id}/scim-token`. +- **Test-mode user expiry**: `expires_at` on test users, `403 test_user_expired`. +- **Self-service application registration**: any person with a verified email can `POST /applications`. +- **Audit exports**: `POST /audit-exports`, `GET /audit-exports/{id}`. +- **Sign out everywhere**: `POST /users/{id}/sessions/revoke-all`; `GET /users?role_id=` finds a role's holders. +- `Deprecation` and `Sunset` exposed to browsers through CORS. diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..786fe20 --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,48 @@ +--- +layout: default +title: API +nav_order: 7 +has_children: true +permalink: /api/ +--- + +# Substratal Platform API +{: .no_toc } + +Every Hub app is a client of the Substratal Apps Platform API. Use this section to integrate your own app or tooling with it. The API's full specification, domain model and design decisions live in the [Platform API spec site](https://compositecode.github.io/substratalapps.com/); this section covers what you need day to day. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Base URL** | `{{ site.data.api.versions[0].base_url }}`. Production; the host root, no version in the path. | +| **API version** | `Substratal-Version: {{ site.data.api.current }}` request header, required. See [Versioning]({{ '/api/versioning/' | relative_url }}). | +| **Specification** | [`openapi.yaml`]({{ '/api/v1/openapi.yaml' | relative_url }}), OpenAPI 3.1, revision **{{ site.data.api.versions[0].spec_version }}**, {{ site.data.api.versions[0].operations }} operations | +| **Authentication** | Bearer tokens: a person's access token or an API key. See [Authentication]({{ '/api/authentication/' | relative_url }}). | +| **Format** | JSON. Errors are `{"error": {"code", "message", "details"}}`. | + +## Your first request + +```sh +curl -i https://api.substratalapps.com/health +# HTTP/2 200 +# {"status":"ok"} + +curl -i https://api.substratalapps.com/users/me \ + -H "Authorization: Bearer $TOKEN" \ + -H "Substratal-Version: 1" +``` + +`/health` doesn't need the version header; everything else does. + +## Conventions in brief + +| Topic | Rule | +|---|---| +| IDs | Opaque strings with a type prefix: `usr_`, `app_`, `org_`, `ent_`, `key_`, `evt_` and more. Use `me` for yourself with a person's token. | +| Pagination | `limit` and `cursor`; read `page.next_cursor` until `page.has_more` is `false`. | +| Creates | Send an `Idempotency-Key` (a UUID) so a retry can't create twice. Some creates require it. | +| Edits | Send `If-Match` with the `ETag` you read; `409 version_conflict` means someone else changed it first. | +| Rate limits | 100 requests a minute per session or key by default; `429` carries `Retry-After`. | +| Request IDs | Every response has `X-Request-Id`. Quote it when asking for help. | + +Full detail: [Conventions](https://compositecode.github.io/substratalapps.com/api-reference/conventions/) on the spec site. diff --git a/docs/api/operations.md b/docs/api/operations.md new file mode 100644 index 0000000..014603f --- /dev/null +++ b/docs/api/operations.md @@ -0,0 +1,217 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "Operations" +parent: API +nav_order: 4 +--- + +# API operations +{: .no_toc } + +All 124 operations in `openapi.yaml` **1.0.0-draft.9** (API version `1`), and which Hub screens use each one. Full request and response shapes are in the [interactive reference]({{ '/api/reference/' | relative_url }}). +{: .fs-6 .fw-300 } + +**Header** ✓ means the operation requires `Substratal-Version`; the few exempt ones are noted in [versioning]({{ '/api/versioning/' | relative_url }}). **Auth** — means no sign-in needed. + +1. TOC +{:toc} + +## API Keys + +| Method | Path | Summary | Header | Auth | Used by | +|---|---|---|:-:|:-:|---| +| `GET` | `/api-keys` | List keys the caller may manage | ✓ | ✓ | [KEY-01]({{ '/screens/key-01/' | relative_url }}) [INT-01]({{ '/screens/int-01/' | relative_url }}) | +| `POST` | `/api-keys` | Create a key (api_keys.manage, or the Application's owner for an app-scoped key) | ✓ | ✓ | [KEY-02]({{ '/screens/key-02/' | relative_url }}) | +| `GET` | `/api-keys/{id}` | Fetch one key (never the secret) | ✓ | ✓ | [KEY-04]({{ '/screens/key-04/' | relative_url }}) | +| `PATCH` | `/api-keys/{id}` | Change name, permissions, or restrict_destructive | ✓ | ✓ | [KEY-04]({{ '/screens/key-04/' | relative_url }}) | +| `DELETE` | `/api-keys/{id}` | Revoke permanently | ✓ | ✓ | [KEY-04]({{ '/screens/key-04/' | relative_url }}) | +| `POST` | `/api-keys/{id}/rotate` | Issue a new secret, invalidating the old one immediately (no overlap) | ✓ | ✓ | [KEY-04]({{ '/screens/key-04/' | relative_url }}) | + +## Applications + +| Method | Path | Summary | Header | Auth | Used by | +|---|---|---|:-:|:-:|---| +| `GET` | `/applications` | List the catalog (filtered by visibility rules) | ✓ | ✓ | [APP-01]({{ '/screens/app-01/' | relative_url }}) [ACX-01]({{ '/screens/acx-01/' | relative_url }}) [APL-01]({{ '/screens/apl-01/' | relative_url }}) [BIL-01]({{ '/screens/bil-01/' | relative_url }}) [NTF-01]({{ '/screens/ntf-01/' | relative_url }}) | +| `POST` | `/applications` | Register an Application (applications.manage, or self-service into review) | ✓ | ✓ | [APL-02]({{ '/screens/apl-02/' | relative_url }}) | +| `GET` | `/applications/{id}` | Fetch one Application | ✓ | ✓ | [APL-03]({{ '/screens/apl-03/' | relative_url }}) | +| `PATCH` | `/applications/{id}` | Update a catalog entry (owner — configuration; applications.manage — anything) | ✓ | ✓ | [APL-03]({{ '/screens/apl-03/' | relative_url }}) [APL-04]({{ '/screens/apl-04/' | relative_url }}) [APL-05]({{ '/screens/apl-05/' | relative_url }}) [APL-06]({{ '/screens/apl-06/' | relative_url }}) | + +## Audit + +| Method | Path | Summary | Header | Auth | Used by | +|---|---|---|:-:|:-:|---| +| `GET` | `/audit-events` | Query the audit log (hot window only) | ✓ | ✓ | [KEY-04]({{ '/screens/key-04/' | relative_url }}) [AUD-01]({{ '/screens/aud-01/' | relative_url }}) [OPS-01]({{ '/screens/ops-01/' | relative_url }}) | +| `GET` | `/audit-events/{id}` | Fetch one Audit Event | ✓ | ✓ | [AUD-02]({{ '/screens/aud-02/' | relative_url }}) | +| `POST` | `/audit-exports` | Export Audit Events to a file (asynchronous) | ✓ | ✓ | | +| `GET` | `/audit-exports/{id}` | Fetch an export's status, and its download link once ready | ✓ | ✓ | | + +## Auth + +| Method | Path | Summary | Header | Auth | Used by | +|---|---|---|:-:|:-:|---| +| `POST` | `/auth/signup` | Create an account with email and password | ✓ | — | [AUTH-03]({{ '/screens/auth-03/' | relative_url }}) | +| `POST` | `/auth/login` | Exchange email and password for a session (or an MFA challenge) | ✓ | — | [AUTH-01]({{ '/screens/auth-01/' | relative_url }}) | +| `POST` | `/auth/mfa/verify` | Complete a login that returned an MFA challenge | ✓ | — | [AUTH-02]({{ '/screens/auth-02/' | relative_url }}) | +| `POST` | `/auth/token/refresh` | Rotate a platform refresh token for a new access token | ✓ | — | [GLB-03]({{ '/screens/glb-03/' | relative_url }}) | +| `POST` | `/auth/logout` | Revoke the current session, or every session | ✓ | ✓ | [ACC-01]({{ '/screens/acc-01/' | relative_url }}) [GLB-03]({{ '/screens/glb-03/' | relative_url }}) | +| `POST` | `/auth/app-tokens` | Mint a per-app JWT from a platform session (embedded login) | ✓ | ✓ | [APP-03]({{ '/screens/app-03/' | relative_url }}) | +| `POST` | `/auth/oauth/authorization-codes` | Mint a one-time authorization code for a hosted launch (PKCE) | ✓ | ✓ | [APP-03]({{ '/screens/app-03/' | relative_url }}) | +| `POST` | `/auth/oauth/token` | Exchange an authorization code or app refresh token for an app token | exempt | — | | +| `POST` | `/auth/email/verify` | Confirm an email address from an emailed token | ✓ | — | [AUTH-04]({{ '/screens/auth-04/' | relative_url }}) | +| `POST` | `/auth/email/verify/resend` | Re-send a verification email (always 202) | ✓ | — | [AUTH-04]({{ '/screens/auth-04/' | relative_url }}) | +| `POST` | `/auth/password/forgot` | Send a password-reset email (always 202) | ✓ | — | [AUTH-05]({{ '/screens/auth-05/' | relative_url }}) | +| `POST` | `/auth/password/reset` | Set a new password from a reset token (revokes every session) | ✓ | — | [AUTH-06]({{ '/screens/auth-06/' | relative_url }}) | +| `POST` | `/auth/invitations/accept` | Set a password on an invited account, activate it, and sign in | ✓ | — | [AUTH-07]({{ '/screens/auth-07/' | relative_url }}) | +| `POST` | `/auth/sso/{provider}/callback` | Complete an SSO login (reserved — 501 until the broker integration ships) | ✓ | — | [AUTH-01]({{ '/screens/auth-01/' | relative_url }}) | +| `POST` | `/users/{id}/password` | Change password given the current one (self only) | ✓ | ✓ | | +| `POST` | `/users/{id}/mfa/totp` | Begin TOTP enrollment (self only) | ✓ | ✓ | | +| `DELETE` | `/users/{id}/mfa/totp` | Support reset of a user's TOTP (requires users.manage) | ✓ | ✓ | [USR-03]({{ '/screens/usr-03/' | relative_url }}) | +| `POST` | `/users/{id}/mfa/totp/confirm` | Confirm TOTP enrollment with a first code; returns recovery codes once | ✓ | ✓ | | +| `POST` | `/users/{id}/mfa/totp/disable` | Turn TOTP off given a current code or recovery code (self only) | ✓ | ✓ | | +| `GET` | `/users/{id}/sessions` | List active sessions (self or users.manage) | ✓ | ✓ | [USR-04]({{ '/screens/usr-04/' | relative_url }}) | +| `POST` | `/users/{id}/sessions/revoke-all` | Revoke every session (self or users.manage) | ✓ | ✓ | [USR-04]({{ '/screens/usr-04/' | relative_url }}) | +| `DELETE` | `/users/{id}/sessions/{sessionId}` | Revoke one session | ✓ | ✓ | [USR-04]({{ '/screens/usr-04/' | relative_url }}) | + +## Billing + +| Method | Path | Summary | Header | Auth | Used by | +|---|---|---|:-:|:-:|---| +| `GET` | `/users/{id}/subscription` | A User's plan, what covers it, and their own subscription (self or billing.manage) | ✓ | ✓ | [USR-03]({{ '/screens/usr-03/' | relative_url }}) | +| `POST` | `/users/me/billing/checkout-sessions` | Start a Stripe Checkout to subscribe yourself to Standard (User token) | ✓ | ✓ | [BIL-03]({{ '/screens/bil-03/' | relative_url }}) | +| `POST` | `/users/me/billing/portal-sessions` | Open the Stripe Customer Portal for your own subscription (User token) | ✓ | ✓ | [BIL-03]({{ '/screens/bil-03/' | relative_url }}) | +| `GET` | `/organizations/{id}/subscription` | An Organization's plan and Standard subscription (org admin or billing.manage) | ✓ | ✓ | [BIL-02]({{ '/screens/bil-02/' | relative_url }}) | +| `PATCH` | `/organizations/{id}/subscription` | Choose which members the Organization's subscription covers (org admin) | ✓ | ✓ | [BIL-02]({{ '/screens/bil-02/' | relative_url }}) | +| `POST` | `/organizations/{id}/billing/checkout-sessions` | Start a Stripe Checkout to subscribe the Organization to Standard (org admin, User token) | ✓ | ✓ | [BIL-03]({{ '/screens/bil-03/' | relative_url }}) | +| `POST` | `/organizations/{id}/billing/portal-sessions` | Open the Stripe Customer Portal for the Organization's subscription (org admin or billing.manage, User token) | ✓ | ✓ | [BIL-03]({{ '/screens/bil-03/' | relative_url }}) | + +## Entitlements + +| Method | Path | Summary | Header | Auth | Used by | +|---|---|---|:-:|:-:|---| +| `GET` | `/users/{id}/entitlements` | A user's entitlements, personal and org-sourced | ✓ | ✓ | [ACX-01]({{ '/screens/acx-01/' | relative_url }}) | +| `POST` | `/users/{id}/entitlements` | Grant a personal Entitlement | ✓ | ✓ | [ACX-02]({{ '/screens/acx-02/' | relative_url }}) | +| `GET` | `/entitlements` | List entitlements across users (entitlements.manage or the Application's owner; app keys and owners confined) | ✓ | ✓ | [ACX-05]({{ '/screens/acx-05/' | relative_url }}) | +| `GET` | `/entitlements/{id}` | Fetch one entitlement | ✓ | ✓ | [ACX-03]({{ '/screens/acx-03/' | relative_url }}) | +| `PATCH` | `/entitlements/{id}` | The toggle: change status, term, source (trial→purchase), order_id, or an org grant's member scope | ✓ | ✓ | [ORG-06]({{ '/screens/org-06/' | relative_url }}) [ORG-07]({{ '/screens/org-07/' | relative_url }}) [ACX-01]({{ '/screens/acx-01/' | relative_url }}) [ACX-03]({{ '/screens/acx-03/' | relative_url }}) | +| `DELETE` | `/entitlements/{id}` | Hard-remove a grant made in error (no order_id, under 24 hours old) | ✓ | ✓ | [ACX-01]({{ '/screens/acx-01/' | relative_url }}) | + +## Meta + +| Method | Path | Summary | Header | Auth | Used by | +|---|---|---|:-:|:-:|---| +| `GET` | `/health` | Health check | exempt | — | [GLB-03]({{ '/screens/glb-03/' | relative_url }}) [OPS-01]({{ '/screens/ops-01/' | relative_url }}) | + +## Notifications + +| Method | Path | Summary | Header | Auth | Used by | +|---|---|---|:-:|:-:|---| +| `GET` | `/applications/{id}/push-credentials` | List the push providers an Application has configured (the owner, the app's own key, or applications.manage) | ✓ | ✓ | [APL-07]({{ '/screens/apl-07/' | relative_url }}) | +| `POST` | `/applications/{id}/push-credentials` | Configure one push provider (the owner or applications.manage, User token only) | ✓ | ✓ | [APL-07]({{ '/screens/apl-07/' | relative_url }}) | +| `GET` | `/applications/{id}/push-credentials/{provider}` | Fetch one provider's configuration | ✓ | ✓ | [APL-07]({{ '/screens/apl-07/' | relative_url }}) | +| `PATCH` | `/applications/{id}/push-credentials/{provider}` | Replace key material, change non-secret fields, or re-enable a failed credential (User token only) | ✓ | ✓ | [APL-07]({{ '/screens/apl-07/' | relative_url }}) | +| `DELETE` | `/applications/{id}/push-credentials/{provider}` | Remove a push provider (User token only) | ✓ | ✓ | [APL-07]({{ '/screens/apl-07/' | relative_url }}) | +| `GET` | `/users/{id}/devices` | List a User's push devices (self, the app's own key, or users.list) | ✓ | ✓ | [NTF-05]({{ '/screens/ntf-05/' | relative_url }}) | +| `POST` | `/users/{id}/devices` | Register a push device, or refresh an existing registration (self or the app's own key) | ✓ | ✓ | | +| `GET` | `/users/{id}/devices/{deviceId}` | Fetch one device | ✓ | ✓ | [NTF-05]({{ '/screens/ntf-05/' | relative_url }}) | +| `PATCH` | `/users/{id}/devices/{deviceId}` | Update a device's name, app version, environment, or rotated token (self or the app's own key) | ✓ | ✓ | | +| `DELETE` | `/users/{id}/devices/{deviceId}` | Unregister a device (self, the app's own key, or users.manage) | ✓ | ✓ | [NTF-05]({{ '/screens/ntf-05/' | relative_url }}) | +| `POST` | `/users/{id}/devices/{deviceId}/test` | Send a test push to one device and wait for the provider's answer (self or the app's own key) | ✓ | ✓ | | +| `GET` | `/users/{id}/notifications` | List a User's notification inbox (self, the app's own key, or users.list) | ✓ | ✓ | [NTF-05]({{ '/screens/ntf-05/' | relative_url }}) | +| `POST` | `/users/{id}/notifications` | Send one notification to one User (the app's own key with notifications.send, or platform notifications.send) | ✓ | ✓ | [NTF-06]({{ '/screens/ntf-06/' | relative_url }}) | +| `GET` | `/users/{id}/notifications/unread-count` | Count unread notifications, in total and per Application (self or the app's own key) | ✓ | ✓ | | +| `POST` | `/users/{id}/notifications/mark-read` | Mark every unread notification matching the filters as read (self or the app's own key) | ✓ | ✓ | | +| `GET` | `/users/{id}/notifications/{notificationId}` | Fetch one notification, with its per-device deliveries | ✓ | ✓ | [NTF-05]({{ '/screens/ntf-05/' | relative_url }}) | +| `PATCH` | `/users/{id}/notifications/{notificationId}` | Mark one notification read, unread, or archived (self or the app's own key) | ✓ | ✓ | | + +## Organizations + +| Method | Path | Summary | Header | Auth | Used by | +|---|---|---|:-:|:-:|---| +| `GET` | `/organizations` | List your Organizations (all, with organizations.manage) | ✓ | ✓ | [APP-01]({{ '/screens/app-01/' | relative_url }}) [ORG-01]({{ '/screens/org-01/' | relative_url }}) | +| `POST` | `/organizations` | Create an Organization (any active User; caller becomes org_admin) | ✓ | ✓ | [ORG-02]({{ '/screens/org-02/' | relative_url }}) | +| `GET` | `/organizations/{id}` | Fetch one Organization | ✓ | ✓ | [ORG-03]({{ '/screens/org-03/' | relative_url }}) | +| `PATCH` | `/organizations/{id}` | Rename (org admin), or suspend/reactivate (organizations.manage) | ✓ | ✓ | [ORG-08]({{ '/screens/org-08/' | relative_url }}) | +| `GET` | `/organizations/{id}/members` | List members | ✓ | ✓ | [ORG-03]({{ '/screens/org-03/' | relative_url }}) [BIL-02]({{ '/screens/bil-02/' | relative_url }}) | +| `POST` | `/organizations/{id}/members` | Invite a member by email (org admin), or add one directly by user_id (organizations.manage) | ✓ | ✓ | [ORG-04]({{ '/screens/org-04/' | relative_url }}) | +| `POST` | `/organizations/{id}/members/me/accept` | Accept a pending membership (the invited User's own token) | ✓ | ✓ | [ORG-09]({{ '/screens/org-09/' | relative_url }}) | +| `PATCH` | `/organizations/{id}/members/{userId}` | Change a member's role | ✓ | ✓ | [ORG-05]({{ '/screens/org-05/' | relative_url }}) | +| `DELETE` | `/organizations/{id}/members/{userId}` | Remove a member or withdraw an invitation; or leave or decline (userId = me) | ✓ | ✓ | [ORG-05]({{ '/screens/org-05/' | relative_url }}) | +| `GET` | `/organizations/{id}/scim-token` | Show the Organization's SCIM token (never its secret) | ✓ | ✓ | [ORG-08]({{ '/screens/org-08/' | relative_url }}) | +| `POST` | `/organizations/{id}/scim-token` | Create (or rotate) the Organization's SCIM token | ✓ | ✓ | [ORG-08]({{ '/screens/org-08/' | relative_url }}) | +| `DELETE` | `/organizations/{id}/scim-token` | Turn SCIM off for the Organization | ✓ | ✓ | [ORG-08]({{ '/screens/org-08/' | relative_url }}) | +| `GET` | `/organizations/{id}/entitlements` | List org-wide (seat) grants | ✓ | ✓ | [ORG-06]({{ '/screens/org-06/' | relative_url }}) | +| `POST` | `/organizations/{id}/entitlements` | Grant an app to the whole Organization | ✓ | ✓ | [ORG-07]({{ '/screens/org-07/' | relative_url }}) | + +## Profiles + +| Method | Path | Summary | Header | Auth | Used by | +|---|---|---|:-:|:-:|---| +| `GET` | `/users/{id}/profile` | Fetch global Profile | ✓ | ✓ | [USR-03]({{ '/screens/usr-03/' | relative_url }}) | +| `PATCH` | `/users/{id}/profile` | Update global Profile (self or users.manage) | ✓ | ✓ | | +| `GET` | `/users/{id}/apps/{appId}/profile` | Fetch per-app AppProfile (self, the app's own key, or platform users.list; defaults if none written yet) | ✓ | ✓ | | +| `PATCH` | `/users/{id}/apps/{appId}/profile` | Update per-app AppProfile (custom is merged one level; null removes a key) | ✓ | ✓ | | + +## Roles & Permissions + +| Method | Path | Summary | Header | Auth | Used by | +|---|---|---|:-:|:-:|---| +| `GET` | `/permissions` | List every Permission key the caller can see | ✓ | ✓ | [ROL-01]({{ '/screens/rol-01/' | relative_url }}) [ROL-02]({{ '/screens/rol-02/' | relative_url }}) | +| `GET` | `/roles` | List Roles | ✓ | ✓ | [ROL-01]({{ '/screens/rol-01/' | relative_url }}) | +| `POST` | `/roles` | Define a Role | ✓ | ✓ | [ROL-03]({{ '/screens/rol-03/' | relative_url }}) | +| `GET` | `/roles/{id}` | Fetch one Role | ✓ | ✓ | [ROL-02]({{ '/screens/rol-02/' | relative_url }}) | +| `PATCH` | `/roles/{id}` | Change a Role's permissions or description | ✓ | ✓ | [ROL-02]({{ '/screens/rol-02/' | relative_url }}) | +| `DELETE` | `/roles/{id}` | Delete an unassigned, non-seed Role | ✓ | ✓ | [ROL-02]({{ '/screens/rol-02/' | relative_url }}) | +| `GET` | `/users/{id}/roles` | List a user's Role assignments (including implicit default_app_role) | ✓ | ✓ | [ROL-04]({{ '/screens/rol-04/' | relative_url }}) | +| `POST` | `/users/{id}/roles/{roleId}` | Assign a Role (idempotent — 200 if already held) | ✓ | ✓ | [ROL-04]({{ '/screens/rol-04/' | relative_url }}) | +| `DELETE` | `/users/{id}/roles/{roleId}` | Remove a Role assignment (idempotent) | ✓ | ✓ | [ROL-04]({{ '/screens/rol-04/' | relative_url }}) | +| `GET` | `/users/{id}/apps/{appId}/effective-permissions` | The live access check (always 200) | ✓ | ✓ | [ACX-04]({{ '/screens/acx-04/' | relative_url }}) | + +## Settings + +| Method | Path | Summary | Header | Auth | Used by | +|---|---|---|:-:|:-:|---| +| `GET` | `/users/{id}/settings` | Fetch global Settings (self, or platform users.list) | ✓ | ✓ | | +| `PATCH` | `/users/{id}/settings` | Update global Settings (self or users.manage) | ✓ | ✓ | | +| `GET` | `/users/{id}/apps/{appId}/settings` | Fetch fully resolved per-app settings (self, the app's own key, or platform users.list) | ✓ | ✓ | | +| `PATCH` | `/users/{id}/apps/{appId}/settings` | Write per-app overrides (validated against settings_schema; null clears) | ✓ | ✓ | | + +## Tenancy + +| Method | Path | Summary | Header | Auth | Used by | +|---|---|---|:-:|:-:|---| +| `GET` | `/tenants` | List Tenants (your own; all with tenants.manage) | ✓ | ✓ | [BIL-04]({{ '/screens/bil-04/' | relative_url }}) | +| `GET` | `/tenants/{id}` | Fetch one Tenant | ✓ | ✓ | [BIL-04]({{ '/screens/bil-04/' | relative_url }}) | +| `GET` | `/tenants/{id}/tier-change-requests` | List tier-change requests for a Tenant | ✓ | ✓ | [BIL-04]({{ '/screens/bil-04/' | relative_url }}) | +| `POST` | `/tenants/{id}/tier-change-requests` | Open a tier-change request on a customer's behalf (tenants.manage) | ✓ | ✓ | [BIL-04]({{ '/screens/bil-04/' | relative_url }}) | +| `GET` | `/tenants/{id}/tier-change-requests/{requestId}` | Fetch one tier-change request | ✓ | ✓ | [BIL-04]({{ '/screens/bil-04/' | relative_url }}) | +| `PATCH` | `/tenants/{id}/tier-change-requests/{requestId}` | Schedule, start, complete, or cancel a tier-change request (tenants.manage) | ✓ | ✓ | [BIL-04]({{ '/screens/bil-04/' | relative_url }}) | + +## Users + +| Method | Path | Summary | Header | Auth | Used by | +|---|---|---|:-:|:-:|---| +| `GET` | `/users` | List or search users | ✓ | ✓ | [USR-01]({{ '/screens/usr-01/' | relative_url }}) [ROL-02]({{ '/screens/rol-02/' | relative_url }}) [NTF-06]({{ '/screens/ntf-06/' | relative_url }}) [GLB-01]({{ '/screens/glb-01/' | relative_url }}) | +| `POST` | `/users` | Create (invite) a user | ✓ | ✓ | [USR-02]({{ '/screens/usr-02/' | relative_url }}) | +| `GET` | `/users/{id}` | Fetch one user | ✓ | ✓ | [USR-03]({{ '/screens/usr-03/' | relative_url }}) | +| `PATCH` | `/users/{id}` | Update email (self, pending verification) or status (users.manage) | ✓ | ✓ | [USR-03]({{ '/screens/usr-03/' | relative_url }}) | +| `DELETE` | `/users/{id}` | Soft-delete a user (self or users.manage) | ✓ | ✓ | [USR-03]({{ '/screens/usr-03/' | relative_url }}) | +| `POST` | `/users/{id}/suspend` | Suspend a user (users.manage) | ✓ | ✓ | [USR-05]({{ '/screens/usr-05/' | relative_url }}) | +| `POST` | `/users/{id}/invitation` | Re-send the invitation email to an invited user (users.manage) | ✓ | ✓ | [USR-03]({{ '/screens/usr-03/' | relative_url }}) | +| `GET` | `/users/{id}/export` | Export everything this API holds about this user | ✓ | ✓ | [USR-03]({{ '/screens/usr-03/' | relative_url }}) | +| `POST` | `/users/{id}/erasure-requests` | Request right-to-erasure (soft-delete now, hard-delete cascade in 7 days) | ✓ | ✓ | | +| `DELETE` | `/users/{id}/erasure-requests/current` | Cancel a scheduled erasure inside its 7-day window (users.manage) | ✓ | ✓ | [USR-03]({{ '/screens/usr-03/' | relative_url }}) | + +## Webhooks + +| Method | Path | Summary | Header | Auth | Used by | +|---|---|---|:-:|:-:|---| +| `GET` | `/webhooks` | List subscriptions visible to the caller | ✓ | ✓ | [WHK-01]({{ '/screens/whk-01/' | relative_url }}) [INT-01]({{ '/screens/int-01/' | relative_url }}) | +| `POST` | `/webhooks` | Subscribe a URL to event types | ✓ | ✓ | [WHK-02]({{ '/screens/whk-02/' | relative_url }}) | +| `GET` | `/webhooks/{id}` | Fetch one subscription | ✓ | ✓ | [WHK-03]({{ '/screens/whk-03/' | relative_url }}) | +| `PATCH` | `/webhooks/{id}` | Change url, events, description, api_version, or re-enable | ✓ | ✓ | [WHK-02]({{ '/screens/whk-02/' | relative_url }}) | +| `DELETE` | `/webhooks/{id}` | Unsubscribe | ✓ | ✓ | [WHK-03]({{ '/screens/whk-03/' | relative_url }}) | +| `POST` | `/webhooks/{id}/rotate-secret` | Issue a new signing secret (24-hour overlap) | ✓ | ✓ | [WHK-03]({{ '/screens/whk-03/' | relative_url }}) | +| `POST` | `/webhooks/{id}/test` | Send a webhook.test event now | ✓ | ✓ | [WHK-03]({{ '/screens/whk-03/' | relative_url }}) | +| `GET` | `/webhooks/{id}/deliveries` | Recent delivery attempts (30-day log) | ✓ | ✓ | [WHK-03]({{ '/screens/whk-03/' | relative_url }}) | +| `POST` | `/webhooks/{id}/deliveries/{deliveryId}/redeliver` | Re-send one past event (same event id) | ✓ | ✓ | [WHK-03]({{ '/screens/whk-03/' | relative_url }}) | diff --git a/docs/api/reference.md b/docs/api/reference.md new file mode 100644 index 0000000..68acf41 --- /dev/null +++ b/docs/api/reference.md @@ -0,0 +1,78 @@ +--- +layout: default +title: Interactive reference +parent: API +nav_order: 3 +permalink: /api/reference/ +--- + +# Interactive reference +{: .no_toc } + +Every operation, parameter, request body and response in the Platform API's `openapi.yaml`, rendered with Swagger UI. Pick the API version you're building against; the reference sends that version in `Substratal-Version` on every **Try it out** request. +{: .fs-6 .fw-300 } + +
+ + + Download openapi.yaml +
+ +**Trying requests:** browsers can't call production from this page, on purpose. The API only accepts browser requests from first-party origins so admin and service credentials never get used from a web page. Use **Local devserver** under Servers (run the API's devserver with `CORS_ALLOWED_ORIGINS=https://compositecode.github.io`), or copy the request as `curl`. +{: .note } + +
+ +
+ + + + + diff --git a/docs/api/v1/openapi.yaml b/docs/api/v1/openapi.yaml new file mode 100644 index 0000000..f36006d --- /dev/null +++ b/docs/api/v1/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" } } diff --git a/docs/api/versioning.md b/docs/api/versioning.md new file mode 100644 index 0000000..233799d --- /dev/null +++ b/docs/api/versioning.md @@ -0,0 +1,74 @@ +--- +layout: default +title: Versioning +parent: API +nav_order: 1 +permalink: /api/versioning/ +--- + +# API versioning +{: .no_toc } + +The API version is a **request header**, not part of the URL. +{: .fs-6 .fw-300 } + +1. TOC +{:toc} + +## The header + +```http +GET /users/me HTTP/1.1 +Host: api.substratalapps.com +Authorization: Bearer eyJ… +Substratal-Version: 1 +``` + +- **Required.** Without it: `400 api_version_required`. With a version the server doesn't serve: `400 unsupported_api_version`. Both list the versions it does serve in `details.supported`. There's no default, so you're never moved to a new version without asking. +- **Echoed.** Every response to a versioned request carries `Substratal-Version` with the version that served it. +- **Whole numbers.** `1` is the only version today. +- **Exempt:** `GET /health`, `POST /auth/oauth/token`, `/.well-known/*` and SCIM (`/scim/v2`). Sending the header to them anyway is harmless. + +## No `/v1` in the URL + +Until API v1.7.0 (`openapi.yaml` 1.0.0-draft.4, October 2026) paths started with `/v1`. They don't any more: + +| Before | Now | +|---|---| +| `POST https://api.substratalapps.com/v1/users` | `POST https://api.substratalapps.com/users` with `Substratal-Version: 1` | + +Old `/v1/...` paths return `404 not_found`; there's no redirect. Because the version isn't in the path, a resource's URL stays the same in every future version: links, caches and stored URLs never change. +{: .important } + +## What changes within a version, and what doesn't + +**Additive changes ship within a version**, with no header change: new endpoints, new optional fields, new enum values. Write clients that ignore fields they don't know and treat unknown enum values as "other". + +**Breaking changes ship as the next version number.** The previous version keeps working for **at least 6 months**. + +## Deprecation and sunset + +When the version you send is deprecated, every response carries two extra headers: + +```http +Deprecation: @1798761600 +Sunset: Wed, 01 Jul 2027 00:00:00 GMT +``` + +- `Deprecation` ([RFC 9745](https://www.rfc-editor.org/rfc/rfc9745)): when the version was deprecated. +- `Sunset` ([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594)): when it stops working. After that, requests naming it get `400 unsupported_api_version`. + +The Hub apps show **Update available**, with the sunset date, when they see these headers, and **Update required** once the version is retired. Your own clients should log and alert on them. + +## Versions + +| `Substratal-Version` | Status | Specification | Operations | +|---|---|---|---| +{% for v in site.data.api.versions %}| `{{ v.version }}` | {{ v.status }} | [openapi.yaml]({{ v.openapi | relative_url }}) ({{ v.spec_version }}) | {{ v.operations }} | +{% endfor %} + +Revisions of the specification within a version (`1.0.0-draft.N`) are listed in the [API changelog]({{ '/api/changelog/' | relative_url }}). Older revisions of this help center, and the API reference as it was then, are in [Docs versions]({{ '/versions/' | relative_url }}). + +## Not the webhook version + +A webhook subscription's `api_version` is a **date** (such as `2026-10-05`) that fixes the shape of the payloads it receives. It's versioned separately from `Substratal-Version`, and the Hub labels it **Payload version**. diff --git a/docs/assets/images/APP-01-home.png b/docs/assets/images/APP-01-home.png new file mode 100644 index 0000000000000000000000000000000000000000..46506762998531c263e6b098cd9dcedc8728d80b GIT binary patch literal 265933 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�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 eX>xA2&la&OSM7Qx}TfdF|RWq0$m-Oeu7 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 literal 0 HcmV?d00001 diff --git a/docs/assets/images/mark.svg b/docs/assets/images/mark.svg new file mode 100644 index 0000000..d003aa3 --- /dev/null +++ b/docs/assets/images/mark.svg @@ -0,0 +1 @@ +Substratal \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md new file mode 100644 index 0000000..0b230b4 --- /dev/null +++ b/docs/changelog.md @@ -0,0 +1,15 @@ +--- +layout: default +title: App changelog +nav_order: 9 +permalink: /changelog/ +--- + +# Substratal for Linux changelog +{: .no_toc } + +## Unreleased + +- **First skeleton.** The GTK 4 + libadwaita app with the full navigation tree, gated by your permissions; all 75 Hub screens reachable by route and `substratal://` link, each laid out from the spec; live sign-in with two-factor; the Users table; Settings › Appearance with all 10 themes; the command palette (Ctrl+K), keyboard shortcuts, About, and help on F1. +- Talks to `https://api.substratalapps.com` with `Substratal-Version: 1` on every request; handles **Update available** (`Deprecation`/`Sunset`) and **Update required** (`unsupported_api_version`). +- `substratalctl` command-line companion: `health`, `version`, `login`, `logout`, `whoami`, `users list`, `screens`, `route`, `help-url`. diff --git a/docs/contributing.md b/docs/contributing.md new file mode 100644 index 0000000..62343d5 --- /dev/null +++ b/docs/contributing.md @@ -0,0 +1,62 @@ +--- +layout: default +title: How these docs stay current +nav_order: 10 +permalink: /contributing/ +--- + +# How these docs stay current +{: .no_toc } + +This help center is built from the same repository as the Linux app and published by GitHub Actions on every change. Reference pages are generated, not written, so they can't drift from the app. +{: .fs-6 .fw-300 } + +1. TOC +{:toc} + +## What triggers an update + +| Change | Workflow | What happens | +|---|---|---| +| Any push to `main` that touches the app, the docs or the vendored API spec | **Docs** (`.github/workflows/docs.yml`) | Regenerates reference pages from the code, builds the site, snapshots it as a new docs version, deploys to GitHub Pages | +| A pull request | **CI** (`.github/workflows/ci.yml`) | Fails if generated pages are out of date (`cargo xtask gen-docs --check`) or the site doesn't build | +| The Platform API's `openapi.yaml` changes | **API spec sync** (`.github/workflows/api-spec-sync.yml`), daily and on demand | Fetches the spec, diffs it with oasdiff, adds an [API changelog]({{ '/api/changelog/' | relative_url }}) entry, regenerates the API pages, opens a pull request; breaking changes are labelled | +| The client specs package changes | **Specs sync** (`.github/workflows/specs-sync.yml`), daily and on demand | Regenerates the screen registry, themes and screen pages, opens a pull request | + +## Generated pages + +| Page | Generated from | +|---|---| +| [Screens]({{ '/screens/' | relative_url }}) and every screen page | the specs package, via `data/spec/screens.json` | +| [Keyboard shortcuts]({{ '/reference/shortcuts/' | relative_url }}) | the shortcut table the app binds | +| [Navigation and permissions]({{ '/reference/navigation/' | relative_url }}) | the app's navigation rules, evaluated for each persona | +| [Status meanings]({{ '/reference/statuses/' | relative_url }}) | `status-semantics.json` | +| [substratalctl]({{ '/reference/cli/' | relative_url }}) | the CLI's own argument definitions | +| [Operations]({{ '/api/operations/' | relative_url }}) and [`openapi.yaml`]({{ '/api/v1/openapi.yaml' | relative_url }}) | the vendored API spec, cross-referenced with the screens | + +Run `cargo xtask gen-docs` after changing any of those sources, and commit the result. + +## Writing pages + +Guides, tutorials and the FAQ are Markdown under `docs/` ([just-the-docs](https://just-the-docs.com/)). Every page has an **Edit this page** link. To preview: + +```sh +cd docs && bundle install && bundle exec jekyll serve +``` + +## Versions + +Each published build is a new docs version with a frozen copy, listed in [Docs versions]({{ '/versions/' | relative_url }}). Put `[docs:minor]` or `[docs:major]` in a commit's subject line for a bigger bump. Docs versions are separate from the API's `Substratal-Version`. + +## Triggering the API sync from the API repo + +The sync also runs every 6 hours. To make it immediate, add this step to the API repo's docs workflow after the spec is published. It needs a token with access to this repo in a secret named `LINUX_REPO_DISPATCH_TOKEN`: + +```yaml +- name: Tell the Linux Hub the spec changed + run: | + curl -fsS -X POST https://api.github.com/repos/CompositeCode/substratalapps-linux/dispatches \ + -H "Authorization: Bearer ${{ secrets.LINUX_REPO_DISPATCH_TOKEN }}" \ + -H "Accept: application/vnd.github+json" \ + -d '{"event_type":"api-spec-updated"}' +``` diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000..62af6fd --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,56 @@ +--- +layout: default +title: FAQ +nav_order: 8 +permalink: /faq/ +--- + +# Frequently asked questions +{: .no_toc } + +1. TOC +{:toc} + +## Accounts and access + +### Why can't I see Administration or Developer? +They only appear if you have permissions or own an app. See [Navigation and permissions]({{ '/reference/navigation/' | relative_url }}). + +### An app shows "Managed by Substratal". Why can't I edit it? +Free apps and apps covered by a Standard subscription are managed by the platform. You can turn them off and back on, but not edit, revoke or delete them. + +### Someone has two ways into the same app. Which one wins? +Access paths add up: if any path is active, the app is on. The access screen shows the resolved status first, and **Why?** shows every path. + +### How do I sign out everywhere? +Your own sessions: **Account › Security**. Someone else's (with `users.manage`): their **Sessions** tab, **Sign out everywhere**. + +## Notifications + +### I don't get desktop notifications on Linux when the app is closed. +Linux desktops have no push service the API supports, so the Linux app checks for notifications every minute while it's running. Everything still lands in your Inbox. + +### How long are notifications kept? +90 days in the Inbox. + +## Plans and billing + +### What does Standard cost? +$6 per person per month, monthly in US dollars. No annual plan or trial today. + +### I paid but still show Free. +The Hub waits up to 30 seconds for Stripe to confirm. If it still says Free, refresh (Ctrl+R); if that doesn't help, contact support with the time you paid. + +## API and integrations + +### Why do my old `/v1/...` requests return 404? +The API version moved into the `Substratal-Version` header in October 2026. Drop `/v1` from the path and send `Substratal-Version: 1`. See [Versioning]({{ '/api/versioning/' | relative_url }}). + +### Why does the API say `api_key_in_browser`? +API keys only work from servers. In a browser, use the signed-in person's own token. + +### Can I call production from the interactive reference? +No, on purpose: production only accepts browser requests from Substratal's own sites. Use `curl`, a generated client, or the local devserver. + +### Where do I report a problem? +In the app, open the error's **Details** and copy the request ID, then open an issue on [GitHub](https://github.com/CompositeCode/substratalapps-linux/issues) or contact support. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md new file mode 100644 index 0000000..b3cc031 --- /dev/null +++ b/docs/getting-started/index.md @@ -0,0 +1,17 @@ +--- +layout: default +title: Getting started +nav_order: 2 +has_children: true +permalink: /getting-started/ +--- + +# Getting started +{: .no_toc } + +Install the Hub, sign in, and find your way around. +{: .fs-6 .fw-300 } + +1. [Install]({{ '/getting-started/install/' | relative_url }}) the app for your platform. +2. [Sign in]({{ '/getting-started/sign-in/' | relative_url }}) with your Substratal account, plus your two-factor code if it's on. +3. [Find your way around]({{ '/getting-started/tour/' | relative_url }}): the sidebar, the command palette and help. diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md new file mode 100644 index 0000000..94dcaa1 --- /dev/null +++ b/docs/getting-started/install.md @@ -0,0 +1,42 @@ +--- +layout: default +title: Install +parent: Getting started +nav_order: 1 +permalink: /getting-started/install/ +--- + +# Install +{: .no_toc } + +1. TOC +{:toc} + +## Linux + +Substratal for Linux is a GNOME app (GTK 4 and libadwaita) that also runs on KDE Plasma and other desktops. It needs GNOME 46 or later, or any desktop with Flatpak. + +The app is in development. Flatpak builds come from every change to `main` (the **Flatpak** workflow on GitHub); the Flathub listing comes when the app reaches its first release. +{: .note } + +**Build and run from source** (Ubuntu 24.04 or Fedora): + +```sh +# Ubuntu: sudo apt install libgtk-4-dev libadwaita-1-dev blueprint-compiler librsvg2-common +# Fedora: sudo dnf install gtk4-devel libadwaita-devel blueprint-compiler +git clone https://github.com/CompositeCode/substratalapps-linux +cd substratalapps-linux +cargo run -p substratal-hub +``` + +No Linux machine? `scripts/dev.sh` runs the same build in a container, and `scripts/screenshot.sh` renders the app headlessly. + +To point the app at the API's local devserver instead of production, start it with `SUBSTRATAL_API=local`. A banner tells you you're not on production. + +## macOS, Windows, iOS/iPadOS, Android and Web + +These apps are built from the same specification by their own teams. Their install pages will be added here as each app ships. + +## The command-line companion + +`substratalctl` uses the Linux app's core. It's built with the app (`cargo build -p substratalctl`). See the [CLI tutorial]({{ '/tutorials/cli/' | relative_url }}). diff --git a/docs/getting-started/sign-in.md b/docs/getting-started/sign-in.md new file mode 100644 index 0000000..2df1732 --- /dev/null +++ b/docs/getting-started/sign-in.md @@ -0,0 +1,30 @@ +--- +layout: default +title: Sign in +parent: Getting started +nav_order: 2 +permalink: /getting-started/sign-in/ +--- + +# Sign in +{: .no_toc } + +1. Open Substratal. If you're not signed in, the **Sign in** window opens ([AUTH-01]({{ '/screens/auth-01/' | relative_url }})). +2. Enter your email and password. **Sign in** turns on when both are filled in. +3. If two-factor authentication is on, enter the 6-digit code from your authenticator app ([AUTH-02]({{ '/screens/auth-02/' | relative_url }})). Lost your phone? Enter one of your recovery codes instead. + +## Staying signed in + +On Linux the app keeps your session in your desktop's keyring (GNOME Keyring or KWallet, through the Secret Service). Only a refresh token is stored there. Nothing else is saved, and passwords never are. If no keyring is available, the app works but you sign in each time you open it. + +Signing out (main menu ☰ › **Sign Out**) ends the session on the server and removes the stored token. + +## If sign-in fails + +| You see | What it means | +|---|---| +| "Email or password is incorrect." | One of them is wrong. The app never says which. | +| "Too many attempts. Try again in N seconds." | Sign-in is rate-limited. Wait for the countdown. | +| "This account is suspended." | An administrator suspended it. Contact them. | +| "Your session ended. Sign in again." | You signed out elsewhere, or an admin signed you out everywhere. | +| "Update required" | This version of the app uses an API version the service no longer accepts. Update the app. See [API versioning]({{ '/api/versioning/' | relative_url }}). | diff --git a/docs/getting-started/tour.md b/docs/getting-started/tour.md new file mode 100644 index 0000000..21e5360 --- /dev/null +++ b/docs/getting-started/tour.md @@ -0,0 +1,40 @@ +--- +layout: default +title: Find your way around +parent: Getting started +nav_order: 3 +permalink: /getting-started/tour/ +--- + +# Find your way around +{: .no_toc } + +## The sidebar + +The sidebar has up to three sections. You only see what you can use. + +- **Personal**: Home, Inbox, Organizations, Account, Plan & Billing, Settings. Everyone has these. +- **Administration**: Users, Access, Roles, Audit Log, Send notification. Staff, and app owners (Access only, for their own apps). +- **Developer**: Applications, API Keys, Webhooks, Tenant. App owners and staff with developer permissions. + +[Navigation and permissions]({{ '/reference/navigation/' | relative_url }}) lists exactly who sees what. On a narrow window (under 600 sp, like a Linux phone) the sidebar becomes its own page; tap back to return to it. + +## Get anywhere with the keyboard + +- Ctrl+K opens the **command palette**: type part of a screen's name, its ID (like `KEY-01`) or its route, then press Enter. +- Alt+1…Alt+6 jump to the Personal destinations. +- F1 opens help for the screen you're on. + +All shortcuts are in [Keyboard shortcuts]({{ '/reference/shortcuts/' | relative_url }}), and Ctrl+? shows them in the app. + +## Links into the app + +Links like `substratal://admin/users` open the matching screen in the running app. Every screen's help page shows its link. + +## Banners at the top + +| Banner | Meaning | +|---|---| +| You're offline | Showing what was already loaded; changes are paused until you reconnect. | +| A newer version is available | This app's API version is deprecated and will stop working on the date shown. Update soon. | +| Connected to …, not production | The app is pointed at a test environment. | diff --git a/docs/guides/administration.md b/docs/guides/administration.md new file mode 100644 index 0000000..3c72dd5 --- /dev/null +++ b/docs/guides/administration.md @@ -0,0 +1,46 @@ +--- +layout: default +title: Administering people and access +parent: Guides +nav_order: 2 +permalink: /guides/administration/ +--- + +# Administering people and access +{: .no_toc } + +For staff with platform permissions, and app owners for their own apps. What you see depends on your permissions; see [Navigation and permissions]({{ '/reference/navigation/' | relative_url }}). +{: .fs-6 .fw-300 } + +1. TOC +{:toc} + +## Users + +[Users]({{ '/screens/usr-01/' | relative_url }}) (needs `users.list`) is a searchable table of every account. Search by name, email or a `usr_` ID; pasting an exact ID jumps straight to that person. Open someone for their [overview]({{ '/screens/usr-03/' | relative_url }}) and tabs: + +- **Access** ([ACX-01]({{ '/screens/acx-01/' | relative_url }})): which apps are on for them and why. +- **Roles** ([ROL-04]({{ '/screens/rol-04/' | relative_url }})): their platform and app roles. +- **Sessions** ([USR-04]({{ '/screens/usr-04/' | relative_url }})): where they're signed in. **Sign out everywhere** ends every session at once. +- **Notifications & devices** ([NTF-05]({{ '/screens/ntf-05/' | relative_url }})): what they've been sent and their push devices. + +Changing an account (invite, suspend, delete) needs `users.manage`. Suspending ([USR-05]({{ '/screens/usr-05/' | relative_url }})) signs the person out everywhere and turns off their access to every app until you reactivate them. + +## Access + +[Access]({{ '/screens/acx-05/' | relative_url }}) lists access across the platform (`entitlements.manage`), or, for an app owner, across their own apps. [Grant access]({{ '/screens/acx-02/' | relative_url }}) to give someone an app; the switch on a row turns it off or back on, with **Undo** for 10 seconds (Ctrl+Z). **Why?** ([ACX-04]({{ '/screens/acx-04/' | relative_url }})) explains how someone has, or doesn't have, an app. + +Rows marked **Managed by Substratal** (free apps and Standard subscriptions) can only be turned off and back on. +{: .note } + +## Roles + +[Roles]({{ '/screens/rol-01/' | relative_url }}) are named sets of permissions. Seed roles (`superadmin`, `support`, `billing_admin` and others) can't be edited. You can only give a role permissions you hold yourself, and only assign roles whose permissions you hold, so nobody can grant themselves more than they have. A role's page lists **People with this role**. + +## Audit log + +The [Audit log]({{ '/screens/aud-01/' | relative_url }}) (`audit.view`) records every change: who did it (a person, an API key or the system), what changed, and the before and after. Filter by person, app, organization, action or time. Each event shows the request ID, which ties it to the API's logs. Events older than the hot window are archived; a banner says when that applies. Exports of a time range are available behind the `audit_export` flag. + +## Send notification + +Staff with `notifications.send` can [send a notification]({{ '/screens/ntf-06/' | relative_url }}) to a person on behalf of any app. It lands in their inbox and is pushed to their devices where push is available. diff --git a/docs/guides/console.md b/docs/guides/console.md new file mode 100644 index 0000000..d4294bd --- /dev/null +++ b/docs/guides/console.md @@ -0,0 +1,29 @@ +--- +layout: default +title: Operations console (web) +parent: Guides +nav_order: 4 +permalink: /guides/console/ +--- + +# Operations console (web) +{: .no_toc } + +The web Hub has an **Operations** section for the people who run Substratal: is production healthy, what's slow or failing, what it costs, and what's deployed. It's web-only for now; the Linux, macOS, Windows and mobile apps don't include it (specs OPEN-QUESTIONS #27). +{: .fs-6 .fw-300 } + +| Screen | What it answers | +|---|---| +| [OPS-01 Overview]({{ '/screens/ops-01/' | relative_url }}) | Is production healthy right now? | +| [OPS-02 Traffic & latency]({{ '/screens/ops-02/' | relative_url }}) | Trends and service-level objectives | +| [OPS-03 Logs]({{ '/screens/ops-03/' | relative_url }}) | What happened to one request, person or key | +| [SEC-01 Security events]({{ '/screens/sec-01/' | relative_url }}) | Failed sign-ins, lockouts, blocked actions | +| [OPS-04 Alarms]({{ '/screens/ops-04/' | relative_url }}) | What's firing and who's told | +| [OPS-05 Jobs & delivery]({{ '/screens/ops-05/' | relative_url }}) | Scheduled jobs and webhook/push delivery | +| [OPS-06 Releases]({{ '/screens/ops-06/' | relative_url }}) | What's deployed | +| [OPS-07 Cost]({{ '/screens/ops-07/' | relative_url }}) | Spend against budgets | +| [CFG-01 Configuration]({{ '/screens/cfg-01/' | relative_url }}) / [CFG-02 Feature flags]({{ '/screens/cfg-02/' | relative_url }}) | What production runs with | +| [RPT-01 Reports]({{ '/screens/rpt-01/' | relative_url }}) | Growth, access and usage over time | +| [INT-01 Integrations]({{ '/screens/int-01/' | relative_url }}) | Every way to connect other tools | + +All of it is read-only in its first version and refreshes at most once a minute. diff --git a/docs/guides/developer.md b/docs/guides/developer.md new file mode 100644 index 0000000..65cab4c --- /dev/null +++ b/docs/guides/developer.md @@ -0,0 +1,50 @@ +--- +layout: default +title: Applications, keys and webhooks +parent: Guides +nav_order: 3 +permalink: /guides/developer/ +--- + +# Applications, keys and webhooks +{: .no_toc } + +For app developers (anyone who owns an Application) and staff with developer permissions. +{: .fs-6 .fw-300 } + +1. TOC +{:toc} + +## Applications + +An **Application** is an app in the Substratal catalog. [Applications]({{ '/screens/apl-01/' | relative_url }}) lists the ones you own, or all of them with `applications.manage`. + +- **Register**: any person with a verified email can [register an application]({{ '/screens/apl-02/' | relative_url }}) (behind the `app_registration_self_service` flag). It starts as **Pending review**. +- **Review**: staff with `applications.manage` approve, reject (with notes) or suspend from [Review]({{ '/screens/apl-06/' | relative_url }}). Suspending is the off switch for a misbehaving app. +- **Configure**: the [overview]({{ '/screens/apl-03/' | relative_url }}) has the launch URL, icon, visibility and access tier; [settings schema]({{ '/screens/apl-04/' | relative_url }}) defines per-user settings; [permissions & roles]({{ '/screens/apl-05/' | relative_url }}) define what people can do inside the app; [push credentials]({{ '/screens/apl-07/' | relative_url }}) hold its APNs, FCM and Web Push keys. + +Push key files (`.p8`, FCM JSON) are read once from the file you pick and sent to the API. The Hub never keeps a copy. +{: .note } + +## API keys + +[API keys]({{ '/screens/key-01/' | relative_url }}) let your backend, or an AI agent, call the API without a person signing in. + +- A key is **platform** or **app** scoped, **live** or **test**, and intended for a **service** or an **agent**. Agent keys get `restrict_destructive` on by default, so they can't delete or revoke things. +- You can only give a key permissions you hold. +- The secret (`satk_live_…` or `satk_test_…`) is shown **once**, when the key is created or rotated ([KEY-03]({{ '/screens/key-03/' | relative_url }})). Copy it straight into your secret store. The Hub clears it from the clipboard after 60 seconds and never saves it. +- **Rotate** replaces the secret; **Revoke** turns the key off for good. +- Keys are for servers. The API refuses them from a browser (`400 api_key_in_browser`). + +See the [API key tutorial]({{ '/tutorials/api-keys/' | relative_url }}). + +## Webhooks + +[Webhooks]({{ '/screens/whk-01/' | relative_url }}) send events (access granted or revoked, users suspended, devices registered and more) to your HTTPS endpoint, signed with a secret. [Deliveries]({{ '/screens/whk-03/' | relative_url }}) shows every attempt; you can redeliver one. + +A webhook's **payload version** (a date such as `2026-10-05`) fixes the shape of the events it receives. It's separate from the API version header. +{: .important } + +## Tenant + +Your [Tenant]({{ '/screens/bil-04/' | relative_url }}) is where your apps' data lives. Every Tenant starts on shared infrastructure; support handles moves to dedicated infrastructure through tier-change requests. diff --git a/docs/guides/index.md b/docs/guides/index.md new file mode 100644 index 0000000..25035bb --- /dev/null +++ b/docs/guides/index.md @@ -0,0 +1,19 @@ +--- +layout: default +title: Guides +nav_order: 3 +has_children: true +permalink: /guides/ +--- + +# Guides +{: .no_toc } + +How each area of the Hub works and how its pieces fit together. For step-by-step tasks, see [Tutorials]({{ '/tutorials/' | relative_url }}). +{: .fs-6 .fw-300 } + +Three ideas run through every area: + +1. **Is the app on for this person?** Each person has an access status per app: Active, Scheduled, Disabled, Expired, Revoked or None. The access switch turns it on and off. +2. **If it's on, what can they do?** Roles grant permissions. +3. **Where does access come from?** Several paths can give one person access to one app: platform-managed ones (`free` apps, and a Standard `subscription`) and grants (`purchase`, `trial`, `admin_grant`, `org_seat`). The Hub shows the resolved status first and the paths one click away. Platform-managed access can only be turned off and back on, never edited or deleted. diff --git a/docs/guides/personal.md b/docs/guides/personal.md new file mode 100644 index 0000000..8b3bcaf --- /dev/null +++ b/docs/guides/personal.md @@ -0,0 +1,40 @@ +--- +layout: default +title: Your apps, inbox and account +parent: Guides +nav_order: 1 +permalink: /guides/personal/ +--- + +# Your apps, inbox and account +{: .no_toc } + +1. TOC +{:toc} + +## Home: your apps + +[Home]({{ '/screens/app-01/' | relative_url }}) shows every app you can use and its access status. Select an app for [its details]({{ '/screens/app-02/' | relative_url }}); **Open** launches it with a short-lived app token, so you don't sign in again. Apps you no longer have are under [Past apps]({{ '/screens/app-05/' | relative_url }}). + +## Inbox + +The [Inbox]({{ '/screens/ntf-01/' | relative_url }}) holds every notification sent to you by any app, plus Substratal's security and account alerts, for 90 days. It's always complete, even when a desktop notification didn't appear. + +**On Linux and Windows**, notifications arrive while the app is running: it checks for new ones every minute and shows a desktop notification. There's no push service for Linux desktops, so if the app is closed you'll see them next time you open it. On iOS, macOS, Android and the Web they're pushed. +{: .note } + +## Organizations + +[Organizations]({{ '/screens/org-01/' | relative_url }}) are teams. Organization admins invite members, grant apps to the whole organization or to chosen members, and can pay for Standard for the members they choose. + +## Plan & Billing + +Everyone starts on **Free**. **Standard** is $6 per person per month and unlocks every standard app. You buy it for yourself in [Plan & Billing]({{ '/screens/bil-01/' | relative_url }}), or an organization buys it for you. Payment always happens on Stripe's page in your browser; when you come back, the Hub waits a few seconds for the change to arrive and then shows your new plan. + +## Account and security + +[Account]({{ '/screens/acc-01/' | relative_url }}) has your profile. [Security]({{ '/screens/acc-04/' | relative_url }}) has your password, two-factor authentication and the list of places you're signed in, where you can sign other sessions out. [Privacy]({{ '/screens/acc-07/' | relative_url }}) lets you export your data or delete your account. + +## Settings and themes + +[Settings]({{ '/screens/set-01/' | relative_url }}) holds your language, time zone, notifications and appearance. Pick **Match system**, **Light** or **Dark**, and one of five light and five dark themes. The defaults are **Moss Paper** (light) and **Forest Floor** (dark). If your desktop uses high contrast, the Hub follows it instead of the theme. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..95ca14c --- /dev/null +++ b/docs/index.md @@ -0,0 +1,40 @@ +--- +layout: default +title: Home +nav_order: 1 +permalink: / +--- + +# Substratal Hub help +{: .fs-9 } + +How to use the Substratal Hub: launch your apps, read your notifications, manage your plan and organizations, and, if you're staff or an app developer, administer users, access, roles, applications, API keys and webhooks. +{: .fs-6 .fw-300 } + +[Get started]({{ '/getting-started/' | relative_url }}){: .btn .btn-primary .mr-2 } [Find a screen]({{ '/screens/' | relative_url }}){: .btn .mr-2 } [API reference]({{ '/api/' | relative_url }}){: .btn } + + +## One help center for every Hub app + +The Hub is the same product on **Linux, macOS, Windows, iOS/iPadOS, Android and the Web**: same screens, same screen IDs, same routes, same words. So this help center serves all of them. + +- Every screen has a page at **`/screens//`**, for example [USR-01 Users]({{ '/screens/usr-01/' | relative_url }}). Each app's **Help** button and F1 open the page for the screen you're on. +- Where an app behaves differently on one platform (keyboard shortcuts, how notifications arrive), the page says so. +- Search (top of the page) covers every guide, tutorial, FAQ and screen. + +## Where to start + +| I want to… | Go to | +|---|---| +| Install the app and sign in | [Getting started]({{ '/getting-started/' | relative_url }}) | +| Understand a screen I'm looking at | [Screens]({{ '/screens/' | relative_url }}) or press F1 in the app | +| Do a common task step by step | [Tutorials]({{ '/tutorials/' | relative_url }}) | +| Learn an area (people, access, apps, keys) | [Guides]({{ '/guides/' | relative_url }}) | +| Look up a shortcut, status or permission | [Reference]({{ '/reference/' | relative_url }}) | +| Integrate with the Platform API | [API]({{ '/api/' | relative_url }}): versioning, authentication, the OpenAPI reference and every operation | +| Get a quick answer | [FAQ]({{ '/faq/' | relative_url }}) | + +## The Platform API version is a header + +Every Hub app talks to `https://api.substratalapps.com` and names the API version in a **`Substratal-Version: 1`** request header. There's no `/v1` in any URL any more; `/v1/...` paths return `404`. See [API versioning]({{ '/api/versioning/' | relative_url }}). +{: .important } diff --git a/docs/reference/cli.md b/docs/reference/cli.md new file mode 100644 index 0000000..720bb11 --- /dev/null +++ b/docs/reference/cli.md @@ -0,0 +1,156 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "substratalctl" +parent: Reference +nav_order: 4 +--- + +# substratalctl command reference +{: .no_toc } + +`substratalctl` is the Hub's command-line companion. It shares the Linux app's core, so it sends the same `Substratal-Version` header, signs in the same way and gets the same permission errors. See the [CLI tutorial]({{ '/tutorials/cli/' | relative_url }}). +{: .fs-6 .fw-300 } + +## `substratalctl` + +Command-line client for the Substratal Apps Platform API + +**Usage:** `substratalctl [OPTIONS] ` + +###### **Subcommands:** + +* `health` — Check that the API is up (`GET /health`) +* `version` — Show this client's version, the API version it sends, and the base URL +* `login` — Sign in with email and password (and a TOTP code if two-factor is on) +* `logout` — Sign out and forget the saved session +* `whoami` — Show the signed-in user and their platform permissions +* `users` — Manage users +* `screens` — List the Hub's screens (IDs, routes, who can use them) +* `route` — Resolve a route or `substratal://` link to its screen +* `help-url` — Print the help-center URL for a screen ID, e.g. `USR-01` + +###### **Options:** + +* `--api ` — API environment: `production`, `local` (the devserver on :8080) or a base URL + + Default value: `production` +* `--no-keyring` — Don't use the Secret Service; you sign in again each run +* `--json` — Print JSON instead of a table + + + +## `substratalctl health` + +Check that the API is up (`GET /health`) + +**Usage:** `substratalctl health` + + + +## `substratalctl version` + +Show this client's version, the API version it sends, and the base URL + +**Usage:** `substratalctl version` + + + +## `substratalctl login` + +Sign in with email and password (and a TOTP code if two-factor is on) + +**Usage:** `substratalctl login [OPTIONS] --email ` + +###### **Options:** + +* `--email ` +* `--password-env ` — Read the password from this environment variable instead of prompting + + + +## `substratalctl logout` + +Sign out and forget the saved session + +**Usage:** `substratalctl logout` + + + +## `substratalctl whoami` + +Show the signed-in user and their platform permissions + +**Usage:** `substratalctl whoami` + + + +## `substratalctl users` + +Manage users + +**Usage:** `substratalctl users ` + +###### **Subcommands:** + +* `list` — List users (`GET /users`); needs `users.list` + + + +## `substratalctl users list` + +List users (`GET /users`); needs `users.list` + +**Usage:** `substratalctl users list [OPTIONS]` + +###### **Options:** + +* `-q`, `--query ` — Search by name, email or `usr_` ID prefix +* `--limit ` + + Default value: `50` + + + +## `substratalctl screens` + +List the Hub's screens (IDs, routes, who can use them) + +**Usage:** `substratalctl screens [OPTIONS]` + +###### **Options:** + +* `--all` — Include web-only screens (the Console) + + + +## `substratalctl route` + +Resolve a route or `substratal://` link to its screen + +**Usage:** `substratalctl route ` + +###### **Arguments:** + +* `` + + + +## `substratalctl help-url` + +Print the help-center URL for a screen ID, e.g. `USR-01` + +**Usage:** `substratalctl help-url ` + +###### **Arguments:** + +* `` + + + +


+ + + This document was generated automatically by + clap-markdown. + diff --git a/docs/reference/errors.md b/docs/reference/errors.md new file mode 100644 index 0000000..6d05304 --- /dev/null +++ b/docs/reference/errors.md @@ -0,0 +1,30 @@ +--- +layout: default +title: Error messages +parent: Reference +nav_order: 5 +permalink: /reference/errors/ +--- + +# Error messages +{: .no_toc } + +What the Hub's error messages mean and what to do. Every error has a **Details** section with the API's error code and a request ID; quote the request ID when asking for help. +{: .fs-6 .fw-300 } + +| Message | Code | What to do | +|---|---|---| +| Email or password is incorrect. | `invalid_credentials` | Check both. Use **Forgot password?** if needed. | +| That code didn't work. | `invalid_mfa_code` | Use the current code from your authenticator, or a recovery code. | +| This account is suspended. | `user_suspended` | Ask an administrator. | +| This test account has expired. | `test_user_expired` | Test accounts last 24 hours unless extended. | +| Your session ended. Sign in again. | `unauthenticated`, `session_revoked` | Sign in again; you return to where you were. | +| You don't have permission to do this. | `forbidden` | Ask an administrator for the permission named in Details. | +| Someone else changed this while you were editing. | `version_conflict` | Choose **Reload** to see their change, or **Re-apply** yours on top. | +| Managed by Substratal — you can only turn it off or back on. | `entitlement_platform_managed` | Free and Standard access can't be edited, only switched. | +| You're doing that too often. | `rate_limited` | Wait for the countdown. | +| Substratal is temporarily unavailable. | `service_unavailable` | Reading retries by itself; try changes again shortly. | +| Something went wrong in the app. | `api_version_required`, `method_not_allowed` | A bug in the app. Update it; report it with the request ID if it continues. | +| Update required | `unsupported_api_version` | Your app's API version has been retired. Update the app. See [Versioning]({{ '/api/versioning/' | relative_url }}). | + +The full list of API error codes is in the spec's [Conventions → Errors](https://compositecode.github.io/substratalapps.com/api-reference/conventions/). diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 0000000..90d64be --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,13 @@ +--- +layout: default +title: Reference +nav_order: 6 +has_children: true +permalink: /reference/ +--- + +# Reference +{: .no_toc } + +Look-up pages. Most are generated from the app's code and the specs on every change, so they always match the app. +{: .fs-6 .fw-300 } diff --git a/docs/reference/linking.md b/docs/reference/linking.md new file mode 100644 index 0000000..bda7e61 --- /dev/null +++ b/docs/reference/linking.md @@ -0,0 +1,35 @@ +--- +layout: default +title: Linking help from an app +parent: Reference +nav_order: 6 +permalink: /reference/linking/ +--- + +# Linking help from an app +{: .no_toc } + +For the teams building each Hub app. This help center is the documentation home for all of them: link to it instead of writing help into the app. +{: .fs-6 .fw-300 } + +## Per-screen help URLs + +Every screen ID has a stable page: + +```text +https://compositecode.github.io/substratalapps-linux/screens// +``` + +For example `USR-01` → `/screens/usr-01/`. Wire each app's Help button (and F1, ⌘? or the platform equivalent) to the page for the visible screen. The Linux app does this in `substratal_core::config::help_url`. + +## Machine-readable index + +[`/screens.json`]({{ '/screens.json' | relative_url }}) lists every screen with its title, route, kind, phase, who can use it, the platforms it's on, and its help URL. Use it to build an in-app help search or to check that every screen you ship has a page. + +## Keeping it current + +Screen pages are generated from the specs package (`linux/SCREENS.md` and `00-shared/SCREEN-SPECS.md`). When the specs change, the Linux repo's **Specs sync** workflow regenerates them, so a new screen gets a help page without anyone writing one. Platform-specific notes (shortcuts, controls) appear on the page where they differ. See [How these docs stay current]({{ '/contributing/' | relative_url }}). + +## If the help center moves + +If it gets its own domain (for example `help.substratalapps.com`), the paths stay the same. Keep the base URL in one constant in each app. diff --git a/docs/reference/navigation.md b/docs/reference/navigation.md new file mode 100644 index 0000000..57772b3 --- /dev/null +++ b/docs/reference/navigation.md @@ -0,0 +1,51 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "Navigation and permissions" +parent: Reference +nav_order: 2 +--- + +# Navigation and permissions +{: .no_toc } + +What each kind of person sees in the sidebar. The app hides what you can never use, and the server checks every action again, so this is a guide, not a guarantee. +{: .fs-6 .fw-300 } + +| Section › Item | Screen | End user | App owner | Support | Billing admin | Superadmin | +|---|---|:-:|:-:|:-:|:-:|:-:| +| Personal › Home | [APP-01]({{ '/screens/app-01/' | relative_url }}) | ✓ | ✓ | ✓ | ✓ | ✓ | +| Personal › Inbox | [NTF-01]({{ '/screens/ntf-01/' | relative_url }}) | ✓ | ✓ | ✓ | ✓ | ✓ | +| Personal › Organizations | [ORG-01]({{ '/screens/org-01/' | relative_url }}) | ✓ | ✓ | ✓ | ✓ | ✓ | +| Personal › Account | [ACC-01]({{ '/screens/acc-01/' | relative_url }}) | ✓ | ✓ | ✓ | ✓ | ✓ | +| Personal › Plan & Billing | [BIL-01]({{ '/screens/bil-01/' | relative_url }}) | ✓ | ✓ | ✓ | ✓ | ✓ | +| Personal › Settings | [SET-01]({{ '/screens/set-01/' | relative_url }}) | ✓ | ✓ | ✓ | ✓ | ✓ | +| Administration › Users | [USR-01]({{ '/screens/usr-01/' | relative_url }}) | | | ✓ | ✓ | ✓ | +| Administration › Access | [ACX-05]({{ '/screens/acx-05/' | relative_url }}) | | ✓ | ✓ | | ✓ | +| Administration › Roles | [ROL-01]({{ '/screens/rol-01/' | relative_url }}) | | | ✓ | ✓ | ✓ | +| Administration › Audit Log | [AUD-01]({{ '/screens/aud-01/' | relative_url }}) | | | ✓ | ✓ | ✓ | +| Administration › Send notification | [NTF-06]({{ '/screens/ntf-06/' | relative_url }}) | | | | | ✓ | +| Developer › Applications | [APL-01]({{ '/screens/apl-01/' | relative_url }}) | | ✓ | | | ✓ | +| Developer › API Keys | [KEY-01]({{ '/screens/key-01/' | relative_url }}) | | ✓ | | | ✓ | +| Developer › Webhooks | [WHK-01]({{ '/screens/whk-01/' | relative_url }}) | | ✓ | | | ✓ | +| Developer › Tenant | [BIL-04]({{ '/screens/bil-04/' | relative_url }}) | | ✓ | ✓ | | ✓ | + +The web Hub also has an **Operations** section (the Console) and **Reports** and **Integrations** items. They're web-only for now; see [Screens]({{ '/screens/' | relative_url }}). + +## Platform permissions + +| Permission | Typical holder | +|---|---| +| `users.list` | Support, Billing admin, Superadmin | +| `users.manage` | Superadmin | +| `entitlements.manage` | Support, Superadmin | +| `applications.manage` | Superadmin | +| `roles.manage` | Superadmin | +| `organizations.manage` | Superadmin | +| `billing.manage` | Support, Billing admin, Superadmin | +| `billing.refund` | Billing admin, Superadmin | +| `audit.view` | Support, Billing admin, Superadmin | +| `webhooks.manage` | Superadmin | +| `api_keys.manage` | Superadmin | +| `notifications.send` | Superadmin | +| `tenants.manage` | Support, Superadmin | diff --git a/docs/reference/shortcuts.md b/docs/reference/shortcuts.md new file mode 100644 index 0000000..4b6923e --- /dev/null +++ b/docs/reference/shortcuts.md @@ -0,0 +1,49 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "Keyboard shortcuts" +parent: Reference +nav_order: 1 +--- + +# Keyboard shortcuts +{: .no_toc } + +Shortcuts in Substratal for Linux. Every shortcut has a visible equivalent: a button, menu item or the command palette. Press **Ctrl+?** in the app to see this list. +{: .fs-6 .fw-300 } + + +## General + +| Keys | Does | +|---|---| +| Ctrl+K | Command palette | +| Ctrl+F | Search | +| F1 | Help for this screen | +| Ctrl+, | Preferences | +| Ctrl+? | Keyboard shortcuts | +| Ctrl+W | Close window | +| Ctrl+Q | Quit | + +## Navigation + +| Keys | Does | +|---|---| +| Alt+1 | Home | +| Alt+2 | Inbox | +| Alt+3 | Organizations | +| Alt+4 | Account | +| Alt+5 | Plan & Billing | +| Alt+6 | Settings | +| Alt+← | Back | +| F9 | Show or hide the sidebar | + +## Editing + +| Keys | Does | +|---|---| +| Ctrl+N | New (in the current list) | +| Ctrl+R or F5 | Refresh | +| Ctrl+Z | Undo an access change | +| Delete | Delete or turn off the selection (asks first) | +| Ctrl+Shift+U | Mark all as read (Inbox) | diff --git a/docs/reference/statuses.md b/docs/reference/statuses.md new file mode 100644 index 0000000..d9f9c23 --- /dev/null +++ b/docs/reference/statuses.md @@ -0,0 +1,28 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "Status meanings" +parent: Reference +nav_order: 3 +--- + +# Status meanings +{: .no_toc } + +Every status badge in every Hub app shows an icon and a label, never colour alone. They mean the same thing everywhere. +{: .fs-6 .fw-300 } + +| Status | Label | Kind | +|---|---|---| +| `active` | Active | success | +| `deleted` | Deleted | critical | +| `disabled` | Turned off | warning | +| `expired` | Expired | neutral | +| `important` | Important | critical | +| `invited` | Invited | accent | +| `none` | No access | neutral | +| `pending` | Pending | info | +| `revoked` | Revoked | critical | +| `scheduled` | Scheduled | info | +| `suspended` | Suspended | warning | +| `unread` | Unread | accent | diff --git a/docs/screens.json b/docs/screens.json new file mode 100644 index 0000000..2a4300a --- /dev/null +++ b/docs/screens.json @@ -0,0 +1,1133 @@ +[ + { + "archetype": "auth", + "help": "https://compositecode.github.io/substratalapps-linux/screens/auth-01/", + "id": "AUTH-01", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Exchange email + password for a session, or start an MFA challenge.", + "route": "/sign-in", + "title": "Sign in", + "who": "Unauthenticated" + }, + { + "archetype": "auth", + "help": "https://compositecode.github.io/substratalapps-linux/screens/auth-02/", + "id": "AUTH-02", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Complete sign-in with a TOTP code or a recovery code.", + "route": "/sign-in/mfa", + "title": "Two-factor challenge", + "who": "Holder of mfa_token" + }, + { + "archetype": "auth", + "help": "https://compositecode.github.io/substratalapps-linux/screens/auth-03/", + "id": "AUTH-03", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Self-service signup with email and password.", + "route": "/sign-up", + "title": "Create account", + "who": "Unauthenticated" + }, + { + "archetype": "auth", + "help": "https://compositecode.github.io/substratalapps-linux/screens/auth-04/", + "id": "AUTH-04", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Tell the user to check email; complete verification from the emailed link.", + "route": "/verify-email", + "title": "Verify your email", + "who": "Signed in (unverified) or token holder" + }, + { + "archetype": "auth", + "help": "https://compositecode.github.io/substratalapps-linux/screens/auth-05/", + "id": "AUTH-05", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Request a password-reset email.", + "route": "/forgot-password", + "title": "Forgot password", + "who": "Unauthenticated" + }, + { + "archetype": "auth", + "help": "https://compositecode.github.io/substratalapps-linux/screens/auth-06/", + "id": "AUTH-06", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Choose a new password from the emailed reset link.", + "route": "/reset-password", + "title": "Set a new password", + "who": "Reset-token holder" + }, + { + "archetype": "auth", + "help": "https://compositecode.github.io/substratalapps-linux/screens/auth-07/", + "id": "AUTH-07", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Activate an account an admin created: set password and sign in.", + "route": "/invite", + "title": "Accept invitation", + "who": "Invitation-token holder" + }, + { + "archetype": "settings", + "help": "https://compositecode.github.io/substratalapps-linux/screens/acc-01/", + "id": "ACC-01", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "View and edit the signed-in user's global profile.", + "route": "/account", + "title": "Account & profile", + "who": "Self" + }, + { + "archetype": "form", + "help": "https://compositecode.github.io/substratalapps-linux/screens/acc-02/", + "id": "ACC-02", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Start an email change; the new address must be verified.", + "route": "/account/email", + "title": "Change email", + "who": "Self" + }, + { + "archetype": "form", + "help": "https://compositecode.github.io/substratalapps-linux/screens/acc-03/", + "id": "ACC-03", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Change password knowing the current one.", + "route": "/account/security/password", + "title": "Change password", + "who": "Self" + }, + { + "archetype": "settings", + "help": "https://compositecode.github.io/substratalapps-linux/screens/acc-04/", + "id": "ACC-04", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Password, two-factor and active sessions in one place.", + "route": "/account/security", + "title": "Security", + "who": "Self" + }, + { + "archetype": "wizard", + "help": "https://compositecode.github.io/substratalapps-linux/screens/acc-05/", + "id": "ACC-05", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Enroll an authenticator app (TOTP) and save recovery codes.", + "route": "/account/security/2fa/enable", + "title": "Turn on two-factor", + "who": "Self" + }, + { + "archetype": "confirm", + "help": "https://compositecode.github.io/substratalapps-linux/screens/acc-06/", + "id": "ACC-06", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Disable TOTP with a current code or a recovery code.", + "route": "/account/security/2fa/disable", + "title": "Turn off two-factor", + "who": "Self" + }, + { + "archetype": "settings", + "help": "https://compositecode.github.io/substratalapps-linux/screens/acc-07/", + "id": "ACC-07", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Export personal data and delete the account.", + "route": "/account/privacy", + "title": "Privacy", + "who": "Self" + }, + { + "archetype": "confirm", + "help": "https://compositecode.github.io/substratalapps-linux/screens/acc-08/", + "id": "ACC-08", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Request erasure: deactivate now, erase after 7 days.", + "route": "/account/privacy/delete", + "title": "Delete account", + "who": "Self" + }, + { + "archetype": "grid", + "help": "https://compositecode.github.io/substratalapps-linux/screens/app-01/", + "id": "APP-01", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "See every app you can use, its access status, and launch it.", + "route": "/", + "title": "Home — My Apps", + "who": "Signed in" + }, + { + "archetype": "popover", + "help": "https://compositecode.github.io/substratalapps-linux/screens/app-02/", + "id": "APP-02", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Explain an app's access status and how the user has access.", + "route": "/apps/:appId", + "title": "App info", + "who": "Signed in" + }, + { + "archetype": "handoff", + "help": "https://compositecode.github.io/substratalapps-linux/screens/app-03/", + "id": "APP-03", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Mint an app token (or authorization code) and open the app.", + "route": "/apps/:appId/launch", + "title": "Launch app", + "who": "Active entitlement" + }, + { + "archetype": "form", + "help": "https://compositecode.github.io/substratalapps-linux/screens/app-04/", + "id": "APP-04", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "Per-app display handle and custom profile fields.", + "route": "/apps/:appId/profile", + "title": "App profile", + "who": "Self, active entitlement" + }, + { + "archetype": "list", + "help": "https://compositecode.github.io/substratalapps-linux/screens/app-05/", + "id": "APP-05", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Apps whose access was revoked or expired.", + "route": "/apps/past", + "title": "Past apps", + "who": "Signed in" + }, + { + "archetype": "settings", + "help": "https://compositecode.github.io/substratalapps-linux/screens/set-01/", + "id": "SET-01", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Preferences that apply to every app.", + "route": "/settings", + "title": "Settings — Global", + "who": "Self" + }, + { + "archetype": "settings", + "help": "https://compositecode.github.io/substratalapps-linux/screens/set-02/", + "id": "SET-02", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "Override global settings and set app-specific preferences from the app's settings_schema.", + "route": "/settings/apps/:appId", + "title": "Settings — Per app", + "who": "Self, active entitlement" + }, + { + "archetype": "list", + "help": "https://compositecode.github.io/substratalapps-linux/screens/org-01/", + "id": "ORG-01", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Organizations you belong to, plus pending invitations.", + "route": "/orgs", + "title": "Organizations", + "who": "Signed in (all orgs with organizations.manage)" + }, + { + "archetype": "form", + "help": "https://compositecode.github.io/substratalapps-linux/screens/org-02/", + "id": "ORG-02", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Create an organization; creator becomes org admin.", + "route": "/orgs/new", + "title": "New organization", + "who": "Any active user" + }, + { + "archetype": "detail", + "help": "https://compositecode.github.io/substratalapps-linux/screens/org-03/", + "id": "ORG-03", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "See and manage members and pending invitations.", + "route": "/orgs/:orgId/members", + "title": "Organization — Members", + "who": "Member (read), org_admin / organizations.manage (write)" + }, + { + "archetype": "form", + "help": "https://compositecode.github.io/substratalapps-linux/screens/org-04/", + "id": "ORG-04", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Invite people by email (or add an existing user, staff only).", + "route": "/orgs/:orgId/members/invite", + "title": "Invite member", + "who": "org_admin; organizations.manage may add by user_id" + }, + { + "archetype": "confirm", + "help": "https://compositecode.github.io/substratalapps-linux/screens/org-05/", + "id": "ORG-05", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Change a member's role or remove them.", + "route": "/orgs/:orgId/members/:userId", + "title": "Member actions", + "who": "org_admin / organizations.manage" + }, + { + "archetype": "tab", + "help": "https://compositecode.github.io/substratalapps-linux/screens/org-06/", + "id": "ORG-06", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "Org-wide app grants and who they include.", + "route": "/orgs/:orgId/apps", + "title": "Organization — Apps & seats", + "who": "Member (read), org_admin (write)" + }, + { + "archetype": "form", + "help": "https://compositecode.github.io/substratalapps-linux/screens/org-07/", + "id": "ORG-07", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "Create an org-wide grant or change who it includes.", + "route": "/orgs/:orgId/apps/grant", + "title": "Grant app to organization / member scope", + "who": "entitlements.manage or app owner; scope edits by org_admin" + }, + { + "archetype": "settings", + "help": "https://compositecode.github.io/substratalapps-linux/screens/org-08/", + "id": "ORG-08", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Rename, leave, set up SCIM provisioning, or (staff) suspend the organization.", + "route": "/orgs/:orgId/settings", + "title": "Organization — Settings", + "who": "org_admin (rename, SCIM), organizations.manage (suspend, SCIM), any member (leave)" + }, + { + "archetype": "confirm", + "help": "https://compositecode.github.io/substratalapps-linux/screens/org-09/", + "id": "ORG-09", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Accept or decline an organization invitation.", + "route": "/orgs/:orgId/invitation", + "title": "Invitation", + "who": "Invited user" + }, + { + "archetype": "table", + "help": "https://compositecode.github.io/substratalapps-linux/screens/usr-01/", + "id": "USR-01", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Find and manage user accounts.", + "route": "/admin/users", + "title": "Users", + "who": "users.list (write actions need users.manage)" + }, + { + "archetype": "form", + "help": "https://compositecode.github.io/substratalapps-linux/screens/usr-02/", + "id": "USR-02", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Create a user and send an invitation (or create active).", + "route": "/admin/users/new", + "title": "Invite user", + "who": "users.manage" + }, + { + "archetype": "detail", + "help": "https://compositecode.github.io/substratalapps-linux/screens/usr-03/", + "id": "USR-03", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "One user's account state and administrative actions.", + "route": "/admin/users/:userId", + "title": "User — Overview", + "who": "users.list (read), users.manage (write)" + }, + { + "archetype": "tab", + "help": "https://compositecode.github.io/substratalapps-linux/screens/usr-04/", + "id": "USR-04", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "See and revoke a user's sessions.", + "route": "/admin/users/:userId/sessions", + "title": "User — Sessions", + "who": "users.manage" + }, + { + "archetype": "confirm", + "help": "https://compositecode.github.io/substratalapps-linux/screens/usr-05/", + "id": "USR-05", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Suspend an account (signs them out everywhere).", + "route": "/admin/users/:userId/suspend", + "title": "Suspend user", + "who": "users.manage" + }, + { + "archetype": "tab", + "help": "https://compositecode.github.io/substratalapps-linux/screens/acx-01/", + "id": "ACX-01", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Turn apps on or off for one person; see every path that grants access.", + "route": "/admin/users/:userId/access", + "title": "User — Access", + "who": "users.list (read), entitlements.manage or app owner (write)" + }, + { + "archetype": "form", + "help": "https://compositecode.github.io/substratalapps-linux/screens/acx-02/", + "id": "ACX-02", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Give a person access to an app.", + "route": "/admin/users/:userId/access/grant", + "title": "Grant access", + "who": "entitlements.manage or app owner (own app)" + }, + { + "archetype": "confirm", + "help": "https://compositecode.github.io/substratalapps-linux/screens/acx-03/", + "id": "ACX-03", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Confirm turning off access, or edit term/source.", + "route": "/admin/entitlements/:entId", + "title": "Turn off / edit access", + "who": "entitlements.manage or app owner" + }, + { + "archetype": "popover", + "help": "https://compositecode.github.io/substratalapps-linux/screens/acx-04/", + "id": "ACX-04", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Explain exactly why a person can or can't use an app.", + "route": "/admin/users/:userId/apps/:appId/why", + "title": "Access check (Why?)", + "who": "users.list or app owner" + }, + { + "archetype": "table", + "help": "https://compositecode.github.io/substratalapps-linux/screens/acx-05/", + "id": "ACX-05", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Every grant across users: audit expirations, trials, revocations.", + "route": "/admin/access", + "title": "Access (all)", + "who": "entitlements.manage or app owner (confined to own apps)" + }, + { + "archetype": "list", + "help": "https://compositecode.github.io/substratalapps-linux/screens/rol-01/", + "id": "ROL-01", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Platform and app roles.", + "route": "/admin/roles", + "title": "Roles", + "who": "users.list (read), roles.manage (write)" + }, + { + "archetype": "detail", + "help": "https://compositecode.github.io/substratalapps-linux/screens/rol-02/", + "id": "ROL-02", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "View or edit a role's permissions.", + "route": "/admin/roles/:roleId", + "title": "Role detail", + "who": "users.list (read), roles.manage (write)" + }, + { + "archetype": "form", + "help": "https://compositecode.github.io/substratalapps-linux/screens/rol-03/", + "id": "ROL-03", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Define a custom role.", + "route": "/admin/roles/new", + "title": "New role", + "who": "roles.manage" + }, + { + "archetype": "tab", + "help": "https://compositecode.github.io/substratalapps-linux/screens/rol-04/", + "id": "ROL-04", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Assign and remove roles for a user.", + "route": "/admin/users/:userId/roles", + "title": "User — Roles", + "who": "users.list (read), roles.manage (write)" + }, + { + "archetype": "list", + "help": "https://compositecode.github.io/substratalapps-linux/screens/apl-01/", + "id": "APL-01", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Applications you own or administer.", + "route": "/dev/apps", + "title": "Applications", + "who": "Any (catalog), owner/applications.manage (manage)" + }, + { + "archetype": "wizard", + "help": "https://compositecode.github.io/substratalapps-linux/screens/apl-02/", + "id": "APL-02", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Register a new application.", + "route": "/dev/apps/new", + "title": "Register application", + "who": "applications.manage, or anyone with a verified email (self-service)" + }, + { + "archetype": "detail", + "help": "https://compositecode.github.io/substratalapps-linux/screens/apl-03/", + "id": "APL-03", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Configure an application.", + "route": "/dev/apps/:appId", + "title": "Application — Overview", + "who": "Owner (configure), applications.manage (anything)" + }, + { + "archetype": "tab", + "help": "https://compositecode.github.io/substratalapps-linux/screens/apl-04/", + "id": "APL-04", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "Declare per-user settings the app stores in Substratal.", + "route": "/dev/apps/:appId/schema", + "title": "Settings schema", + "who": "Owner / applications.manage" + }, + { + "archetype": "tab", + "help": "https://compositecode.github.io/substratalapps-linux/screens/apl-05/", + "id": "APL-05", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "Declare app permissions, available app roles and the default role.", + "route": "/dev/apps/:appId/roles", + "title": "Permissions & roles", + "who": "Owner / applications.manage" + }, + { + "archetype": "form", + "help": "https://compositecode.github.io/substratalapps-linux/screens/apl-06/", + "id": "APL-06", + "phase": "Phase 3", + "platforms": [ + "all" + ], + "purpose": "Staff moderation of an application.", + "route": "/dev/apps/:appId/review", + "title": "Review", + "who": "applications.manage" + }, + { + "archetype": "tab", + "help": "https://compositecode.github.io/substratalapps-linux/screens/apl-07/", + "id": "APL-07", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "Configure APNs, FCM and Web Push for an Application so it can send push notifications.", + "route": "/dev/apps/:appId/push", + "title": "Push credentials", + "who": "Owner or applications.manage with a User token (write); app key / owner (read)" + }, + { + "archetype": "table", + "help": "https://compositecode.github.io/substratalapps-linux/screens/key-01/", + "id": "KEY-01", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Service and agent credentials.", + "route": "/dev/keys", + "title": "API keys", + "who": "api_keys.manage or app owner" + }, + { + "archetype": "form", + "help": "https://compositecode.github.io/substratalapps-linux/screens/key-02/", + "id": "KEY-02", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Create a key with least-privilege permissions.", + "route": "/dev/keys/new", + "title": "Create API key", + "who": "api_keys.manage or app owner (app-scoped)" + }, + { + "archetype": "secret", + "help": "https://compositecode.github.io/substratalapps-linux/screens/key-03/", + "id": "KEY-03", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Show a new API key secret, webhook signing secret, or recovery codes exactly once.", + "route": "(modal)", + "title": "Secret reveal", + "who": "Creator" + }, + { + "archetype": "detail", + "help": "https://compositecode.github.io/substratalapps-linux/screens/key-04/", + "id": "KEY-04", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Edit, rotate or revoke a key.", + "route": "/dev/keys/:keyId", + "title": "API key detail", + "who": "api_keys.manage or app owner" + }, + { + "archetype": "list", + "help": "https://compositecode.github.io/substratalapps-linux/screens/whk-01/", + "id": "WHK-01", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "Endpoints that receive access-change events.", + "route": "/dev/webhooks", + "title": "Webhooks", + "who": "webhooks.manage or app owner" + }, + { + "archetype": "form", + "help": "https://compositecode.github.io/substratalapps-linux/screens/whk-02/", + "id": "WHK-02", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "Subscribe a URL to events.", + "route": "/dev/webhooks/new", + "title": "Add / edit webhook", + "who": "webhooks.manage or app owner" + }, + { + "archetype": "detail", + "help": "https://compositecode.github.io/substratalapps-linux/screens/whk-03/", + "id": "WHK-03", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "Delivery history, testing and secret rotation.", + "route": "/dev/webhooks/:hookId", + "title": "Webhook — Deliveries", + "who": "webhooks.manage or app owner" + }, + { + "archetype": "popover", + "help": "https://compositecode.github.io/substratalapps-linux/screens/whk-04/", + "id": "WHK-04", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "One delivery attempt in detail.", + "route": "/dev/webhooks/:hookId/deliveries/:deliveryId", + "title": "Delivery detail", + "who": "webhooks.manage or app owner" + }, + { + "archetype": "settings", + "help": "https://compositecode.github.io/substratalapps-linux/screens/bil-01/", + "id": "BIL-01", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "Free or Standard: what covers you, your own subscription, upgrade and manage billing.", + "route": "/billing", + "title": "My plan", + "who": "Self; billing.manage can view any User's (read-only)" + }, + { + "archetype": "tab", + "help": "https://compositecode.github.io/substratalapps-linux/screens/bil-02/", + "id": "BIL-02", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "An organization's Standard subscription and which members it covers.", + "route": "/orgs/:orgId/subscription", + "title": "Organization subscription", + "who": "org_admin (manage); billing.manage (read + portal)" + }, + { + "archetype": "handoff", + "help": "https://compositecode.github.io/substratalapps-linux/screens/bil-03/", + "id": "BIL-03", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "Hand off to Stripe in the browser and confirm the result on return.", + "route": "/billing/return", + "title": "Checkout / billing portal", + "who": "Self (individual); org_admin (org checkout); org_admin or billing.manage (org portal)" + }, + { + "archetype": "detail", + "help": "https://compositecode.github.io/substratalapps-linux/screens/bil-04/", + "id": "BIL-04", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "Where an Application owner's data lives, and support-run tier changes. Tenants aren't billed.", + "route": "/dev/tenant", + "title": "Tenant & infrastructure", + "who": "Tenant owner (read), tenants.manage (write)" + }, + { + "archetype": "table", + "help": "https://compositecode.github.io/substratalapps-linux/screens/aud-01/", + "id": "AUD-01", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Who changed what, when.", + "route": "/admin/audit", + "title": "Audit log", + "who": "audit.view" + }, + { + "archetype": "popover", + "help": "https://compositecode.github.io/substratalapps-linux/screens/aud-02/", + "id": "AUD-02", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "One event with before/after diff.", + "route": "/admin/audit/:eventId", + "title": "Audit event", + "who": "audit.view" + }, + { + "archetype": "list", + "help": "https://compositecode.github.io/substratalapps-linux/screens/ntf-01/", + "id": "NTF-01", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "Every notification the person has received — from every app and from Substratal — with read and archived state.", + "route": "/inbox", + "title": "Inbox", + "who": "Self" + }, + { + "archetype": "popover", + "help": "https://compositecode.github.io/substratalapps-linux/screens/ntf-02/", + "id": "NTF-02", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "One notification in full, with where it leads and how its push went.", + "route": "/inbox/:notificationId", + "title": "Notification", + "who": "Self (users.list via NTF-05)" + }, + { + "archetype": "settings", + "help": "https://compositecode.github.io/substratalapps-linux/screens/ntf-03/", + "id": "NTF-03", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "Where push notifications reach this person, and controls for this device.", + "route": "/account/devices", + "title": "Devices & notifications", + "who": "Self" + }, + { + "archetype": "wizard", + "help": "https://compositecode.github.io/substratalapps-linux/screens/ntf-04/", + "id": "NTF-04", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "Explain, request the OS permission, and register this install for push.", + "route": "/account/devices/enable", + "title": "Turn on notifications", + "who": "Self" + }, + { + "archetype": "tab", + "help": "https://compositecode.github.io/substratalapps-linux/screens/ntf-05/", + "id": "NTF-05", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "Support view of a person's inbox and push devices for investigations.", + "route": "/admin/users/:userId/notifications", + "title": "User — Notifications & devices", + "who": "users.list (read), users.manage (delete devices)" + }, + { + "archetype": "form", + "help": "https://compositecode.github.io/substratalapps-linux/screens/ntf-06/", + "id": "NTF-06", + "phase": "Phase 2", + "platforms": [ + "all" + ], + "purpose": "Staff tool: send one notification to one person on behalf of an Application.", + "route": "/admin/notifications/new", + "title": "Send notification", + "who": "platform notifications.send" + }, + { + "archetype": "overlay", + "help": "https://compositecode.github.io/substratalapps-linux/screens/glb-01/", + "id": "GLB-01", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Jump anywhere and run any action by typing.", + "route": "(overlay)", + "title": "Command palette", + "who": "Signed in" + }, + { + "archetype": "hub", + "help": "https://compositecode.github.io/substratalapps-linux/screens/glb-02/", + "id": "GLB-02", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "Compact-layout landing list for the Administration and Developer sections.", + "route": "/manage", + "title": "Manage hub", + "who": "Any admin or developer permission" + }, + { + "archetype": "overlay", + "help": "https://compositecode.github.io/substratalapps-linux/screens/glb-03/", + "id": "GLB-03", + "phase": "MVP", + "platforms": [ + "all" + ], + "purpose": "App-wide banners and blocking states.", + "route": "(global)", + "title": "System states", + "who": "—" + }, + { + "archetype": "detail", + "help": "https://compositecode.github.io/substratalapps-linux/screens/ops-01/", + "id": "OPS-01", + "phase": "Console M3 (web only)", + "platforms": [ + "web" + ], + "purpose": "Is production healthy right now, and is anything costing or failing?", + "route": "/ops", + "title": "Overview", + "who": "operations.view (superadmin until G10)" + }, + { + "archetype": "detail", + "help": "https://compositecode.github.io/substratalapps-linux/screens/ops-02/", + "id": "OPS-02", + "phase": "Console M3 (web only)", + "platforms": [ + "web" + ], + "purpose": "Trends and SLO tracking.", + "route": "/ops/traffic", + "title": "Traffic & latency", + "who": "operations.view" + }, + { + "archetype": "table", + "help": "https://compositecode.github.io/substratalapps-linux/screens/ops-03/", + "id": "OPS-03", + "phase": "Console M3 (web only)", + "platforms": [ + "web" + ], + "purpose": "Find what happened to one request, user or key.", + "route": "/ops/logs", + "title": "Logs", + "who": "operations.view" + }, + { + "archetype": "table", + "help": "https://compositecode.github.io/substratalapps-linux/screens/sec-01/", + "id": "SEC-01", + "phase": "Console M3 (web only)", + "platforms": [ + "web" + ], + "purpose": "Spot attacks and lockouts.", + "route": "/ops/security", + "title": "Security events", + "who": "operations.view" + }, + { + "archetype": "table", + "help": "https://compositecode.github.io/substratalapps-linux/screens/ops-04/", + "id": "OPS-04", + "phase": "Console M3 (web only)", + "platforms": [ + "web" + ], + "purpose": "What's firing, what fired, and who gets told.", + "route": "/ops/alarms", + "title": "Alarms", + "who": "operations.view" + }, + { + "archetype": "table", + "help": "https://compositecode.github.io/substratalapps-linux/screens/ops-05/", + "id": "OPS-05", + "phase": "Console M3 (web only)", + "platforms": [ + "web" + ], + "purpose": "Are the scheduled jobs and deliveries keeping up?", + "route": "/ops/jobs", + "title": "Jobs & delivery", + "who": "operations.view" + }, + { + "archetype": "table", + "help": "https://compositecode.github.io/substratalapps-linux/screens/ops-06/", + "id": "OPS-06", + "phase": "Console M3 (web only)", + "platforms": [ + "web" + ], + "purpose": "What's deployed.", + "route": "/ops/releases", + "title": "Releases", + "who": "operations.view" + }, + { + "archetype": "detail", + "help": "https://compositecode.github.io/substratalapps-linux/screens/ops-07/", + "id": "OPS-07", + "phase": "Console M3 (web only)", + "platforms": [ + "web" + ], + "purpose": "Spend against the budgets.", + "route": "/ops/cost", + "title": "Cost", + "who": "operations.view" + }, + { + "archetype": "settings", + "help": "https://compositecode.github.io/substratalapps-linux/screens/cfg-01/", + "id": "CFG-01", + "phase": "Console M3 (web only)", + "platforms": [ + "web" + ], + "purpose": "What production is actually running with.", + "route": "/ops/config", + "title": "Configuration", + "who": "operations.view" + }, + { + "archetype": "settings", + "help": "https://compositecode.github.io/substratalapps-linux/screens/cfg-02/", + "id": "CFG-02", + "phase": "Console M4 (web only)", + "platforms": [ + "web" + ], + "purpose": "See, and later change, the client feature flags (CLIENT-ARCHITECTURE §10).", + "route": "/ops/config/flags", + "title": "Feature flags", + "who": "operations.view (read), operations.manage (write, after G10)" + }, + { + "archetype": "detail", + "help": "https://compositecode.github.io/substratalapps-linux/screens/rpt-01/", + "id": "RPT-01", + "phase": "Console M4 (web only)", + "platforms": [ + "web" + ], + "purpose": "Growth, access, usage, subscribers and delivery over time.", + "route": "/admin/reports", + "title": "Reports", + "who": "users.list (platform reports); an Application owner sees only their own app" + }, + { + "archetype": "list", + "help": "https://compositecode.github.io/substratalapps-linux/screens/int-01/", + "id": "INT-01", + "phase": "Console M2 (web only)", + "platforms": [ + "web" + ], + "purpose": "Every way to connect Substratal to other tools, in one place.", + "route": "/dev/integrations", + "title": "Integrations", + "who": "webhooks.manage or api_keys.manage" + } +] diff --git a/docs/screens/acc-01.md b/docs/screens/acc-01.md new file mode 100644 index 0000000..abf4c5f --- /dev/null +++ b/docs/screens/acc-01.md @@ -0,0 +1,56 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ACC-01 Account & profile" +permalink: /screens/acc-01/ +nav_exclude: true +screen_id: ACC-01 +--- + +# Account & profile +{: .no_toc } + +View and edit the signed-in user's global profile. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ACC-01` | +| **Route** | `/account` · deep link `substratal://account` | +| **Where** | Personal › Account | +| **Who can use it** | Self | +| **Kind** | settings | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwPreferencesPage` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Avatar | image-picker | Profile.avatar_url | camera / photo library / file / paste; client uploads to app asset storage then PATCHes URL* | GtkFileDialog (portal) | +| Display name | text-field | Profile.display_name | required; ≤ 100; save on blur/Return | AdwEntryRow | +| Email | row → ACC-02 | User.email + email_verified + pending_email | shows "Pending: {pending_email}" when set | AdwActionRow activatable | +| User ID | id-chip | User.id | copy | GtkLabel .monospace + copy | +| Contact email | email-field | Profile.contact_email | optional | AdwEntryRow input-purpose=email | +| Contact phone | phone-field | Profile.contact_phone | optional; E.164 formatting by locale | AdwEntryRow input-purpose=phone | +| Plan | row → BIL-01 | User.plan free\ | standard + plan_sources | AdwActionRow activatable | +| Member since / last sign-in | caption | User.created_at, last_login_at | locale formatted | GtkLabel .caption .dim-label | +| Sign out | button | POST /auth/logout | always visible at the bottom of Account (mobile) / account menu (desktop) | GtkButton | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Edit field | `PATCH /users/me/profile (If-Match)` | Save per field; 409 version_conflict → reload prompt; 422 → field error. | +| Sign out | `POST /auth/logout` | best effort; always wipe local credentials and cache → AUTH-01 | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES +- *The API stores `avatar_url` only. Upload target is a client-side decision (spec gap — see OPEN-QUESTIONS). + +## For developers + +Loads: `GET /users/me` · `GET /users/me/profile` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/acc-02.md b/docs/screens/acc-02.md new file mode 100644 index 0000000..6024902 --- /dev/null +++ b/docs/screens/acc-02.md @@ -0,0 +1,38 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ACC-02 Change email" +permalink: /screens/acc-02/ +nav_exclude: true +screen_id: ACC-02 +--- + +# Change email +{: .no_toc } + +Start an email change; the new address must be verified. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ACC-02` | +| **Route** | `/account/email` · deep link `substratal://account/email` | +| **Where** | Personal › Account | +| **Who can use it** | Self | +| **Kind** | form | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| New email | email-field | body.email | required; differs from current | AdwEntryRow input-purpose=email | +| Save | primary-button | PATCH /users/me | | GtkButton .suggested-action .pill | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Save | `PATCH /users/me {email}` | 200 → User.pending_email set; show AUTH-04-style "check your new inbox". 409 not_unique → "That email is already in use." | + diff --git a/docs/screens/acc-03.md b/docs/screens/acc-03.md new file mode 100644 index 0000000..d1465d2 --- /dev/null +++ b/docs/screens/acc-03.md @@ -0,0 +1,39 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ACC-03 Change password" +permalink: /screens/acc-03/ +nav_exclude: true +screen_id: ACC-03 +--- + +# Change password +{: .no_toc } + +Change password knowing the current one. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ACC-03` | +| **Route** | `/account/security/password` · deep link `substratal://account/security/password` | +| **Where** | Personal › Account | +| **Who can use it** | Self | +| **Kind** | form | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Current password | password-field | body.current_password | autofill current-password | AdwPasswordEntryRow | +| New password | password-field | body.new_password | strength meter; autofill new-password | AdwPasswordEntryRow | +| Save | primary-button | POST /users/me/password | | GtkButton .suggested-action .pill | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Save | `POST /users/me/password` | 204 → toast "Password changed". 401 → "Current password is incorrect." 422 → policy errors. | + diff --git a/docs/screens/acc-04.md b/docs/screens/acc-04.md new file mode 100644 index 0000000..29c913c --- /dev/null +++ b/docs/screens/acc-04.md @@ -0,0 +1,53 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ACC-04 Security" +permalink: /screens/acc-04/ +nav_exclude: true +screen_id: ACC-04 +--- + +# Security +{: .no_toc } + +Password, two-factor and active sessions in one place. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ACC-04` | +| **Route** | `/account/security` · deep link `substratal://account/security` | +| **Where** | Personal › Account | +| **Who can use it** | Self | +| **Kind** | settings | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwPreferencesPage` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Password | row → ACC-03 | | "Change…" | AdwActionRow activatable | +| Two-factor authentication | toggle | User.mfa_enabled | on → ACC-05; off → ACC-06 | AdwSwitchRow | +| Sessions | list | Session[]: user_agent → device name/icon, ip_address → approximate location*, last_seen_at, current | current session marked "This device" and not revocable here | GtkListBox .boxed-list | +| Sign out | row-button | DELETE /users/me/sessions/{sessionId} | per non-current row | suffix GtkButton .flat | +| Sign out of all other sessions | destructive-button | POST /users/me/sessions/revoke-all {keep_current: true} | confirm first; also stops push to those sessions' devices | GtkButton .destructive-action | +| Devices & notifications | row → NTF-03 | count of active devices | | AdwActionRow activatable | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Revoke one | `DELETE /users/me/sessions/{sessionId}` | Optimistic remove; 404 → already gone, refresh. | +| Sign out everywhere else | `POST /users/me/sessions/revoke-all {keep_current: true}` | 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. | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES +- *IP → city lookup is client-side/optional; never show the raw IP to non-staff unless the user expands details. + +## For developers + +Loads: `GET /users/me` · `GET /users/me/sessions` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/acc-05.md b/docs/screens/acc-05.md new file mode 100644 index 0000000..339d6f9 --- /dev/null +++ b/docs/screens/acc-05.md @@ -0,0 +1,44 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ACC-05 Turn on two-factor" +permalink: /screens/acc-05/ +nav_exclude: true +screen_id: ACC-05 +--- + +# Turn on two-factor +{: .no_toc } + +Enroll an authenticator app (TOTP) and save recovery codes. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ACC-05` | +| **Route** | `/account/security/2fa/enable` · deep link `substratal://account/security/2fa/enable` | +| **Where** | Personal › Account | +| **Who can use it** | Self | +| **Kind** | wizard | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwDialog` with `AdwNavigationView` pages | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Step 1 — Scan | qr + secret | POST /users/me/mfa/totp → otpauth_uri, secret | QR, "Copy setup key", "Open in authenticator" (otpauth:// link on mobile) | GtkPicture | +| Step 2 — Confirm | otp-field | body.code | 6 digits | AdwEntryRow input-purpose=digits | +| Step 3 — Recovery codes | secret-list | confirm response: 10 recovery codes | Copy all · Save as file · Print · Share (mobile); "I've saved these" checkbox gates Done | SecretReveal + GtkFileDialog/print | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Start | `POST /users/me/mfa/totp` | 409 → already enabled; close. | +| Confirm | `POST /users/me/mfa/totp/confirm` | 200 → step 3. 401 → wrong code. | + +## Good to know + +- **Loading, empty and error states:** Cancel before confirm leaves MFA off (no server cleanup needed) + diff --git a/docs/screens/acc-06.md b/docs/screens/acc-06.md new file mode 100644 index 0000000..5255fe1 --- /dev/null +++ b/docs/screens/acc-06.md @@ -0,0 +1,38 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ACC-06 Turn off two-factor" +permalink: /screens/acc-06/ +nav_exclude: true +screen_id: ACC-06 +--- + +# Turn off two-factor +{: .no_toc } + +Disable TOTP with a current code or a recovery code. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ACC-06` | +| **Route** | `/account/security/2fa/disable` · deep link `substratal://account/security/2fa/disable` | +| **Where** | Personal › Account | +| **Who can use it** | Self | +| **Kind** | confirm | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwAlertDialog` (`extra-child` for fields) | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Code or recovery code | otp-field / text-field | body.code \ | body.recovery_code | AdwEntryRow input-purpose=digits | +| Turn off | destructive-button | POST /users/me/mfa/totp/disable | | GtkButton .destructive-action | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Turn off | `POST /users/me/mfa/totp/disable` | 204 → toggle off, toast. 401 → wrong code. 429 → countdown. | + diff --git a/docs/screens/acc-07.md b/docs/screens/acc-07.md new file mode 100644 index 0000000..5405991 --- /dev/null +++ b/docs/screens/acc-07.md @@ -0,0 +1,38 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ACC-07 Privacy" +permalink: /screens/acc-07/ +nav_exclude: true +screen_id: ACC-07 +--- + +# Privacy +{: .no_toc } + +Export personal data and delete the account. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ACC-07` | +| **Route** | `/account/privacy` · deep link `substratal://account/privacy` | +| **Where** | Personal › Account | +| **Who can use it** | Self | +| **Kind** | settings | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwPreferencesPage` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Download my data | button | GET /users/me/export | saves `substratal-export-{date}.json` via save dialog / share sheet / download | GtkButton | +| Delete account | destructive-row → ACC-08 | | | AdwButtonRow .destructive-action | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Download | `GET /users/me/export` | 200 → save file. 429 → "You can export again in {n} minutes." | + diff --git a/docs/screens/acc-08.md b/docs/screens/acc-08.md new file mode 100644 index 0000000..bc744ee --- /dev/null +++ b/docs/screens/acc-08.md @@ -0,0 +1,44 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ACC-08 Delete account" +permalink: /screens/acc-08/ +nav_exclude: true +screen_id: ACC-08 +--- + +# Delete account +{: .no_toc } + +Request erasure: deactivate now, erase after 7 days. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ACC-08` | +| **Route** | `/account/privacy/delete` · deep link `substratal://account/privacy/delete` | +| **Where** | Personal › Account | +| **Who can use it** | Self | +| **Kind** | confirm | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwAlertDialog` (`extra-child` for fields) | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| What happens | text | | list: signed out everywhere; apps lose access; erased on {date}; support can cancel within 7 days | GtkLabel | +| Reason | text-area | body.reason | optional | GtkTextView in .card | +| Type DELETE | text-field | client only | must equal localized confirm word | AdwEntryRow | +| Delete account | destructive-button | POST /users/me/erasure-requests | | GtkButton .destructive-action | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Delete account | `POST /users/me/erasure-requests` | 202 ErasureRequest → clear local data → AUTH-01 with "Your account is scheduled for deletion on {scheduled_for}." | + +## Good to know + +- 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). + diff --git a/docs/screens/acx-01.md b/docs/screens/acx-01.md new file mode 100644 index 0000000..a7540d5 --- /dev/null +++ b/docs/screens/acx-01.md @@ -0,0 +1,53 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ACX-01 User — Access" +permalink: /screens/acx-01/ +nav_exclude: true +screen_id: ACX-01 +--- + +# User — Access +{: .no_toc } + +Turn apps on or off for one person; see every path that grants access. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ACX-01` | +| **Route** | `/admin/users/:userId/access` · deep link `substratal://admin/users/:userId/access` | +| **Where** | Administration › Users | +| **Who can use it** | users.list (read), entitlements.manage or app owner (write) | +| **Kind** | tab | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwViewStack` page | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Rows (one per app) | list | resolved status per app + paths (personal Entitlement, org grants) | StatusBadge + AccessSwitch on the personal path | GtkListBox .boxed-list | +| Paths disclosure | expandable | Entitlement.granted_via, source, subscription_id, organization_id, member_decision, starts_at/ends_at, disabled_reason | platform-managed paths (`free`, `subscription`) show a "Managed by Substratal" caption; developer grants and platform rows can coexist | AdwExpanderRow | +| Org-only access | caption + link | | switch replaced by "Via {org}" → ORG-06 | GtkLabel .caption .dim-label | +| Grant access | primary-button | → ACX-02 | | GtkButton .suggested-action .pill | +| Why? | button | → ACX-04 | | GtkButton | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Switch off | `→ ACX-03 confirm → PATCH /entitlements/{id} {status:disabled, disabled_reason}` | Optimistic with Undo (PATCH status:active) for 10 s | +| Switch on | `PATCH /entitlements/{id} {status:active}` | No confirm | +| Remove grant made in error | `DELETE /entitlements/{id}` | Only developer grants with no order_id and < 24 h old; never on `free`/`subscription` rows (409 entitlement_platform_managed); 409 otherwise | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES +- 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. + +## For developers + +Loads: `GET /users/{id}/entitlements?include_inactive=true` · `GET /applications` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/acx-02.md b/docs/screens/acx-02.md new file mode 100644 index 0000000..edf98cd --- /dev/null +++ b/docs/screens/acx-02.md @@ -0,0 +1,41 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ACX-02 Grant access" +permalink: /screens/acx-02/ +nav_exclude: true +screen_id: ACX-02 +--- + +# Grant access +{: .no_toc } + +Give a person access to an app. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ACX-02` | +| **Route** | `/admin/users/:userId/access/grant` · deep link `substratal://admin/users/:userId/access/grant` | +| **Where** | Administration › Users | +| **Who can use it** | entitlements.manage or app owner (own app) | +| **Kind** | form | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| App | select | body.application_id | app owners see only their apps | AdwComboRow | +| Type | radio-group | body.source purchase \ | trial \ | GtkCheckButton group | +| Order ID | text-field | body.order_id | required feel for purchase | AdwEntryRow | +| Starts | date-time | body.starts_at | default now | GtkCalendar in popover | +| Ends | date-time | body.ends_at | optional; required for trial | GtkCalendar in popover | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Grant | `POST /users/{id}/entitlements (Idempotency-Key, required)` | 201 → row Active. 409 entitlement_already_exists → "Already has access — view it". A platform-managed row doesn't block a grant (they coexist). | + diff --git a/docs/screens/acx-03.md b/docs/screens/acx-03.md new file mode 100644 index 0000000..555c13e --- /dev/null +++ b/docs/screens/acx-03.md @@ -0,0 +1,48 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ACX-03 Turn off / edit access" +permalink: /screens/acx-03/ +nav_exclude: true +screen_id: ACX-03 +--- + +# Turn off / edit access +{: .no_toc } + +Confirm turning off access, or edit term/source. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ACX-03` | +| **Route** | `/admin/entitlements/:entId` · deep link `substratal://admin/entitlements/:entId` | +| **Who can use it** | entitlements.manage or app owner | +| **Kind** | confirm | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwAlertDialog` (`extra-child` for fields) | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Title | text | | "Turn off {app} for {name}?" | GtkLabel | +| Reason | text-field | body.disabled_reason | optional; audit | AdwEntryRow | +| Edit term (advanced) | disclosure | ends_at, source trial→purchase, order_id | hidden for platform-managed rows | AdwExpanderRow | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Turn off | `PATCH /entitlements/{id} {status:disabled, disabled_reason} (If-Match)` | 409 version_conflict → reload and re-apply | +| Save changes | `PATCH /entitlements/{id}` | | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /entitlements/{id}` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/acx-04.md b/docs/screens/acx-04.md new file mode 100644 index 0000000..f9324b3 --- /dev/null +++ b/docs/screens/acx-04.md @@ -0,0 +1,42 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ACX-04 Access check (Why?)" +permalink: /screens/acx-04/ +nav_exclude: true +screen_id: ACX-04 +--- + +# Access check (Why?) +{: .no_toc } + +Explain exactly why a person can or can't use an app. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ACX-04` | +| **Route** | `/admin/users/:userId/apps/:appId/why` · deep link `substratal://admin/users/:userId/apps/:appId/why` | +| **Where** | Administration › Users | +| **Who can use it** | users.list or app owner | +| **Kind** | popover | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `GtkPopover` or `AdwOverlaySplitView` sidebar | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Verdict | banner | allowed | "Dana can use Timetrack" / "can't" + first failing reason (user_status, entitlement_status) | AdwBanner | +| Checks | checklist | user_status, entitlement_status, access_paths[], roles, effective_permissions | each with pass/fail icon | GtkListBox with status icons | +| Computed at | caption | computed_at | | GtkLabel .caption .dim-label | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /users/{id}/apps/{appId}/effective-permissions` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/acx-05.md b/docs/screens/acx-05.md new file mode 100644 index 0000000..1d98d10 --- /dev/null +++ b/docs/screens/acx-05.md @@ -0,0 +1,48 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ACX-05 Access (all)" +permalink: /screens/acx-05/ +nav_exclude: true +screen_id: ACX-05 +--- + +# Access (all) +{: .no_toc } + +Every grant across users: audit expirations, trials, revocations. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ACX-05` | +| **Route** | `/admin/access` · deep link `substratal://admin/access` | +| **Where** | Administration › Access | +| **Who can use it** | entitlements.manage or app owner (confined to own apps) | +| **Kind** | table | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `GtkColumnView` (.data-table) in a card | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Filters | chips/menus | app, status, source, ends before (Expiring in 7/30 days presets) | | GtkToggleButton pills | +| Columns | table | user, app, status, source (incl. free / subscription), subscription_id, starts_at, ends_at, order_id, granted_via | source filter defaults to developer grants; "Show platform-managed" chip adds free/subscription rows | GtkColumnView .data-table | +| Multi-select | selection | | bulk Turn off (confirm, one PATCH per row, progress + per-row result) | AdwComboRow | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Open row | `→ ACX-01 for that user` | | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /entitlements?application_id&status&source&user_id&organization_id&order_id&ends_before&include_inactive` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/apl-01.md b/docs/screens/apl-01.md new file mode 100644 index 0000000..4b779ea --- /dev/null +++ b/docs/screens/apl-01.md @@ -0,0 +1,41 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "APL-01 Applications" +permalink: /screens/apl-01/ +nav_exclude: true +screen_id: APL-01 +--- + +# Applications +{: .no_toc } + +Applications you own or administer. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `APL-01` | +| **Route** | `/dev/apps` · deep link `substratal://dev/apps` | +| **Where** | Developer › Applications | +| **Who can use it** | Any (catalog), owner/applications.manage (manage) | +| **Kind** | list | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Nested `AdwNavigationSplitView` list pane | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Rows | list | ApplicationSummary: icon, name, slug, visibility, review_status | | GtkListBox .boxed-list | +| Register app | primary-button | → APL-02 | applications.manage, or anyone with a verified email (self-service, flag `app_registration_self_service`); unverified → disabled with "Verify your email to register apps" | GtkButton .suggested-action .pill | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /applications?owned=true (staff: all, filter review_status)` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/apl-02.md b/docs/screens/apl-02.md new file mode 100644 index 0000000..6b265e1 --- /dev/null +++ b/docs/screens/apl-02.md @@ -0,0 +1,46 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "APL-02 Register application" +permalink: /screens/apl-02/ +nav_exclude: true +screen_id: APL-02 +--- + +# Register application +{: .no_toc } + +Register a new application. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `APL-02` | +| **Route** | `/dev/apps/new` · deep link `substratal://dev/apps/new` | +| **Where** | Developer › Applications | +| **Who can use it** | applications.manage, or anyone with a verified email (self-service) | +| **Kind** | wizard | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwDialog` with `AdwNavigationView` pages | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Name | text-field | name | | AdwEntryRow | +| Slug | text-field | slug | ^[a-z][a-z0-9-]{1,63}$; 'platform' reserved; immutable — warn | AdwEntryRow | +| Launch URL | url-field | launch_url | https | AdwEntryRow input-purpose=url | +| Redirect URIs | url-list | redirect_uris | | AdwPreferencesGroup of AdwEntryRow + add button | +| Visibility | radio-group | visibility public \ | invite_only \ | GtkCheckButton group | +| Access tier | radio-group | access_tier free \ | standard (default standard) | GtkCheckButton group | +| Owner | select | owner_user_id \ | owner_organization_id | AdwComboRow | +| Icon | image-picker | icon_url | | GtkFileDialog (portal) | +| Support URL | url-field | support_url | | AdwEntryRow input-purpose=url | +| Email from name | text-field | email_from_name | | AdwEntryRow | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Register | `POST /applications (Idempotency-Key)` | 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). | + diff --git a/docs/screens/apl-03.md b/docs/screens/apl-03.md new file mode 100644 index 0000000..86a87e4 --- /dev/null +++ b/docs/screens/apl-03.md @@ -0,0 +1,50 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "APL-03 Application — Overview" +permalink: /screens/apl-03/ +nav_exclude: true +screen_id: APL-03 +--- + +# Application — Overview +{: .no_toc } + +Configure an application. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `APL-03` | +| **Route** | `/dev/apps/:appId` · deep link `substratal://dev/apps/:appId` | +| **Where** | Developer › Applications | +| **Who can use it** | Owner (configure), applications.manage (anything) | +| **Kind** | detail | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Content pane; `AdwViewSwitcher`; header-bar actions | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Header | icon, name, id-chip, review StatusBadge, "Third-party" chip when `self_registered` | | | GtkLabel .monospace + copy | +| Tabs | tabs | Overview · Settings schema · Permissions & roles · Push · Keys · Webhooks · Access · Activity | Push = APL-07; Keys/Webhooks/Access = KEY-01/WHK-01/ACX-05 filtered to app | AdwViewSwitcher / AdwInlineViewSwitcher | +| Fields | settings rows | name, description, launch_url, redirect_uris, support_url, email_from_name, icon_url (owner); visibility, access_tier (applications.manage) | slug read-only with lock | Adw*Rows | +| Platform alerts | toggle | receives_platform_notifications | 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) | AdwSwitchRow | +| Review notes | banner | review_status rejected/suspended + review_notes | | AdwBanner | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Save field | `PATCH /applications/{id} (If-Match)` | 422 → field errors | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /applications/{id}` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/apl-04.md b/docs/screens/apl-04.md new file mode 100644 index 0000000..f0c294c --- /dev/null +++ b/docs/screens/apl-04.md @@ -0,0 +1,40 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "APL-04 Settings schema" +permalink: /screens/apl-04/ +nav_exclude: true +screen_id: APL-04 +--- + +# Settings schema +{: .no_toc } + +Declare per-user settings the app stores in Substratal. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `APL-04` | +| **Route** | `/dev/apps/:appId/schema` · deep link `substratal://dev/apps/:appId/schema` | +| **Where** | Developer › Applications | +| **Who can use it** | Owner / applications.manage | +| **Kind** | tab | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | `AdwViewStack` page | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Properties | list | settings_schema.properties: key, type, enum, default, min/max, maxLength | add/edit via form | GtkListBox .boxed-list | +| JSON editor | code-editor | settings_schema | JSON validation; diff before save | GtkSourceView (JSON) | +| Preview | rendered SET-02 form | | shows exactly what users will see | SchemaForm preview | +| Stats | caption | settings_schema_stats | | GtkLabel .caption .dim-label | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Save | `PATCH /applications/{id} {settings_schema}` | 422 → schema rule errors | + diff --git a/docs/screens/apl-05.md b/docs/screens/apl-05.md new file mode 100644 index 0000000..b6d3400 --- /dev/null +++ b/docs/screens/apl-05.md @@ -0,0 +1,39 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "APL-05 Permissions & roles" +permalink: /screens/apl-05/ +nav_exclude: true +screen_id: APL-05 +--- + +# Permissions & roles +{: .no_toc } + +Declare app permissions, available app roles and the default role. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `APL-05` | +| **Route** | `/dev/apps/:appId/roles` · deep link `substratal://dev/apps/:appId/roles` | +| **Where** | Developer › Applications | +| **Who can use it** | Owner / applications.manage | +| **Kind** | tab | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | `AdwViewStack` page | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Permissions | editable list | permissions[] {key, description} | key app..* | GtkColumnView .data-table | +| Available app roles | token list | available_app_roles (≤ 20) | | custom entry with pills | +| Default app role | select | default_app_role | held implicitly by everyone with access | AdwComboRow | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Save | `PATCH /applications/{id}` | | + diff --git a/docs/screens/apl-06.md b/docs/screens/apl-06.md new file mode 100644 index 0000000..2c7aeaf --- /dev/null +++ b/docs/screens/apl-06.md @@ -0,0 +1,38 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "APL-06 Review" +permalink: /screens/apl-06/ +nav_exclude: true +screen_id: APL-06 +--- + +# Review +{: .no_toc } + +Staff moderation of an application. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `APL-06` | +| **Route** | `/dev/apps/:appId/review` · deep link `substratal://dev/apps/:appId/review` | +| **Where** | Developer › Applications | +| **Who can use it** | applications.manage | +| **Kind** | form | +| **Available in** | All Hub apps (Phase 3) | +| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Review status | radio-group | review_status | | GtkCheckButton group | +| Notes | text-area | review_notes | visible to owner | GtkTextView in .card | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Save | `PATCH /applications/{id} {review_status, review_notes}` | | + diff --git a/docs/screens/apl-07.md b/docs/screens/apl-07.md new file mode 100644 index 0000000..c458493 --- /dev/null +++ b/docs/screens/apl-07.md @@ -0,0 +1,56 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "APL-07 Push credentials" +permalink: /screens/apl-07/ +nav_exclude: true +screen_id: APL-07 +--- + +# Push credentials +{: .no_toc } + +Configure APNs, FCM and Web Push for an Application so it can send push notifications. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `APL-07` | +| **Route** | `/dev/apps/:appId/push` · deep link `substratal://dev/apps/:appId/push` | +| **Where** | Developer › Applications | +| **Who can use it** | Owner or applications.manage with a User token (write); app key / owner (read) | +| **Kind** | tab | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | `AdwViewStack` page | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Providers | list | PushCredential per provider apns \ | fcm \ | GtkListBox .boxed-list | +| Add APNs | form | POST body: provider apns, team_id (10), key_id (10), topic (bundle id), private_key (.p8) | file picker / drop / paste for the .p8; key never shown again | AdwDialog form | +| Add FCM | form | POST body: provider fcm, service_account (JSON file) | drop/pick the downloaded JSON; validate shape client-side | AdwDialog form | +| Add Web Push | form | POST body: provider web_push, subject (mailto:/https:) | platform generates VAPID keys; show vapid_public_key with Copy for the web client | AdwDialog form | +| Rotate / fix | form | PATCH /applications/{id}/push-credentials/{provider} {key material \ | status: active} | AdwDialog form | +| Remove | destructive-button | DELETE /applications/{id}/push-credentials/{provider} | confirm; web_push removal deactivates every browser subscription | GtkButton .destructive-action | +| Platform alerts | toggle | Application.receives_platform_notifications | mirror of APL-03 toggle | AdwSwitchRow | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Add | `POST /applications/{id}/push-credentials` | 201; 409 push_credential_exists → Edit; 422 invalid_push_credential → field error; 400 user_token_required never happens in the Hub (User token) | +| Fetch one | `GET /applications/{id}/push-credentials/{provider}` | on Edit | +| Save | `PATCH /applications/{id}/push-credentials/{provider}` | 409 invalid_status_transition | +| Remove | `DELETE /applications/{id}/push-credentials/{provider}` | 204 | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES +- 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). + +## For developers + +Loads: `GET /applications/{id}/push-credentials` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/app-01.md b/docs/screens/app-01.md new file mode 100644 index 0000000..6b9c1ac --- /dev/null +++ b/docs/screens/app-01.md @@ -0,0 +1,57 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "APP-01 Home — My Apps" +permalink: /screens/app-01/ +nav_exclude: true +screen_id: APP-01 +--- + +# Home — My Apps +{: .no_toc } + +See every app you can use, its access status, and launch it. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `APP-01` | +| **Route** | `/` · deep link `substratal://` | +| **Where** | Personal › Home | +| **Who can use it** | Signed in | +| **Kind** | grid | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Content page, `GtkFlowBox` in `AdwClamp` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Search | search | client filter on name/description | | GtkSearchEntry | +| Grid / List | segmented | client preference | persisted locally | linked GtkToggleButtons | +| Invitation banner | banner | Organization.my_membership_status = pending | "{org} invited you…" → ORG-09 | AdwBanner | +| Verify-email banner | banner | User.email_verified = false | → AUTH-04 | AdwBanner | +| Your apps | tile-grid | join Application × resolved entitlement per app | tile = icon, name, StatusBadge, helper text (COPY); order: recently launched, then A–Z; pin to top (local) | GtkFlowBox .card children | +| Included with Standard | tile-grid | standard apps with status none while User.plan = free | badge "Standard"; tap → upsell sheet → BIL-01 ("Get every app for $6/month") | GtkFlowBox .card children | +| Available | tile-grid | other public apps with status none | "Get access" → app support/launch page | GtkFlowBox .card children | +| Inbox bell | toolbar button + count | NotificationUnreadCount.unread_count | → NTF-01 | AdwHeaderBar buttons | +| Past apps | row → APP-05 | count of revoked/expired | | AdwActionRow activatable | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Open tile (active) | `→ APP-03` | Launch | +| Open tile (other status) | `→ APP-02` | Explain status | +| Context menu / long-press | | Open · App settings (SET-02) · App profile (APP-04) · Pin · Copy app ID | + +## Good to know + +- **Loading, empty and error 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 +- 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. + +## For developers + +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)` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/app-02.md b/docs/screens/app-02.md new file mode 100644 index 0000000..e26be31 --- /dev/null +++ b/docs/screens/app-02.md @@ -0,0 +1,46 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "APP-02 App info" +permalink: /screens/app-02/ +nav_exclude: true +screen_id: APP-02 +--- + +# App info +{: .no_toc } + +Explain an app's access status and how the user has access. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `APP-02` | +| **Route** | `/apps/:appId` · deep link `substratal://apps/:appId` | +| **Who can use it** | Signed in | +| **Kind** | popover | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `GtkPopover` or `AdwOverlaySplitView` sidebar | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Header | icon + name + StatusBadge | Application, EffectivePermissions.entitlement_status | | GtkImage + labels + badge | +| Description | text | Application.description | | GtkLabel | +| Access paths | list | EffectivePermissions.access_paths[]: source, status, organization_id, member_decision | "Free", "Standard · your subscription", "Standard · via Northwind", "Personal · Granted by admin", "Via Northwind (seat)"; member_decision excluded explained | GtkListBox .boxed-list | +| Tier | badge | Application.access_tier | "Free" or "Standard" | StatusBadge (.status-badge box) | +| Your roles | chips | EffectivePermissions.roles | Phase 2 | GtkToggleButton pills | +| Primary action | button | | Active → Open; none + standard tier + plan free → Upgrade to Standard (BIL-01); none+public → Get access; disabled/expired/revoked → Contact support (support_url) | GtkButton | +| Notification settings | row → SET-02 | resolved notifications.push for this app | "Push notifications: On/Off" | AdwActionRow activatable | +| App settings / App profile | rows | → SET-02, APP-04 | only when active | AdwActionRows | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /applications/{appId}` · `GET /users/me/apps/{appId}/effective-permissions` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/app-03.md b/docs/screens/app-03.md new file mode 100644 index 0000000..ab6923d --- /dev/null +++ b/docs/screens/app-03.md @@ -0,0 +1,42 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "APP-03 Launch app" +permalink: /screens/app-03/ +nav_exclude: true +screen_id: APP-03 +--- + +# Launch app +{: .no_toc } + +Mint an app token (or authorization code) and open the app. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `APP-03` | +| **Route** | `/apps/:appId/launch` · deep link `substratal://apps/:appId/launch` | +| **Who can use it** | Active entitlement | +| **Kind** | handoff | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `GtkUriLauncher` (OpenURI portal) | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Progress | inline spinner on tile | | ≤ 1 s target | GtkSpinner on tile | +| Organization picker | menu | body.organization_id | only when the user has access via >1 org and the app needs org context | GtkMenuButton | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Embedded launch | `POST /auth/app-tokens {application_id, organization_id?}` | 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. | +| Hosted launch (Phase 3) | `POST /auth/oauth/authorization-codes (PKCE S256)` | 201 → open redirect_uri?code=…&state=… in system browser / auth session | + +## Good to know + +- Record launch time locally for "recently launched" ordering. + diff --git a/docs/screens/app-04.md b/docs/screens/app-04.md new file mode 100644 index 0000000..2154729 --- /dev/null +++ b/docs/screens/app-04.md @@ -0,0 +1,46 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "APP-04 App profile" +permalink: /screens/app-04/ +nav_exclude: true +screen_id: APP-04 +--- + +# App profile +{: .no_toc } + +Per-app display handle and custom profile fields. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `APP-04` | +| **Route** | `/apps/:appId/profile` · deep link `substratal://apps/:appId/profile` | +| **Who can use it** | Self, active entitlement | +| **Kind** | form | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Display handle | text-field | AppProfile.display_handle | optional; shows effective_display_name as placeholder | AdwEntryRow | +| Custom fields | key/value editor | AppProfile.custom | only keys the app documents; null removes a key | AdwPreferencesGroup of AdwEntryRow pairs | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Save | `PATCH /users/me/apps/{appId}/profile (If-Match)` | | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /users/me/apps/{appId}/profile` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/app-05.md b/docs/screens/app-05.md new file mode 100644 index 0000000..44f9850 --- /dev/null +++ b/docs/screens/app-05.md @@ -0,0 +1,39 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "APP-05 Past apps" +permalink: /screens/app-05/ +nav_exclude: true +screen_id: APP-05 +--- + +# Past apps +{: .no_toc } + +Apps whose access was revoked or expired. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `APP-05` | +| **Route** | `/apps/past` · deep link `substratal://apps/past` | +| **Who can use it** | Signed in | +| **Kind** | list | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Nested `AdwNavigationSplitView` list pane | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Rows | list | Application name/icon, status, ended/revoked date, source | tap → APP-02 | GtkListBox .boxed-list | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /users/me/entitlements?include_inactive=true&status=revoked,expired` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/aud-01.md b/docs/screens/aud-01.md new file mode 100644 index 0000000..715e39d --- /dev/null +++ b/docs/screens/aud-01.md @@ -0,0 +1,43 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "AUD-01 Audit log" +permalink: /screens/aud-01/ +nav_exclude: true +screen_id: AUD-01 +--- + +# Audit log +{: .no_toc } + +Who changed what, when. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `AUD-01` | +| **Route** | `/admin/audit` · deep link `substratal://admin/audit` | +| **Where** | Administration › Audit Log | +| **Who can use it** | audit.view | +| **Kind** | table | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `GtkColumnView` (.data-table) in a card | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Filters | search + menus | time range (presets 1h/24h/7d/custom), actor type, action (grouped by prefix, wildcard), resource | filters encoded in URL/state for sharing | GtkSearchEntry | +| Rows | table | timestamp, actor (type icon + name/id), action, target (type + link), app/org | relative time with absolute on hover/long-press | GtkColumnView .data-table | +| Inspector | side panel | → AUD-02 | desktop: trailing pane; compact: push | AdwOverlaySplitView | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES +- 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. + +## For developers + +Loads: `GET /audit-events?since&until&actor_type&actor_id&action&target_type&target_id&target_user_id&application_id&organization_id&tenant_id` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/aud-02.md b/docs/screens/aud-02.md new file mode 100644 index 0000000..10c1e2f --- /dev/null +++ b/docs/screens/aud-02.md @@ -0,0 +1,41 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "AUD-02 Audit event" +permalink: /screens/aud-02/ +nav_exclude: true +screen_id: AUD-02 +--- + +# Audit event +{: .no_toc } + +One event with before/after diff. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `AUD-02` | +| **Route** | `/admin/audit/:eventId` · deep link `substratal://admin/audit/:eventId` | +| **Where** | Administration › Audit Log | +| **Who can use it** | audit.view | +| **Kind** | popover | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `GtkPopover` or `AdwOverlaySplitView` sidebar | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Facts | key-value | action, actor, target, target_user_id, application_id, organization_id, tenant_id, request_id, timestamp | each id links to its screen | AdwActionRow subtitle | +| Diff | diff view | before / after | JSON diff, changed keys highlighted | GtkSourceView diff | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /audit-events/{id}` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/auth-01.md b/docs/screens/auth-01.md new file mode 100644 index 0000000..bd67f48 --- /dev/null +++ b/docs/screens/auth-01.md @@ -0,0 +1,50 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "AUTH-01 Sign in" +permalink: /screens/auth-01/ +nav_exclude: true +screen_id: AUTH-01 +--- + +# Sign in +{: .no_toc } + +Exchange email + password for a session, or start an MFA challenge. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `AUTH-01` | +| **Route** | `/sign-in` · deep link `substratal://sign-in` | +| **Who can use it** | Unauthenticated | +| **Kind** | auth | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Separate `AdwWindow` with `AdwClamp` | + +**How you get here:** App launch with no stored session; Session expired / revoked (preserves `?next=` destination); Sign out + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Email | email-field | body.email | required; RFC 5322; trim; autofill username | AdwEntryRow input-purpose=email | +| Password | password-field | body.password | required; reveal toggle; autofill current-password | AdwPasswordEntryRow | +| Sign in | primary-button | POST /auth/login | disabled until both fields non-empty | GtkButton .suggested-action .pill | +| Forgot password? | link | → AUTH-05 | | GtkButton .flat / GtkLinkButton | +| Create an account | link | → AUTH-03 | | GtkButton .flat / GtkLinkButton | +| Continue with SSO | button | POST /auth/sso/{provider}/callback | hidden behind flag `sso` (API returns 501 today) | GtkButton | +| Sign in with a passkey | button | — | hidden behind flag `passkeys`; reserved for when the API supports WebAuthn | GtkButton | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Sign in | `POST /auth/login` | 200 AuthSession → store tokens → go to `next` or APP-01. 200 MfaChallenge → AUTH-02 with mfa_token. | +| — | | 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. | + +## Good to know + +- **Loading, empty and error states:** Submitting: button spinner, fields read-only; Locked out (5 failures / 15 min): explain wait time +- `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. + diff --git a/docs/screens/auth-02.md b/docs/screens/auth-02.md new file mode 100644 index 0000000..217b1b7 --- /dev/null +++ b/docs/screens/auth-02.md @@ -0,0 +1,46 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "AUTH-02 Two-factor challenge" +permalink: /screens/auth-02/ +nav_exclude: true +screen_id: AUTH-02 +--- + +# Two-factor challenge +{: .no_toc } + +Complete sign-in with a TOTP code or a recovery code. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `AUTH-02` | +| **Route** | `/sign-in/mfa` · deep link `substratal://sign-in/mfa` | +| **Who can use it** | Holder of mfa_token | +| **Kind** | auth | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Separate `AdwWindow` with `AdwClamp` | + +**How you get here:** AUTH-01 returned MfaChallenge + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Code | otp-field | body.code | 6 digits; paste fills all; auto-submit on 6th digit; OS one-time-code autofill | AdwEntryRow input-purpose=digits | +| Use a recovery code | link | swaps to Recovery code field | only if `methods` includes recovery_code | GtkButton .flat / GtkLinkButton | +| Recovery code | text-field | body.recovery_code | format xxxx-xxxx; case-insensitive | AdwEntryRow | +| Expiry countdown | caption | MfaChallenge.expires_in | announce at 60 s and 10 s | GtkLabel .caption .dim-label | +| Verify | primary-button | POST /auth/mfa/verify | | GtkButton .suggested-action .pill | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Verify | `POST /auth/mfa/verify` | 200 → same as AUTH-01 success. 401 → "That code didn't work." clear field. Expired → back to AUTH-01 with message. 429 → countdown. | + +## Good to know + +- **Loading, empty and error states:** Challenge expired + diff --git a/docs/screens/auth-03.md b/docs/screens/auth-03.md new file mode 100644 index 0000000..aa6e338 --- /dev/null +++ b/docs/screens/auth-03.md @@ -0,0 +1,46 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "AUTH-03 Create account" +permalink: /screens/auth-03/ +nav_exclude: true +screen_id: AUTH-03 +--- + +# Create account +{: .no_toc } + +Self-service signup with email and password. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `AUTH-03` | +| **Route** | `/sign-up` · deep link `substratal://sign-up` | +| **Who can use it** | Unauthenticated | +| **Kind** | auth | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Separate `AdwWindow` with `AdwClamp` | + +**How you get here:** AUTH-01 link; Marketing/app deep link with optional `application_id` + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Display name | text-field | body.display_name | optional; ≤ 100 | AdwEntryRow | +| Email | email-field | body.email | required | AdwEntryRow input-purpose=email | +| Password | password-field | body.password | required; strength meter; autofill new-password; show policy hint | AdwPasswordEntryRow | +| Create account | primary-button | POST /auth/signup | | GtkButton .suggested-action .pill | +| Terms / Privacy | links | external | | GtkLinkButton | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Create account | `POST /auth/signup` | 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. | + +## Good to know + +- 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. + diff --git a/docs/screens/auth-04.md b/docs/screens/auth-04.md new file mode 100644 index 0000000..136753a --- /dev/null +++ b/docs/screens/auth-04.md @@ -0,0 +1,42 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "AUTH-04 Verify your email" +permalink: /screens/auth-04/ +nav_exclude: true +screen_id: AUTH-04 +--- + +# Verify your email +{: .no_toc } + +Tell the user to check email; complete verification from the emailed link. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `AUTH-04` | +| **Route** | `/verify-email` · deep link `substratal://verify-email` | +| **Who can use it** | Signed in (unverified) or token holder | +| **Kind** | auth | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Separate `AdwWindow` with `AdwClamp` | + +**How you get here:** After signup; Persistent banner on APP-01 while `email_verified` is false; Deep link `/verify-email?token=…` + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Instruction | text | User.email | "We sent a link to {email}." | GtkLabel | +| Resend email | button | POST /auth/email/verify/resend | 30 s cooldown after each send | GtkButton | +| Continue to Substratal | button | → APP-01 | unverified users may continue | GtkButton | +| Change email | link | → ACC-02 | | GtkButton .flat / GtkLinkButton | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Deep link opened | `POST /auth/email/verify {token}` | 204 → success state, refresh User. 400 → "This link has expired." + Resend. | +| Resend email | `POST /auth/email/verify/resend` | Always 202 → toast "If that address needs verifying, we sent a new link." | + diff --git a/docs/screens/auth-05.md b/docs/screens/auth-05.md new file mode 100644 index 0000000..ae2ff51 --- /dev/null +++ b/docs/screens/auth-05.md @@ -0,0 +1,37 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "AUTH-05 Forgot password" +permalink: /screens/auth-05/ +nav_exclude: true +screen_id: AUTH-05 +--- + +# Forgot password +{: .no_toc } + +Request a password-reset email. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `AUTH-05` | +| **Route** | `/forgot-password` · deep link `substratal://forgot-password` | +| **Who can use it** | Unauthenticated | +| **Kind** | auth | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Separate `AdwWindow` with `AdwClamp` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Email | email-field | body.email | prefilled from AUTH-01 | AdwEntryRow input-purpose=email | +| Send reset link | primary-button | POST /auth/password/forgot | | GtkButton .suggested-action .pill | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Send reset link | `POST /auth/password/forgot` | Always 202 → confirmation "If an account exists for {email}, we sent a reset link." 429 → countdown. | + diff --git a/docs/screens/auth-06.md b/docs/screens/auth-06.md new file mode 100644 index 0000000..e340cec --- /dev/null +++ b/docs/screens/auth-06.md @@ -0,0 +1,41 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "AUTH-06 Set a new password" +permalink: /screens/auth-06/ +nav_exclude: true +screen_id: AUTH-06 +--- + +# Set a new password +{: .no_toc } + +Choose a new password from the emailed reset link. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `AUTH-06` | +| **Route** | `/reset-password` · deep link `substratal://reset-password` | +| **Who can use it** | Reset-token holder | +| **Kind** | auth | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Separate `AdwWindow` with `AdwClamp` | + +**How you get here:** Deep link `/reset-password?token=…` + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| New password | password-field | body.new_password | strength meter; autofill new-password | AdwPasswordEntryRow | +| Confirm password | password-field | client only | must match | AdwPasswordEntryRow | +| Notice | caption | | "You'll be signed out on all devices." | GtkLabel .caption .dim-label | +| Save password | primary-button | POST /auth/password/reset | | GtkButton .suggested-action .pill | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Save password | `POST /auth/password/reset` | 204 → clear local session → AUTH-01 with success banner. 400 → expired link + link to AUTH-05. 422 → field errors. | + diff --git a/docs/screens/auth-07.md b/docs/screens/auth-07.md new file mode 100644 index 0000000..cb4f803 --- /dev/null +++ b/docs/screens/auth-07.md @@ -0,0 +1,40 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "AUTH-07 Accept invitation" +permalink: /screens/auth-07/ +nav_exclude: true +screen_id: AUTH-07 +--- + +# Accept invitation +{: .no_toc } + +Activate an account an admin created: set password and sign in. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `AUTH-07` | +| **Route** | `/invite` · deep link `substratal://invite` | +| **Who can use it** | Invitation-token holder | +| **Kind** | auth | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Separate `AdwWindow` with `AdwClamp` | + +**How you get here:** Deep link `/invite?token=…` from the invitation email + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Display name | text-field | body.display_name | optional | AdwEntryRow | +| Password | password-field | body.password | strength meter | AdwPasswordEntryRow | +| Accept and continue | primary-button | POST /auth/invitations/accept | | GtkButton .suggested-action .pill | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Accept and continue | `POST /auth/invitations/accept` | 200 AuthSession → APP-01. 400 → "This invitation is no longer valid. Ask your admin to resend it." | + diff --git a/docs/screens/bil-01.md b/docs/screens/bil-01.md new file mode 100644 index 0000000..99ee2c6 --- /dev/null +++ b/docs/screens/bil-01.md @@ -0,0 +1,47 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "BIL-01 My plan" +permalink: /screens/bil-01/ +nav_exclude: true +screen_id: BIL-01 +--- + +# My plan +{: .no_toc } + +Free or Standard: what covers you, your own subscription, upgrade and manage billing. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `BIL-01` | +| **Route** | `/billing` · deep link `substratal://billing` | +| **Where** | Personal › Plan & Billing | +| **Who can use it** | Self; billing.manage can view any User's (read-only) | +| **Kind** | settings | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | `AdwPreferencesPage` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Plan card | card | UserSubscription.plan free\ | standard | .card box | +| Covered by | list | plan_sources[]: {type user\ | organization, id} | GtkListBox .boxed-list | +| Double coverage tip | banner | plan_sources has both user and organization | "Your organization already covers you. You can cancel your own subscription in Manage billing." (no automatic refund) | AdwBanner | +| Your subscription | key-value | Subscription.status, current_period_end, cancel_at_period_end, unit_amount, currency | status badge via status-semantics; past_due → warning banner (Stripe retries ~3 weeks) | AdwActionRow subtitle | +| Upgrade to Standard | primary-button | → BIL-03 checkout | 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 | GtkButton .suggested-action .pill | +| Manage billing | button | → BIL-03 portal | when the user has completed a checkout (own subscription exists and isn't incomplete) | GtkButton | +| What Standard includes | link | standard apps from GET /applications?access_tier=standard | count + list | GtkButton .flat / GtkLinkButton | + +## Good to know + +- **Loading, empty and error 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." +- 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. + +## For developers + +Loads: `GET /users/me/subscription` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/bil-02.md b/docs/screens/bil-02.md new file mode 100644 index 0000000..a7b1eda --- /dev/null +++ b/docs/screens/bil-02.md @@ -0,0 +1,52 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "BIL-02 Organization subscription" +permalink: /screens/bil-02/ +nav_exclude: true +screen_id: BIL-02 +--- + +# Organization subscription +{: .no_toc } + +An organization's Standard subscription and which members it covers. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `BIL-02` | +| **Route** | `/orgs/:orgId/subscription` · deep link `substratal://orgs/:orgId/subscription` | +| **Where** | Personal › Organizations | +| **Who can use it** | org_admin (manage); billing.manage (read + portal) | +| **Kind** | tab | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | `AdwViewStack` page | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Plan card | card | OrganizationSubscription.plan + Subscription.status, current_period_end, cancel_at_period_end | | .card box | +| Billed users | key-value | quantity.current (live), quantity.last_synced + last_synced_at, unit_amount | "31 covered · 30 billed on next invoice (synced daily)" | AdwActionRow subtitle | +| Who's covered | radio-group | member_scope all_members \ | allowlist \ | GtkCheckButton group | +| Members | member-picker | member_overrides (active members only) | visible for allowlist/denylist | AdwDialog with GtkSearchEntry list | +| Subscribe the organization | primary-button | → BIL-03 org checkout (member_scope optional) | org_admin; when no subscription or not in force | GtkButton .suggested-action .pill | +| Manage billing | button | → BIL-03 org portal | org_admin or billing.manage | GtkButton | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Save coverage | `PATCH /organizations/{id}/subscription {member_scope, member_overrides} (If-Match)` | effective immediately (access.granted/revoked); billed from next daily sync. 422 member_override_not_a_member → mark rows. 404 subscription_not_found → show Subscribe. | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES +- Members gained or lost take effect at once; say so before saving. + +## For developers + +Loads: `GET /organizations/{id}/subscription` · `GET /organizations/{id}/members (for the member picker)` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/bil-03.md b/docs/screens/bil-03.md new file mode 100644 index 0000000..e72853e --- /dev/null +++ b/docs/screens/bil-03.md @@ -0,0 +1,39 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "BIL-03 Checkout / billing portal" +permalink: /screens/bil-03/ +nav_exclude: true +screen_id: BIL-03 +--- + +# Checkout / billing portal +{: .no_toc } + +Hand off to Stripe in the browser and confirm the result on return. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `BIL-03` | +| **Route** | `/billing/return` · deep link `substratal://billing/return` | +| **Where** | Personal › Plan & Billing | +| **Who can use it** | Self (individual); org_admin (org checkout); org_admin or billing.manage (org portal) | +| **Kind** | handoff | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | `GtkUriLauncher` (OpenURI portal) | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Upgrade (individual) | `POST /users/me/billing/checkout-sessions {plan:standard, success_url, cancel_url} (Idempotency-Key)` | 201 {url, expires_at} → open url externally. 409 subscription_exists → open portal instead. 409 test_mode_billing_unavailable / 503 billing_unavailable → explain. | +| Manage billing (individual) | `POST /users/me/billing/portal-sessions {return_url}` | 201 → open url externally. 409 no_billing_account → hide button. | +| Subscribe organization | `POST /organizations/{id}/billing/checkout-sessions {plan:standard, success_url, cancel_url, member_scope?, member_overrides?} (Idempotency-Key)` | as individual | +| Manage organization billing | `POST /organizations/{id}/billing/portal-sessions {return_url}` | as individual | +| Return | `deep link /billing/return?checkout=success\` | cancelled (or /orgs/:orgId/subscription?…)` | + +## Good to know + +- success_url / cancel_url / return_url must be https: use https://dashboard.substratalapps.com/billing/return… (Universal/App Links open the native app). + diff --git a/docs/screens/bil-04.md b/docs/screens/bil-04.md new file mode 100644 index 0000000..cbe209f --- /dev/null +++ b/docs/screens/bil-04.md @@ -0,0 +1,50 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "BIL-04 Tenant & infrastructure" +permalink: /screens/bil-04/ +nav_exclude: true +screen_id: BIL-04 +--- + +# Tenant & infrastructure +{: .no_toc } + +Where an Application owner's data lives, and support-run tier changes. Tenants aren't billed. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `BIL-04` | +| **Route** | `/dev/tenant` · deep link `substratal://dev/tenant` | +| **Where** | Developer › Tenant | +| **Who can use it** | Tenant owner (read), tenants.manage (write) | +| **Kind** | detail | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | Content pane; `AdwViewSwitcher`; header-bar actions | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Facts | key-value | Tenant: tier, region, status, owner, application_count | status migrating → banner, writes may 503 | AdwActionRow subtitle | +| Tier change requests | list | TenantTierChangeRequest: from→requested tier/region, status, scheduled_for, reason, notes | | GtkListBox .boxed-list | +| Request change | button | → form: requested_tier, requested_region, reason | tenants.manage only; owners see "isolated and dedicated-region tiers come with business pricing — contact sales" | GtkButton | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Open request | `POST /tenants/{id}/tier-change-requests` | 422/409 errors | +| Open request detail | `GET /tenants/{id}/tier-change-requests/{requestId}` | polls every 30 s while visible | +| Advance request | `PATCH /tenants/{id}/tier-change-requests/{requestId} {status, scheduled_for, notes} (If-Match)` | staff only; status scheduled → in_progress → completed, or cancelled | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /tenants` · `GET /tenants/{id}` · `GET /tenants/{id}/tier-change-requests` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/cfg-01.md b/docs/screens/cfg-01.md new file mode 100644 index 0000000..42cf657 --- /dev/null +++ b/docs/screens/cfg-01.md @@ -0,0 +1,43 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "CFG-01 Configuration" +permalink: /screens/cfg-01/ +nav_exclude: true +screen_id: CFG-01 +--- + +# Configuration +{: .no_toc } + +What production is actually running with. +{: .fs-6 .fw-300 } + +This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). +{: .note } + +| | | +|---|---| +| **Screen ID** | `CFG-01` | +| **Route** | `/ops/config` | +| **Where** | Operations › Configuration | +| **Who can use it** | operations.view | +| **Kind** | settings | +| **Available in** | Web Hub only (Console M3 (web only)) | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | +|---|---|---|---| +| Per function | key-value groups | non-secret settings from an allow-list: URLs, CORS origins, backends, log level | secrets never leave the gateway | +| Policy | key-value | rate-limit defaults, token lifetimes, password policy, API version served | each with its source (Terraform variable or code constant) | + +## Good to know + +- Read-only. Terraform-owned values say "Change this in the API repo" and link to the variable. + +## For developers + +Loads: `GET /ops/config` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/cfg-02.md b/docs/screens/cfg-02.md new file mode 100644 index 0000000..f69ea99 --- /dev/null +++ b/docs/screens/cfg-02.md @@ -0,0 +1,38 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "CFG-02 Feature flags" +permalink: /screens/cfg-02/ +nav_exclude: true +screen_id: CFG-02 +--- + +# Feature flags +{: .no_toc } + +See, and later change, the client feature flags (CLIENT-ARCHITECTURE §10). +{: .fs-6 .fw-300 } + +This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). +{: .note } + +| | | +|---|---| +| **Screen ID** | `CFG-02` | +| **Route** | `/ops/config/flags` | +| **Who can use it** | operations.view (read), operations.manage (write, after G10) | +| **Kind** | settings | +| **Available in** | Web Hub only (Console M4 (web only)) | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | +|---|---|---|---| +| Flags | toggle list | every flag in CLIENT-ARCHITECTURE §10 | read-only until G11; then editable and audited | +| Platform settings | settings rows | signup open, read-only maintenance mode, per-key rate-limit overrides ([G12 #49](https://github.com/CompositeCode/substratalapps.com/issues/49)) | hidden until the API has them | + +## For developers + +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 + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/glb-01.md b/docs/screens/glb-01.md new file mode 100644 index 0000000..58a6566 --- /dev/null +++ b/docs/screens/glb-01.md @@ -0,0 +1,30 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "GLB-01 Command palette" +permalink: /screens/glb-01/ +nav_exclude: true +screen_id: GLB-01 +--- + +# Command palette +{: .no_toc } + +Jump anywhere and run any action by typing. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `GLB-01` | +| **Who can use it** | Signed in | +| **Kind** | overlay | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Popover / overlay | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Query | search | | Cmd/Ctrl+K (desktop), search button (touch) | GtkSearchEntry | +| Results | grouped list | Navigation, Apps (launch), Users (GET /users?q), Orgs, Inbox (recent notifications), Actions (Invite user, Create key, Upgrade to Standard, Mark all read…) | permission-filtered; recent items first; ID prefixes resolve directly | GtkListBox headers | + diff --git a/docs/screens/glb-02.md b/docs/screens/glb-02.md new file mode 100644 index 0000000..166047f --- /dev/null +++ b/docs/screens/glb-02.md @@ -0,0 +1,31 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "GLB-02 Manage hub" +permalink: /screens/glb-02/ +nav_exclude: true +screen_id: GLB-02 +--- + +# Manage hub +{: .no_toc } + +Compact-layout landing list for the Administration and Developer sections. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `GLB-02` | +| **Route** | `/manage` · deep link `substratal://manage` | +| **Who can use it** | Any admin or developer permission | +| **Kind** | hub | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Collapsed sidebar page | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Administration | list | Users, Access, Roles, Audit Log, Send notification — those the caller may open, each with a live count | section hidden when no rights | GtkListBox .boxed-list | +| Developer | list | Applications, API Keys, Webhooks, Tenant — with counts and health (unhealthy webhooks, failed push credentials) | section hidden when no rights | GtkListBox .boxed-list | + diff --git a/docs/screens/glb-03.md b/docs/screens/glb-03.md new file mode 100644 index 0000000..c9ae682 --- /dev/null +++ b/docs/screens/glb-03.md @@ -0,0 +1,48 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "GLB-03 System states" +permalink: /screens/glb-03/ +nav_exclude: true +screen_id: GLB-03 +--- + +# System states +{: .no_toc } + +App-wide banners and blocking states. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `GLB-03` | +| **Who can use it** | — | +| **Kind** | overlay | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Popover / overlay | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Offline | banner | network status | reads from cache, writes disabled with explanation | AdwBanner | +| Maintenance / tenant migrating | banner | 503 service_unavailable + Retry-After | countdown, auto-retry reads | AdwBanner | +| Session ended | modal → AUTH-01 | 401 after failed refresh | preserve destination | AdwAlertDialog → AUTH-01 | +| Forbidden | inline state | 403 | COPY | GtkSpinner on tile | +| Not built yet | inline state | 501 not_implemented (Notifications today) | hide the feature behind its flag; never show an error to end users | GtkSpinner on tile | +| Update available | banner | `Deprecation` + `Sunset` response headers on this build's API version | once per day; shows the sunset date; "Update" opens the store / updater (Web: reload); dismissible until 14 days before `Sunset` | AdwBanner | +| Update required | blocking screen | 400 unsupported_api_version (`details.supported`) | 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 | AdwStatusPage window | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Silent token refresh | `POST /auth/token/refresh` | single-flight; rotate refresh token atomically (CLIENT-ARCHITECTURE §2) | +| Connectivity probe | `GET /health` | used after offline/503 to decide when to clear the banner (exempt from `Substratal-Version`) | +| Forced sign-out | `POST /auth/logout` | on session_revoked or user request; the server deactivates this session's push devices | +| Badge refresh | `GET /users/me/notifications/unread-count` | on launch, foreground, after a push arrives, and every 60 s while visible (Windows/Linux: also in background) → app icon/Dock/taskbar/PWA badge | + +## Good to know + +- `POST /auth/oauth/token` is called by the launched Application (PKCE exchange), never by the Hub. + diff --git a/docs/screens/index.md b/docs/screens/index.md new file mode 100644 index 0000000..84b0dbe --- /dev/null +++ b/docs/screens/index.md @@ -0,0 +1,179 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "Screens" +nav_order: 5 +has_children: false +permalink: /screens/ +--- + +# Screens +{: .no_toc } + +Every screen in the Substratal Hub, with the same ID, route and behavior in all six apps (Linux, macOS, Windows, iOS/iPadOS, Android and Web). Each has its own help page at `/screens//`, which every app links to from its Help button or **F1**. +{: .fs-6 .fw-300 } + +Generated from the [specs package](https://github.com/CompositeCode/substratalapps-applications-specs) at `cebf679`. A machine-readable list is at [`screens.json`]({{ '/screens.json' | relative_url }}). + + +## D1 · Sign-in & Identity + +| ID | Screen | Route | Who | Apps | +|---|---|---|---|---| +| [AUTH-01]({{ '/screens/auth-01/' | relative_url }}) | Sign in | `/sign-in` | Unauthenticated | All Hub apps | +| [AUTH-02]({{ '/screens/auth-02/' | relative_url }}) | Two-factor challenge | `/sign-in/mfa` | Holder of mfa_token | All Hub apps | +| [AUTH-03]({{ '/screens/auth-03/' | relative_url }}) | Create account | `/sign-up` | Unauthenticated | All Hub apps | +| [AUTH-04]({{ '/screens/auth-04/' | relative_url }}) | Verify your email | `/verify-email` | Signed in (unverified) or token holder | All Hub apps | +| [AUTH-05]({{ '/screens/auth-05/' | relative_url }}) | Forgot password | `/forgot-password` | Unauthenticated | All Hub apps | +| [AUTH-06]({{ '/screens/auth-06/' | relative_url }}) | Set a new password | `/reset-password` | Reset-token holder | All Hub apps | +| [AUTH-07]({{ '/screens/auth-07/' | relative_url }}) | Accept invitation | `/invite` | Invitation-token holder | All Hub apps | + +## D2 · Account & Security + +| ID | Screen | Route | Who | Apps | +|---|---|---|---|---| +| [ACC-01]({{ '/screens/acc-01/' | relative_url }}) | Account & profile | `/account` | Self | All Hub apps | +| [ACC-02]({{ '/screens/acc-02/' | relative_url }}) | Change email | `/account/email` | Self | All Hub apps | +| [ACC-03]({{ '/screens/acc-03/' | relative_url }}) | Change password | `/account/security/password` | Self | All Hub apps | +| [ACC-04]({{ '/screens/acc-04/' | relative_url }}) | Security | `/account/security` | Self | All Hub apps | +| [ACC-05]({{ '/screens/acc-05/' | relative_url }}) | Turn on two-factor | `/account/security/2fa/enable` | Self | All Hub apps | +| [ACC-06]({{ '/screens/acc-06/' | relative_url }}) | Turn off two-factor | `/account/security/2fa/disable` | Self | All Hub apps | +| [ACC-07]({{ '/screens/acc-07/' | relative_url }}) | Privacy | `/account/privacy` | Self | All Hub apps | +| [ACC-08]({{ '/screens/acc-08/' | relative_url }}) | Delete account | `/account/privacy/delete` | Self | All Hub apps | + +## D3 · My Apps + +| ID | Screen | Route | Who | Apps | +|---|---|---|---|---| +| [APP-01]({{ '/screens/app-01/' | relative_url }}) | Home — My Apps | `/` | Signed in | All Hub apps | +| [APP-02]({{ '/screens/app-02/' | relative_url }}) | App info | `/apps/:appId` | Signed in | All Hub apps | +| [APP-03]({{ '/screens/app-03/' | relative_url }}) | Launch app | `/apps/:appId/launch` | Active entitlement | All Hub apps | +| [APP-04]({{ '/screens/app-04/' | relative_url }}) | App profile | `/apps/:appId/profile` | Self, active entitlement | All Hub apps | +| [APP-05]({{ '/screens/app-05/' | relative_url }}) | Past apps | `/apps/past` | Signed in | All Hub apps | + +## D4 · Settings + +| ID | Screen | Route | Who | Apps | +|---|---|---|---|---| +| [SET-01]({{ '/screens/set-01/' | relative_url }}) | Settings — Global | `/settings` | Self | All Hub apps | +| [SET-02]({{ '/screens/set-02/' | relative_url }}) | Settings — Per app | `/settings/apps/:appId` | Self, active entitlement | All Hub apps | + +## D5 · Organizations + +| ID | Screen | Route | Who | Apps | +|---|---|---|---|---| +| [ORG-01]({{ '/screens/org-01/' | relative_url }}) | Organizations | `/orgs` | Signed in (all orgs with organizations.manage) | All Hub apps | +| [ORG-02]({{ '/screens/org-02/' | relative_url }}) | New organization | `/orgs/new` | Any active user | All Hub apps | +| [ORG-03]({{ '/screens/org-03/' | relative_url }}) | Organization — Members | `/orgs/:orgId/members` | Member (read), org_admin / organizations.manage (write) | All Hub apps | +| [ORG-04]({{ '/screens/org-04/' | relative_url }}) | Invite member | `/orgs/:orgId/members/invite` | org_admin; organizations.manage may add by user_id | All Hub apps | +| [ORG-05]({{ '/screens/org-05/' | relative_url }}) | Member actions | `/orgs/:orgId/members/:userId` | org_admin / organizations.manage | All Hub apps | +| [ORG-06]({{ '/screens/org-06/' | relative_url }}) | Organization — Apps & seats | `/orgs/:orgId/apps` | Member (read), org_admin (write) | All Hub apps | +| [ORG-07]({{ '/screens/org-07/' | relative_url }}) | Grant app to organization / member scope | `/orgs/:orgId/apps/grant` | entitlements.manage or app owner; scope edits by org_admin | All Hub apps | +| [ORG-08]({{ '/screens/org-08/' | relative_url }}) | Organization — Settings | `/orgs/:orgId/settings` | org_admin (rename, SCIM), organizations.manage (suspend, SCIM), any member (leave) | All Hub apps | +| [ORG-09]({{ '/screens/org-09/' | relative_url }}) | Invitation | `/orgs/:orgId/invitation` | Invited user | All Hub apps | + +## D6 · Users + +| ID | Screen | Route | Who | Apps | +|---|---|---|---|---| +| [USR-01]({{ '/screens/usr-01/' | relative_url }}) | Users | `/admin/users` | users.list (write actions need users.manage) | All Hub apps | +| [USR-02]({{ '/screens/usr-02/' | relative_url }}) | Invite user | `/admin/users/new` | users.manage | All Hub apps | +| [USR-03]({{ '/screens/usr-03/' | relative_url }}) | User — Overview | `/admin/users/:userId` | users.list (read), users.manage (write) | All Hub apps | +| [USR-04]({{ '/screens/usr-04/' | relative_url }}) | User — Sessions | `/admin/users/:userId/sessions` | users.manage | All Hub apps | +| [USR-05]({{ '/screens/usr-05/' | relative_url }}) | Suspend user | `/admin/users/:userId/suspend` | users.manage | All Hub apps | + +## D7 · Access + +| ID | Screen | Route | Who | Apps | +|---|---|---|---|---| +| [ACX-01]({{ '/screens/acx-01/' | relative_url }}) | User — Access | `/admin/users/:userId/access` | users.list (read), entitlements.manage or app owner (write) | All Hub apps | +| [ACX-02]({{ '/screens/acx-02/' | relative_url }}) | Grant access | `/admin/users/:userId/access/grant` | entitlements.manage or app owner (own app) | All Hub apps | +| [ACX-03]({{ '/screens/acx-03/' | relative_url }}) | Turn off / edit access | `/admin/entitlements/:entId` | entitlements.manage or app owner | All Hub apps | +| [ACX-04]({{ '/screens/acx-04/' | relative_url }}) | Access check (Why?) | `/admin/users/:userId/apps/:appId/why` | users.list or app owner | All Hub apps | +| [ACX-05]({{ '/screens/acx-05/' | relative_url }}) | Access (all) | `/admin/access` | entitlements.manage or app owner (confined to own apps) | All Hub apps | + +## D8 · Roles & Permissions + +| ID | Screen | Route | Who | Apps | +|---|---|---|---|---| +| [ROL-01]({{ '/screens/rol-01/' | relative_url }}) | Roles | `/admin/roles` | users.list (read), roles.manage (write) | All Hub apps | +| [ROL-02]({{ '/screens/rol-02/' | relative_url }}) | Role detail | `/admin/roles/:roleId` | users.list (read), roles.manage (write) | All Hub apps | +| [ROL-03]({{ '/screens/rol-03/' | relative_url }}) | New role | `/admin/roles/new` | roles.manage | All Hub apps | +| [ROL-04]({{ '/screens/rol-04/' | relative_url }}) | User — Roles | `/admin/users/:userId/roles` | users.list (read), roles.manage (write) | All Hub apps | + +## D9 · Applications + +| ID | Screen | Route | Who | Apps | +|---|---|---|---|---| +| [APL-01]({{ '/screens/apl-01/' | relative_url }}) | Applications | `/dev/apps` | Any (catalog), owner/applications.manage (manage) | All Hub apps | +| [APL-02]({{ '/screens/apl-02/' | relative_url }}) | Register application | `/dev/apps/new` | applications.manage, or anyone with a verified email (self-service) | All Hub apps | +| [APL-03]({{ '/screens/apl-03/' | relative_url }}) | Application — Overview | `/dev/apps/:appId` | Owner (configure), applications.manage (anything) | All Hub apps | +| [APL-04]({{ '/screens/apl-04/' | relative_url }}) | Settings schema | `/dev/apps/:appId/schema` | Owner / applications.manage | All Hub apps | +| [APL-05]({{ '/screens/apl-05/' | relative_url }}) | Permissions & roles | `/dev/apps/:appId/roles` | Owner / applications.manage | All Hub apps | +| [APL-06]({{ '/screens/apl-06/' | relative_url }}) | Review | `/dev/apps/:appId/review` | applications.manage | All Hub apps | +| [APL-07]({{ '/screens/apl-07/' | relative_url }}) | Push credentials | `/dev/apps/:appId/push` | Owner or applications.manage with a User token (write); app key / owner (read) | All Hub apps | + +## D10 · Developer Credentials + +| ID | Screen | Route | Who | Apps | +|---|---|---|---|---| +| [KEY-01]({{ '/screens/key-01/' | relative_url }}) | API keys | `/dev/keys` | api_keys.manage or app owner | All Hub apps | +| [KEY-02]({{ '/screens/key-02/' | relative_url }}) | Create API key | `/dev/keys/new` | api_keys.manage or app owner (app-scoped) | All Hub apps | +| [KEY-03]({{ '/screens/key-03/' | relative_url }}) | Secret reveal | `(modal)` | Creator | All Hub apps | +| [KEY-04]({{ '/screens/key-04/' | relative_url }}) | API key detail | `/dev/keys/:keyId` | api_keys.manage or app owner | All Hub apps | +| [WHK-01]({{ '/screens/whk-01/' | relative_url }}) | Webhooks | `/dev/webhooks` | webhooks.manage or app owner | All Hub apps | +| [WHK-02]({{ '/screens/whk-02/' | relative_url }}) | Add / edit webhook | `/dev/webhooks/new` | webhooks.manage or app owner | All Hub apps | +| [WHK-03]({{ '/screens/whk-03/' | relative_url }}) | Webhook — Deliveries | `/dev/webhooks/:hookId` | webhooks.manage or app owner | All Hub apps | +| [WHK-04]({{ '/screens/whk-04/' | relative_url }}) | Delivery detail | `/dev/webhooks/:hookId/deliveries/:deliveryId` | webhooks.manage or app owner | All Hub apps | + +## D11 · Plan & Billing + +| ID | Screen | Route | Who | Apps | +|---|---|---|---|---| +| [BIL-01]({{ '/screens/bil-01/' | relative_url }}) | My plan | `/billing` | Self; billing.manage can view any User's (read-only) | All Hub apps | +| [BIL-02]({{ '/screens/bil-02/' | relative_url }}) | Organization subscription | `/orgs/:orgId/subscription` | org_admin (manage); billing.manage (read + portal) | All Hub apps | +| [BIL-03]({{ '/screens/bil-03/' | relative_url }}) | Checkout / billing portal | `/billing/return` | Self (individual); org_admin (org checkout); org_admin or billing.manage (org portal) | All Hub apps | +| [BIL-04]({{ '/screens/bil-04/' | relative_url }}) | Tenant & infrastructure | `/dev/tenant` | Tenant owner (read), tenants.manage (write) | All Hub apps | + +## D12 · Audit Log + +| ID | Screen | Route | Who | Apps | +|---|---|---|---|---| +| [AUD-01]({{ '/screens/aud-01/' | relative_url }}) | Audit log | `/admin/audit` | audit.view | All Hub apps | +| [AUD-02]({{ '/screens/aud-02/' | relative_url }}) | Audit event | `/admin/audit/:eventId` | audit.view | All Hub apps | + +## D13 · Notifications + +| ID | Screen | Route | Who | Apps | +|---|---|---|---|---| +| [NTF-01]({{ '/screens/ntf-01/' | relative_url }}) | Inbox | `/inbox` | Self | All Hub apps | +| [NTF-02]({{ '/screens/ntf-02/' | relative_url }}) | Notification | `/inbox/:notificationId` | Self (users.list via NTF-05) | All Hub apps | +| [NTF-03]({{ '/screens/ntf-03/' | relative_url }}) | Devices & notifications | `/account/devices` | Self | All Hub apps | +| [NTF-04]({{ '/screens/ntf-04/' | relative_url }}) | Turn on notifications | `/account/devices/enable` | Self | All Hub apps | +| [NTF-05]({{ '/screens/ntf-05/' | relative_url }}) | User — Notifications & devices | `/admin/users/:userId/notifications` | users.list (read), users.manage (delete devices) | All Hub apps | +| [NTF-06]({{ '/screens/ntf-06/' | relative_url }}) | Send notification | `/admin/notifications/new` | platform notifications.send | All Hub apps | + +## G · Global / Shell + +| ID | Screen | Route | Who | Apps | +|---|---|---|---|---| +| [GLB-01]({{ '/screens/glb-01/' | relative_url }}) | Command palette | `(overlay)` | Signed in | All Hub apps | +| [GLB-02]({{ '/screens/glb-02/' | relative_url }}) | Manage hub | `/manage` | Any admin or developer permission | All Hub apps | +| [GLB-03]({{ '/screens/glb-03/' | relative_url }}) | System states | `(global)` | — | All Hub apps | + +## C · Console + +| ID | Screen | Route | Who | Apps | +|---|---|---|---|---| +| [OPS-01]({{ '/screens/ops-01/' | relative_url }}) | Overview | `/ops` | operations.view (superadmin until G10) | Web Hub only | +| [OPS-02]({{ '/screens/ops-02/' | relative_url }}) | Traffic & latency | `/ops/traffic` | operations.view | Web Hub only | +| [OPS-03]({{ '/screens/ops-03/' | relative_url }}) | Logs | `/ops/logs` | operations.view | Web Hub only | +| [SEC-01]({{ '/screens/sec-01/' | relative_url }}) | Security events | `/ops/security` | operations.view | Web Hub only | +| [OPS-04]({{ '/screens/ops-04/' | relative_url }}) | Alarms | `/ops/alarms` | operations.view | Web Hub only | +| [OPS-05]({{ '/screens/ops-05/' | relative_url }}) | Jobs & delivery | `/ops/jobs` | operations.view | Web Hub only | +| [OPS-06]({{ '/screens/ops-06/' | relative_url }}) | Releases | `/ops/releases` | operations.view | Web Hub only | +| [OPS-07]({{ '/screens/ops-07/' | relative_url }}) | Cost | `/ops/cost` | operations.view | Web Hub only | +| [CFG-01]({{ '/screens/cfg-01/' | relative_url }}) | Configuration | `/ops/config` | operations.view | Web Hub only | +| [CFG-02]({{ '/screens/cfg-02/' | relative_url }}) | Feature flags | `/ops/config/flags` | operations.view (read), operations.manage (write, after G10) | Web Hub only | +| [RPT-01]({{ '/screens/rpt-01/' | relative_url }}) | Reports | `/admin/reports` | users.list (platform reports); an Application owner sees only their own app | Web Hub only | +| [INT-01]({{ '/screens/int-01/' | relative_url }}) | Integrations | `/dev/integrations` | webhooks.manage or api_keys.manage | Web Hub only | diff --git a/docs/screens/int-01.md b/docs/screens/int-01.md new file mode 100644 index 0000000..3d695ab --- /dev/null +++ b/docs/screens/int-01.md @@ -0,0 +1,43 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "INT-01 Integrations" +permalink: /screens/int-01/ +nav_exclude: true +screen_id: INT-01 +--- + +# Integrations +{: .no_toc } + +Every way to connect Substratal to other tools, in one place. +{: .fs-6 .fw-300 } + +This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). +{: .note } + +| | | +|---|---| +| **Screen ID** | `INT-01` | +| **Route** | `/dev/integrations` | +| **Where** | Developer › Integrations | +| **Who can use it** | webhooks.manage or api_keys.manage | +| **Kind** | list | +| **Available in** | Web Hub only (Console M2 (web only)) | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | +|---|---|---|---| +| Platform webhooks | list | → WHK-01 filtered to `scope: platform` | for SIEM, warehouse or chat tools | +| Agent and service keys | list | → KEY-01 filtered by `intended_use` | | +| MCP server | instructions | `https://api.substratalapps.com/mcp` with a restricted agent key | copy button | +| Alarm destinations | list | → OPS-04 subscribers; chat delivery via AWS Chatbot | Terraform-owned: link to the variable | +| Stripe | key-value | portal links; `billing_unavailable` state | | +| Log export | text | a CloudWatch subscription filter, configured in Terraform | documentation only | + +## For developers + +Loads: `GET /webhooks?scope=platform` · `GET /api-keys` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/key-01.md b/docs/screens/key-01.md new file mode 100644 index 0000000..9364f0e --- /dev/null +++ b/docs/screens/key-01.md @@ -0,0 +1,42 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "KEY-01 API keys" +permalink: /screens/key-01/ +nav_exclude: true +screen_id: KEY-01 +--- + +# API keys +{: .no_toc } + +Service and agent credentials. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `KEY-01` | +| **Route** | `/dev/keys` · deep link `substratal://dev/keys` | +| **Where** | Developer › API Keys | +| **Who can use it** | api_keys.manage or app owner | +| **Kind** | table | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `GtkColumnView` (.data-table) in a card | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Filters | chips | mode live\ | test, intended_use, include revoked | GtkToggleButton pills | +| Columns | table | name, scope, secret_hint, mode, intended_use, restrict_destructive, last_used_at, expires_at, status(revoked_at) | | GtkColumnView .data-table | +| Create key | primary-button | → KEY-02 | | GtkButton .suggested-action .pill | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /api-keys?scope&mode&intended_use&include_inactive` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/key-02.md b/docs/screens/key-02.md new file mode 100644 index 0000000..5df9206 --- /dev/null +++ b/docs/screens/key-02.md @@ -0,0 +1,43 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "KEY-02 Create API key" +permalink: /screens/key-02/ +nav_exclude: true +screen_id: KEY-02 +--- + +# Create API key +{: .no_toc } + +Create a key with least-privilege permissions. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `KEY-02` | +| **Route** | `/dev/keys/new` · deep link `substratal://dev/keys/new` | +| **Where** | Developer › API Keys | +| **Who can use it** | api_keys.manage or app owner (app-scoped) | +| **Kind** | form | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Name | text-field | name | | AdwEntryRow | +| Scope | select | scope platform \ | application | AdwComboRow | +| Mode | segmented | mode live \ | test | linked GtkToggleButtons | +| Used by | radio-group | intended_use service \ | agent | GtkCheckButton group | +| Block destructive operations | toggle | restrict_destructive | | AdwSwitchRow | +| Permissions | checkbox-list | permissions | filtered by scope via GET /permissions; app-scoped keys offer the five app-confined platform permissions incl. notifications.send | AdwPreferencesGroup of check rows | +| Expires | date-field | expires_at | optional; presets 30/90/365 days | GtkCalendar in popover | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Create | `POST /api-keys` | 201 ApiKeyWithSecret → KEY-03 | + diff --git a/docs/screens/key-03.md b/docs/screens/key-03.md new file mode 100644 index 0000000..f4790d2 --- /dev/null +++ b/docs/screens/key-03.md @@ -0,0 +1,31 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "KEY-03 Secret reveal" +permalink: /screens/key-03/ +nav_exclude: true +screen_id: KEY-03 +--- + +# Secret reveal +{: .no_toc } + +Show a new API key secret, webhook signing secret, or recovery codes exactly once. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `KEY-03` | +| **Who can use it** | Creator | +| **Kind** | secret | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwDialog` `can-close=false` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Secret | monospace selectable text | secret | never logged, never persisted, excluded from screenshots where the OS allows | GtkLabel selectable .monospace | +| Copy | button | clipboard (sensitive) | auto-clear after 60 s where supported | GtkButton | +| Acknowledge | checkbox | client | gates Done; modal not dismissible until checked | GtkCheckButton | + diff --git a/docs/screens/key-04.md b/docs/screens/key-04.md new file mode 100644 index 0000000..325e96e --- /dev/null +++ b/docs/screens/key-04.md @@ -0,0 +1,51 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "KEY-04 API key detail" +permalink: /screens/key-04/ +nav_exclude: true +screen_id: KEY-04 +--- + +# API key detail +{: .no_toc } + +Edit, rotate or revoke a key. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `KEY-04` | +| **Route** | `/dev/keys/:keyId` · deep link `substratal://dev/keys/:keyId` | +| **Where** | Developer › API Keys | +| **Who can use it** | api_keys.manage or app owner | +| **Kind** | detail | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Content pane; `AdwViewSwitcher`; header-bar actions | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Fields | settings rows | name, permissions, restrict_destructive (editable); scope, mode, intended_use, expires_at, created_by, last_used_at (read-only) | | Adw*Rows | +| Rotate | button | POST /api-keys/{id}/rotate | confirm: no overlap | GtkButton | +| Revoke | destructive-button | DELETE /api-keys/{id} | confirm | GtkButton .destructive-action | +| Recent activity | list | audit events by this key | | GtkListBox .boxed-list | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Save | `PATCH /api-keys/{id}` | | +| Rotate | `POST …/rotate` | 200 → KEY-03 | +| Revoke | `DELETE /api-keys/{id}` | 204 → row shows Revoked | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /api-keys/{id}` · `GET /audit-events?actor_id={id}` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ntf-01.md b/docs/screens/ntf-01.md new file mode 100644 index 0000000..a5abf57 --- /dev/null +++ b/docs/screens/ntf-01.md @@ -0,0 +1,56 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "NTF-01 Inbox" +permalink: /screens/ntf-01/ +nav_exclude: true +screen_id: NTF-01 +--- + +# Inbox +{: .no_toc } + +Every notification the person has received — from every app and from Substratal — with read and archived state. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `NTF-01` | +| **Route** | `/inbox` · deep link `substratal://inbox` | +| **Where** | Personal › Inbox | +| **Who can use it** | Self | +| **Kind** | list | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | Nested `AdwNavigationSplitView` list pane | + +**How you get here:** Bell in the toolbar / Inbox in the sidebar (desktop) / Inbox tab (compact); Tapping a push; Badges (app icon, Dock/taskbar, PWA) + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Filter | segmented | Unread · All · Archived (state) | default All (unarchived) | linked GtkToggleButtons | +| App filter | menu | application_id, source=platform ("Substratal") | per-app unread counts from unread-count.applications[] | GtkMenuButton | +| Rows | list | Notification: app icon (or Substratal mark for source=platform), title, body (2 lines), category, priority high → "Important" marker, created_at relative, unread dot | grouped Today / Yesterday / Earlier; high priority security.* rows show a shield icon | GtkListBox .boxed-list | +| Mark all as read | button | POST /users/me/notifications/mark-read {before: newest loaded created_at, application_id?} | respects current app filter; uses `before` so items arriving now aren't swept | GtkButton | +| Multi-select | selection | | bulk Mark read / Mark unread / Archive (one PATCH per row) | AdwComboRow | +| Row actions | row-button | PATCH /users/me/notifications/{notificationId} {state} | swipe/hover: Read ⇄ Unread, Archive | suffix GtkButton .flat | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Open row | `→ NTF-02 and PATCH {state:read}` | optimistic; badge decrements | +| Archive | `PATCH /users/me/notifications/{notificationId} {state:archived}` | toast with Undo (PATCH state:read) | +| Mark read / unread | `PATCH /users/me/notifications/{notificationId} {state}` | | + +## Good to know + +- **Loading, empty and error 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) +- 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. + +## For developers + +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)` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ntf-02.md b/docs/screens/ntf-02.md new file mode 100644 index 0000000..ba3679a --- /dev/null +++ b/docs/screens/ntf-02.md @@ -0,0 +1,51 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "NTF-02 Notification" +permalink: /screens/ntf-02/ +nav_exclude: true +screen_id: NTF-02 +--- + +# Notification +{: .no_toc } + +One notification in full, with where it leads and how its push went. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `NTF-02` | +| **Route** | `/inbox/:notificationId` · deep link `substratal://inbox/:notificationId` | +| **Where** | Personal › Inbox | +| **Who can use it** | Self (users.list via NTF-05) | +| **Kind** | popover | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | `GtkPopover` or `AdwOverlaySplitView` sidebar | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Header | icon + app name + time | application_id / source, created_at | | GtkImage + labels + badge | +| Title & body | text | title, body | full text | GtkLabel | +| Open | primary-button | action_url | Hub route → navigate in-app; https → open in browser / installed app (Universal/App Link); private scheme → hand to OS; hidden when null | GtkButton .suggested-action .pill | +| Contextual actions | buttons | by category | 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) | GtkButton | +| Delivery | disclosure | push.status, devices_targeted/sent/failed, skip_reason, deliveries[] (30 days) | plain-language: "Pushed to 2 devices", "Not pushed — notifications are off for this app" | AdwExpanderRow | +| Mark unread / Archive | toolbar | PATCH {state} | | AdwHeaderBar buttons | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Opened | `PATCH /users/me/notifications/{notificationId} {state:read}` | on open if unread | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /users/me/notifications/{notificationId}` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ntf-03.md b/docs/screens/ntf-03.md new file mode 100644 index 0000000..f4553f9 --- /dev/null +++ b/docs/screens/ntf-03.md @@ -0,0 +1,53 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "NTF-03 Devices & notifications" +permalink: /screens/ntf-03/ +nav_exclude: true +screen_id: NTF-03 +--- + +# Devices & notifications +{: .no_toc } + +Where push notifications reach this person, and controls for this device. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `NTF-03` | +| **Route** | `/account/devices` · deep link `substratal://account/devices` | +| **Where** | Personal › Account | +| **Who can use it** | Self | +| **Kind** | settings | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | `AdwPreferencesPage` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| This device | card | Hub Device for this install (matched by stored device id) | status, "Notifications are on" / off / blocked by OS; button Turn on (→ NTF-04) or Turn off | .card box | +| Push notifications (all apps) | toggle | Settings.notifications.push | same control as SET-01 | AdwSwitchRow | +| Devices | list | Device: name, application_id (app icon), provider, environment, status, inactive_reason, last_registered_at, last_delivery_at, token_hint | grouped by app; inactive rows dimmed with reason ("Signed out", "Not opened in 9 months"…) | GtkListBox .boxed-list | +| Rename | row-button | PATCH /users/me/devices/{deviceId} {name} | | suffix GtkButton .flat | +| Send test notification | row-button | POST /users/me/devices/{deviceId}/test | Hub devices only; shows provider result inline (sent / failed + error) within 10 s | suffix GtkButton .flat | +| Remove | row-button | DELETE /users/me/devices/{deviceId} | confirm; that install stops receiving pushes until it registers again | suffix GtkButton .flat | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Turn off on this device | `DELETE /users/me/devices/{deviceId}` | and stop re-registering until the user turns it back on | +| Test | `POST /users/me/devices/{deviceId}/test` | 429 → "Try again later (10 tests per hour)"; 409 push_not_configured → "Push isn't set up for this app yet" | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES +- Device tokens are never displayed (only token_hint). + +## For developers + +Loads: `GET /users/me/devices?include_inactive=true` · `GET /users/me/settings (notifications.push)` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ntf-04.md b/docs/screens/ntf-04.md new file mode 100644 index 0000000..31d6b9e --- /dev/null +++ b/docs/screens/ntf-04.md @@ -0,0 +1,47 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "NTF-04 Turn on notifications" +permalink: /screens/ntf-04/ +nav_exclude: true +screen_id: NTF-04 +--- + +# Turn on notifications +{: .no_toc } + +Explain, request the OS permission, and register this install for push. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `NTF-04` | +| **Route** | `/account/devices/enable` · deep link `substratal://account/devices/enable` | +| **Where** | Personal › Account | +| **Who can use it** | Self | +| **Kind** | wizard | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | `AdwDialog` with `AdwNavigationView` pages | + +**How you get here:** 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 + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Primer | text + illustration | | what you'll get: security alerts, invitations, access changes; "You can change this anytime" | GtkLabel | +| Turn on | primary-button | OS permission prompt | platform API per PLATFORM-SPEC | GtkButton .suggested-action .pill | +| Not now | button | | snooze 14 days | GtkButton | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Permission granted | `POST /users/me/devices {application_id: app_hub, provider, token \` | subscription, environment (apns), name, app_version}` | +| Token rotated by OS | `PATCH /users/me/devices/{deviceId} {token \` | subscription}` | +| Permission denied | `—` | Settings shows "Blocked — Open system settings" deep link | + +## Good to know + +- 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). + diff --git a/docs/screens/ntf-05.md b/docs/screens/ntf-05.md new file mode 100644 index 0000000..922b023 --- /dev/null +++ b/docs/screens/ntf-05.md @@ -0,0 +1,43 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "NTF-05 User — Notifications & devices" +permalink: /screens/ntf-05/ +nav_exclude: true +screen_id: NTF-05 +--- + +# User — Notifications & devices +{: .no_toc } + +Support view of a person's inbox and push devices for investigations. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `NTF-05` | +| **Route** | `/admin/users/:userId/notifications` · deep link `substratal://admin/users/:userId/notifications` | +| **Where** | Administration › Users | +| **Who can use it** | users.list (read), users.manage (delete devices) | +| **Kind** | tab | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | `AdwViewStack` page | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Inbox | table | Notification: created_at, application/source, category, priority, title, state, push.status, skip_reason | read-only; never mark the user's items read | GtkColumnView .data-table | +| Delivery detail | side panel | deliveries[]: provider, status, attempts, error, sent_at | | AdwOverlaySplitView | +| Devices | table | Device: app, provider, environment, status, inactive_reason, name, app_version, last_registered_at, last_delivery_at | | GtkColumnView .data-table | +| Delete device | destructive-button | DELETE /users/{id}/devices/{deviceId} | users.manage; audited (device.deleted) | GtkButton .destructive-action | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +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}` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ntf-06.md b/docs/screens/ntf-06.md new file mode 100644 index 0000000..60bd3c0 --- /dev/null +++ b/docs/screens/ntf-06.md @@ -0,0 +1,47 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "NTF-06 Send notification" +permalink: /screens/ntf-06/ +nav_exclude: true +screen_id: NTF-06 +--- + +# Send notification +{: .no_toc } + +Staff tool: send one notification to one person on behalf of an Application. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `NTF-06` | +| **Route** | `/admin/notifications/new` · deep link `substratal://admin/notifications/new` | +| **Where** | Administration › Send notification | +| **Who can use it** | platform notifications.send | +| **Kind** | form | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Recipient | picker | user (GET /users?q) | must have active access to the chosen app | AdwDialog with GtkSearchEntry list | +| Application | select | body.application_id | required for platform senders | AdwComboRow | +| Category | text-field | body.category | ^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$; security./account./billing. reserved (422 reserved_category) | AdwEntryRow | +| Priority | segmented | body.priority normal \ | high | linked GtkToggleButtons | +| Title | text-field | body.title | 1–120 | AdwEntryRow | +| Body | text-area | body.body | ≤ 1,000 | GtkTextView in .card | +| Data | key/value editor | body.data | ≤ 20 string pairs, 2 KB; key `substratal` reserved | AdwPreferencesGroup of AdwEntryRow pairs | +| Open link | url-field | body.action_url | https or private scheme ≤ 2,048 | AdwEntryRow input-purpose=url | +| Advanced | disclosure | push (bool), badge, sound default\ | silent, collapse_key, ttl_seconds | AdwExpanderRow | +| Payload size | meters | client estimate of the 4 KB push payload | 422 notification_too_large → details.bytes/max | GtkLevelBar | +| Preview | card | lock-screen style preview per platform | warns: lock screens show title/body — keep sensitive content out | .card box | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Send | `POST /users/{id}/notifications (Idempotency-Key)` | 201 → result panel with push.status / skip_reason; 403 entitlement_required → recipient has no access; 429 scope sender\ | + diff --git a/docs/screens/ops-01.md b/docs/screens/ops-01.md new file mode 100644 index 0000000..3e233c6 --- /dev/null +++ b/docs/screens/ops-01.md @@ -0,0 +1,47 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "OPS-01 Overview" +permalink: /screens/ops-01/ +nav_exclude: true +screen_id: OPS-01 +--- + +# Overview +{: .no_toc } + +Is production healthy right now, and is anything costing or failing? +{: .fs-6 .fw-300 } + +This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). +{: .note } + +| | | +|---|---| +| **Screen ID** | `OPS-01` | +| **Route** | `/ops` | +| **Where** | Operations › Overview | +| **Who can use it** | operations.view (superadmin until G10) | +| **Kind** | detail | +| **Available in** | Web Hub only (Console M3 (web only)) | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | +|---|---|---|---| +| Health | StatusBadge | `GET /health` | Down → banner linking OPS-04 | +| Alarms | summary chips | firing / OK counts | any firing → sidebar badge on Operations | +| Traffic tiles | metric tiles | requests, 4xx rate, 5xx rate, p95 latency, Lambda errors and throttles, Aurora ACU, webhook DLQ depth | each tile → OPS-02 at that metric | +| Cost | progress bar | month-to-date vs warn and hard budgets | → OPS-07 | +| Recent security events | list | → SEC-01 | 5 newest | +| Recent admin activity | list | `GET /audit-events` | → AUD-01 | + +## Good to know + +- **Loading, empty and error 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." + +## For developers + +Loads: `GET /health` (API) · `GET /ops/metrics?panel=overview&range=1h` · `GET /ops/alarms` · `GET /ops/cost` · `GET /audit-events?limit=10` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ops-02.md b/docs/screens/ops-02.md new file mode 100644 index 0000000..f665199 --- /dev/null +++ b/docs/screens/ops-02.md @@ -0,0 +1,44 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "OPS-02 Traffic & latency" +permalink: /screens/ops-02/ +nav_exclude: true +screen_id: OPS-02 +--- + +# Traffic & latency +{: .no_toc } + +Trends and SLO tracking. +{: .fs-6 .fw-300 } + +This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). +{: .note } + +| | | +|---|---| +| **Screen ID** | `OPS-02` | +| **Route** | `/ops/traffic` | +| **Where** | Operations › Traffic & latency | +| **Who can use it** | operations.view | +| **Kind** | detail | +| **Available in** | Web Hub only (Console M3 (web only)) | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | +|---|---|---|---| +| Range | segmented control | 1h · 24h · 7d · 30d | in the URL | +| Charts | time series | requests, 4xx, 5xx, p95 latency, throttles | accessible table view of each chart (ACCESSIBILITY) | +| SLOs | progress + burn rate | 99.9 % non-5xx; p95 < 150 ms on effective-permissions, app-tokens and oauth/token; p95 < 400 ms elsewhere | per-operation SLOs hidden until [G6 #43](https://github.com/CompositeCode/substratalapps.com/issues/43) gives per-operation data | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry + +## For developers + +Loads: `GET /ops/metrics?panel=traffic&range={1h|24h|7d|30d}` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ops-03.md b/docs/screens/ops-03.md new file mode 100644 index 0000000..971d4a8 --- /dev/null +++ b/docs/screens/ops-03.md @@ -0,0 +1,44 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "OPS-03 Logs" +permalink: /screens/ops-03/ +nav_exclude: true +screen_id: OPS-03 +--- + +# Logs +{: .no_toc } + +Find what happened to one request, user or key. +{: .fs-6 .fw-300 } + +This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). +{: .note } + +| | | +|---|---| +| **Screen ID** | `OPS-03` | +| **Route** | `/ops/logs` | +| **Where** | Operations › Logs | +| **Who can use it** | operations.view | +| **Kind** | table | +| **Available in** | Web Hub only (Console M3 (web only)) | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | +|---|---|---|---| +| Query | select + typed fields | a template from the gateway's catalog (by request ID, user, key, status, error code), never free text in v1 | `?request_id=` deep link prefills | +| Range | segmented control | default 24 h | longer ranges warn about query cost | +| Rows | table | timestamp, log group, level, message fields | request ID links to AUD-01 filtered and back | + +## Good to know + +- **Loading, empty and error states:** Loading: progress while the query runs; Empty: "No log lines match."; Error: inline with Retry + +## For developers + +Loads: `POST /ops/logs/query {template, params, range}` → `GET /ops/logs/query/{id}` (poll every 2 s until complete) + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ops-04.md b/docs/screens/ops-04.md new file mode 100644 index 0000000..65ce76b --- /dev/null +++ b/docs/screens/ops-04.md @@ -0,0 +1,40 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "OPS-04 Alarms" +permalink: /screens/ops-04/ +nav_exclude: true +screen_id: OPS-04 +--- + +# Alarms +{: .no_toc } + +What's firing, what fired, and who gets told. +{: .fs-6 .fw-300 } + +This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). +{: .note } + +| | | +|---|---| +| **Screen ID** | `OPS-04` | +| **Route** | `/ops/alarms` | +| **Where** | Operations › Alarms | +| **Who can use it** | operations.view | +| **Kind** | table | +| **Available in** | Web Hub only (Console M3 (web only)) | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | +|---|---|---|---| +| Rows | table | alarm name, StatusBadge (OK · ALARM · INSUFFICIENT_DATA), since, threshold | firing first | +| History | popover | state changes for one alarm | links to the matching OPS-03 query | +| Subscribers | list | email subscriptions to the alarm topic, with pending confirmations | read-only in v1; changing them is Terraform | + +## For developers + +Loads: `GET /ops/alarms` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ops-05.md b/docs/screens/ops-05.md new file mode 100644 index 0000000..70b03e4 --- /dev/null +++ b/docs/screens/ops-05.md @@ -0,0 +1,39 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "OPS-05 Jobs & delivery" +permalink: /screens/ops-05/ +nav_exclude: true +screen_id: OPS-05 +--- + +# Jobs & delivery +{: .no_toc } + +Are the scheduled jobs and deliveries keeping up? +{: .fs-6 .fw-300 } + +This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). +{: .note } + +| | | +|---|---| +| **Screen ID** | `OPS-05` | +| **Route** | `/ops/jobs` | +| **Where** | Operations › Jobs & delivery | +| **Who can use it** | operations.view | +| **Kind** | table | +| **Available in** | Web Hub only (Console M3 (web only)) | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | +|---|---|---|---| +| Jobs | table | each scheduled job: schedule, last run, outcome, duration | outcome is inferred from logs until [G9 #46](https://github.com/CompositeCode/substratalapps.com/issues/46) records runs | +| Delivery | metric tiles | webhook and push queue depth, DLQ depth, failure rate | failures across subscriptions need [G8 #45](https://github.com/CompositeCode/substratalapps.com/issues/45); until then link WHK-03 per webhook | + +## For developers + +Loads: `GET /ops/jobs` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ops-06.md b/docs/screens/ops-06.md new file mode 100644 index 0000000..8fa3ede --- /dev/null +++ b/docs/screens/ops-06.md @@ -0,0 +1,40 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "OPS-06 Releases" +permalink: /screens/ops-06/ +nav_exclude: true +screen_id: OPS-06 +--- + +# Releases +{: .no_toc } + +What's deployed. +{: .fs-6 .fw-300 } + +This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). +{: .note } + +| | | +|---|---| +| **Screen ID** | `OPS-06` | +| **Route** | `/ops/releases` | +| **Where** | Operations › Releases | +| **Who can use it** | operations.view | +| **Kind** | table | +| **Available in** | Web Hub only (Console M3 (web only)) | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | +|---|---|---|---| +| Functions | table | function, `live` alias → version → commit SHA, deployed at | SHA links to the commit | +| Docs | key-value | published spec version | | +| Links | link-buttons | release, deploy and rollback workflows | open GitHub; the console never deploys | + +## For developers + +Loads: `GET /ops/releases` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ops-07.md b/docs/screens/ops-07.md new file mode 100644 index 0000000..897ef23 --- /dev/null +++ b/docs/screens/ops-07.md @@ -0,0 +1,44 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "OPS-07 Cost" +permalink: /screens/ops-07/ +nav_exclude: true +screen_id: OPS-07 +--- + +# Cost +{: .no_toc } + +Spend against the budgets. +{: .fs-6 .fw-300 } + +This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). +{: .note } + +| | | +|---|---| +| **Screen ID** | `OPS-07` | +| **Route** | `/ops/cost` | +| **Where** | Operations › Cost | +| **Who can use it** | operations.view | +| **Kind** | detail | +| **Available in** | Web Hub only (Console M3 (web only)) | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | +|---|---|---|---| +| Budget | progress bar | month-to-date and forecast vs warn and hard budgets | forecast over warn → warning banner | +| By service | bar chart + table | cost by AWS service, this month | | +| Anomalies | list | recent cost anomalies | | + +## Good to know + +- Show "Updated {time}" because the data is up to 6 hours old. + +## For developers + +Loads: `GET /ops/cost` (cached 6 h by the gateway) + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/org-01.md b/docs/screens/org-01.md new file mode 100644 index 0000000..34e4156 --- /dev/null +++ b/docs/screens/org-01.md @@ -0,0 +1,43 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ORG-01 Organizations" +permalink: /screens/org-01/ +nav_exclude: true +screen_id: ORG-01 +--- + +# Organizations +{: .no_toc } + +Organizations you belong to, plus pending invitations. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ORG-01` | +| **Route** | `/orgs` · deep link `substratal://orgs` | +| **Where** | Personal › Organizations | +| **Who can use it** | Signed in (all orgs with organizations.manage) | +| **Kind** | list | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Nested `AdwNavigationSplitView` list pane | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Pending invitations | section | my_membership_status = pending | Accept / Decline inline → ORG-09 | AdwPreferencesGroup | +| Rows | list | Organization: name, member_count, my_role, status | suspended orgs show badge | GtkListBox .boxed-list | +| Search | search | q | server-side when > 50 | GtkSearchEntry | +| New organization | primary-button | → ORG-02 | | GtkButton .suggested-action .pill | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /organizations` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/org-02.md b/docs/screens/org-02.md new file mode 100644 index 0000000..d8ca722 --- /dev/null +++ b/docs/screens/org-02.md @@ -0,0 +1,37 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ORG-02 New organization" +permalink: /screens/org-02/ +nav_exclude: true +screen_id: ORG-02 +--- + +# New organization +{: .no_toc } + +Create an organization; creator becomes org admin. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ORG-02` | +| **Route** | `/orgs/new` · deep link `substratal://orgs/new` | +| **Where** | Personal › Organizations | +| **Who can use it** | Any active user | +| **Kind** | form | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Name | text-field | body.name | required; ≤ 100 | AdwEntryRow | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Create | `POST /organizations (Idempotency-Key)` | 201 → ORG-03. 429 → rate limited. | + diff --git a/docs/screens/org-03.md b/docs/screens/org-03.md new file mode 100644 index 0000000..6055e7a --- /dev/null +++ b/docs/screens/org-03.md @@ -0,0 +1,50 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ORG-03 Organization — Members" +permalink: /screens/org-03/ +nav_exclude: true +screen_id: ORG-03 +--- + +# Organization — Members +{: .no_toc } + +See and manage members and pending invitations. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ORG-03` | +| **Route** | `/orgs/:orgId/members` · deep link `substratal://orgs/:orgId/members` | +| **Where** | Personal › Organizations | +| **Who can use it** | Member (read), org_admin / organizations.manage (write) | +| **Kind** | detail | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Content pane; `AdwViewSwitcher`; header-bar actions | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Header | avatar + name + id-chip + status | Organization | my_role shown as caption | GtkLabel .monospace + copy | +| Tabs | tabs | Members · Apps & seats · Subscription · Activity · Settings | Subscription tab = BIL-02 (org_admin / billing.manage only) | AdwViewSwitcher / AdwInlineViewSwitcher | +| Filter | chips | role, membership_status | | GtkToggleButton pills | +| Rows | list | OrganizationMember: display_name/email, role, membership_status, joined_at/invited_at | pending rows show Invited badge + invited date | GtkListBox .boxed-list | +| Invite | primary-button | → ORG-04 | org_admin only | GtkButton .suggested-action .pill | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Row menu | `→ ORG-05` | Change role · Remove · Withdraw invitation | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /organizations/{id}` · `GET /organizations/{id}/members` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/org-04.md b/docs/screens/org-04.md new file mode 100644 index 0000000..d1e6ca3 --- /dev/null +++ b/docs/screens/org-04.md @@ -0,0 +1,43 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ORG-04 Invite member" +permalink: /screens/org-04/ +nav_exclude: true +screen_id: ORG-04 +--- + +# Invite member +{: .no_toc } + +Invite people by email (or add an existing user, staff only). +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ORG-04` | +| **Route** | `/orgs/:orgId/members/invite` · deep link `substratal://orgs/:orgId/members/invite` | +| **Where** | Personal › Organizations | +| **Who can use it** | org_admin; organizations.manage may add by user_id | +| **Kind** | form | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Emails | token-field | body.email (one request per address) | paste a list separated by commas/newlines | custom entry with pills | +| Role | radio-group | body.role org_admin \ | member | GtkCheckButton group | +| Add existing user | user-picker | body.user_id | organizations.manage only | AdwDialog with GtkSearchEntry list | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Send invitations | `POST /organizations/{id}/members × n` | Per-address results summary; 409 → "already a member" per address. Responses look identical whether or not the address has an account (by design). | + +## Good to know + +- Existing Users also receive an `account.organization_invitation` platform alert (NTF-01, and a push where the Hub has a device). + diff --git a/docs/screens/org-05.md b/docs/screens/org-05.md new file mode 100644 index 0000000..b93eada --- /dev/null +++ b/docs/screens/org-05.md @@ -0,0 +1,39 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ORG-05 Member actions" +permalink: /screens/org-05/ +nav_exclude: true +screen_id: ORG-05 +--- + +# Member actions +{: .no_toc } + +Change a member's role or remove them. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ORG-05` | +| **Route** | `/orgs/:orgId/members/:userId` · deep link `substratal://orgs/:orgId/members/:userId` | +| **Where** | Personal › Organizations | +| **Who can use it** | org_admin / organizations.manage | +| **Kind** | confirm | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwAlertDialog` (`extra-child` for fields) | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Role | radio-group | body.role | | GtkCheckButton group | +| Remove from organization | destructive-button | DELETE …/members/{userId} | COPY confirm | GtkButton .destructive-action | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Change role | `PATCH /organizations/{id}/members/{userId}` | 409 → last admin can't be demoted (explain). | +| Remove | `DELETE /organizations/{id}/members/{userId}` | 409 → last admin can't leave/remove. | + diff --git a/docs/screens/org-06.md b/docs/screens/org-06.md new file mode 100644 index 0000000..75d66bb --- /dev/null +++ b/docs/screens/org-06.md @@ -0,0 +1,48 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ORG-06 Organization — Apps & seats" +permalink: /screens/org-06/ +nav_exclude: true +screen_id: ORG-06 +--- + +# Organization — Apps & seats +{: .no_toc } + +Org-wide app grants and who they include. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ORG-06` | +| **Route** | `/orgs/:orgId/apps` · deep link `substratal://orgs/:orgId/apps` | +| **Where** | Personal › Organizations | +| **Who can use it** | Member (read), org_admin (write) | +| **Kind** | tab | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | `AdwViewStack` page | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Rows | list | Entitlement (org): app, status, source (org_seat \ | subscription), member_scope, included_member_count, starts_at/ends_at | GtkListBox .boxed-list | +| Grant app | button | → ORG-07 | | GtkButton | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Toggle | `PATCH /entitlements/{id} {status}` | Off requires confirm (COPY). On platform-managed rows only active ⇄ disabled is allowed; anything else 409 entitlement_platform_managed. | +| Edit scope | `→ ORG-07 (org_seat) / BIL-02 (subscription)` | | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /organizations/{id}/entitlements` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/org-07.md b/docs/screens/org-07.md new file mode 100644 index 0000000..1f47356 --- /dev/null +++ b/docs/screens/org-07.md @@ -0,0 +1,42 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ORG-07 Grant app to organization / member scope" +permalink: /screens/org-07/ +nav_exclude: true +screen_id: ORG-07 +--- + +# Grant app to organization / member scope +{: .no_toc } + +Create an org-wide grant or change who it includes. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ORG-07` | +| **Route** | `/orgs/:orgId/apps/grant` · deep link `substratal://orgs/:orgId/apps/grant` | +| **Where** | Personal › Organizations | +| **Who can use it** | entitlements.manage or app owner; scope edits by org_admin | +| **Kind** | form | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| App | select | body.application_id | create only | AdwComboRow | +| Who gets access | radio-group | member_scope all_members \ | allowlist \ | GtkCheckButton group | +| Members | member-picker | member_overrides | visible for allowlist/denylist | AdwDialog with GtkSearchEntry list | +| Starts / Ends | date-fields | starts_at, ends_at | optional | GtkCalendar in popover | +| Order ID | text-field | order_id | optional; staff/app owner | AdwEntryRow | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Grant | `POST /organizations/{id}/entitlements (Idempotency-Key)` | 409 entitlement_already_exists → already granted (view it). 422 → field errors. | +| Save scope | `PATCH /entitlements/{id} {member_scope, member_overrides}` | | + diff --git a/docs/screens/org-08.md b/docs/screens/org-08.md new file mode 100644 index 0000000..7a85686 --- /dev/null +++ b/docs/screens/org-08.md @@ -0,0 +1,50 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ORG-08 Organization — Settings" +permalink: /screens/org-08/ +nav_exclude: true +screen_id: ORG-08 +--- + +# Organization — Settings +{: .no_toc } + +Rename, leave, set up SCIM provisioning, or (staff) suspend the organization. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ORG-08` | +| **Route** | `/orgs/:orgId/settings` · deep link `substratal://orgs/:orgId/settings` | +| **Where** | Personal › Organizations | +| **Who can use it** | org_admin (rename, SCIM), organizations.manage (suspend, SCIM), any member (leave) | +| **Kind** | settings | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwPreferencesPage` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Name | text-field | Organization.name | | AdwEntryRow | +| Leave organization | destructive-row | DELETE …/members/me | | AdwButtonRow .destructive-action | +| SCIM provisioning | section | GET …/scim-token → `created_at`, `created_by`, `last_used_at`, `base_url` | org_admin or organizations.manage; behind `scim` flag; 404 scim_token_not_found = "Not set up" | AdwPreferencesGroup | +| Set up / Rotate token | button | POST …/scim-token → token + base_url | rotate confirms first ("Your identity provider stops syncing until you paste the new token"); secret shown once (KEY-03 pattern) | AdwButtonRow | +| Turn off SCIM | destructive-row | DELETE …/scim-token | confirm; members already provisioned stay | AdwButtonRow .destructive-action | +| Suspend / Reactivate | destructive-row | PATCH {status, reason} | staff only; reason required | AdwButtonRow .destructive-action | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Rename | `PATCH /organizations/{id} {name}` | | +| Leave | `DELETE /organizations/{id}/members/me` | 409 last admin | +| Load SCIM status | `GET /organizations/{id}/scim-token` | 404 scim_token_not_found → "Not set up" with a Set up button | +| Create / rotate SCIM token | `POST /organizations/{id}/scim-token` | 201 → one-time secret sheet showing `base_url` and `token` with Copy; the previous token stops working at once | +| Turn off SCIM | `DELETE /organizations/{id}/scim-token` | 204 → "Not set up" | + +## Good to know + +- 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. + diff --git a/docs/screens/org-09.md b/docs/screens/org-09.md new file mode 100644 index 0000000..406ae32 --- /dev/null +++ b/docs/screens/org-09.md @@ -0,0 +1,42 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ORG-09 Invitation" +permalink: /screens/org-09/ +nav_exclude: true +screen_id: ORG-09 +--- + +# Invitation +{: .no_toc } + +Accept or decline an organization invitation. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ORG-09` | +| **Route** | `/orgs/:orgId/invitation` · deep link `substratal://orgs/:orgId/invitation` | +| **Where** | Personal › Organizations | +| **Who can use it** | Invited user | +| **Kind** | confirm | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwAlertDialog` (`extra-child` for fields) | + +**How you get here:** Invitation banner on APP-01; ORG-01 pending section; `account.organization_invitation` notification (NTF-01 / push action "Accept") + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Org name + inviter | text | Organization | | GtkLabel | +| Accept | primary-button | | | GtkButton .suggested-action .pill | +| Decline | button | | | GtkButton | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Accept | `POST /organizations/{id}/members/me/accept` | 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. | +| Decline | `DELETE /organizations/{id}/members/me` | | + diff --git a/docs/screens/rol-01.md b/docs/screens/rol-01.md new file mode 100644 index 0000000..a547022 --- /dev/null +++ b/docs/screens/rol-01.md @@ -0,0 +1,42 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ROL-01 Roles" +permalink: /screens/rol-01/ +nav_exclude: true +screen_id: ROL-01 +--- + +# Roles +{: .no_toc } + +Platform and app roles. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ROL-01` | +| **Route** | `/admin/roles` · deep link `substratal://admin/roles` | +| **Where** | Administration › Roles | +| **Who can use it** | users.list (read), roles.manage (write) | +| **Kind** | list | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Nested `AdwNavigationSplitView` list pane | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Scope | segmented | scope platform \ | {application_id} | linked GtkToggleButtons | +| Rows | list | Role: name, scope, seed, assignment_count, description | seed → lock icon | GtkListBox .boxed-list | +| New role | primary-button | → ROL-03 | roles.manage | GtkButton .suggested-action .pill | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /roles?scope` · `GET /permissions (includes notifications.send)` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/rol-02.md b/docs/screens/rol-02.md new file mode 100644 index 0000000..37604f9 --- /dev/null +++ b/docs/screens/rol-02.md @@ -0,0 +1,51 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ROL-02 Role detail" +permalink: /screens/rol-02/ +nav_exclude: true +screen_id: ROL-02 +--- + +# Role detail +{: .no_toc } + +View or edit a role's permissions. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ROL-02` | +| **Route** | `/admin/roles/:roleId` · deep link `substratal://admin/roles/:roleId` | +| **Where** | Administration › Roles | +| **Who can use it** | users.list (read), roles.manage (write) | +| **Kind** | detail | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Content pane; `AdwViewSwitcher`; header-bar actions | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Header | name, id-chip, scope, assignment_count | | | GtkLabel .monospace + copy | +| Description | text-field | Role.description | read-only when seed | AdwEntryRow | +| Permissions | checkbox-list grouped by prefix | Role.permissions ⊂ Permission[].key | read-only when seed; show Permission.description | AdwPreferencesGroup of check rows | +| Holders | link | → USR-01 filtered by `GET /users?role_id={id}` | explicit assignments only; for an app's `default_app_role`, add "and everyone with access to {app}" → ACX-05 filtered | GtkButton .flat / GtkLinkButton | +| Delete role | destructive-button | DELETE /roles/{id} | only non-seed with assignment_count 0 | GtkButton .destructive-action | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Save | `PATCH /roles/{id} {description, permissions} (If-Match)` | | +| Delete | `DELETE /roles/{id}` | 409 → still assigned | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /roles/{id}` · `GET /permissions?scope` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/rol-03.md b/docs/screens/rol-03.md new file mode 100644 index 0000000..145c961 --- /dev/null +++ b/docs/screens/rol-03.md @@ -0,0 +1,40 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ROL-03 New role" +permalink: /screens/rol-03/ +nav_exclude: true +screen_id: ROL-03 +--- + +# New role +{: .no_toc } + +Define a custom role. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ROL-03` | +| **Route** | `/admin/roles/new` · deep link `substratal://admin/roles/new` | +| **Where** | Administration › Roles | +| **Who can use it** | roles.manage | +| **Kind** | form | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Name | text-field | body.name | ^[a-z][a-z0-9_]{1,40}$; live validation | AdwEntryRow | +| Scope | select | body.scope | platform or an application | AdwComboRow | +| Description | text-area | body.description | | GtkTextView in .card | +| Permissions | checkbox-list | body.permissions | ≥ 1 | AdwPreferencesGroup of check rows | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Create | `POST /roles` | 409 → name taken in scope | + diff --git a/docs/screens/rol-04.md b/docs/screens/rol-04.md new file mode 100644 index 0000000..be183a1 --- /dev/null +++ b/docs/screens/rol-04.md @@ -0,0 +1,48 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "ROL-04 User — Roles" +permalink: /screens/rol-04/ +nav_exclude: true +screen_id: ROL-04 +--- + +# User — Roles +{: .no_toc } + +Assign and remove roles for a user. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `ROL-04` | +| **Route** | `/admin/users/:userId/roles` · deep link `substratal://admin/users/:userId/roles` | +| **Where** | Administration › Users | +| **Who can use it** | users.list (read), roles.manage (write) | +| **Kind** | tab | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwViewStack` page | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Rows | list | UserRoleAssignment: role, application_id, assigned_at, assigned_by, implicit | implicit → "Granted with app access", not removable | GtkListBox .boxed-list | +| Assign role | button → role picker | | | AdwDialog with GtkSearchEntry list | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Assign | `POST /users/{id}/roles/{roleId}` | idempotent; 200 already held | +| Remove | `DELETE /users/{id}/roles/{roleId}` | 409 → implicit role | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /users/{id}/roles` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/rpt-01.md b/docs/screens/rpt-01.md new file mode 100644 index 0000000..655ed93 --- /dev/null +++ b/docs/screens/rpt-01.md @@ -0,0 +1,45 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "RPT-01 Reports" +permalink: /screens/rpt-01/ +nav_exclude: true +screen_id: RPT-01 +--- + +# Reports +{: .no_toc } + +Growth, access, usage, subscribers and delivery over time. +{: .fs-6 .fw-300 } + +This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). +{: .note } + +| | | +|---|---| +| **Screen ID** | `RPT-01` | +| **Route** | `/admin/reports` | +| **Where** | Administration › Reports | +| **Who can use it** | users.list (platform reports); an Application owner sees only their own app | +| **Kind** | detail | +| **Available in** | Web Hub only (Console M4 (web only)) | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | +|---|---|---|---| +| Report | select | platform growth · access · usage · subscribers · delivery | subscribers needs [G20 #57](https://github.com/CompositeCode/substratalapps.com/issues/57) | +| Range | date range | | in the URL | +| Chart + table | chart, table | the report's series | accessible table view | +| Export | button | CSV of the loaded series | client-side | + +## Good to know + +- Revenue stays in Stripe; link to the Stripe dashboard instead of mirroring it. + +## For developers + +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. + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/sec-01.md b/docs/screens/sec-01.md new file mode 100644 index 0000000..bc7b656 --- /dev/null +++ b/docs/screens/sec-01.md @@ -0,0 +1,44 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "SEC-01 Security events" +permalink: /screens/sec-01/ +nav_exclude: true +screen_id: SEC-01 +--- + +# Security events +{: .no_toc } + +Spot attacks and lockouts. +{: .fs-6 .fw-300 } + +This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). +{: .note } + +| | | +|---|---| +| **Screen ID** | `SEC-01` | +| **Route** | `/ops/security` | +| **Where** | Operations › Security events | +| **Who can use it** | operations.view | +| **Kind** | table | +| **Available in** | Web Hub only (Console M3 (web only)) | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | +|---|---|---|---| +| Group by | segmented control | user · IP · key | | +| Rows | table | failed logins, lockouts, MFA challenges, refresh-token reuse, 403 denials, `destructive_operation_restricted`, `api_key_in_browser` | user → USR-03; key → KEY-04 | +| Actions | menu | → USR-05 Suspend · USR-04 Sign out everywhere · KEY-04 Revoke | each is the normal API call, audited with the person as actor | + +## Good to know + +- 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. + +## For developers + +Loads: `POST /ops/logs/query {template: security_events, range}` · `GET /ops/metrics?panel=security` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/set-01.md b/docs/screens/set-01.md new file mode 100644 index 0000000..26d31ee --- /dev/null +++ b/docs/screens/set-01.md @@ -0,0 +1,56 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "SET-01 Settings — Global" +permalink: /screens/set-01/ +nav_exclude: true +screen_id: SET-01 +--- + +# Settings — Global +{: .no_toc } + +Preferences that apply to every app. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `SET-01` | +| **Route** | `/settings` · deep link `substratal://settings` | +| **Where** | Personal › Settings | +| **Who can use it** | Self | +| **Kind** | settings | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwPreferencesPage` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Language | select | Settings.locale | BCP 47; default = OS language; changing re-localizes the UI live | AdwComboRow | +| Time zone | searchable select | Settings.timezone | IANA; default = OS zone; "Use device time zone" option | AdwComboRow enable-search | +| Appearance | segmented | Settings.theme light/dark/system | Light · Dark · Match system (default). Applies instantly; Match system follows OS changes live. See THEMES.md | linked GtkToggleButtons | +| Light theme | swatch picker | `app_hub` setting `appearance.light_theme` | 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" | GtkFlowBox of swatch GtkCheckButtons (grouped) in an AdwPreferencesGroup | +| Dark theme | swatch picker | `app_hub` setting `appearance.dark_theme` | 5 themes: Night Soil, Deep Teal, Graphite Spore, **Forest Floor (default)**, Ember Clay. Same behaviour as Light theme | GtkFlowBox of swatch GtkCheckButtons (grouped) in an AdwPreferencesGroup | +| Push notifications | toggle | Settings.notifications.push (missing = on) | 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" | AdwSwitchRow | +| Email notifications | toggle | Settings.notifications.email | | AdwSwitchRow | +| SMS notifications | toggle | Settings.notifications.sms | disabled until Profile.contact_phone set | AdwSwitchRow | +| Share usage analytics | toggle | client preference | default per platform policy; see CLIENT-ARCHITECTURE §10 | AdwSwitchRow | +| Scope list | list | Global + each app with active access | → SET-02 (desktop: middle column) | GtkListBox .boxed-list | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Change any row | `PATCH /users/me/settings (If-Match)` | Optimistic; revert + toast on error. | +| Choose a light or dark theme | `PATCH /users/me/apps/app_hub/settings (If-Match)` with `appearance.light_theme` / `appearance.dark_theme` | Apply instantly, cache locally, then sync; 409 → refetch and re-apply; 422 → revert + toast "Couldn't save your theme. Try again." | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /users/me/settings` · `GET /users/me/apps/app_hub/settings` (theme choices) + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/set-02.md b/docs/screens/set-02.md new file mode 100644 index 0000000..de6389d --- /dev/null +++ b/docs/screens/set-02.md @@ -0,0 +1,50 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "SET-02 Settings — Per app" +permalink: /screens/set-02/ +nav_exclude: true +screen_id: SET-02 +--- + +# Settings — Per app +{: .no_toc } + +Override global settings and set app-specific preferences from the app's settings_schema. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `SET-02` | +| **Route** | `/settings/apps/:appId` · deep link `substratal://settings/apps/:appId` | +| **Where** | Personal › Settings | +| **Who can use it** | Self, active entitlement | +| **Kind** | settings | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | `AdwPreferencesPage` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Schema form | generated rows | settings_schema properties × AppSettingsResolved.resolved | 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 | SchemaForm | +| Source caption | caption per row | AppSettingsResolved.sources[key] | app_override → "Changed for this app · Reset"; global → "Using your global setting"; app_default → "App default" | GtkLabel .caption .dim-label | +| Reset | link per overridden row | PATCH overrides {key:null} | | AdwActionRow activatable | +| Push notifications for this app | toggle | resolved notifications.push; override via overrides.notifications.push | source caption as other rows; Reset clears the override | AdwSwitchRow | +| Stale overrides | banner | AppSettingsResolved.stale_overrides | "Some saved settings no longer apply" → Clear | AdwBanner | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Change row | `PATCH /users/me/apps/{appId}/settings {overrides:{key:value}} (If-Match)` | 422 → field error from details (enum_mismatch, out_of_range, undeclared_key). | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /applications/{appId} (settings_schema)` · `GET /users/me/apps/{appId}/settings` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/usr-01.md b/docs/screens/usr-01.md new file mode 100644 index 0000000..50b5882 --- /dev/null +++ b/docs/screens/usr-01.md @@ -0,0 +1,52 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "USR-01 Users" +permalink: /screens/usr-01/ +nav_exclude: true +screen_id: USR-01 +--- + +# Users +{: .no_toc } + +Find and manage user accounts. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `USR-01` | +| **Route** | `/admin/users` · deep link `substratal://admin/users` | +| **Where** | Administration › Users | +| **Who can use it** | users.list (write actions need users.manage) | +| **Kind** | table | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `GtkColumnView` (.data-table) in a card | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Search | search | q (name, email, ID prefix usr_) | debounced 300 ms; exact ID jumps to USR-03 | GtkSearchEntry | +| Status filter | chips | status active \ | invited \ | GtkToggleButton pills | +| More filters | menu | application_id, organization_id, created range | | GtkMenuButton | +| Columns | table | display name + email, status, id, last_login_at, created_at | sortable client-side within loaded page; server order = created_at desc | GtkColumnView .data-table | +| Multi-select | selection | | bulk: Suspend, Resend invitation, Export CSV (client-side of loaded rows) | AdwComboRow | +| Invite user | primary-button | → USR-02 | users.manage | GtkButton .suggested-action .pill | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Open row | `→ USR-03` | | +| Row menu | | Suspend · Reactivate · Resend invitation · Copy ID · Copy email | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /users?limit=50&cursor&status&q&application_id&organization_id&created_after&created_before` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/usr-02.md b/docs/screens/usr-02.md new file mode 100644 index 0000000..6f821c4 --- /dev/null +++ b/docs/screens/usr-02.md @@ -0,0 +1,40 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "USR-02 Invite user" +permalink: /screens/usr-02/ +nav_exclude: true +screen_id: USR-02 +--- + +# Invite user +{: .no_toc } + +Create a user and send an invitation (or create active). +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `USR-02` | +| **Route** | `/admin/users/new` · deep link `substratal://admin/users/new` | +| **Where** | Administration › Users | +| **Who can use it** | users.manage | +| **Kind** | form | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Email | email-field | body.email | required | AdwEntryRow input-purpose=email | +| Display name | text-field | body.display_name | optional | AdwEntryRow | +| Send invitation email | toggle | body.send_invitation | default on | AdwSwitchRow | +| Create as active | toggle | body.status active\ | invited | AdwSwitchRow | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Invite | `POST /users (Idempotency-Key)` | 201 → USR-03. 409 → "A user with that email exists" + link. | + diff --git a/docs/screens/usr-03.md b/docs/screens/usr-03.md new file mode 100644 index 0000000..431ae0f --- /dev/null +++ b/docs/screens/usr-03.md @@ -0,0 +1,55 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "USR-03 User — Overview" +permalink: /screens/usr-03/ +nav_exclude: true +screen_id: USR-03 +--- + +# User — Overview +{: .no_toc } + +One user's account state and administrative actions. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `USR-03` | +| **Route** | `/admin/users/:userId` · deep link `substratal://admin/users/:userId` | +| **Where** | Administration › Users | +| **Who can use it** | users.list (read), users.manage (write) | +| **Kind** | detail | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | Content pane; `AdwViewSwitcher`; header-bar actions | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Header | avatar, display name, email, id-chip, StatusBadge | User + Profile | | GtkLabel .monospace + copy | +| Tabs | tabs | Overview · Access · Roles · Sessions · Notifications · Activity | Activity = AUD-01 filtered target_user_id; Notifications = NTF-05 | AdwViewSwitcher / AdwInlineViewSwitcher | +| Facts | key-value list | 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") | plan_sources link to the org or to BIL-01 subscription view (billing.manage) | AdwActionRow subtitle | +| Actions | toolbar/menu | | Suspend (USR-05) · Reactivate · Resend invitation · Reset two-factor · Export data · Delete · Restore · Cancel scheduled erasure | GtkMenuButton | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Reactivate / Restore | `PATCH /users/{id} {status:active} (If-Match)` | | +| Resend invitation | `POST /users/{id}/invitation` | 202 toast; 409 → not in invited state | +| Reset two-factor | `DELETE /users/{id}/mfa/totp` | confirm | +| Export | `GET /users/{id}/export` | save file | +| Delete | `DELETE /users/{id}` | confirm | +| Cancel erasure | `DELETE /users/{id}/erasure-requests/current` | visible during 7-day window | +| Extend (test-mode) | `PATCH /users/{id} {expires_at} (If-Match)` | 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. | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /users/{id}` · `GET /users/{id}/profile` · `GET /users/{id}/subscription (billing.manage only)` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/usr-04.md b/docs/screens/usr-04.md new file mode 100644 index 0000000..7a7da87 --- /dev/null +++ b/docs/screens/usr-04.md @@ -0,0 +1,47 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "USR-04 User — Sessions" +permalink: /screens/usr-04/ +nav_exclude: true +screen_id: USR-04 +--- + +# User — Sessions +{: .no_toc } + +See and revoke a user's sessions. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `USR-04` | +| **Route** | `/admin/users/:userId/sessions` · deep link `substratal://admin/users/:userId/sessions` | +| **Where** | Administration › Users | +| **Who can use it** | users.manage | +| **Kind** | tab | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwViewStack` page | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Rows | list | Session: user_agent, ip_address, amr, created_at, last_seen_at, expires_at | staff see full IP | GtkListBox .boxed-list | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Revoke | `DELETE /users/{id}/sessions/{sessionId}` | | +| Sign out everywhere | `POST /users/{id}/sessions/revoke-all` | Confirm (COPY "Sign out everywhere (staff)"). 200 `{revoked}` → toast; the list empties. Their account stays active. | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /users/{id}/sessions` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/usr-05.md b/docs/screens/usr-05.md new file mode 100644 index 0000000..6fa922b --- /dev/null +++ b/docs/screens/usr-05.md @@ -0,0 +1,41 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "USR-05 Suspend user" +permalink: /screens/usr-05/ +nav_exclude: true +screen_id: USR-05 +--- + +# Suspend user +{: .no_toc } + +Suspend an account (signs them out everywhere). +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `USR-05` | +| **Route** | `/admin/users/:userId/suspend` · deep link `substratal://admin/users/:userId/suspend` | +| **Where** | Administration › Users | +| **Who can use it** | users.manage | +| **Kind** | confirm | +| **Available in** | All Hub apps (MVP) | +| **On Linux** | `AdwAlertDialog` (`extra-child` for fields) | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Reason | text-area | body.reason | optional, written to audit | GtkTextView in .card | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Suspend | `POST /users/{id}/suspend` | 200 → badge Suspended. 409 → already suspended. | + +## Good to know + +- Suspension revokes sessions and deactivates every push device the user holds; say so in the confirm body. + diff --git a/docs/screens/whk-01.md b/docs/screens/whk-01.md new file mode 100644 index 0000000..a56f1d7 --- /dev/null +++ b/docs/screens/whk-01.md @@ -0,0 +1,41 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "WHK-01 Webhooks" +permalink: /screens/whk-01/ +nav_exclude: true +screen_id: WHK-01 +--- + +# Webhooks +{: .no_toc } + +Endpoints that receive access-change events. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `WHK-01` | +| **Route** | `/dev/webhooks` · deep link `substratal://dev/webhooks` | +| **Where** | Developer › Webhooks | +| **Who can use it** | webhooks.manage or app owner | +| **Kind** | list | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | Nested `AdwNavigationSplitView` list pane | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Rows | list | Webhook: url, events count, status healthy\ | unhealthy\ | GtkListBox .boxed-list | +| Add webhook | primary-button | → WHK-02 | | GtkButton .suggested-action .pill | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /webhooks?application_id` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/whk-02.md b/docs/screens/whk-02.md new file mode 100644 index 0000000..3c8a912 --- /dev/null +++ b/docs/screens/whk-02.md @@ -0,0 +1,43 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "WHK-02 Add / edit webhook" +permalink: /screens/whk-02/ +nav_exclude: true +screen_id: WHK-02 +--- + +# Add / edit webhook +{: .no_toc } + +Subscribe a URL to events. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `WHK-02` | +| **Route** | `/dev/webhooks/new` · deep link `substratal://dev/webhooks/new` | +| **Where** | Developer › Webhooks | +| **Who can use it** | webhooks.manage or app owner | +| **Kind** | form | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| URL | url-field | url | https required | AdwEntryRow input-purpose=url | +| Events | checkbox-list grouped | events (WebhookEvent enum: access.*, entitlement.*, role.*, user.*, organization.*, application.*, device.*, notification.push_failed, push_credential.failed, webhook.test) | presets: Access changes (access.granted, access.revoked) · Push health (notification.push_failed, push_credential.failed, device.deactivated) | AdwPreferencesGroup of check rows | +| Description | text-field | description | | AdwEntryRow | +| Scope | select | scope | | AdwComboRow | +| Payload version | select | api_version (a date) | edit only; the webhook payload version, independent of the `Substratal-Version` request header (CLIENT-ARCHITECTURE §4.1) | AdwComboRow | +| Enabled | toggle | status disabled ↔ healthy | edit only (re-enable) | AdwSwitchRow | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Create | `POST /webhooks` | 201 WebhookWithSecret → KEY-03 | +| Save | `PATCH /webhooks/{id}` | | + diff --git a/docs/screens/whk-03.md b/docs/screens/whk-03.md new file mode 100644 index 0000000..8c45359 --- /dev/null +++ b/docs/screens/whk-03.md @@ -0,0 +1,51 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "WHK-03 Webhook — Deliveries" +permalink: /screens/whk-03/ +nav_exclude: true +screen_id: WHK-03 +--- + +# Webhook — Deliveries +{: .no_toc } + +Delivery history, testing and secret rotation. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `WHK-03` | +| **Route** | `/dev/webhooks/:hookId` · deep link `substratal://dev/webhooks/:hookId` | +| **Where** | Developer › Webhooks | +| **Who can use it** | webhooks.manage or app owner | +| **Kind** | detail | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | Content pane; `AdwViewSwitcher`; header-bar actions | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Header actions | toolbar | | Send test · Rotate secret · Edit · Delete | AdwHeaderBar buttons | +| Filter | chips | status pending\ | succeeded\ | GtkToggleButton pills | +| Rows | table | WebhookDelivery: status, response_status, event_type, event_id, attempt, duration_ms, created_at, next_retry_at, error | failed → Redeliver | GtkColumnView .data-table | + +## What happens when you act + +| Action | API call | Result | +|---|---|---| +| Send test | `POST /webhooks/{id}/test` | 202 → new pending row appears on refresh (poll 2 s × 5) | +| Rotate secret | `POST /webhooks/{id}/rotate-secret` | confirm (24 h overlap) → KEY-03 | +| Redeliver | `POST /webhooks/{id}/deliveries/{deliveryId}/redeliver` | 202 toast; same event_id | +| Delete | `DELETE /webhooks/{id}` | confirm | + +## Good to know + +- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES + +## For developers + +Loads: `GET /webhooks/{id}` · `GET /webhooks/{id}/deliveries?status&since&event_id` + +Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/whk-04.md b/docs/screens/whk-04.md new file mode 100644 index 0000000..bda540a --- /dev/null +++ b/docs/screens/whk-04.md @@ -0,0 +1,32 @@ +--- +# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. +layout: default +title: "WHK-04 Delivery detail" +permalink: /screens/whk-04/ +nav_exclude: true +screen_id: WHK-04 +--- + +# Delivery detail +{: .no_toc } + +One delivery attempt in detail. +{: .fs-6 .fw-300 } + +| | | +|---|---| +| **Screen ID** | `WHK-04` | +| **Route** | `/dev/webhooks/:hookId/deliveries/:deliveryId` · deep link `substratal://dev/webhooks/:hookId/deliveries/:deliveryId` | +| **Where** | Developer › Webhooks | +| **Who can use it** | webhooks.manage or app owner | +| **Kind** | popover | +| **Available in** | All Hub apps (Phase 2) | +| **On Linux** | `GtkPopover` or `AdwOverlaySplitView` sidebar | + +## What's on this screen + +| Element | What it is | Shows / does | Rules | On Linux | +|---|---|---|---|---| +| Facts | key-value | all WebhookDelivery fields | | AdwActionRow subtitle | +| Redeliver | button | | | GtkButton | + diff --git a/docs/tutorials/api-keys.md b/docs/tutorials/api-keys.md new file mode 100644 index 0000000..46a9a00 --- /dev/null +++ b/docs/tutorials/api-keys.md @@ -0,0 +1,38 @@ +--- +layout: default +title: Create, use and rotate an API key +parent: Tutorials +nav_order: 2 +permalink: /tutorials/api-keys/ +--- + +# Create, use and rotate an API key +{: .no_toc } + +**You need:** to own the app (for an app-scoped key) or hold `api_keys.manage`. + +## Create it + +1. **Developer › API Keys › New key** (Ctrl+N). +2. Name it after what will use it ("Notes backend, production"). +3. Pick the scope (your app, or platform), **live** or **test**, and **service** or **agent**. For an AI agent, keep **Restrict destructive actions** on. +4. Tick only the permissions it needs. +5. **Create**. The secret appears once. Copy it into your secret manager (for example AWS Secrets Manager), then confirm you've saved it. + +## Use it + +From your server, never from a browser: + +```sh +curl -s https://api.substratalapps.com/users/usr_123/apps/app_notes/effective-permissions \ + -H "Authorization: Bearer $SUBSTRATAL_KEY" \ + -H "Substratal-Version: 1" +``` + +## Rotate it + +Open the key and select **Rotate**. The new secret is shown once, and the old one stops working. Update your secret store first, then deploy. **Revoke** turns a key off for good. + +## Keep keys tidy + +The key list shows when each key was last used and when it expires. Revoke keys nobody uses. diff --git a/docs/tutorials/applications.md b/docs/tutorials/applications.md new file mode 100644 index 0000000..1ed5e64 --- /dev/null +++ b/docs/tutorials/applications.md @@ -0,0 +1,28 @@ +--- +layout: default +title: Register, review and suspend an application +parent: Tutorials +nav_order: 3 +permalink: /tutorials/applications/ +--- + +# Register, review and suspend an application +{: .no_toc } + +## Register (developers) + +1. **Developer › Applications › Register app.** You need a verified email. +2. Give it a name, a slug, a launch URL and an icon. The registration is **Pending review**. +3. While you wait, create a **test** API key and build against test-mode users. + +## Review (staff with `applications.manage`) + +1. Open [Review]({{ '/screens/apl-06/' | relative_url }}). The queue is oldest first. +2. **Approve** to put the app in the catalog, or **Reject** with notes (required). The developer edits and resubmits. Every decision is in the audit log as `application.review_status_changed`. + +## Suspend + +If an approved app misbehaves, **Suspend** it from its review panel, with notes saying why. Suspension removes the app from the catalog, blocks new grants and stops new launches. People who already have access keep it, and open sessions aren't cut off; turning off existing people's access is a separate, deliberate step in [Access]({{ '/screens/acx-05/' | relative_url }}). **Reinstate** puts it back. + +Retiring or deleting an application isn't available yet: suspension is the off switch today. +{: .note } diff --git a/docs/tutorials/cli.md b/docs/tutorials/cli.md new file mode 100644 index 0000000..ad08f85 --- /dev/null +++ b/docs/tutorials/cli.md @@ -0,0 +1,49 @@ +--- +layout: default +title: Use substratalctl in scripts and CI +parent: Tutorials +nav_order: 5 +permalink: /tutorials/cli/ +--- + +# Use substratalctl in scripts and CI +{: .no_toc } + +`substratalctl` is the Hub's command-line companion. It shares the Linux app's code, so it behaves the same: same API version header, same sign-in, same errors. +{: .fs-6 .fw-300 } + +## Check the API + +```sh +$ substratalctl health +ok +$ substratalctl version +substratalctl 0.1.0 +API version Substratal-Version: 1 (header, not in the URL) +API base URL https://api.substratalapps.com/ +``` + +## Sign in + +```sh +substratalctl login --email you@example.com # prompts for the password, then a 2FA code if needed +substratalctl whoami +``` + +On a desktop the session goes into your keyring. On a server with no keyring, add `--no-keyring` and sign in within the same command run, or use an API key with `curl` instead. + +## Find a screen, or its help + +```sh +$ substratalctl route substratal://admin/users/usr_42 +USR-03 User — Overview (/admin/users/:userId) + userId = usr_42 + help: https://compositecode.github.io/substratalapps-linux/screens/usr-03/ +$ substratalctl screens --json | jq length +``` + +## Point at another environment + +`--api local` (or `SUBSTRATAL_API=local`) uses the API's devserver on `localhost:8080`. Any URL works too. + +Every command and flag is in the [reference]({{ '/reference/cli/' | relative_url }}). diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md new file mode 100644 index 0000000..b6c07f5 --- /dev/null +++ b/docs/tutorials/index.md @@ -0,0 +1,13 @@ +--- +layout: default +title: Tutorials +nav_order: 4 +has_children: true +permalink: /tutorials/ +--- + +# Tutorials +{: .no_toc } + +Step-by-step walkthroughs of common tasks, in the app and against the API. +{: .fs-6 .fw-300 } diff --git a/docs/tutorials/invite-and-grant.md b/docs/tutorials/invite-and-grant.md new file mode 100644 index 0000000..e079327 --- /dev/null +++ b/docs/tutorials/invite-and-grant.md @@ -0,0 +1,38 @@ +--- +layout: default +title: Invite someone and give them an app +parent: Tutorials +nav_order: 1 +permalink: /tutorials/invite-and-grant/ +--- + +# Invite someone and give them an app +{: .no_toc } + +**You need:** `users.manage` to invite, and `entitlements.manage` (or owning the app) to grant access. + +1. Go to **Administration › Users** (or press Ctrl+K and type `users`). +2. Select **Invite user**, enter their email, and send. They appear with status **Invited** and get an email with a link that's valid for 7 days. +3. Open their row, then the **Access** tab, and select **Grant access**. +4. Choose the app and how they get it (an admin grant, a trial with an end date, …), then **Grant**. +5. The access switch shows **Active**, or **Scheduled** if you set a start date in the future. + +To take it away later, turn the switch off. You have 10 seconds to undo (Ctrl+Z). + +## The same with the API + +```sh +API=https://api.substratalapps.com +H=(-H "Authorization: Bearer $TOKEN" -H "Substratal-Version: 1" -H "Content-Type: application/json") + +# 1. Invite (Idempotency-Key makes a retry safe) +curl -s "${H[@]}" -H "Idempotency-Key: $(uuidgen)" \ + -d '{"email":"dana@example.com"}' "$API/users" + +# 2. Grant an app (Idempotency-Key is required here) +curl -s "${H[@]}" -H "Idempotency-Key: $(uuidgen)" \ + -d '{"application_id":"app_notes","source":"admin_grant"}' \ + "$API/users/usr_123/entitlements" +``` + +Request bodies are in the [API reference]({{ '/api/reference/' | relative_url }}) under `POST /users` and `POST /users/{id}/entitlements`. diff --git a/docs/tutorials/request-id.md b/docs/tutorials/request-id.md new file mode 100644 index 0000000..5422fe2 --- /dev/null +++ b/docs/tutorials/request-id.md @@ -0,0 +1,24 @@ +--- +layout: default +title: Trace a problem with a request ID +parent: Tutorials +nav_order: 4 +permalink: /tutorials/request-id/ +--- + +# Trace a problem with a request ID +{: .no_toc } + +Every API response carries an `X-Request-Id` (`req_…`). When something fails, it's how support finds exactly what happened. + +1. When the Hub shows an error, open **Details**. It shows the error code and the request ID. +2. Copy the request ID and send it to support, or, with `audit.view`, search the [Audit log]({{ '/screens/aud-01/' | relative_url }}). Changes made by that request carry the same ID. +3. Operators can look the request up in the web Hub's [Logs]({{ '/screens/ops-03/' | relative_url }}). + +`substratalctl` prints the request ID on every failure: + +```text +$ substratalctl users list +substratalctl: 403 forbidden: … +request id: req_01M5MR798VSPN5PFEFT4XHNG7M +``` diff --git a/docs/versions/index.md b/docs/versions/index.md new file mode 100644 index 0000000..ef2618b --- /dev/null +++ b/docs/versions/index.md @@ -0,0 +1,29 @@ +--- +layout: default +title: Docs versions +nav_order: 11 +permalink: /versions/ +--- + +# Versions +{: .no_toc } + +Every released version of this help center, including the API reference as it was then. Each time the site is published, it gets a new version, and a frozen, read-only copy of it is kept here. A link to an old version keeps showing exactly what that version said. +{: .fs-6 .fw-300 } + +This is the version of the **help center**. It is separate from the API's own version, the `Substratal-Version` header, which is covered in [API versioning](../api/versioning/). For what changed, see the [app changelog](../changelog/) and the [API changelog](../api/changelog/). + +--- + +{% assign current = site.data.versions.current -%} +{% if current -%} +| Version | Released | Change | +|---|---|---| +{% assign listed = site.data.versions.archive | unshift: current -%} +{% for v in listed -%} +| [v{{ v.version }}]({{ v.version }}/){% if forloop.first %} **(current)**{% endif %} | {{ v.released }} | {{ v.summary | default: "" | replace: "|", "\|" }}{% if v.commit %} ([`{{ v.commit | slice: 0, 7 }}`](https://github.com/CompositeCode/substratalapps-linux/commit/{{ v.commit }})){% endif %} | +{% endfor %} +{% else %} +This is a local build. Version numbers and the archive are assigned only when the site is built and published on GitHub. +{: .note } +{% endif %} 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..0bcb447 --- /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 help center's [API versioning](../docs/api/versioning.md) 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/docs-build.rb b/scripts/docs-build.rb new file mode 100755 index 0000000..ed5f29c --- /dev/null +++ b/scripts/docs-build.rb @@ -0,0 +1,130 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Versioned build of the docs site. Run by .github/workflows/docs.yml on every +# push to main that touches docs/, and runnable locally the same way. +# +# scripts/docs-build.rb --archive DIR --out DIR [--bump patch|minor|major] +# [--commit SHA] [--summary TEXT] [--released YYYY-MM-DD] +# +# --archive is a checkout of the `docs-versions` branch (an empty dir is fine +# for the very first build). It holds manifest.yml plus one frozen static +# build per released version, at /. +# +# Each run: +# 1. Picks the next version: 1.0.0 for an empty archive, otherwise the +# newest archived version bumped by --bump (default patch). +# 2. Writes docs/_data/versions.yml, which the header badge and the +# Versions page read. +# 3. Builds this version as a frozen snapshot (baseurl /versions//, +# labelled archived) into // and records it in the manifest. +# 4. Builds the live site into --out, then copies every archived snapshot +# into /versions//. +# +# Committing the archive dir back to docs-versions and deploying --out are +# the caller's job (the workflow does both). + +require "date" +require "fileutils" +require "optparse" +require "tmpdir" +require "yaml" + +ROOT = File.expand_path("..", __dir__) +DOCS = File.join(ROOT, "docs") +DATA_FILE = File.join(DOCS, "_data", "versions.yml") +BUMPS = %w[patch minor major].freeze + +def abort_with(msg) + warn "docs-build: #{msg}" + exit 1 +end + +opts = { bump: "patch", released: Date.today.iso8601 } +OptionParser.new do |o| + o.on("--archive DIR") { |v| opts[:archive] = File.expand_path(v) } + o.on("--out DIR") { |v| opts[:out] = File.expand_path(v) } + o.on("--bump LEVEL", BUMPS) { |v| opts[:bump] = v } + o.on("--commit SHA") { |v| opts[:commit] = v } + o.on("--summary TEXT") { |v| opts[:summary] = v } + o.on("--released DATE") { |v| opts[:released] = v } +end.parse! +abort_with "--archive and --out are required" unless opts[:archive] && opts[:out] + +def next_version(latest, bump) + return "1.0.0" unless latest + + major, minor, patch = latest.split(".").map(&:to_i) + case bump + when "major" then "#{major + 1}.0.0" + when "minor" then "#{major}.#{minor + 1}.0" + else "#{major}.#{minor}.#{patch + 1}" + end +end + +def jekyll_build(dest, overlay = nil) + configs = ["_config.yml", overlay].compact.join(",") + Dir.chdir(DOCS) do + ok = system("bundle", "exec", "jekyll", "build", "--config", configs, "--destination", dest) + abort_with "jekyll build failed (#{dest})" unless ok + end +end + +FileUtils.mkdir_p(opts[:archive]) +manifest_path = File.join(opts[:archive], "manifest.yml") +manifest = File.exist?(manifest_path) ? (YAML.safe_load(File.read(manifest_path)) || {}) : {} +versions = manifest["versions"] || [] +versions.sort_by! { |v| Gem::Version.new(v["version"]) }.reverse! + +version = next_version(versions.first&.fetch("version"), opts[:bump]) +abort_with "#{opts[:archive]}/#{version} already exists" if File.exist?(File.join(opts[:archive], version)) + +current = { "version" => version, "released" => opts[:released] } +current["commit"] = opts[:commit] if opts[:commit] +current["summary"] = opts[:summary].lines.first.strip if opts[:summary] && !opts[:summary].strip.empty? + +FileUtils.mkdir_p(File.dirname(DATA_FILE)) +File.write(DATA_FILE, <<~HEAD + { "current" => current, "archive" => versions }.to_yaml.sub(/\A---\n/, "")) + # GENERATED by scripts/docs-build.rb on every GitHub build. Not committed; + # edits here are overwritten. The real history lives on the docs-versions + # branch (manifest.yml). +HEAD +puts "docs-build: building v#{version} (#{opts[:bump]} bump from #{versions.first&.fetch('version') || 'nothing'})" + +config = YAML.safe_load(File.read(File.join(DOCS, "_config.yml"))) + +Dir.mktmpdir("docs-build-") do |tmp| + # 1. Frozen snapshot of this version. + overlay = File.join(tmp, "_config.archive.yml") + snapshot = File.join(tmp, "snapshot") + File.write(overlay, { + "baseurl" => "#{config.fetch('baseurl')}/versions/#{version}", + "version_archive_build" => true, + # A snapshot never contains the archive itself. + "exclude" => (config["exclude"] || []) + ["versions"], + }.to_yaml) + jekyll_build(snapshot, overlay) + # Site-root-only files mean nothing inside a subdirectory. + %w[robots.txt sitemap.xml 404.html].each { |f| FileUtils.rm_f(File.join(snapshot, f)) } + FileUtils.cp_r(snapshot, File.join(opts[:archive], version)) +end + +versions.unshift(current) +File.write(manifest_path, <<~HEAD + { "versions" => versions }.to_yaml.sub(/\A---\n/, "")) + # Every released version of the docs site, newest first. Written by + # scripts/docs-build.rb on main's GitHub build; each entry has a frozen + # static copy at / on this branch, served at /versions//. +HEAD + +# 2. Live site, with every archived snapshot alongside it. +FileUtils.rm_rf(opts[:out]) +jekyll_build(opts[:out]) +versions.each do |v| + src = File.join(opts[:archive], v["version"]) + abort_with "manifest lists v#{v['version']} but #{src} is missing" unless File.directory?(src) + FileUtils.mkdir_p(File.join(opts[:out], "versions")) + FileUtils.cp_r(src, File.join(opts[:out], "versions", v["version"])) +end + +File.write(ENV["GITHUB_OUTPUT"], "version=#{version}\n", mode: "a") if ENV["GITHUB_OUTPUT"] +puts "docs-build: v#{version} built into #{opts[:out]} with #{versions.size} archived version(s)" 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" } } From d91845717a10e4e3d69f14029eabfe7f7a5a98ea Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 23 Oct 2026 09:20:12 -0700 Subject: [PATCH 2/4] Build the Flatpak on pull requests that change packaging Co-Authored-By: Claude Opus 5.5 --- .github/workflows/flatpak.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/flatpak.yml b/.github/workflows/flatpak.yml index 5aaa25e..9ea56c2 100644 --- a/.github/workflows/flatpak.yml +++ b/.github/workflows/flatpak.yml @@ -7,6 +7,8 @@ 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: From dae946e69de3def1f3b4713ec5c9386ebedbf4b2 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 23 Oct 2026 09:24:29 -0700 Subject: [PATCH 3/4] Make the keyring feature opt-in per crate The Flatpak SDK's cargo rejects overriding default-features of a workspace dependency, so the workspace now declares substratal-core without default features and the app and CLI enable keyring. Co-Authored-By: Claude Opus 5.5 --- Cargo.toml | 4 ++-- crates/substratal-hub/Cargo.toml | 2 +- crates/substratalctl/Cargo.toml | 2 +- crates/xtask/Cargo.toml | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a4c04f3..779c2e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,8 +11,8 @@ repository = "https://github.com/CompositeCode/substratalapps-linux" rust-version = "1.85" [workspace.dependencies] -substratal-core = { path = "crates/substratal-core" } -substratalctl = { path = "crates/substratalctl" } +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"] } diff --git a/crates/substratal-hub/Cargo.toml b/crates/substratal-hub/Cargo.toml index 184e78b..5d3366b 100644 --- a/crates/substratal-hub/Cargo.toml +++ b/crates/substratal-hub/Cargo.toml @@ -12,7 +12,7 @@ name = "substratal-hub" path = "src/main.rs" [dependencies] -substratal-core.workspace = true +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 diff --git a/crates/substratalctl/Cargo.toml b/crates/substratalctl/Cargo.toml index 9cfe437..b7af50c 100644 --- a/crates/substratalctl/Cargo.toml +++ b/crates/substratalctl/Cargo.toml @@ -19,7 +19,7 @@ default = ["keyring"] keyring = ["substratal-core/keyring"] [dependencies] -substratal-core = { workspace = true, default-features = false } +substratal-core.workspace = true anyhow.workspace = true clap.workspace = true serde_json.workspace = true diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml index 3265bb3..2530736 100644 --- a/crates/xtask/Cargo.toml +++ b/crates/xtask/Cargo.toml @@ -6,8 +6,8 @@ edition.workspace = true publish = false [dependencies] -substratal-core = { workspace = true, default-features = false } -substratalctl = { workspace = true, default-features = false } +substratal-core.workspace = true +substratalctl.workspace = true anyhow.workspace = true clap.workspace = true clap-markdown = "0.1" From c6f6a86cff2b782c10f16c0bf8603810ef79006d Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 23 Oct 2026 16:15:42 -0700 Subject: [PATCH 4/4] Move the help docs to the Hub help center in the specs repo The Hub help center (help/ in CompositeCode/substratalapps-applications-specs) is the one documentation site for every Hub app, so this repo no longer builds its own: - Remove docs/, scripts/docs-build.rb and the Pages deploy workflow. - Help links (F1, Help buttons, About, AppStream) open .../substratalapps-applications-specs/help/, screens at help/screens/.html. - xtask gen-docs becomes gen-help: it writes the Linux pages that come from this code (keyboard shortcuts, substratalctl) into the specs repo's help/. The Linux guide and these pages are in substratalapps-applications-specs PR #3. - New docs-dispatch workflow: on push to main, regenerate those pages, open a PR in the specs repo and send app-docs-updated (needs SPECS_DOCS_TOKEN). - API spec sync now only updates vendor/openapi.yaml (with the oasdiff changelog in the PR); the help center builds its API reference from the live spec. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/api-spec-sync.yml | 53 +- .github/workflows/ci.yml | 25 +- .github/workflows/docs-dispatch.yml | 79 + .github/workflows/docs.yml | 130 - .github/workflows/specs-sync.yml | 9 +- .gitignore | 6 +- Cargo.lock | 27 - README.md | 14 +- crates/substratal-core/src/config.rs | 13 +- crates/xtask/Cargo.toml | 2 - crates/xtask/src/main.rs | 580 +-- data/com.substratalapps.Hub.metainfo.xml | 4 +- docs/Gemfile | 7 - docs/Gemfile.lock | 294 -- docs/_config.yml | 69 - docs/_data/api.yml | 10 - docs/_includes/header_custom.html | 24 - docs/_sass/color_schemes/substratal.scss | 21 - docs/_sass/custom/custom.scss | 144 - docs/api/authentication.md | 47 - docs/api/changelog.md | 27 - docs/api/index.md | 48 - docs/api/operations.md | 217 -- docs/api/reference.md | 78 - docs/api/v1/openapi.yaml | 4446 ---------------------- docs/api/versioning.md | 74 - docs/assets/images/APP-01-home.png | Bin 265933 -> 0 bytes docs/assets/images/mark.svg | 1 - docs/changelog.md | 15 - docs/contributing.md | 62 - docs/faq.md | 56 - docs/getting-started/index.md | 17 - docs/getting-started/install.md | 42 - docs/getting-started/sign-in.md | 30 - docs/getting-started/tour.md | 40 - docs/guides/administration.md | 46 - docs/guides/console.md | 29 - docs/guides/developer.md | 50 - docs/guides/index.md | 19 - docs/guides/personal.md | 40 - docs/index.md | 40 - docs/reference/cli.md | 156 - docs/reference/errors.md | 30 - docs/reference/index.md | 13 - docs/reference/linking.md | 35 - docs/reference/navigation.md | 51 - docs/reference/shortcuts.md | 49 - docs/reference/statuses.md | 28 - docs/screens.json | 1133 ------ docs/screens/acc-01.md | 56 - docs/screens/acc-02.md | 38 - docs/screens/acc-03.md | 39 - docs/screens/acc-04.md | 53 - docs/screens/acc-05.md | 44 - docs/screens/acc-06.md | 38 - docs/screens/acc-07.md | 38 - docs/screens/acc-08.md | 44 - docs/screens/acx-01.md | 53 - docs/screens/acx-02.md | 41 - docs/screens/acx-03.md | 48 - docs/screens/acx-04.md | 42 - docs/screens/acx-05.md | 48 - docs/screens/apl-01.md | 41 - docs/screens/apl-02.md | 46 - docs/screens/apl-03.md | 50 - docs/screens/apl-04.md | 40 - docs/screens/apl-05.md | 39 - docs/screens/apl-06.md | 38 - docs/screens/apl-07.md | 56 - docs/screens/app-01.md | 57 - docs/screens/app-02.md | 46 - docs/screens/app-03.md | 42 - docs/screens/app-04.md | 46 - docs/screens/app-05.md | 39 - docs/screens/aud-01.md | 43 - docs/screens/aud-02.md | 41 - docs/screens/auth-01.md | 50 - docs/screens/auth-02.md | 46 - docs/screens/auth-03.md | 46 - docs/screens/auth-04.md | 42 - docs/screens/auth-05.md | 37 - docs/screens/auth-06.md | 41 - docs/screens/auth-07.md | 40 - docs/screens/bil-01.md | 47 - docs/screens/bil-02.md | 52 - docs/screens/bil-03.md | 39 - docs/screens/bil-04.md | 50 - docs/screens/cfg-01.md | 43 - docs/screens/cfg-02.md | 38 - docs/screens/glb-01.md | 30 - docs/screens/glb-02.md | 31 - docs/screens/glb-03.md | 48 - docs/screens/index.md | 179 - docs/screens/int-01.md | 43 - docs/screens/key-01.md | 42 - docs/screens/key-02.md | 43 - docs/screens/key-03.md | 31 - docs/screens/key-04.md | 51 - docs/screens/ntf-01.md | 56 - docs/screens/ntf-02.md | 51 - docs/screens/ntf-03.md | 53 - docs/screens/ntf-04.md | 47 - docs/screens/ntf-05.md | 43 - docs/screens/ntf-06.md | 47 - docs/screens/ops-01.md | 47 - docs/screens/ops-02.md | 44 - docs/screens/ops-03.md | 44 - docs/screens/ops-04.md | 40 - docs/screens/ops-05.md | 39 - docs/screens/ops-06.md | 40 - docs/screens/ops-07.md | 44 - docs/screens/org-01.md | 43 - docs/screens/org-02.md | 37 - docs/screens/org-03.md | 50 - docs/screens/org-04.md | 43 - docs/screens/org-05.md | 39 - docs/screens/org-06.md | 48 - docs/screens/org-07.md | 42 - docs/screens/org-08.md | 50 - docs/screens/org-09.md | 42 - docs/screens/rol-01.md | 42 - docs/screens/rol-02.md | 51 - docs/screens/rol-03.md | 40 - docs/screens/rol-04.md | 48 - docs/screens/rpt-01.md | 45 - docs/screens/sec-01.md | 44 - docs/screens/set-01.md | 56 - docs/screens/set-02.md | 50 - docs/screens/usr-01.md | 52 - docs/screens/usr-02.md | 40 - docs/screens/usr-03.md | 55 - docs/screens/usr-04.md | 47 - docs/screens/usr-05.md | 41 - docs/screens/whk-01.md | 41 - docs/screens/whk-02.md | 43 - docs/screens/whk-03.md | 51 - docs/screens/whk-04.md | 32 - docs/tutorials/api-keys.md | 38 - docs/tutorials/applications.md | 28 - docs/tutorials/cli.md | 49 - docs/tutorials/index.md | 13 - docs/tutorials/invite-and-grant.md | 38 - docs/tutorials/request-id.md | 24 - docs/versions/index.md | 29 - planning/PREREQUISITES.md | 2 +- scripts/docs-build.rb | 130 - 146 files changed, 191 insertions(+), 12632 deletions(-) create mode 100644 .github/workflows/docs-dispatch.yml delete mode 100644 .github/workflows/docs.yml delete mode 100644 docs/Gemfile delete mode 100644 docs/Gemfile.lock delete mode 100644 docs/_config.yml delete mode 100644 docs/_data/api.yml delete mode 100644 docs/_includes/header_custom.html delete mode 100644 docs/_sass/color_schemes/substratal.scss delete mode 100644 docs/_sass/custom/custom.scss delete mode 100644 docs/api/authentication.md delete mode 100644 docs/api/changelog.md delete mode 100644 docs/api/index.md delete mode 100644 docs/api/operations.md delete mode 100644 docs/api/reference.md delete mode 100644 docs/api/v1/openapi.yaml delete mode 100644 docs/api/versioning.md delete mode 100644 docs/assets/images/APP-01-home.png delete mode 100644 docs/assets/images/mark.svg delete mode 100644 docs/changelog.md delete mode 100644 docs/contributing.md delete mode 100644 docs/faq.md delete mode 100644 docs/getting-started/index.md delete mode 100644 docs/getting-started/install.md delete mode 100644 docs/getting-started/sign-in.md delete mode 100644 docs/getting-started/tour.md delete mode 100644 docs/guides/administration.md delete mode 100644 docs/guides/console.md delete mode 100644 docs/guides/developer.md delete mode 100644 docs/guides/index.md delete mode 100644 docs/guides/personal.md delete mode 100644 docs/index.md delete mode 100644 docs/reference/cli.md delete mode 100644 docs/reference/errors.md delete mode 100644 docs/reference/index.md delete mode 100644 docs/reference/linking.md delete mode 100644 docs/reference/navigation.md delete mode 100644 docs/reference/shortcuts.md delete mode 100644 docs/reference/statuses.md delete mode 100644 docs/screens.json delete mode 100644 docs/screens/acc-01.md delete mode 100644 docs/screens/acc-02.md delete mode 100644 docs/screens/acc-03.md delete mode 100644 docs/screens/acc-04.md delete mode 100644 docs/screens/acc-05.md delete mode 100644 docs/screens/acc-06.md delete mode 100644 docs/screens/acc-07.md delete mode 100644 docs/screens/acc-08.md delete mode 100644 docs/screens/acx-01.md delete mode 100644 docs/screens/acx-02.md delete mode 100644 docs/screens/acx-03.md delete mode 100644 docs/screens/acx-04.md delete mode 100644 docs/screens/acx-05.md delete mode 100644 docs/screens/apl-01.md delete mode 100644 docs/screens/apl-02.md delete mode 100644 docs/screens/apl-03.md delete mode 100644 docs/screens/apl-04.md delete mode 100644 docs/screens/apl-05.md delete mode 100644 docs/screens/apl-06.md delete mode 100644 docs/screens/apl-07.md delete mode 100644 docs/screens/app-01.md delete mode 100644 docs/screens/app-02.md delete mode 100644 docs/screens/app-03.md delete mode 100644 docs/screens/app-04.md delete mode 100644 docs/screens/app-05.md delete mode 100644 docs/screens/aud-01.md delete mode 100644 docs/screens/aud-02.md delete mode 100644 docs/screens/auth-01.md delete mode 100644 docs/screens/auth-02.md delete mode 100644 docs/screens/auth-03.md delete mode 100644 docs/screens/auth-04.md delete mode 100644 docs/screens/auth-05.md delete mode 100644 docs/screens/auth-06.md delete mode 100644 docs/screens/auth-07.md delete mode 100644 docs/screens/bil-01.md delete mode 100644 docs/screens/bil-02.md delete mode 100644 docs/screens/bil-03.md delete mode 100644 docs/screens/bil-04.md delete mode 100644 docs/screens/cfg-01.md delete mode 100644 docs/screens/cfg-02.md delete mode 100644 docs/screens/glb-01.md delete mode 100644 docs/screens/glb-02.md delete mode 100644 docs/screens/glb-03.md delete mode 100644 docs/screens/index.md delete mode 100644 docs/screens/int-01.md delete mode 100644 docs/screens/key-01.md delete mode 100644 docs/screens/key-02.md delete mode 100644 docs/screens/key-03.md delete mode 100644 docs/screens/key-04.md delete mode 100644 docs/screens/ntf-01.md delete mode 100644 docs/screens/ntf-02.md delete mode 100644 docs/screens/ntf-03.md delete mode 100644 docs/screens/ntf-04.md delete mode 100644 docs/screens/ntf-05.md delete mode 100644 docs/screens/ntf-06.md delete mode 100644 docs/screens/ops-01.md delete mode 100644 docs/screens/ops-02.md delete mode 100644 docs/screens/ops-03.md delete mode 100644 docs/screens/ops-04.md delete mode 100644 docs/screens/ops-05.md delete mode 100644 docs/screens/ops-06.md delete mode 100644 docs/screens/ops-07.md delete mode 100644 docs/screens/org-01.md delete mode 100644 docs/screens/org-02.md delete mode 100644 docs/screens/org-03.md delete mode 100644 docs/screens/org-04.md delete mode 100644 docs/screens/org-05.md delete mode 100644 docs/screens/org-06.md delete mode 100644 docs/screens/org-07.md delete mode 100644 docs/screens/org-08.md delete mode 100644 docs/screens/org-09.md delete mode 100644 docs/screens/rol-01.md delete mode 100644 docs/screens/rol-02.md delete mode 100644 docs/screens/rol-03.md delete mode 100644 docs/screens/rol-04.md delete mode 100644 docs/screens/rpt-01.md delete mode 100644 docs/screens/sec-01.md delete mode 100644 docs/screens/set-01.md delete mode 100644 docs/screens/set-02.md delete mode 100644 docs/screens/usr-01.md delete mode 100644 docs/screens/usr-02.md delete mode 100644 docs/screens/usr-03.md delete mode 100644 docs/screens/usr-04.md delete mode 100644 docs/screens/usr-05.md delete mode 100644 docs/screens/whk-01.md delete mode 100644 docs/screens/whk-02.md delete mode 100644 docs/screens/whk-03.md delete mode 100644 docs/screens/whk-04.md delete mode 100644 docs/tutorials/api-keys.md delete mode 100644 docs/tutorials/applications.md delete mode 100644 docs/tutorials/cli.md delete mode 100644 docs/tutorials/index.md delete mode 100644 docs/tutorials/invite-and-grant.md delete mode 100644 docs/tutorials/request-id.md delete mode 100644 docs/versions/index.md delete mode 100755 scripts/docs-build.rb diff --git a/.github/workflows/api-spec-sync.yml b/.github/workflows/api-spec-sync.yml index 932e816..71eaa12 100644 --- a/.github/workflows/api-spec-sync.yml +++ b/.github/workflows/api-spec-sync.yml @@ -1,14 +1,12 @@ -# Keeps the vendored Platform API spec (vendor/openapi.yaml) and the help -# center's API pages current with the API repo. +# 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 when the API repo sends a -# `repository_dispatch` of type `api-spec-updated` (see docs/contributing.md -# for the one-step snippet to add there). If openapi.yaml changed, it: -# 1. diffs old vs new with oasdiff (changelog + breaking-change check), -# 2. adds an entry to docs/api/changelog.md, -# 3. updates vendor/openapi.yaml and regenerates the API pages, -# 4. opens (or updates) a pull request, labelled `breaking-change` when -# oasdiff finds one, so the app code gets reviewed against the change. +# 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: @@ -61,38 +59,13 @@ jobs: fi cat _changelog.md - - name: Add an API changelog entry - if: steps.fetch.outputs.changed == 'true' - env: - VERSION: ${{ steps.fetch.outputs.version }} - BREAKING: ${{ steps.diff.outputs.breaking }} - run: | - python3 - <<'PY' - import datetime, os, pathlib - path = pathlib.Path("docs/api/changelog.md") - body = pathlib.Path("_changelog.md").read_text().strip() - # oasdiff starts with its own H1/H2 headings; demote them under ours. - body = "\n".join(("###" + l.lstrip("#")) if l.startswith("#") else l for l in body.splitlines()) - warn = "" - if os.environ["BREAKING"] == "true": - warn = "\n**Breaking for clients of this API version** (oasdiff). Check the app before merging.\n{: .important }\n" - entry = f"## {os.environ['VERSION']} — {datetime.date.today().isoformat()}\n{warn}\n{body or 'No API-visible changes (descriptions or examples only).'}\n" - marker = "" - text = path.read_text() - path.write_text(text.replace(marker, marker + "\n\n" + entry, 1)) - PY - - - uses: dtolnay/rust-toolchain@stable - if: steps.fetch.outputs.changed == 'true' - - uses: Swatinem/rust-cache@v2 - if: steps.fetch.outputs.changed == 'true' - - - name: Update vendored spec and regenerate API pages + - 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 - cargo xtask gen-docs - uses: peter-evans/create-pull-request@v8 if: steps.fetch.outputs.changed == 'true' @@ -107,8 +80,10 @@ jobs: api-spec ${{ steps.diff.outputs.breaking == 'true' && 'breaking-change' || '' }} body: | - The Platform API's `openapi.yaml` changed upstream. This updates `vendor/openapi.yaml`, regenerates the help center's API pages (`cargo xtask gen-docs`) and adds an entry to `docs/api/changelog.md`. + 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 index df25f95..0960085 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,5 @@ -# Build, lint and test the app; check that generated files match their -# sources (specs sync and generated docs); check the help site builds. +# Build, lint and test the app; check that spec-derived files and the +# Linux help pages match their sources. name: CI on: @@ -34,8 +34,6 @@ jobs: - run: cargo fmt --all --check - run: cargo clippy --workspace --all-targets -- -D warnings - run: cargo test --workspace - - name: Generated docs are current - run: cargo xtask gen-docs --check - name: Desktop and AppStream files are valid run: | desktop-file-validate data/com.substratalapps.Hub.desktop @@ -52,16 +50,11 @@ jobs: 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 - docs: - name: Help site builds - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v7 - - uses: ruby/setup-ruby@v1 - with: - ruby-version: "3.2" - bundler-cache: true - working-directory: docs - - run: bundle exec jekyll build --strict_front_matter - working-directory: docs 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/docs.yml b/.github/workflows/docs.yml deleted file mode 100644 index 42794f8..0000000 --- a/.github/workflows/docs.yml +++ /dev/null @@ -1,130 +0,0 @@ -# Versioned build + deploy of the help center (docs/) to GitHub Pages. -# -# Runs on every push to main that changes the app, the docs, the vendored -# API spec or the spec-derived data, so the published help always matches -# main. Generated reference pages are rebuilt from the code first -# (cargo xtask gen-docs), then the site is built and versioned exactly like -# the Platform API's docs: patch bump by default, [docs:minor] or -# [docs:major] in a commit subject for more. Each version's frozen snapshot -# goes to the docs-versions branch and is served at /versions//. -name: Docs - -on: - push: - branches: [main] - paths: - - "docs/**" - - "crates/**" - - "data/**" - - "vendor/**" - - "scripts/docs-build.rb" - - ".github/workflows/docs.yml" - workflow_dispatch: - inputs: - bump: - description: Version bump for this release - type: choice - options: [patch, minor, major] - default: patch - -permissions: - contents: write # push snapshots to docs-versions - pages: write - id-token: write - -# One release at a time, never cancelled midway: each run reads the archive -# the previous run wrote, so overlapping runs would race on version numbers. -concurrency: - group: docs-pages - cancel-in-progress: false - -jobs: - release: - runs-on: ubuntu-24.04 - environment: - name: github-pages - url: ${{ steps.deploy.outputs.page_url }} - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 # bump markers are read from every commit since the last version - - # The generator only needs the GTK-free crates (core and the CLI). - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - name: Regenerate reference pages from the code - run: cargo xtask gen-docs - - - name: Check out version archive - run: | - if git ls-remote --exit-code --heads origin docs-versions >/dev/null; then - git fetch --depth=1 origin +docs-versions:refs/remotes/origin/docs-versions - git worktree add _archive -B docs-versions origin/docs-versions - else - echo "No docs-versions branch yet; starting the archive at 1.0.0." - git worktree add --orphan -b docs-versions _archive - fi - - - uses: ruby/setup-ruby@v1 - with: - ruby-version: "3.2" - bundler-cache: true - working-directory: docs - - - name: Pick version bump - id: bump - env: - INPUT_BUMP: ${{ inputs.bump }} - run: | - bump="${INPUT_BUMP:-patch}" - last=$(ruby -ryaml -e 'm = File.exist?("_archive/manifest.yml") ? YAML.load_file("_archive/manifest.yml") : nil - v = ((m || {})["versions"] || []).first - print v ? v["commit"].to_s : ""') - if [ -n "$last" ] && git cat-file -e "$last^{commit}" 2>/dev/null; then - msgs=$(git log --format=%s "$last..HEAD") - else - msgs=$(git log -1 --format=%s) - fi - if grep -q '\[docs:major\]' <<<"$msgs"; then bump=major - elif [ "$bump" != major ] && grep -q '\[docs:minor\]' <<<"$msgs"; then bump=minor; fi - echo "level=$bump" >> "$GITHUB_OUTPUT" - - - name: Build versioned site - id: build - run: | - ruby scripts/docs-build.rb \ - --archive _archive \ - --out _site \ - --bump "${{ steps.bump.outputs.level }}" \ - --commit "$GITHUB_SHA" \ - --summary "$(git log -1 --format=%s)" - - - name: Commit snapshot to docs-versions - working-directory: _archive - env: - VERSION: ${{ steps.build.outputs.version }} - run: | - if [ ! -f README.md ]; then - cat > README.md <<'README' - # docs-versions - - Archive of every released version of the help center, written by - `.github/workflows/docs.yml` on `main`. Don't edit by hand. - - - `manifest.yml`: every version, newest first. - - `/`: that version's frozen static build, served at - https://compositecode.github.io/substratalapps-linux/versions// - README - fi - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "Archive docs v$VERSION from ${GITHUB_SHA::7}" - git push origin docs-versions - - - uses: actions/configure-pages@v6 - - uses: actions/upload-pages-artifact@v5 - with: - path: _site - - id: deploy - uses: actions/deploy-pages@v5 diff --git a/.github/workflows/specs-sync.yml b/.github/workflows/specs-sync.yml index 20d5ae4..b207e9c 100644 --- a/.github/workflows/specs-sync.yml +++ b/.github/workflows/specs-sync.yml @@ -1,6 +1,5 @@ # Keeps the spec-derived files (screen registry, themes, icons, status -# semantics) and the help center's screen pages current with the client -# specs package. Daily and on demand; opens a pull request when anything +# semantics) current with the client specs package. Daily and on demand; opens a pull request when anything # changed. name: Specs sync @@ -34,10 +33,6 @@ jobs: python3 scripts/sync-specs.py --specs _specs echo "commit=$(git -C _specs rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" rm -rf _specs - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - - name: Regenerate screen pages - run: cargo xtask gen-docs - uses: peter-evans/create-pull-request@v8 with: token: ${{ secrets.SYNC_TOKEN || github.token }} @@ -47,6 +42,6 @@ jobs: 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/`, the bundled themes and icons, and the help center's screen pages. + 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 index 0f6aaf0..e6ecad1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,6 @@ /target /_site -/docs/_site -/docs/.jekyll-cache -/docs/.bundle -/docs/vendor -/docs/_data/versions.yml +/_specs /_archive .DS_Store /build-aux/flatpak/.flatpak-builder diff --git a/Cargo.lock b/Cargo.lock index 3c10888..4f8592d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1967,12 +1967,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - [[package]] name = "same-file" version = "1.0.6" @@ -2093,19 +2087,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "sha2" version = "0.10.9" @@ -2560,12 +2541,6 @@ version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -2873,8 +2848,6 @@ dependencies = [ "anyhow", "clap", "clap-markdown", - "serde_json", - "serde_yaml", "substratal-core", "substratalctl", ] diff --git a/README.md b/README.md index b9c699c..21f3985 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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 center:** https://compositecode.github.io/substratalapps-linux/. It's built from `docs/` in this repo and serves every Hub app (Linux, macOS, Windows, iOS/iPadOS, Android, Web). +**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. @@ -13,10 +13,9 @@ The Substratal Hub for Linux (GNOME): a GTK 4 + libadwaita client of the [Substr | `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-docs`: generates the help center's reference pages from the code, the specs and the API spec | +| `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 | -| `docs/` | The help center (Jekyll + just-the-docs), versioned and deployed to GitHub Pages | | `build-aux/` | Flatpak manifest, Linux dev container | | `planning/` | Plans, the API-gap list and the prerequisites check | @@ -44,7 +43,6 @@ scripts/dev.sh scripts/screenshot.sh _screens / /admin/users /settings # headl cargo fmt --all --check cargo clippy --workspace --all-targets -- -D warnings cargo test --workspace -cargo xtask gen-docs --check # generated help pages match the code python3 scripts/sync-specs.py --check # spec-derived files match the specs package ``` @@ -52,10 +50,10 @@ python3 scripts/sync-specs.py --check # spec-derived files match the spec | Workflow | When | Does | |---|---|---| -| `ci.yml` | PRs, pushes to `main` | Build, lint, test, generated-docs and spec checks, desktop/AppStream validation, site build | -| `docs.yml` | Pushes to `main` touching app, docs, data or spec | Regenerates reference pages, builds a new docs version, deploys to GitHub Pages | -| `api-spec-sync.yml` | Every 6 h, on demand, `repository_dispatch: api-spec-updated` | Pulls the API's `openapi.yaml`, diffs it with oasdiff, updates the API changelog and pages, opens a PR (labelled when breaking) | -| `specs-sync.yml` | Daily, on demand | Pulls the client specs, regenerates screens, themes and help pages, opens a PR | +| `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 diff --git a/crates/substratal-core/src/config.rs b/crates/substratal-core/src/config.rs index 7ff22f3..c18ec7c 100644 --- a/crates/substratal-core/src/config.rs +++ b/crates/substratal-core/src/config.rs @@ -6,8 +6,11 @@ use url::Url; 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 (this repo's `docs/`, on GitHub Pages). -pub const HELP_BASE: &str = "https://compositecode.github.io/substratalapps-linux/"; +/// 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. @@ -76,11 +79,11 @@ impl Environment { } } -/// Help center page for a screen, e.g. `…/screens/usr-01/`. Every Hub app +/// 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/{}/", screen_id.to_ascii_lowercase()) + format!("{HELP_BASE}screens/{}.html", screen_id.to_ascii_lowercase()) } #[cfg(test)] @@ -115,7 +118,7 @@ mod tests { fn help_urls_are_lowercase() { assert_eq!( help_url("USR-01"), - "https://compositecode.github.io/substratalapps-linux/screens/usr-01/" + "https://compositecode.github.io/substratalapps-applications-specs/help/screens/usr-01.html" ); } } diff --git a/crates/xtask/Cargo.toml b/crates/xtask/Cargo.toml index 2530736..b9ef382 100644 --- a/crates/xtask/Cargo.toml +++ b/crates/xtask/Cargo.toml @@ -11,5 +11,3 @@ substratalctl.workspace = true anyhow.workspace = true clap.workspace = true clap-markdown = "0.1" -serde_json.workspace = true -serde_yaml.workspace = true diff --git a/crates/xtask/src/main.rs b/crates/xtask/src/main.rs index 94bbf79..3364a0d 100644 --- a/crates/xtask/src/main.rs +++ b/crates/xtask/src/main.rs @@ -1,355 +1,91 @@ //! Repo automation. //! -//! cargo xtask gen-docs regenerate the help center's generated pages -//! cargo xtask gen-docs --check fail if they're out of date (CI) +//! 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 //! -//! Generated pages come from the code and the specs, never by hand, so the -//! help center can't drift from what the app does: +//! 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 | -//! |------------------------------|------------------------------------------------| -//! | screens/index.md, screens/* | data/spec/screens.json (the specs package) | -//! | reference/shortcuts.md | substratal_core::shortcuts::SHORTCUTS | -//! | reference/navigation.md | substratal_core::nav + capabilities::personas | -//! | reference/statuses.md | data/spec/status-semantics.json | -//! | reference/cli.md | substratalctl's clap definition | -//! | api/operations.md | vendor/openapi.yaml + screens' API calls | -//! | api/v1/openapi.yaml | vendor/openapi.yaml | -//! | _data/api.yml | vendor/openapi.yaml + core::version | +//! | 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::{Path, PathBuf}; +use std::path::PathBuf; use anyhow::{Context, Result, bail}; use clap::Parser; -use serde_json::Value as Json; -use serde_yaml::Value as Yaml; -use substratal_core::capabilities::personas; -use substratal_core::screens::{self, Screen}; -use substratal_core::{config, nav, shortcuts, version}; +use substratal_core::{shortcuts, version}; -const GENERATED: &str = "GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate."; +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 { - /// Regenerate the help center's generated pages. - GenDocs { + /// 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 root() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(2) - .unwrap() - .to_path_buf() -} - fn main() -> Result<()> { match Cmd::parse() { - Cmd::GenDocs { check } => gen_docs(check), - } -} - -struct Out { - files: BTreeMap, -} - -impl Out { - fn put(&mut self, rel: &str, body: String) { - self.files.insert(root().join("docs").join(rel), body); + Cmd::GenHelp { out, check } => gen_help(out, check), } } -fn gen_docs(check: bool) -> Result<()> { - let spec_text = std::fs::read_to_string(root().join("vendor/openapi.yaml")) - .context("vendor/openapi.yaml")?; - let spec: Yaml = serde_yaml::from_str(&spec_text)?; - let mut out = Out { - files: BTreeMap::new(), - }; - - screens_pages(&mut out); - shortcuts_page(&mut out); - navigation_page(&mut out); - statuses_page(&mut out)?; - cli_page(&mut out); - operations_page(&mut out, &spec); - out.put("api/v1/openapi.yaml", spec_text.clone()); - api_data(&mut out, &spec); - - // Stale generated screen pages (a screen removed from the specs). - let screens_dir = root().join("docs/screens"); - let mut stale = Vec::new(); - if let Ok(rd) = std::fs::read_dir(&screens_dir) { - for e in rd.flatten() { - let p = e.path(); - if p.extension().is_some_and(|x| x == "md") && !out.files.contains_key(&p) { - stale.push(p); - } - } +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 (path, body) in &out.files { - if std::fs::read_to_string(path).ok().as_deref() != Some(body.as_str()) { - changed.push(path.clone()); + 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::create_dir_all(path.parent().unwrap())?; - std::fs::write(path, body)?; + std::fs::write(&path, body).with_context(|| path.display().to_string())?; } } } - if !check { - for p in &stale { - std::fs::remove_file(p)?; - } - } - let rel = |p: &PathBuf| p.strip_prefix(root()).unwrap().display().to_string(); - if check { - if !changed.is_empty() || !stale.is_empty() { - let list: Vec<_> = changed.iter().chain(&stale).map(rel).collect(); - bail!( - "generated docs are out of date:\n {}\nRun `cargo xtask gen-docs` and commit.", - list.join("\n ") - ); - } - println!("gen-docs: {} generated pages up to date", out.files.len()); - } else { - println!( - "gen-docs: wrote {} of {} pages, removed {}", - changed.len(), - out.files.len(), - stale.len() + if check && !changed.is_empty() { + bail!( + "help pages out of date: {}\nRun `cargo xtask gen-help --out /help`.", + changed.join(", ") ); } - Ok(()) -} - -fn md_cell(s: &str) -> String { - s.replace('|', "\\|").replace('\n', " ") -} - -/// Liquid would try to render `{{ … }}` and `{% … %}` that appear in spec text. -fn raw(s: &str) -> String { - if s.contains("{{") || s.contains("{%") { - format!("{{% raw %}}{s}{{% endraw %}}") - } else { - s.to_string() - } -} - -fn front(title: &str, extra: &[(&str, String)]) -> String { - let mut f = format!( - "---\n# {GENERATED}\nlayout: default\ntitle: \"{}\"\n", - title.replace('"', "\\\"") + println!( + "gen-help: {} of {} pages {}", + changed.len(), + pages.len(), + if check { "differ" } else { "written" } ); - for (k, v) in extra { - let _ = writeln!(f, "{k}: {v}"); - } - f.push_str("---\n\n"); - f -} - -fn platform_label(s: &Screen) -> &'static str { - if s.on_linux() { - "All Hub apps" - } else { - "Web Hub only" - } -} - -fn nav_path(s: &Screen) -> Option { - nav::item_for_route(&s.route) - .or_else(|| nav::TREE.iter().find(|i| i.screen_id == s.id)) - .map(|i| format!("{} › {}", i.section.title(), i.title)) -} - -fn screens_pages(out: &mut Out) { - let mut index = front( - "Screens", - &[ - ("nav_order", "5".into()), - ("has_children", "false".into()), - ("permalink", "/screens/".into()), - ], - ); - let _ = write!( - index, - "# Screens\n{{: .no_toc }}\n\nEvery screen in the Substratal Hub, with the same ID, route and behavior in all six apps (Linux, macOS, Windows, iOS/iPadOS, Android and Web). Each has its own help page at `/screens//`, which every app links to from its Help button or **F1**.\n{{: .fs-6 .fw-300 }}\n\nGenerated from the [specs package](https://github.com/CompositeCode/substratalapps-applications-specs) at `{}`. A machine-readable list is at [`screens.json`]({{{{ '/screens.json' | relative_url }}}}).\n\n", - &screens::spec_commit()[..7] - ); - let mut current_heading = String::new(); - for s in screens::all() { - let heading = s.section_heading.clone().unwrap_or_default(); - if heading != current_heading { - let _ = write!( - index, - "\n## {heading}\n\n| ID | Screen | Route | Who | Apps |\n|---|---|---|---|---|\n" - ); - current_heading = heading; - } - let _ = writeln!( - index, - "| [{id}]({{{{ '/screens/{lid}/' | relative_url }}}}) | {title} | `{route}` | {who} | {plat} |", - id = s.id, - lid = s.id.to_lowercase(), - title = md_cell(&s.title), - route = md_cell(&s.route), - who = md_cell(&raw(&s.who)), - plat = platform_label(s) - ); - } - out.put("screens/index.md", index); - - let mut json = Vec::new(); - for s in screens::all() { - json.push(serde_json::json!({ - "id": s.id, "title": s.title, "route": s.route, "archetype": s.archetype, "phase": s.phase, - "who": s.who, "purpose": s.purpose, "platforms": s.platforms, - "help": config::help_url(&s.id), - })); - } - out.put( - "screens.json", - serde_json::to_string_pretty(&json).unwrap() + "\n", - ); - - for s in screens::all() { - out.put( - &format!("screens/{}.md", s.id.to_lowercase()), - screen_page(s), - ); - } -} - -fn screen_page(s: &Screen) -> String { - let mut p = front( - &format!("{} {}", s.id, s.title), - &[ - ("permalink", format!("/screens/{}/", s.id.to_lowercase())), - ("nav_exclude", "true".into()), - ("screen_id", s.id.clone()), - ], - ); - let _ = write!(p, "# {}\n{{: .no_toc }}\n\n", s.title); - if !s.purpose.is_empty() { - let _ = write!(p, "{}\n{{: .fs-6 .fw-300 }}\n\n", raw(&s.purpose)); - } - if !s.on_linux() { - p.push_str("This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27).\n{: .note }\n\n"); - } - let _ = writeln!(p, "| | |\n|---|---|"); - let _ = writeln!(p, "| **Screen ID** | `{}` |", s.id); - if s.route.starts_with('/') { - let deep = if s.on_linux() { - format!(" · deep link `substratal:/{}`", s.route) - } else { - String::new() - }; - let _ = writeln!(p, "| **Route** | `{}`{} |", md_cell(&s.route), deep); - } - if let Some(n) = nav_path(s) { - let _ = writeln!(p, "| **Where** | {} |", n); - } - let _ = writeln!( - p, - "| **Who can use it** | {} |", - md_cell(&raw(if s.who.is_empty() { "—" } else { &s.who })) - ); - let _ = writeln!(p, "| **Kind** | {} |", s.archetype); - let _ = writeln!( - p, - "| **Available in** | {} ({}) |", - platform_label(s), - md_cell(&s.phase) - ); - if !s.container.is_empty() { - let _ = writeln!(p, "| **On Linux** | {} |", md_cell(&raw(&s.container))); - } - p.push('\n'); - if !s.entry_points.is_empty() { - let _ = write!(p, "**How you get here:** {}\n\n", raw(&s.entry_points)); - } - if !s.elements.is_empty() { - let linux = s.elements.iter().any(|e| !e.control.is_empty()); - p.push_str("## What's on this screen\n\n"); - if linux { - p.push_str("| Element | What it is | Shows / does | Rules | On Linux |\n|---|---|---|---|---|\n"); - } else { - p.push_str("| Element | What it is | Shows / does | Rules |\n|---|---|---|---|\n"); - } - for e in &s.elements { - let _ = write!( - p, - "| {} | {} | {} | {} |", - md_cell(&raw(&e.name)), - md_cell(&e.kind), - md_cell(&raw(&e.data)), - md_cell(&raw(&e.rules)) - ); - if linux { - let _ = write!(p, " {} |", md_cell(&raw(&e.control))); - } - p.push('\n'); - } - p.push('\n'); - } - if !s.actions.is_empty() { - p.push_str( - "## What happens when you act\n\n| Action | API call | Result |\n|---|---|---|\n", - ); - for a in &s.actions { - let call = if a.call.is_empty() { - String::new() - } else { - format!("`{}`", md_cell(&a.call)) - }; - let _ = writeln!( - p, - "| {} | {} | {} |", - md_cell(&raw(&a.action)), - call, - md_cell(&raw(&a.result)) - ); - } - p.push('\n'); - } - if !s.states.is_empty() || !s.notes.is_empty() { - p.push_str("## Good to know\n\n"); - if !s.states.is_empty() { - let _ = writeln!( - p, - "- **Loading, empty and error states:** {}", - raw(&s.states) - ); - } - if !s.notes.is_empty() { - let _ = writeln!(p, "- {}", raw(&s.notes)); - } - p.push('\n'); - } - if !s.loads.is_empty() { - let _ = write!( - p, - "## For developers\n\nLoads: {}\n\nEvery call sends `Substratal-Version: {}` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{{{ '/api/versioning/' | relative_url }}}}) and the [API reference]({{{{ '/api/reference/' | relative_url }}}}).\n", - raw(&s.loads), - version::API_VERSION - ); - } - p + Ok(()) } -fn shortcuts_page(out: &mut Out) { - let mut p = front( - "Keyboard shortcuts", - &[("parent", "Reference".into()), ("nav_order", "1".into())], +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" ); - p.push_str("# Keyboard shortcuts\n{: .no_toc }\n\nShortcuts in Substratal for Linux. Every shortcut has a visible equivalent: a button, menu item or the command palette. Press **Ctrl+?** in the app to see this list.\n{: .fs-6 .fw-300 }\n\n"); let mut group = ""; for s in shortcuts::SHORTCUTS { if s.group != group { @@ -359,211 +95,33 @@ fn shortcuts_page(out: &mut Out) { let keys: Vec<_> = s .accels .iter() - .map(|a| format!("{}", shortcuts::display(a))) + .map(|a| format!("**{}**", shortcuts::display(a))) .collect(); let _ = writeln!(p, "| {} | {} |", keys.join(" or "), s.label); } - out.put("reference/shortcuts.md", p); -} - -fn navigation_page(out: &mut Out) { - let mut p = front( - "Navigation and permissions", - &[("parent", "Reference".into()), ("nav_order", "2".into())], - ); - p.push_str("# Navigation and permissions\n{: .no_toc }\n\nWhat each kind of person sees in the sidebar. The app hides what you can never use, and the server checks every action again, so this is a guide, not a guarantee.\n{: .fs-6 .fw-300 }\n\n"); - let ps = personas::all(); - p.push_str("| Section › Item | Screen |"); - for (name, _) in &ps { - let _ = write!(p, " {name} |"); - } - p.push_str("\n|---|---|"); - for _ in &ps { - p.push_str(":-:|"); - } - p.push('\n'); - for item in nav::TREE.iter().filter(|i| !i.web_only) { - let _ = write!( - p, - "| {} › {} | [{}]({{{{ '/screens/{}/' | relative_url }}}}) |", - item.section.title(), - item.title, - item.screen_id, - item.screen_id.to_lowercase() - ); - for (_, caps) in &ps { - let visible = nav::visible_sections(caps) - .iter() - .any(|(_, items)| items.iter().any(|i| i.screen_id == item.screen_id)); - p.push_str(if visible { " ✓ |" } else { " |" }); - } - p.push('\n'); - } - p.push_str("\nThe web Hub also has an **Operations** section (the Console) and **Reports** and **Integrations** items. They're web-only for now; see [Screens]({{ '/screens/' | relative_url }}).\n\n## Platform permissions\n\n| Permission | Typical holder |\n|---|---|\n"); - for perm in substratal_core::capabilities::perm::ALL { - let holders: Vec<_> = ps - .iter() - .filter(|(_, c)| c.has(perm)) - .map(|(n, _)| *n) - .collect(); - let _ = writeln!(p, "| `{perm}` | {} |", holders.join(", ")); - } - out.put("reference/navigation.md", p); -} - -fn statuses_page(out: &mut Out) -> Result<()> { - let v: Json = serde_json::from_str(&std::fs::read_to_string( - root().join("data/spec/status-semantics.json"), - )?)?; - let mut p = front( - "Status meanings", - &[("parent", "Reference".into()), ("nav_order", "3".into())], - ); - p.push_str("# Status meanings\n{: .no_toc }\n\nEvery status badge in every Hub app shows an icon and a label, never colour alone. They mean the same thing everywhere.\n{: .fs-6 .fw-300 }\n\n| Status | Label | Kind |\n|---|---|---|\n"); - if let Some(m) = v.get("statuses").and_then(Json::as_object) { - for (k, s) in m { - let _ = writeln!( - p, - "| `{k}` | {} | {} |", - s.get("label").and_then(Json::as_str).unwrap_or(k), - s.get("role").and_then(Json::as_str).unwrap_or("") - ); - } - } - out.put("reference/statuses.md", p); - Ok(()) + p } -fn cli_page(out: &mut Out) { - let mut p = front( - "substratalctl", - &[("parent", "Reference".into()), ("nav_order", "4".into())], +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 ); - p.push_str("# substratalctl command reference\n{: .no_toc }\n\n`substratalctl` is the Hub's command-line companion. It shares the Linux app's core, so it sends the same `Substratal-Version` header, signs in the same way and gets the same permission errors. See the [CLI tutorial]({{ '/tutorials/cli/' | relative_url }}).\n{: .fs-6 .fw-300 }\n\n"); let md = clap_markdown::help_markdown_command(&substratalctl::command()); - // clap-markdown starts with its own H1; the page has one already. - let body: String = md + // 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("## ") && !l.starts_with("**Usage")) - .collect::>() - .join("\n"); - p.push_str(&raw(&body)); - p.push('\n'); - out.put("reference/cli.md", p); -} - -fn op_screens(method: &str, path: &str) -> Vec<&'static str> { - // Matches "GET /users" or "GET /users?…" in a screen's loads and actions. - let needle = format!("{method} {path}"); - let hit = |text: &str| { - text.match_indices(&needle).any(|(i, _)| { - let next = text[i + needle.len()..].chars().next(); - matches!( - next, - None | Some(' ' | '?' | '`' | ',' | ')' | ';' | '·' | '\n') - ) - }) - }; - screens::all() - .iter() - .filter(|s| { - hit(&s.loads) - || s.actions.iter().any(|a| hit(&a.call)) - || s.elements.iter().any(|e| hit(&e.data)) - }) - .map(|s| s.id.as_str()) - .collect() -} - -fn operations_page(out: &mut Out, spec: &Yaml) { - let info_version = spec["info"]["version"].as_str().unwrap_or("?"); - // tag → (method, path, summary, versioned, public) - type Op = (String, String, String, bool, bool); - let mut by_tag: BTreeMap> = BTreeMap::new(); - let mut total = 0; - if let Some(paths) = spec["paths"].as_mapping() { - for (path, item) in paths { - let path = path.as_str().unwrap_or_default(); - for method in ["get", "post", "put", "patch", "delete"] { - let op = &item[method]; - if op.is_null() { - continue; - } - total += 1; - let tag = op["tags"][0].as_str().unwrap_or("Other").to_string(); - let summary = op["summary"].as_str().unwrap_or("").to_string(); - let versioned = op["parameters"].as_sequence().is_some_and(|ps| { - ps.iter() - .any(|p| p["$ref"].as_str() == Some("#/components/parameters/ApiVersion")) - }); - let public = op["security"].as_sequence().is_some_and(|s| s.is_empty()); - by_tag.entry(tag).or_default().push(( - method.to_uppercase(), - path.to_string(), - summary, - versioned, - public, - )); + .skip_while(|l| !l.starts_with("## ")) + .map(|l| { + if l.starts_with("## ") { + format!("#{l}") + } else { + l.to_string() } - } - } - let mut p = front( - "Operations", - &[("parent", "API".into()), ("nav_order", "4".into())], - ); - let _ = write!( - p, - "# API operations\n{{: .no_toc }}\n\nAll {total} operations in `openapi.yaml` **{info_version}** (API version `{}`), and which Hub screens use each one. Full request and response shapes are in the [interactive reference]({{{{ '/api/reference/' | relative_url }}}}).\n{{: .fs-6 .fw-300 }}\n\n**Header** ✓ means the operation requires `Substratal-Version`; the few exempt ones are noted in [versioning]({{{{ '/api/versioning/' | relative_url }}}}). **Auth** — means no sign-in needed.\n\n1. TOC\n{{:toc}}\n", - version::API_VERSION - ); - for (tag, ops) in &by_tag { - let _ = write!( - p, - "\n## {tag}\n\n| Method | Path | Summary | Header | Auth | Used by |\n|---|---|---|:-:|:-:|---|\n" - ); - for (m, path, summary, versioned, public) in ops { - let used: Vec<_> = op_screens(m, path) - .into_iter() - .map(|id| { - format!( - "[{id}]({{{{ '/screens/{}/' | relative_url }}}})", - id.to_lowercase() - ) - }) - .collect(); - let _ = writeln!( - p, - "| `{m}` | `{}` | {} | {} | {} | {} |", - md_cell(path), - md_cell(&raw(summary)), - if *versioned { "✓" } else { "exempt" }, - if *public { "—" } else { "✓" }, - used.join(" ") - ); - } - } - out.put("api/operations.md", p); -} - -fn api_data(out: &mut Out, spec: &Yaml) { - let ops: usize = spec["paths"] - .as_mapping() - .map(|m| { - m.values() - .map(|i| { - ["get", "post", "put", "patch", "delete"] - .iter() - .filter(|k| !i[**k].is_null()) - .count() - }) - .sum() }) - .unwrap_or(0); - let body = format!( - "# {GENERATED}\n# The API version(s) this help center documents. One entry per Substratal-Version value.\ncurrent: \"{v}\"\nversions:\n - version: \"{v}\"\n status: current\n spec_version: \"{sv}\"\n operations: {ops}\n openapi: /api/v{v}/openapi.yaml\n base_url: \"{base}\"\n", - v = version::API_VERSION, - sv = spec["info"]["version"].as_str().unwrap_or("?"), - base = config::PRODUCTION_API, - ); - out.put("_data/api.yml", body); + .collect(); + p.push_str(&body.join("\n")); + p.push('\n'); + p } diff --git a/data/com.substratalapps.Hub.metainfo.xml b/data/com.substratalapps.Hub.metainfo.xml index 4ddfa64..f16fa44 100644 --- a/data/com.substratalapps.Hub.metainfo.xml +++ b/data/com.substratalapps.Hub.metainfo.xml @@ -22,8 +22,8 @@ #E8E8F7 #15132E - https://compositecode.github.io/substratalapps-linux/ - https://compositecode.github.io/substratalapps-linux/ + 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 diff --git a/docs/Gemfile b/docs/Gemfile deleted file mode 100644 index 6563f8c..0000000 --- a/docs/Gemfile +++ /dev/null @@ -1,7 +0,0 @@ -source "https://rubygems.org" - -# Pinned to the exact version GitHub Pages' classic build currently runs -# (checked against https://rubygems.org/api/v1/gems/github-pages.json). -# Keep this in sync if GitHub bumps their build image, so local builds -# never diverge from what actually gets deployed. -gem "github-pages", "232", group: :jekyll_plugins diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock deleted file mode 100644 index 3f7b4c1..0000000 --- a/docs/Gemfile.lock +++ /dev/null @@ -1,294 +0,0 @@ -GEM - remote: https://rubygems.org/ - specs: - activesupport (8.1.4) - base64 - bigdecimal - concurrent-ruby (~> 1.0, >= 1.3.1) - connection_pool (>= 2.2.5) - drb - i18n (>= 1.6, < 2) - json - logger (>= 1.4.2) - minitest (>= 5.1) - securerandom (>= 0.3) - tzinfo (~> 2.0, >= 2.0.5) - uri (>= 0.13.1) - addressable (2.9.0) - public_suffix (>= 2.0.2, < 8.0) - base64 (0.3.0) - bigdecimal (4.1.3) - coffee-script (2.4.1) - coffee-script-source - execjs - coffee-script-source (1.12.2) - colorator (1.1.0) - commonmarker (0.23.12) - concurrent-ruby (1.3.8) - connection_pool (3.0.2) - csv (3.3.6) - dnsruby (1.74.0) - base64 (>= 0.2) - logger (~> 1.6) - simpleidn (~> 0.2.1) - drb (2.2.3) - em-websocket (0.5.3) - eventmachine (>= 0.12.9) - http_parser.rb (~> 0) - ethon (0.18.0) - ffi (>= 1.15.0) - logger - eventmachine (1.2.7) - execjs (2.10.2) - json (>= 2) - faraday (2.15.0) - faraday-net_http (>= 2.0, < 3.6) - json - logger - faraday-net_http (3.5.0) - net-http (~> 0.5) - ffi (1.17.4-arm64-darwin) - ffi (1.17.4-x86_64-linux-gnu) - forwardable-extended (2.6.0) - gemoji (4.1.0) - github-pages (232) - github-pages-health-check (= 1.18.2) - jekyll (= 3.10.0) - jekyll-avatar (= 0.8.0) - jekyll-coffeescript (= 1.2.2) - jekyll-commonmark-ghpages (= 0.5.1) - jekyll-default-layout (= 0.1.5) - jekyll-feed (= 0.17.0) - jekyll-gist (= 1.5.0) - jekyll-github-metadata (= 2.16.1) - jekyll-include-cache (= 0.2.1) - jekyll-mentions (= 1.6.0) - jekyll-optional-front-matter (= 0.3.2) - jekyll-paginate (= 1.1.0) - jekyll-readme-index (= 0.3.0) - jekyll-redirect-from (= 0.16.0) - jekyll-relative-links (= 0.6.1) - jekyll-remote-theme (= 0.4.3) - jekyll-sass-converter (= 1.5.2) - jekyll-seo-tag (= 2.8.0) - jekyll-sitemap (= 1.4.0) - jekyll-swiss (= 1.0.0) - jekyll-theme-architect (= 0.2.0) - jekyll-theme-cayman (= 0.2.0) - jekyll-theme-dinky (= 0.2.0) - jekyll-theme-hacker (= 0.2.0) - jekyll-theme-leap-day (= 0.2.0) - jekyll-theme-merlot (= 0.2.0) - jekyll-theme-midnight (= 0.2.0) - jekyll-theme-minimal (= 0.2.0) - jekyll-theme-modernist (= 0.2.0) - jekyll-theme-primer (= 0.6.0) - jekyll-theme-slate (= 0.2.0) - jekyll-theme-tactile (= 0.2.0) - jekyll-theme-time-machine (= 0.2.0) - jekyll-titles-from-headings (= 0.5.3) - jemoji (= 0.13.0) - kramdown (= 2.4.0) - kramdown-parser-gfm (= 1.1.0) - liquid (= 4.0.4) - mercenary (~> 0.3) - minima (= 2.5.1) - nokogiri (>= 1.16.2, < 2.0) - rouge (= 3.30.0) - terminal-table (~> 1.4) - webrick (~> 1.8) - github-pages-health-check (1.18.2) - addressable (~> 2.3) - dnsruby (~> 1.60) - octokit (>= 4, < 8) - public_suffix (>= 3.0, < 6.0) - typhoeus (~> 1.3) - html-pipeline (2.14.3) - activesupport (>= 2) - nokogiri (>= 1.4) - http_parser.rb (0.8.1) - i18n (1.15.2) - concurrent-ruby (~> 1.0) - jekyll (3.10.0) - addressable (~> 2.4) - colorator (~> 1.0) - csv (~> 3.0) - em-websocket (~> 0.5) - i18n (>= 0.7, < 2) - jekyll-sass-converter (~> 1.0) - jekyll-watch (~> 2.0) - kramdown (>= 1.17, < 3) - liquid (~> 4.0) - mercenary (~> 0.3.3) - pathutil (~> 0.9) - rouge (>= 1.7, < 4) - safe_yaml (~> 1.0) - webrick (>= 1.0) - jekyll-avatar (0.8.0) - jekyll (>= 3.0, < 5.0) - jekyll-coffeescript (1.2.2) - coffee-script (~> 2.2) - coffee-script-source (~> 1.12) - jekyll-commonmark (1.4.0) - commonmarker (~> 0.22) - jekyll-commonmark-ghpages (0.5.1) - commonmarker (>= 0.23.7, < 1.1.0) - jekyll (>= 3.9, < 4.0) - jekyll-commonmark (~> 1.4.0) - rouge (>= 2.0, < 5.0) - jekyll-default-layout (0.1.5) - jekyll (>= 3.0, < 5.0) - jekyll-feed (0.17.0) - jekyll (>= 3.7, < 5.0) - jekyll-gist (1.5.0) - octokit (~> 4.2) - jekyll-github-metadata (2.16.1) - jekyll (>= 3.4, < 5.0) - octokit (>= 4, < 7, != 4.4.0) - jekyll-include-cache (0.2.1) - jekyll (>= 3.7, < 5.0) - jekyll-mentions (1.6.0) - html-pipeline (~> 2.3) - jekyll (>= 3.7, < 5.0) - jekyll-optional-front-matter (0.3.2) - jekyll (>= 3.0, < 5.0) - jekyll-paginate (1.1.0) - jekyll-readme-index (0.3.0) - jekyll (>= 3.0, < 5.0) - jekyll-redirect-from (0.16.0) - jekyll (>= 3.3, < 5.0) - jekyll-relative-links (0.6.1) - jekyll (>= 3.3, < 5.0) - jekyll-remote-theme (0.4.3) - addressable (~> 2.0) - jekyll (>= 3.5, < 5.0) - jekyll-sass-converter (>= 1.0, <= 3.0.0, != 2.0.0) - rubyzip (>= 1.3.0, < 3.0) - jekyll-sass-converter (1.5.2) - sass (~> 3.4) - jekyll-seo-tag (2.8.0) - jekyll (>= 3.8, < 5.0) - jekyll-sitemap (1.4.0) - jekyll (>= 3.7, < 5.0) - jekyll-swiss (1.0.0) - jekyll-theme-architect (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-cayman (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-dinky (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-hacker (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-leap-day (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-merlot (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-midnight (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-minimal (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-modernist (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-primer (0.6.0) - jekyll (> 3.5, < 5.0) - jekyll-github-metadata (~> 2.9) - jekyll-seo-tag (~> 2.0) - jekyll-theme-slate (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-tactile (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-theme-time-machine (0.2.0) - jekyll (> 3.5, < 5.0) - jekyll-seo-tag (~> 2.0) - jekyll-titles-from-headings (0.5.3) - jekyll (>= 3.3, < 5.0) - jekyll-watch (2.2.1) - listen (~> 3.0) - jemoji (0.13.0) - gemoji (>= 3, < 5) - html-pipeline (~> 2.2) - jekyll (>= 3.0, < 5.0) - json (3.1.0) - kramdown (2.4.0) - rexml - kramdown-parser-gfm (1.1.0) - kramdown (~> 2.0) - liquid (4.0.4) - listen (3.10.1) - logger - rb-fsevent (~> 0.10, >= 0.10.3) - rb-inotify (~> 0.9, >= 0.9.10) - logger (1.7.0) - mercenary (0.3.6) - minima (2.5.1) - jekyll (>= 3.5, < 5.0) - jekyll-feed (~> 0.9) - jekyll-seo-tag (~> 2.1) - minitest (6.0.6) - drb (~> 2.0) - prism (~> 1.5) - net-http (0.10.0) - net-protocol (>= 0.3.0) - uri (>= 0.11.1) - net-protocol (0.4.0) - timeout - nokogiri (1.19.4-arm64-darwin) - racc (~> 1.4) - nokogiri (1.19.4-x86_64-linux-gnu) - racc (~> 1.4) - octokit (4.25.1) - faraday (>= 1, < 3) - sawyer (~> 0.9) - pathutil (0.16.2) - forwardable-extended (~> 2.6) - prism (1.9.0) - public_suffix (5.1.1) - racc (1.8.1) - rb-fsevent (0.11.2) - rb-inotify (0.11.1) - ffi (~> 1.0) - rexml (3.4.4) - rouge (3.30.0) - rubyzip (2.4.1) - safe_yaml (1.0.5) - sass (3.7.4) - sass-listen (~> 4.0.0) - sass-listen (4.0.0) - rb-fsevent (~> 0.9, >= 0.9.4) - rb-inotify (~> 0.9, >= 0.9.7) - sawyer (0.9.3) - addressable (>= 2.3.5) - faraday (>= 0.17.3, < 3) - securerandom (0.4.1) - simpleidn (0.2.3) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) - timeout (0.6.1) - typhoeus (1.6.0) - ethon (>= 0.18.0) - tzinfo (2.0.6) - concurrent-ruby (~> 1.0) - unicode-display_width (1.8.0) - uri (1.1.2) - webrick (1.9.2) - -PLATFORMS - arm64-darwin-23 - x86_64-linux - -DEPENDENCIES - github-pages (= 232) - -BUNDLED WITH - 2.4.10 diff --git a/docs/_config.yml b/docs/_config.yml deleted file mode 100644 index 4528a0c..0000000 --- a/docs/_config.yml +++ /dev/null @@ -1,69 +0,0 @@ -title: Substratal Hub Help -description: >- - Help center for the Substratal Hub apps (Linux, macOS, Windows, iOS/iPadOS, - Android and Web): guides, tutorials, FAQs, a page for every screen, and the - versioned Platform API reference. - -# Project page on the default github.io URL. If a custom domain is added -# later (for example help.substratalapps.com), update both of these, add a -# CNAME file, and change HELP_BASE in crates/substratal-core/src/config.rs. -url: "https://compositecode.github.io" -baseurl: "/substratalapps-linux" - -permalink: pretty - -remote_theme: just-the-docs/just-the-docs@v0.12.0 -plugins: - - jekyll-remote-theme - - jekyll-seo-tag - - jekyll-sitemap - -color_scheme: substratal -logo: "/assets/images/mark.svg" -search_enabled: true -search: - heading_level: 3 - previews: 2 - preview_words_before: 4 - preview_words_after: 8 - tokenizer_separator: /[\s/-]+/ - rel_url: true - button: true -heading_anchors: true -back_to_top: true -back_to_top_text: "Back to top" - -aux_links: - "Platform API spec": "https://compositecode.github.io/substratalapps.com/" - "GitHub": "https://github.com/CompositeCode/substratalapps-linux" -aux_links_new_tab: true - -gh_edit_link: true -gh_edit_link_text: "Edit this page on GitHub" -gh_edit_repository: "https://github.com/CompositeCode/substratalapps-linux" -gh_edit_branch: "main" -gh_edit_source: docs -gh_edit_view_mode: "edit" - -callouts_level: quiet -callouts: - note: - title: Note - color: blue - important: - title: Important - color: red - tip: - title: Tip - color: green - -footer_content: >- - Substratal Hub help center. Reference pages are generated from the app's - code, the client specs and the Platform API's openapi.yaml on every change. - -exclude: - - Gemfile - - Gemfile.lock - - vendor - - node_modules - - README.md diff --git a/docs/_data/api.yml b/docs/_data/api.yml deleted file mode 100644 index e819a08..0000000 --- a/docs/_data/api.yml +++ /dev/null @@ -1,10 +0,0 @@ -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -# The API version(s) this help center documents. One entry per Substratal-Version value. -current: "1" -versions: - - version: "1" - status: current - spec_version: "1.0.0-draft.9" - operations: 124 - openapi: /api/v1/openapi.yaml - base_url: "https://api.substratalapps.com" diff --git a/docs/_includes/header_custom.html b/docs/_includes/header_custom.html deleted file mode 100644 index d913059..0000000 --- a/docs/_includes/header_custom.html +++ /dev/null @@ -1,24 +0,0 @@ -{%- comment -%} - Docs version badge, top right of every page's header. Version data is - _data/versions.yml, generated by scripts/docs-build.rb on each GitHub - build, so a plain local `jekyll build` has none and shows "local". - `version_archive_build` is set only when that script freezes a snapshot - into /versions//, so an archived copy labels itself and links to latest. -{%- endcomment -%} -{%- assign current = site.data.versions.current -%} -{%- if site.version_archive_build -%} - {%- assign latest_baseurl = site.baseurl | split: "/versions/" | first -%} -
- v{{ current.version }} · archived - View latest → - All versions -
-{%- elsif current -%} - -{%- else -%} -
- local -
-{%- endif -%} diff --git a/docs/_sass/color_schemes/substratal.scss b/docs/_sass/color_schemes/substratal.scss deleted file mode 100644 index 32dc859..0000000 --- a/docs/_sass/color_schemes/substratal.scss +++ /dev/null @@ -1,21 +0,0 @@ -// Substratal color scheme for just-the-docs. -// Cool stone neutrals + a verdigris (oxidized copper) accent — a nod to -// "strata": layered bedrock, not another blue-SaaS or cream-and-terracotta -// doc site. - -$body-background-color: #f2f3f0; -$sidebar-color: #e9ebe4; -$search-background-color: #ffffff; -$table-background-color: #ebede6; -$code-background-color: #e9ebe4; -$feedback-color: $sidebar-color; - -$body-text-color: #20242a; -$body-heading-color: #171a1e; -$search-result-preview-color: #5b6168; -$nav-child-link-color: #4b5159; - -$link-color: #2e6f62; -$btn-primary-color: #2e6f62; -$base-button-color: #e9ebe4; -$border-color: #d7dad2; diff --git a/docs/_sass/custom/custom.scss b/docs/_sass/custom/custom.scss deleted file mode 100644 index 7ece77b..0000000 --- a/docs/_sass/custom/custom.scss +++ /dev/null @@ -1,144 +0,0 @@ -// Loaded last by just-the-docs — safe place to win the cascade without !important. - -@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&family=IBM+Plex+Serif:ital,wght@0,400;0,500;0,600;1,400&display=swap"); - -$accent: #2e6f62; - -body { - font-family: "IBM Plex Serif", Georgia, "Times New Roman", serif; - font-size: 17px; - line-height: 1.65; -} - -.site-title, -.site-nav, -.nav-list, -.search-input, -.label, -.btn, -.main-content h1, -.main-content h2, -.main-content h3, -.main-content h4 { - font-family: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif; -} - -.main-content h1 { - font-weight: 700; - letter-spacing: -0.01em; -} - -.main-content h2 { - font-weight: 600; - margin-top: 2.2em; - padding-top: 0.9em; - border-top: 1px solid $border-color; -} - -.main-content h3 { - font-weight: 600; -} - -code, -pre, -.highlight, -kbd, -.code-example, -.language-plaintext { - font-family: "IBM Plex Mono", SFMono-Regular, Consolas, monospace; -} - -.site-nav .nav-list .nav-list-item .nav-list-link.active { - color: $accent; - font-weight: 600; -} - -.site-title { - font-weight: 700; -} - -// Thin layered-strata motif on the header — a quiet nod to "Substratal." -.site-header { - border-bottom: 4px solid transparent; - border-image: linear-gradient(to right, #8a7a63, $accent, #4b5159) 1; -} - -// Docs version badge (_includes/header_custom.html), top right of the header. -.docs-version { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0 1rem; - margin-left: auto; - font-family: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, sans-serif; - font-size: 0.8125rem; - white-space: nowrap; -} - -.docs-version__badge { - display: inline-block; - padding: 0.15rem 0.6rem; - border: 1px solid $accent; - border-radius: 999px; - color: $accent; - font-family: "IBM Plex Mono", SFMono-Regular, Consolas, monospace; - font-weight: 500; - text-decoration: none; - - &:hover { - background-color: $accent; - color: #fff; - text-decoration: none; - } -} - -.docs-version--archived .docs-version__badge { - border-color: #b7791f; - background-color: #fdf3e1; - color: #8a5a12; - - &:hover { - background-color: #fdf3e1; - color: #8a5a12; - } -} - -.docs-version__link { - color: $accent; -} - -// Interactive Reference (docs/openapi/reference.md): give Swagger UI the -// full content width and undo just-the-docs' table/code styling inside it. -.swagger-host { - margin-top: 1.5rem; - - .swagger-ui { - font-size: 14px; - - table { display: table; min-width: 0; border: 0; box-shadow: none; } - th, td { border: 0; background: transparent; padding: 8px 10px; } - code { background: transparent; border: 0; padding: 0; } - pre { margin: 0; } - .wrapper { padding: 0; max-width: none; } - .information-container .info { margin: 0 0 1rem; } - h2, h3, h4, h5 { border-top: 0; margin-top: 0; padding-top: 0; font-family: sans-serif; } - } -} - -// Keyboard keys in the shortcuts reference. -kbd { - display: inline-block; - padding: 0.05rem 0.4rem; - border: 1px solid $border-color; - border-bottom-width: 2px; - border-radius: 4px; - font-size: 0.85em; -} - -// API version picker on the interactive reference. -.api-version-picker { - display: flex; - gap: 0.75rem; - align-items: center; - margin: 1rem 0; -} diff --git a/docs/api/authentication.md b/docs/api/authentication.md deleted file mode 100644 index 0267e99..0000000 --- a/docs/api/authentication.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -layout: default -title: Authentication -parent: API -nav_order: 2 -permalink: /api/authentication/ ---- - -# Authentication -{: .no_toc } - -Every request except a few public ones (`/health`, sign-in, sign-up, password reset) sends a bearer token: - -```http -Authorization: Bearer -``` - -1. TOC -{:toc} - -## People: platform sessions - -The Hub signs people in with email and password: - -1. `POST /auth/login` with `{email, password}` returns either a session or, when two-factor is on, an MFA challenge (`mfa_required: true` and an `mfa_token`). -2. `POST /auth/mfa/verify` with the `mfa_token` and a 6-digit `code` (or a `recovery_code`) returns the session. -3. The session has a 15-minute **access token** (a JWT) and a long-lived, single-use **refresh token**. -4. `POST /auth/token/refresh` swaps the refresh token for a new pair. Each refresh token works **once**: reusing one revokes the whole session and raises a security alert. Refresh one request at a time and save the new refresh token before using the new access token. -5. `POST /auth/logout` ends the session. - -## Apps: app tokens - -A launched app gets a 5-minute **app token** for one Application: from `POST /auth/app-tokens` (embedded launch from the Hub) or through the hosted sign-in with PKCE (`POST /auth/oauth/authorization-codes` and `POST /auth/oauth/token`). - -## Servers and agents: API keys - -An API key (`satk_live_…` or `satk_test_…`) is a bearer token for your backend or an AI agent. It holds only the permissions it was created with, and an agent key can be restricted from destructive actions. Keys: - -- never work from a browser (`400 api_key_in_browser`); -- can't act as a person (endpoints using `me`, or `app-tokens`, return `400 user_token_required`); -- can't manage other keys. - -Create and rotate them in the Hub; see [API keys]({{ '/tutorials/api-keys/' | relative_url }}). - -## Permissions - -What a token can do comes from its holder's roles, or the key's permissions. A `403 forbidden` names the missing permission in `details.required_permission`. See [Navigation and permissions]({{ '/reference/navigation/' | relative_url }}) for the platform permissions. diff --git a/docs/api/changelog.md b/docs/api/changelog.md deleted file mode 100644 index 5d1b234..0000000 --- a/docs/api/changelog.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -layout: default -title: API changelog -parent: API -nav_order: 5 -permalink: /api/changelog/ ---- - -# API changelog -{: .no_toc } - -Changes to the Platform API specification this help center documents, newest first. Entries are added automatically by the **API spec sync** workflow when the API's `openapi.yaml` changes; it compares the old and new specification with [oasdiff](https://github.com/oasdiff/oasdiff) and flags anything breaking. The API team's own record is the [spec changelog](https://compositecode.github.io/substratalapps.com/changelog/). -{: .fs-6 .fw-300 } - - - -## 1.0.0-draft.9 (API v1.8.0) — 2026-10-22 - -Baseline for this help center: 124 operations under `Substratal-Version: 1`. - -- **Versioning moved to a header** (draft.4 to draft.5, API v1.7.0 to v1.7.3): `/v1` dropped from every path; `Substratal-Version` required; `Deprecation` and `Sunset` on deprecated versions; `405 method_not_allowed` for a wrong method on a known path. -- **SCIM 2.0 provisioning** per organization: `GET`/`POST`/`DELETE /organizations/{id}/scim-token`. -- **Test-mode user expiry**: `expires_at` on test users, `403 test_user_expired`. -- **Self-service application registration**: any person with a verified email can `POST /applications`. -- **Audit exports**: `POST /audit-exports`, `GET /audit-exports/{id}`. -- **Sign out everywhere**: `POST /users/{id}/sessions/revoke-all`; `GET /users?role_id=` finds a role's holders. -- `Deprecation` and `Sunset` exposed to browsers through CORS. diff --git a/docs/api/index.md b/docs/api/index.md deleted file mode 100644 index 786fe20..0000000 --- a/docs/api/index.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -layout: default -title: API -nav_order: 7 -has_children: true -permalink: /api/ ---- - -# Substratal Platform API -{: .no_toc } - -Every Hub app is a client of the Substratal Apps Platform API. Use this section to integrate your own app or tooling with it. The API's full specification, domain model and design decisions live in the [Platform API spec site](https://compositecode.github.io/substratalapps.com/); this section covers what you need day to day. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Base URL** | `{{ site.data.api.versions[0].base_url }}`. Production; the host root, no version in the path. | -| **API version** | `Substratal-Version: {{ site.data.api.current }}` request header, required. See [Versioning]({{ '/api/versioning/' | relative_url }}). | -| **Specification** | [`openapi.yaml`]({{ '/api/v1/openapi.yaml' | relative_url }}), OpenAPI 3.1, revision **{{ site.data.api.versions[0].spec_version }}**, {{ site.data.api.versions[0].operations }} operations | -| **Authentication** | Bearer tokens: a person's access token or an API key. See [Authentication]({{ '/api/authentication/' | relative_url }}). | -| **Format** | JSON. Errors are `{"error": {"code", "message", "details"}}`. | - -## Your first request - -```sh -curl -i https://api.substratalapps.com/health -# HTTP/2 200 -# {"status":"ok"} - -curl -i https://api.substratalapps.com/users/me \ - -H "Authorization: Bearer $TOKEN" \ - -H "Substratal-Version: 1" -``` - -`/health` doesn't need the version header; everything else does. - -## Conventions in brief - -| Topic | Rule | -|---|---| -| IDs | Opaque strings with a type prefix: `usr_`, `app_`, `org_`, `ent_`, `key_`, `evt_` and more. Use `me` for yourself with a person's token. | -| Pagination | `limit` and `cursor`; read `page.next_cursor` until `page.has_more` is `false`. | -| Creates | Send an `Idempotency-Key` (a UUID) so a retry can't create twice. Some creates require it. | -| Edits | Send `If-Match` with the `ETag` you read; `409 version_conflict` means someone else changed it first. | -| Rate limits | 100 requests a minute per session or key by default; `429` carries `Retry-After`. | -| Request IDs | Every response has `X-Request-Id`. Quote it when asking for help. | - -Full detail: [Conventions](https://compositecode.github.io/substratalapps.com/api-reference/conventions/) on the spec site. diff --git a/docs/api/operations.md b/docs/api/operations.md deleted file mode 100644 index 014603f..0000000 --- a/docs/api/operations.md +++ /dev/null @@ -1,217 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "Operations" -parent: API -nav_order: 4 ---- - -# API operations -{: .no_toc } - -All 124 operations in `openapi.yaml` **1.0.0-draft.9** (API version `1`), and which Hub screens use each one. Full request and response shapes are in the [interactive reference]({{ '/api/reference/' | relative_url }}). -{: .fs-6 .fw-300 } - -**Header** ✓ means the operation requires `Substratal-Version`; the few exempt ones are noted in [versioning]({{ '/api/versioning/' | relative_url }}). **Auth** — means no sign-in needed. - -1. TOC -{:toc} - -## API Keys - -| Method | Path | Summary | Header | Auth | Used by | -|---|---|---|:-:|:-:|---| -| `GET` | `/api-keys` | List keys the caller may manage | ✓ | ✓ | [KEY-01]({{ '/screens/key-01/' | relative_url }}) [INT-01]({{ '/screens/int-01/' | relative_url }}) | -| `POST` | `/api-keys` | Create a key (api_keys.manage, or the Application's owner for an app-scoped key) | ✓ | ✓ | [KEY-02]({{ '/screens/key-02/' | relative_url }}) | -| `GET` | `/api-keys/{id}` | Fetch one key (never the secret) | ✓ | ✓ | [KEY-04]({{ '/screens/key-04/' | relative_url }}) | -| `PATCH` | `/api-keys/{id}` | Change name, permissions, or restrict_destructive | ✓ | ✓ | [KEY-04]({{ '/screens/key-04/' | relative_url }}) | -| `DELETE` | `/api-keys/{id}` | Revoke permanently | ✓ | ✓ | [KEY-04]({{ '/screens/key-04/' | relative_url }}) | -| `POST` | `/api-keys/{id}/rotate` | Issue a new secret, invalidating the old one immediately (no overlap) | ✓ | ✓ | [KEY-04]({{ '/screens/key-04/' | relative_url }}) | - -## Applications - -| Method | Path | Summary | Header | Auth | Used by | -|---|---|---|:-:|:-:|---| -| `GET` | `/applications` | List the catalog (filtered by visibility rules) | ✓ | ✓ | [APP-01]({{ '/screens/app-01/' | relative_url }}) [ACX-01]({{ '/screens/acx-01/' | relative_url }}) [APL-01]({{ '/screens/apl-01/' | relative_url }}) [BIL-01]({{ '/screens/bil-01/' | relative_url }}) [NTF-01]({{ '/screens/ntf-01/' | relative_url }}) | -| `POST` | `/applications` | Register an Application (applications.manage, or self-service into review) | ✓ | ✓ | [APL-02]({{ '/screens/apl-02/' | relative_url }}) | -| `GET` | `/applications/{id}` | Fetch one Application | ✓ | ✓ | [APL-03]({{ '/screens/apl-03/' | relative_url }}) | -| `PATCH` | `/applications/{id}` | Update a catalog entry (owner — configuration; applications.manage — anything) | ✓ | ✓ | [APL-03]({{ '/screens/apl-03/' | relative_url }}) [APL-04]({{ '/screens/apl-04/' | relative_url }}) [APL-05]({{ '/screens/apl-05/' | relative_url }}) [APL-06]({{ '/screens/apl-06/' | relative_url }}) | - -## Audit - -| Method | Path | Summary | Header | Auth | Used by | -|---|---|---|:-:|:-:|---| -| `GET` | `/audit-events` | Query the audit log (hot window only) | ✓ | ✓ | [KEY-04]({{ '/screens/key-04/' | relative_url }}) [AUD-01]({{ '/screens/aud-01/' | relative_url }}) [OPS-01]({{ '/screens/ops-01/' | relative_url }}) | -| `GET` | `/audit-events/{id}` | Fetch one Audit Event | ✓ | ✓ | [AUD-02]({{ '/screens/aud-02/' | relative_url }}) | -| `POST` | `/audit-exports` | Export Audit Events to a file (asynchronous) | ✓ | ✓ | | -| `GET` | `/audit-exports/{id}` | Fetch an export's status, and its download link once ready | ✓ | ✓ | | - -## Auth - -| Method | Path | Summary | Header | Auth | Used by | -|---|---|---|:-:|:-:|---| -| `POST` | `/auth/signup` | Create an account with email and password | ✓ | — | [AUTH-03]({{ '/screens/auth-03/' | relative_url }}) | -| `POST` | `/auth/login` | Exchange email and password for a session (or an MFA challenge) | ✓ | — | [AUTH-01]({{ '/screens/auth-01/' | relative_url }}) | -| `POST` | `/auth/mfa/verify` | Complete a login that returned an MFA challenge | ✓ | — | [AUTH-02]({{ '/screens/auth-02/' | relative_url }}) | -| `POST` | `/auth/token/refresh` | Rotate a platform refresh token for a new access token | ✓ | — | [GLB-03]({{ '/screens/glb-03/' | relative_url }}) | -| `POST` | `/auth/logout` | Revoke the current session, or every session | ✓ | ✓ | [ACC-01]({{ '/screens/acc-01/' | relative_url }}) [GLB-03]({{ '/screens/glb-03/' | relative_url }}) | -| `POST` | `/auth/app-tokens` | Mint a per-app JWT from a platform session (embedded login) | ✓ | ✓ | [APP-03]({{ '/screens/app-03/' | relative_url }}) | -| `POST` | `/auth/oauth/authorization-codes` | Mint a one-time authorization code for a hosted launch (PKCE) | ✓ | ✓ | [APP-03]({{ '/screens/app-03/' | relative_url }}) | -| `POST` | `/auth/oauth/token` | Exchange an authorization code or app refresh token for an app token | exempt | — | | -| `POST` | `/auth/email/verify` | Confirm an email address from an emailed token | ✓ | — | [AUTH-04]({{ '/screens/auth-04/' | relative_url }}) | -| `POST` | `/auth/email/verify/resend` | Re-send a verification email (always 202) | ✓ | — | [AUTH-04]({{ '/screens/auth-04/' | relative_url }}) | -| `POST` | `/auth/password/forgot` | Send a password-reset email (always 202) | ✓ | — | [AUTH-05]({{ '/screens/auth-05/' | relative_url }}) | -| `POST` | `/auth/password/reset` | Set a new password from a reset token (revokes every session) | ✓ | — | [AUTH-06]({{ '/screens/auth-06/' | relative_url }}) | -| `POST` | `/auth/invitations/accept` | Set a password on an invited account, activate it, and sign in | ✓ | — | [AUTH-07]({{ '/screens/auth-07/' | relative_url }}) | -| `POST` | `/auth/sso/{provider}/callback` | Complete an SSO login (reserved — 501 until the broker integration ships) | ✓ | — | [AUTH-01]({{ '/screens/auth-01/' | relative_url }}) | -| `POST` | `/users/{id}/password` | Change password given the current one (self only) | ✓ | ✓ | | -| `POST` | `/users/{id}/mfa/totp` | Begin TOTP enrollment (self only) | ✓ | ✓ | | -| `DELETE` | `/users/{id}/mfa/totp` | Support reset of a user's TOTP (requires users.manage) | ✓ | ✓ | [USR-03]({{ '/screens/usr-03/' | relative_url }}) | -| `POST` | `/users/{id}/mfa/totp/confirm` | Confirm TOTP enrollment with a first code; returns recovery codes once | ✓ | ✓ | | -| `POST` | `/users/{id}/mfa/totp/disable` | Turn TOTP off given a current code or recovery code (self only) | ✓ | ✓ | | -| `GET` | `/users/{id}/sessions` | List active sessions (self or users.manage) | ✓ | ✓ | [USR-04]({{ '/screens/usr-04/' | relative_url }}) | -| `POST` | `/users/{id}/sessions/revoke-all` | Revoke every session (self or users.manage) | ✓ | ✓ | [USR-04]({{ '/screens/usr-04/' | relative_url }}) | -| `DELETE` | `/users/{id}/sessions/{sessionId}` | Revoke one session | ✓ | ✓ | [USR-04]({{ '/screens/usr-04/' | relative_url }}) | - -## Billing - -| Method | Path | Summary | Header | Auth | Used by | -|---|---|---|:-:|:-:|---| -| `GET` | `/users/{id}/subscription` | A User's plan, what covers it, and their own subscription (self or billing.manage) | ✓ | ✓ | [USR-03]({{ '/screens/usr-03/' | relative_url }}) | -| `POST` | `/users/me/billing/checkout-sessions` | Start a Stripe Checkout to subscribe yourself to Standard (User token) | ✓ | ✓ | [BIL-03]({{ '/screens/bil-03/' | relative_url }}) | -| `POST` | `/users/me/billing/portal-sessions` | Open the Stripe Customer Portal for your own subscription (User token) | ✓ | ✓ | [BIL-03]({{ '/screens/bil-03/' | relative_url }}) | -| `GET` | `/organizations/{id}/subscription` | An Organization's plan and Standard subscription (org admin or billing.manage) | ✓ | ✓ | [BIL-02]({{ '/screens/bil-02/' | relative_url }}) | -| `PATCH` | `/organizations/{id}/subscription` | Choose which members the Organization's subscription covers (org admin) | ✓ | ✓ | [BIL-02]({{ '/screens/bil-02/' | relative_url }}) | -| `POST` | `/organizations/{id}/billing/checkout-sessions` | Start a Stripe Checkout to subscribe the Organization to Standard (org admin, User token) | ✓ | ✓ | [BIL-03]({{ '/screens/bil-03/' | relative_url }}) | -| `POST` | `/organizations/{id}/billing/portal-sessions` | Open the Stripe Customer Portal for the Organization's subscription (org admin or billing.manage, User token) | ✓ | ✓ | [BIL-03]({{ '/screens/bil-03/' | relative_url }}) | - -## Entitlements - -| Method | Path | Summary | Header | Auth | Used by | -|---|---|---|:-:|:-:|---| -| `GET` | `/users/{id}/entitlements` | A user's entitlements, personal and org-sourced | ✓ | ✓ | [ACX-01]({{ '/screens/acx-01/' | relative_url }}) | -| `POST` | `/users/{id}/entitlements` | Grant a personal Entitlement | ✓ | ✓ | [ACX-02]({{ '/screens/acx-02/' | relative_url }}) | -| `GET` | `/entitlements` | List entitlements across users (entitlements.manage or the Application's owner; app keys and owners confined) | ✓ | ✓ | [ACX-05]({{ '/screens/acx-05/' | relative_url }}) | -| `GET` | `/entitlements/{id}` | Fetch one entitlement | ✓ | ✓ | [ACX-03]({{ '/screens/acx-03/' | relative_url }}) | -| `PATCH` | `/entitlements/{id}` | The toggle: change status, term, source (trial→purchase), order_id, or an org grant's member scope | ✓ | ✓ | [ORG-06]({{ '/screens/org-06/' | relative_url }}) [ORG-07]({{ '/screens/org-07/' | relative_url }}) [ACX-01]({{ '/screens/acx-01/' | relative_url }}) [ACX-03]({{ '/screens/acx-03/' | relative_url }}) | -| `DELETE` | `/entitlements/{id}` | Hard-remove a grant made in error (no order_id, under 24 hours old) | ✓ | ✓ | [ACX-01]({{ '/screens/acx-01/' | relative_url }}) | - -## Meta - -| Method | Path | Summary | Header | Auth | Used by | -|---|---|---|:-:|:-:|---| -| `GET` | `/health` | Health check | exempt | — | [GLB-03]({{ '/screens/glb-03/' | relative_url }}) [OPS-01]({{ '/screens/ops-01/' | relative_url }}) | - -## Notifications - -| Method | Path | Summary | Header | Auth | Used by | -|---|---|---|:-:|:-:|---| -| `GET` | `/applications/{id}/push-credentials` | List the push providers an Application has configured (the owner, the app's own key, or applications.manage) | ✓ | ✓ | [APL-07]({{ '/screens/apl-07/' | relative_url }}) | -| `POST` | `/applications/{id}/push-credentials` | Configure one push provider (the owner or applications.manage, User token only) | ✓ | ✓ | [APL-07]({{ '/screens/apl-07/' | relative_url }}) | -| `GET` | `/applications/{id}/push-credentials/{provider}` | Fetch one provider's configuration | ✓ | ✓ | [APL-07]({{ '/screens/apl-07/' | relative_url }}) | -| `PATCH` | `/applications/{id}/push-credentials/{provider}` | Replace key material, change non-secret fields, or re-enable a failed credential (User token only) | ✓ | ✓ | [APL-07]({{ '/screens/apl-07/' | relative_url }}) | -| `DELETE` | `/applications/{id}/push-credentials/{provider}` | Remove a push provider (User token only) | ✓ | ✓ | [APL-07]({{ '/screens/apl-07/' | relative_url }}) | -| `GET` | `/users/{id}/devices` | List a User's push devices (self, the app's own key, or users.list) | ✓ | ✓ | [NTF-05]({{ '/screens/ntf-05/' | relative_url }}) | -| `POST` | `/users/{id}/devices` | Register a push device, or refresh an existing registration (self or the app's own key) | ✓ | ✓ | | -| `GET` | `/users/{id}/devices/{deviceId}` | Fetch one device | ✓ | ✓ | [NTF-05]({{ '/screens/ntf-05/' | relative_url }}) | -| `PATCH` | `/users/{id}/devices/{deviceId}` | Update a device's name, app version, environment, or rotated token (self or the app's own key) | ✓ | ✓ | | -| `DELETE` | `/users/{id}/devices/{deviceId}` | Unregister a device (self, the app's own key, or users.manage) | ✓ | ✓ | [NTF-05]({{ '/screens/ntf-05/' | relative_url }}) | -| `POST` | `/users/{id}/devices/{deviceId}/test` | Send a test push to one device and wait for the provider's answer (self or the app's own key) | ✓ | ✓ | | -| `GET` | `/users/{id}/notifications` | List a User's notification inbox (self, the app's own key, or users.list) | ✓ | ✓ | [NTF-05]({{ '/screens/ntf-05/' | relative_url }}) | -| `POST` | `/users/{id}/notifications` | Send one notification to one User (the app's own key with notifications.send, or platform notifications.send) | ✓ | ✓ | [NTF-06]({{ '/screens/ntf-06/' | relative_url }}) | -| `GET` | `/users/{id}/notifications/unread-count` | Count unread notifications, in total and per Application (self or the app's own key) | ✓ | ✓ | | -| `POST` | `/users/{id}/notifications/mark-read` | Mark every unread notification matching the filters as read (self or the app's own key) | ✓ | ✓ | | -| `GET` | `/users/{id}/notifications/{notificationId}` | Fetch one notification, with its per-device deliveries | ✓ | ✓ | [NTF-05]({{ '/screens/ntf-05/' | relative_url }}) | -| `PATCH` | `/users/{id}/notifications/{notificationId}` | Mark one notification read, unread, or archived (self or the app's own key) | ✓ | ✓ | | - -## Organizations - -| Method | Path | Summary | Header | Auth | Used by | -|---|---|---|:-:|:-:|---| -| `GET` | `/organizations` | List your Organizations (all, with organizations.manage) | ✓ | ✓ | [APP-01]({{ '/screens/app-01/' | relative_url }}) [ORG-01]({{ '/screens/org-01/' | relative_url }}) | -| `POST` | `/organizations` | Create an Organization (any active User; caller becomes org_admin) | ✓ | ✓ | [ORG-02]({{ '/screens/org-02/' | relative_url }}) | -| `GET` | `/organizations/{id}` | Fetch one Organization | ✓ | ✓ | [ORG-03]({{ '/screens/org-03/' | relative_url }}) | -| `PATCH` | `/organizations/{id}` | Rename (org admin), or suspend/reactivate (organizations.manage) | ✓ | ✓ | [ORG-08]({{ '/screens/org-08/' | relative_url }}) | -| `GET` | `/organizations/{id}/members` | List members | ✓ | ✓ | [ORG-03]({{ '/screens/org-03/' | relative_url }}) [BIL-02]({{ '/screens/bil-02/' | relative_url }}) | -| `POST` | `/organizations/{id}/members` | Invite a member by email (org admin), or add one directly by user_id (organizations.manage) | ✓ | ✓ | [ORG-04]({{ '/screens/org-04/' | relative_url }}) | -| `POST` | `/organizations/{id}/members/me/accept` | Accept a pending membership (the invited User's own token) | ✓ | ✓ | [ORG-09]({{ '/screens/org-09/' | relative_url }}) | -| `PATCH` | `/organizations/{id}/members/{userId}` | Change a member's role | ✓ | ✓ | [ORG-05]({{ '/screens/org-05/' | relative_url }}) | -| `DELETE` | `/organizations/{id}/members/{userId}` | Remove a member or withdraw an invitation; or leave or decline (userId = me) | ✓ | ✓ | [ORG-05]({{ '/screens/org-05/' | relative_url }}) | -| `GET` | `/organizations/{id}/scim-token` | Show the Organization's SCIM token (never its secret) | ✓ | ✓ | [ORG-08]({{ '/screens/org-08/' | relative_url }}) | -| `POST` | `/organizations/{id}/scim-token` | Create (or rotate) the Organization's SCIM token | ✓ | ✓ | [ORG-08]({{ '/screens/org-08/' | relative_url }}) | -| `DELETE` | `/organizations/{id}/scim-token` | Turn SCIM off for the Organization | ✓ | ✓ | [ORG-08]({{ '/screens/org-08/' | relative_url }}) | -| `GET` | `/organizations/{id}/entitlements` | List org-wide (seat) grants | ✓ | ✓ | [ORG-06]({{ '/screens/org-06/' | relative_url }}) | -| `POST` | `/organizations/{id}/entitlements` | Grant an app to the whole Organization | ✓ | ✓ | [ORG-07]({{ '/screens/org-07/' | relative_url }}) | - -## Profiles - -| Method | Path | Summary | Header | Auth | Used by | -|---|---|---|:-:|:-:|---| -| `GET` | `/users/{id}/profile` | Fetch global Profile | ✓ | ✓ | [USR-03]({{ '/screens/usr-03/' | relative_url }}) | -| `PATCH` | `/users/{id}/profile` | Update global Profile (self or users.manage) | ✓ | ✓ | | -| `GET` | `/users/{id}/apps/{appId}/profile` | Fetch per-app AppProfile (self, the app's own key, or platform users.list; defaults if none written yet) | ✓ | ✓ | | -| `PATCH` | `/users/{id}/apps/{appId}/profile` | Update per-app AppProfile (custom is merged one level; null removes a key) | ✓ | ✓ | | - -## Roles & Permissions - -| Method | Path | Summary | Header | Auth | Used by | -|---|---|---|:-:|:-:|---| -| `GET` | `/permissions` | List every Permission key the caller can see | ✓ | ✓ | [ROL-01]({{ '/screens/rol-01/' | relative_url }}) [ROL-02]({{ '/screens/rol-02/' | relative_url }}) | -| `GET` | `/roles` | List Roles | ✓ | ✓ | [ROL-01]({{ '/screens/rol-01/' | relative_url }}) | -| `POST` | `/roles` | Define a Role | ✓ | ✓ | [ROL-03]({{ '/screens/rol-03/' | relative_url }}) | -| `GET` | `/roles/{id}` | Fetch one Role | ✓ | ✓ | [ROL-02]({{ '/screens/rol-02/' | relative_url }}) | -| `PATCH` | `/roles/{id}` | Change a Role's permissions or description | ✓ | ✓ | [ROL-02]({{ '/screens/rol-02/' | relative_url }}) | -| `DELETE` | `/roles/{id}` | Delete an unassigned, non-seed Role | ✓ | ✓ | [ROL-02]({{ '/screens/rol-02/' | relative_url }}) | -| `GET` | `/users/{id}/roles` | List a user's Role assignments (including implicit default_app_role) | ✓ | ✓ | [ROL-04]({{ '/screens/rol-04/' | relative_url }}) | -| `POST` | `/users/{id}/roles/{roleId}` | Assign a Role (idempotent — 200 if already held) | ✓ | ✓ | [ROL-04]({{ '/screens/rol-04/' | relative_url }}) | -| `DELETE` | `/users/{id}/roles/{roleId}` | Remove a Role assignment (idempotent) | ✓ | ✓ | [ROL-04]({{ '/screens/rol-04/' | relative_url }}) | -| `GET` | `/users/{id}/apps/{appId}/effective-permissions` | The live access check (always 200) | ✓ | ✓ | [ACX-04]({{ '/screens/acx-04/' | relative_url }}) | - -## Settings - -| Method | Path | Summary | Header | Auth | Used by | -|---|---|---|:-:|:-:|---| -| `GET` | `/users/{id}/settings` | Fetch global Settings (self, or platform users.list) | ✓ | ✓ | | -| `PATCH` | `/users/{id}/settings` | Update global Settings (self or users.manage) | ✓ | ✓ | | -| `GET` | `/users/{id}/apps/{appId}/settings` | Fetch fully resolved per-app settings (self, the app's own key, or platform users.list) | ✓ | ✓ | | -| `PATCH` | `/users/{id}/apps/{appId}/settings` | Write per-app overrides (validated against settings_schema; null clears) | ✓ | ✓ | | - -## Tenancy - -| Method | Path | Summary | Header | Auth | Used by | -|---|---|---|:-:|:-:|---| -| `GET` | `/tenants` | List Tenants (your own; all with tenants.manage) | ✓ | ✓ | [BIL-04]({{ '/screens/bil-04/' | relative_url }}) | -| `GET` | `/tenants/{id}` | Fetch one Tenant | ✓ | ✓ | [BIL-04]({{ '/screens/bil-04/' | relative_url }}) | -| `GET` | `/tenants/{id}/tier-change-requests` | List tier-change requests for a Tenant | ✓ | ✓ | [BIL-04]({{ '/screens/bil-04/' | relative_url }}) | -| `POST` | `/tenants/{id}/tier-change-requests` | Open a tier-change request on a customer's behalf (tenants.manage) | ✓ | ✓ | [BIL-04]({{ '/screens/bil-04/' | relative_url }}) | -| `GET` | `/tenants/{id}/tier-change-requests/{requestId}` | Fetch one tier-change request | ✓ | ✓ | [BIL-04]({{ '/screens/bil-04/' | relative_url }}) | -| `PATCH` | `/tenants/{id}/tier-change-requests/{requestId}` | Schedule, start, complete, or cancel a tier-change request (tenants.manage) | ✓ | ✓ | [BIL-04]({{ '/screens/bil-04/' | relative_url }}) | - -## Users - -| Method | Path | Summary | Header | Auth | Used by | -|---|---|---|:-:|:-:|---| -| `GET` | `/users` | List or search users | ✓ | ✓ | [USR-01]({{ '/screens/usr-01/' | relative_url }}) [ROL-02]({{ '/screens/rol-02/' | relative_url }}) [NTF-06]({{ '/screens/ntf-06/' | relative_url }}) [GLB-01]({{ '/screens/glb-01/' | relative_url }}) | -| `POST` | `/users` | Create (invite) a user | ✓ | ✓ | [USR-02]({{ '/screens/usr-02/' | relative_url }}) | -| `GET` | `/users/{id}` | Fetch one user | ✓ | ✓ | [USR-03]({{ '/screens/usr-03/' | relative_url }}) | -| `PATCH` | `/users/{id}` | Update email (self, pending verification) or status (users.manage) | ✓ | ✓ | [USR-03]({{ '/screens/usr-03/' | relative_url }}) | -| `DELETE` | `/users/{id}` | Soft-delete a user (self or users.manage) | ✓ | ✓ | [USR-03]({{ '/screens/usr-03/' | relative_url }}) | -| `POST` | `/users/{id}/suspend` | Suspend a user (users.manage) | ✓ | ✓ | [USR-05]({{ '/screens/usr-05/' | relative_url }}) | -| `POST` | `/users/{id}/invitation` | Re-send the invitation email to an invited user (users.manage) | ✓ | ✓ | [USR-03]({{ '/screens/usr-03/' | relative_url }}) | -| `GET` | `/users/{id}/export` | Export everything this API holds about this user | ✓ | ✓ | [USR-03]({{ '/screens/usr-03/' | relative_url }}) | -| `POST` | `/users/{id}/erasure-requests` | Request right-to-erasure (soft-delete now, hard-delete cascade in 7 days) | ✓ | ✓ | | -| `DELETE` | `/users/{id}/erasure-requests/current` | Cancel a scheduled erasure inside its 7-day window (users.manage) | ✓ | ✓ | [USR-03]({{ '/screens/usr-03/' | relative_url }}) | - -## Webhooks - -| Method | Path | Summary | Header | Auth | Used by | -|---|---|---|:-:|:-:|---| -| `GET` | `/webhooks` | List subscriptions visible to the caller | ✓ | ✓ | [WHK-01]({{ '/screens/whk-01/' | relative_url }}) [INT-01]({{ '/screens/int-01/' | relative_url }}) | -| `POST` | `/webhooks` | Subscribe a URL to event types | ✓ | ✓ | [WHK-02]({{ '/screens/whk-02/' | relative_url }}) | -| `GET` | `/webhooks/{id}` | Fetch one subscription | ✓ | ✓ | [WHK-03]({{ '/screens/whk-03/' | relative_url }}) | -| `PATCH` | `/webhooks/{id}` | Change url, events, description, api_version, or re-enable | ✓ | ✓ | [WHK-02]({{ '/screens/whk-02/' | relative_url }}) | -| `DELETE` | `/webhooks/{id}` | Unsubscribe | ✓ | ✓ | [WHK-03]({{ '/screens/whk-03/' | relative_url }}) | -| `POST` | `/webhooks/{id}/rotate-secret` | Issue a new signing secret (24-hour overlap) | ✓ | ✓ | [WHK-03]({{ '/screens/whk-03/' | relative_url }}) | -| `POST` | `/webhooks/{id}/test` | Send a webhook.test event now | ✓ | ✓ | [WHK-03]({{ '/screens/whk-03/' | relative_url }}) | -| `GET` | `/webhooks/{id}/deliveries` | Recent delivery attempts (30-day log) | ✓ | ✓ | [WHK-03]({{ '/screens/whk-03/' | relative_url }}) | -| `POST` | `/webhooks/{id}/deliveries/{deliveryId}/redeliver` | Re-send one past event (same event id) | ✓ | ✓ | [WHK-03]({{ '/screens/whk-03/' | relative_url }}) | diff --git a/docs/api/reference.md b/docs/api/reference.md deleted file mode 100644 index 68acf41..0000000 --- a/docs/api/reference.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -layout: default -title: Interactive reference -parent: API -nav_order: 3 -permalink: /api/reference/ ---- - -# Interactive reference -{: .no_toc } - -Every operation, parameter, request body and response in the Platform API's `openapi.yaml`, rendered with Swagger UI. Pick the API version you're building against; the reference sends that version in `Substratal-Version` on every **Try it out** request. -{: .fs-6 .fw-300 } - -
- - - Download openapi.yaml -
- -**Trying requests:** browsers can't call production from this page, on purpose. The API only accepts browser requests from first-party origins so admin and service credentials never get used from a web page. Use **Local devserver** under Servers (run the API's devserver with `CORS_ALLOWED_ORIGINS=https://compositecode.github.io`), or copy the request as `curl`. -{: .note } - -
- -
- - - - - diff --git a/docs/api/v1/openapi.yaml b/docs/api/v1/openapi.yaml deleted file mode 100644 index f36006d..0000000 --- a/docs/api/v1/openapi.yaml +++ /dev/null @@ -1,4446 +0,0 @@ -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" } } diff --git a/docs/api/versioning.md b/docs/api/versioning.md deleted file mode 100644 index 233799d..0000000 --- a/docs/api/versioning.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -layout: default -title: Versioning -parent: API -nav_order: 1 -permalink: /api/versioning/ ---- - -# API versioning -{: .no_toc } - -The API version is a **request header**, not part of the URL. -{: .fs-6 .fw-300 } - -1. TOC -{:toc} - -## The header - -```http -GET /users/me HTTP/1.1 -Host: api.substratalapps.com -Authorization: Bearer eyJ… -Substratal-Version: 1 -``` - -- **Required.** Without it: `400 api_version_required`. With a version the server doesn't serve: `400 unsupported_api_version`. Both list the versions it does serve in `details.supported`. There's no default, so you're never moved to a new version without asking. -- **Echoed.** Every response to a versioned request carries `Substratal-Version` with the version that served it. -- **Whole numbers.** `1` is the only version today. -- **Exempt:** `GET /health`, `POST /auth/oauth/token`, `/.well-known/*` and SCIM (`/scim/v2`). Sending the header to them anyway is harmless. - -## No `/v1` in the URL - -Until API v1.7.0 (`openapi.yaml` 1.0.0-draft.4, October 2026) paths started with `/v1`. They don't any more: - -| Before | Now | -|---|---| -| `POST https://api.substratalapps.com/v1/users` | `POST https://api.substratalapps.com/users` with `Substratal-Version: 1` | - -Old `/v1/...` paths return `404 not_found`; there's no redirect. Because the version isn't in the path, a resource's URL stays the same in every future version: links, caches and stored URLs never change. -{: .important } - -## What changes within a version, and what doesn't - -**Additive changes ship within a version**, with no header change: new endpoints, new optional fields, new enum values. Write clients that ignore fields they don't know and treat unknown enum values as "other". - -**Breaking changes ship as the next version number.** The previous version keeps working for **at least 6 months**. - -## Deprecation and sunset - -When the version you send is deprecated, every response carries two extra headers: - -```http -Deprecation: @1798761600 -Sunset: Wed, 01 Jul 2027 00:00:00 GMT -``` - -- `Deprecation` ([RFC 9745](https://www.rfc-editor.org/rfc/rfc9745)): when the version was deprecated. -- `Sunset` ([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594)): when it stops working. After that, requests naming it get `400 unsupported_api_version`. - -The Hub apps show **Update available**, with the sunset date, when they see these headers, and **Update required** once the version is retired. Your own clients should log and alert on them. - -## Versions - -| `Substratal-Version` | Status | Specification | Operations | -|---|---|---|---| -{% for v in site.data.api.versions %}| `{{ v.version }}` | {{ v.status }} | [openapi.yaml]({{ v.openapi | relative_url }}) ({{ v.spec_version }}) | {{ v.operations }} | -{% endfor %} - -Revisions of the specification within a version (`1.0.0-draft.N`) are listed in the [API changelog]({{ '/api/changelog/' | relative_url }}). Older revisions of this help center, and the API reference as it was then, are in [Docs versions]({{ '/versions/' | relative_url }}). - -## Not the webhook version - -A webhook subscription's `api_version` is a **date** (such as `2026-10-05`) that fixes the shape of the payloads it receives. It's versioned separately from `Substratal-Version`, and the Hub labels it **Payload version**. diff --git a/docs/assets/images/APP-01-home.png b/docs/assets/images/APP-01-home.png deleted file mode 100644 index 46506762998531c263e6b098cd9dcedc8728d80b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 265933 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�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 eX>xA2&la&OSM7Qx}TfdF|RWq0$m-Oeu7 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 diff --git a/docs/assets/images/mark.svg b/docs/assets/images/mark.svg deleted file mode 100644 index d003aa3..0000000 --- a/docs/assets/images/mark.svg +++ /dev/null @@ -1 +0,0 @@ -Substratal \ No newline at end of file diff --git a/docs/changelog.md b/docs/changelog.md deleted file mode 100644 index 0b230b4..0000000 --- a/docs/changelog.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -layout: default -title: App changelog -nav_order: 9 -permalink: /changelog/ ---- - -# Substratal for Linux changelog -{: .no_toc } - -## Unreleased - -- **First skeleton.** The GTK 4 + libadwaita app with the full navigation tree, gated by your permissions; all 75 Hub screens reachable by route and `substratal://` link, each laid out from the spec; live sign-in with two-factor; the Users table; Settings › Appearance with all 10 themes; the command palette (Ctrl+K), keyboard shortcuts, About, and help on F1. -- Talks to `https://api.substratalapps.com` with `Substratal-Version: 1` on every request; handles **Update available** (`Deprecation`/`Sunset`) and **Update required** (`unsupported_api_version`). -- `substratalctl` command-line companion: `health`, `version`, `login`, `logout`, `whoami`, `users list`, `screens`, `route`, `help-url`. diff --git a/docs/contributing.md b/docs/contributing.md deleted file mode 100644 index 62343d5..0000000 --- a/docs/contributing.md +++ /dev/null @@ -1,62 +0,0 @@ ---- -layout: default -title: How these docs stay current -nav_order: 10 -permalink: /contributing/ ---- - -# How these docs stay current -{: .no_toc } - -This help center is built from the same repository as the Linux app and published by GitHub Actions on every change. Reference pages are generated, not written, so they can't drift from the app. -{: .fs-6 .fw-300 } - -1. TOC -{:toc} - -## What triggers an update - -| Change | Workflow | What happens | -|---|---|---| -| Any push to `main` that touches the app, the docs or the vendored API spec | **Docs** (`.github/workflows/docs.yml`) | Regenerates reference pages from the code, builds the site, snapshots it as a new docs version, deploys to GitHub Pages | -| A pull request | **CI** (`.github/workflows/ci.yml`) | Fails if generated pages are out of date (`cargo xtask gen-docs --check`) or the site doesn't build | -| The Platform API's `openapi.yaml` changes | **API spec sync** (`.github/workflows/api-spec-sync.yml`), daily and on demand | Fetches the spec, diffs it with oasdiff, adds an [API changelog]({{ '/api/changelog/' | relative_url }}) entry, regenerates the API pages, opens a pull request; breaking changes are labelled | -| The client specs package changes | **Specs sync** (`.github/workflows/specs-sync.yml`), daily and on demand | Regenerates the screen registry, themes and screen pages, opens a pull request | - -## Generated pages - -| Page | Generated from | -|---|---| -| [Screens]({{ '/screens/' | relative_url }}) and every screen page | the specs package, via `data/spec/screens.json` | -| [Keyboard shortcuts]({{ '/reference/shortcuts/' | relative_url }}) | the shortcut table the app binds | -| [Navigation and permissions]({{ '/reference/navigation/' | relative_url }}) | the app's navigation rules, evaluated for each persona | -| [Status meanings]({{ '/reference/statuses/' | relative_url }}) | `status-semantics.json` | -| [substratalctl]({{ '/reference/cli/' | relative_url }}) | the CLI's own argument definitions | -| [Operations]({{ '/api/operations/' | relative_url }}) and [`openapi.yaml`]({{ '/api/v1/openapi.yaml' | relative_url }}) | the vendored API spec, cross-referenced with the screens | - -Run `cargo xtask gen-docs` after changing any of those sources, and commit the result. - -## Writing pages - -Guides, tutorials and the FAQ are Markdown under `docs/` ([just-the-docs](https://just-the-docs.com/)). Every page has an **Edit this page** link. To preview: - -```sh -cd docs && bundle install && bundle exec jekyll serve -``` - -## Versions - -Each published build is a new docs version with a frozen copy, listed in [Docs versions]({{ '/versions/' | relative_url }}). Put `[docs:minor]` or `[docs:major]` in a commit's subject line for a bigger bump. Docs versions are separate from the API's `Substratal-Version`. - -## Triggering the API sync from the API repo - -The sync also runs every 6 hours. To make it immediate, add this step to the API repo's docs workflow after the spec is published. It needs a token with access to this repo in a secret named `LINUX_REPO_DISPATCH_TOKEN`: - -```yaml -- name: Tell the Linux Hub the spec changed - run: | - curl -fsS -X POST https://api.github.com/repos/CompositeCode/substratalapps-linux/dispatches \ - -H "Authorization: Bearer ${{ secrets.LINUX_REPO_DISPATCH_TOKEN }}" \ - -H "Accept: application/vnd.github+json" \ - -d '{"event_type":"api-spec-updated"}' -``` diff --git a/docs/faq.md b/docs/faq.md deleted file mode 100644 index 62af6fd..0000000 --- a/docs/faq.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -layout: default -title: FAQ -nav_order: 8 -permalink: /faq/ ---- - -# Frequently asked questions -{: .no_toc } - -1. TOC -{:toc} - -## Accounts and access - -### Why can't I see Administration or Developer? -They only appear if you have permissions or own an app. See [Navigation and permissions]({{ '/reference/navigation/' | relative_url }}). - -### An app shows "Managed by Substratal". Why can't I edit it? -Free apps and apps covered by a Standard subscription are managed by the platform. You can turn them off and back on, but not edit, revoke or delete them. - -### Someone has two ways into the same app. Which one wins? -Access paths add up: if any path is active, the app is on. The access screen shows the resolved status first, and **Why?** shows every path. - -### How do I sign out everywhere? -Your own sessions: **Account › Security**. Someone else's (with `users.manage`): their **Sessions** tab, **Sign out everywhere**. - -## Notifications - -### I don't get desktop notifications on Linux when the app is closed. -Linux desktops have no push service the API supports, so the Linux app checks for notifications every minute while it's running. Everything still lands in your Inbox. - -### How long are notifications kept? -90 days in the Inbox. - -## Plans and billing - -### What does Standard cost? -$6 per person per month, monthly in US dollars. No annual plan or trial today. - -### I paid but still show Free. -The Hub waits up to 30 seconds for Stripe to confirm. If it still says Free, refresh (Ctrl+R); if that doesn't help, contact support with the time you paid. - -## API and integrations - -### Why do my old `/v1/...` requests return 404? -The API version moved into the `Substratal-Version` header in October 2026. Drop `/v1` from the path and send `Substratal-Version: 1`. See [Versioning]({{ '/api/versioning/' | relative_url }}). - -### Why does the API say `api_key_in_browser`? -API keys only work from servers. In a browser, use the signed-in person's own token. - -### Can I call production from the interactive reference? -No, on purpose: production only accepts browser requests from Substratal's own sites. Use `curl`, a generated client, or the local devserver. - -### Where do I report a problem? -In the app, open the error's **Details** and copy the request ID, then open an issue on [GitHub](https://github.com/CompositeCode/substratalapps-linux/issues) or contact support. diff --git a/docs/getting-started/index.md b/docs/getting-started/index.md deleted file mode 100644 index b3cc031..0000000 --- a/docs/getting-started/index.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -layout: default -title: Getting started -nav_order: 2 -has_children: true -permalink: /getting-started/ ---- - -# Getting started -{: .no_toc } - -Install the Hub, sign in, and find your way around. -{: .fs-6 .fw-300 } - -1. [Install]({{ '/getting-started/install/' | relative_url }}) the app for your platform. -2. [Sign in]({{ '/getting-started/sign-in/' | relative_url }}) with your Substratal account, plus your two-factor code if it's on. -3. [Find your way around]({{ '/getting-started/tour/' | relative_url }}): the sidebar, the command palette and help. diff --git a/docs/getting-started/install.md b/docs/getting-started/install.md deleted file mode 100644 index 94dcaa1..0000000 --- a/docs/getting-started/install.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -layout: default -title: Install -parent: Getting started -nav_order: 1 -permalink: /getting-started/install/ ---- - -# Install -{: .no_toc } - -1. TOC -{:toc} - -## Linux - -Substratal for Linux is a GNOME app (GTK 4 and libadwaita) that also runs on KDE Plasma and other desktops. It needs GNOME 46 or later, or any desktop with Flatpak. - -The app is in development. Flatpak builds come from every change to `main` (the **Flatpak** workflow on GitHub); the Flathub listing comes when the app reaches its first release. -{: .note } - -**Build and run from source** (Ubuntu 24.04 or Fedora): - -```sh -# Ubuntu: sudo apt install libgtk-4-dev libadwaita-1-dev blueprint-compiler librsvg2-common -# Fedora: sudo dnf install gtk4-devel libadwaita-devel blueprint-compiler -git clone https://github.com/CompositeCode/substratalapps-linux -cd substratalapps-linux -cargo run -p substratal-hub -``` - -No Linux machine? `scripts/dev.sh` runs the same build in a container, and `scripts/screenshot.sh` renders the app headlessly. - -To point the app at the API's local devserver instead of production, start it with `SUBSTRATAL_API=local`. A banner tells you you're not on production. - -## macOS, Windows, iOS/iPadOS, Android and Web - -These apps are built from the same specification by their own teams. Their install pages will be added here as each app ships. - -## The command-line companion - -`substratalctl` uses the Linux app's core. It's built with the app (`cargo build -p substratalctl`). See the [CLI tutorial]({{ '/tutorials/cli/' | relative_url }}). diff --git a/docs/getting-started/sign-in.md b/docs/getting-started/sign-in.md deleted file mode 100644 index 2df1732..0000000 --- a/docs/getting-started/sign-in.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -layout: default -title: Sign in -parent: Getting started -nav_order: 2 -permalink: /getting-started/sign-in/ ---- - -# Sign in -{: .no_toc } - -1. Open Substratal. If you're not signed in, the **Sign in** window opens ([AUTH-01]({{ '/screens/auth-01/' | relative_url }})). -2. Enter your email and password. **Sign in** turns on when both are filled in. -3. If two-factor authentication is on, enter the 6-digit code from your authenticator app ([AUTH-02]({{ '/screens/auth-02/' | relative_url }})). Lost your phone? Enter one of your recovery codes instead. - -## Staying signed in - -On Linux the app keeps your session in your desktop's keyring (GNOME Keyring or KWallet, through the Secret Service). Only a refresh token is stored there. Nothing else is saved, and passwords never are. If no keyring is available, the app works but you sign in each time you open it. - -Signing out (main menu ☰ › **Sign Out**) ends the session on the server and removes the stored token. - -## If sign-in fails - -| You see | What it means | -|---|---| -| "Email or password is incorrect." | One of them is wrong. The app never says which. | -| "Too many attempts. Try again in N seconds." | Sign-in is rate-limited. Wait for the countdown. | -| "This account is suspended." | An administrator suspended it. Contact them. | -| "Your session ended. Sign in again." | You signed out elsewhere, or an admin signed you out everywhere. | -| "Update required" | This version of the app uses an API version the service no longer accepts. Update the app. See [API versioning]({{ '/api/versioning/' | relative_url }}). | diff --git a/docs/getting-started/tour.md b/docs/getting-started/tour.md deleted file mode 100644 index 21e5360..0000000 --- a/docs/getting-started/tour.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -layout: default -title: Find your way around -parent: Getting started -nav_order: 3 -permalink: /getting-started/tour/ ---- - -# Find your way around -{: .no_toc } - -## The sidebar - -The sidebar has up to three sections. You only see what you can use. - -- **Personal**: Home, Inbox, Organizations, Account, Plan & Billing, Settings. Everyone has these. -- **Administration**: Users, Access, Roles, Audit Log, Send notification. Staff, and app owners (Access only, for their own apps). -- **Developer**: Applications, API Keys, Webhooks, Tenant. App owners and staff with developer permissions. - -[Navigation and permissions]({{ '/reference/navigation/' | relative_url }}) lists exactly who sees what. On a narrow window (under 600 sp, like a Linux phone) the sidebar becomes its own page; tap back to return to it. - -## Get anywhere with the keyboard - -- Ctrl+K opens the **command palette**: type part of a screen's name, its ID (like `KEY-01`) or its route, then press Enter. -- Alt+1…Alt+6 jump to the Personal destinations. -- F1 opens help for the screen you're on. - -All shortcuts are in [Keyboard shortcuts]({{ '/reference/shortcuts/' | relative_url }}), and Ctrl+? shows them in the app. - -## Links into the app - -Links like `substratal://admin/users` open the matching screen in the running app. Every screen's help page shows its link. - -## Banners at the top - -| Banner | Meaning | -|---|---| -| You're offline | Showing what was already loaded; changes are paused until you reconnect. | -| A newer version is available | This app's API version is deprecated and will stop working on the date shown. Update soon. | -| Connected to …, not production | The app is pointed at a test environment. | diff --git a/docs/guides/administration.md b/docs/guides/administration.md deleted file mode 100644 index 3c72dd5..0000000 --- a/docs/guides/administration.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -layout: default -title: Administering people and access -parent: Guides -nav_order: 2 -permalink: /guides/administration/ ---- - -# Administering people and access -{: .no_toc } - -For staff with platform permissions, and app owners for their own apps. What you see depends on your permissions; see [Navigation and permissions]({{ '/reference/navigation/' | relative_url }}). -{: .fs-6 .fw-300 } - -1. TOC -{:toc} - -## Users - -[Users]({{ '/screens/usr-01/' | relative_url }}) (needs `users.list`) is a searchable table of every account. Search by name, email or a `usr_` ID; pasting an exact ID jumps straight to that person. Open someone for their [overview]({{ '/screens/usr-03/' | relative_url }}) and tabs: - -- **Access** ([ACX-01]({{ '/screens/acx-01/' | relative_url }})): which apps are on for them and why. -- **Roles** ([ROL-04]({{ '/screens/rol-04/' | relative_url }})): their platform and app roles. -- **Sessions** ([USR-04]({{ '/screens/usr-04/' | relative_url }})): where they're signed in. **Sign out everywhere** ends every session at once. -- **Notifications & devices** ([NTF-05]({{ '/screens/ntf-05/' | relative_url }})): what they've been sent and their push devices. - -Changing an account (invite, suspend, delete) needs `users.manage`. Suspending ([USR-05]({{ '/screens/usr-05/' | relative_url }})) signs the person out everywhere and turns off their access to every app until you reactivate them. - -## Access - -[Access]({{ '/screens/acx-05/' | relative_url }}) lists access across the platform (`entitlements.manage`), or, for an app owner, across their own apps. [Grant access]({{ '/screens/acx-02/' | relative_url }}) to give someone an app; the switch on a row turns it off or back on, with **Undo** for 10 seconds (Ctrl+Z). **Why?** ([ACX-04]({{ '/screens/acx-04/' | relative_url }})) explains how someone has, or doesn't have, an app. - -Rows marked **Managed by Substratal** (free apps and Standard subscriptions) can only be turned off and back on. -{: .note } - -## Roles - -[Roles]({{ '/screens/rol-01/' | relative_url }}) are named sets of permissions. Seed roles (`superadmin`, `support`, `billing_admin` and others) can't be edited. You can only give a role permissions you hold yourself, and only assign roles whose permissions you hold, so nobody can grant themselves more than they have. A role's page lists **People with this role**. - -## Audit log - -The [Audit log]({{ '/screens/aud-01/' | relative_url }}) (`audit.view`) records every change: who did it (a person, an API key or the system), what changed, and the before and after. Filter by person, app, organization, action or time. Each event shows the request ID, which ties it to the API's logs. Events older than the hot window are archived; a banner says when that applies. Exports of a time range are available behind the `audit_export` flag. - -## Send notification - -Staff with `notifications.send` can [send a notification]({{ '/screens/ntf-06/' | relative_url }}) to a person on behalf of any app. It lands in their inbox and is pushed to their devices where push is available. diff --git a/docs/guides/console.md b/docs/guides/console.md deleted file mode 100644 index d4294bd..0000000 --- a/docs/guides/console.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -layout: default -title: Operations console (web) -parent: Guides -nav_order: 4 -permalink: /guides/console/ ---- - -# Operations console (web) -{: .no_toc } - -The web Hub has an **Operations** section for the people who run Substratal: is production healthy, what's slow or failing, what it costs, and what's deployed. It's web-only for now; the Linux, macOS, Windows and mobile apps don't include it (specs OPEN-QUESTIONS #27). -{: .fs-6 .fw-300 } - -| Screen | What it answers | -|---|---| -| [OPS-01 Overview]({{ '/screens/ops-01/' | relative_url }}) | Is production healthy right now? | -| [OPS-02 Traffic & latency]({{ '/screens/ops-02/' | relative_url }}) | Trends and service-level objectives | -| [OPS-03 Logs]({{ '/screens/ops-03/' | relative_url }}) | What happened to one request, person or key | -| [SEC-01 Security events]({{ '/screens/sec-01/' | relative_url }}) | Failed sign-ins, lockouts, blocked actions | -| [OPS-04 Alarms]({{ '/screens/ops-04/' | relative_url }}) | What's firing and who's told | -| [OPS-05 Jobs & delivery]({{ '/screens/ops-05/' | relative_url }}) | Scheduled jobs and webhook/push delivery | -| [OPS-06 Releases]({{ '/screens/ops-06/' | relative_url }}) | What's deployed | -| [OPS-07 Cost]({{ '/screens/ops-07/' | relative_url }}) | Spend against budgets | -| [CFG-01 Configuration]({{ '/screens/cfg-01/' | relative_url }}) / [CFG-02 Feature flags]({{ '/screens/cfg-02/' | relative_url }}) | What production runs with | -| [RPT-01 Reports]({{ '/screens/rpt-01/' | relative_url }}) | Growth, access and usage over time | -| [INT-01 Integrations]({{ '/screens/int-01/' | relative_url }}) | Every way to connect other tools | - -All of it is read-only in its first version and refreshes at most once a minute. diff --git a/docs/guides/developer.md b/docs/guides/developer.md deleted file mode 100644 index 65cab4c..0000000 --- a/docs/guides/developer.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -layout: default -title: Applications, keys and webhooks -parent: Guides -nav_order: 3 -permalink: /guides/developer/ ---- - -# Applications, keys and webhooks -{: .no_toc } - -For app developers (anyone who owns an Application) and staff with developer permissions. -{: .fs-6 .fw-300 } - -1. TOC -{:toc} - -## Applications - -An **Application** is an app in the Substratal catalog. [Applications]({{ '/screens/apl-01/' | relative_url }}) lists the ones you own, or all of them with `applications.manage`. - -- **Register**: any person with a verified email can [register an application]({{ '/screens/apl-02/' | relative_url }}) (behind the `app_registration_self_service` flag). It starts as **Pending review**. -- **Review**: staff with `applications.manage` approve, reject (with notes) or suspend from [Review]({{ '/screens/apl-06/' | relative_url }}). Suspending is the off switch for a misbehaving app. -- **Configure**: the [overview]({{ '/screens/apl-03/' | relative_url }}) has the launch URL, icon, visibility and access tier; [settings schema]({{ '/screens/apl-04/' | relative_url }}) defines per-user settings; [permissions & roles]({{ '/screens/apl-05/' | relative_url }}) define what people can do inside the app; [push credentials]({{ '/screens/apl-07/' | relative_url }}) hold its APNs, FCM and Web Push keys. - -Push key files (`.p8`, FCM JSON) are read once from the file you pick and sent to the API. The Hub never keeps a copy. -{: .note } - -## API keys - -[API keys]({{ '/screens/key-01/' | relative_url }}) let your backend, or an AI agent, call the API without a person signing in. - -- A key is **platform** or **app** scoped, **live** or **test**, and intended for a **service** or an **agent**. Agent keys get `restrict_destructive` on by default, so they can't delete or revoke things. -- You can only give a key permissions you hold. -- The secret (`satk_live_…` or `satk_test_…`) is shown **once**, when the key is created or rotated ([KEY-03]({{ '/screens/key-03/' | relative_url }})). Copy it straight into your secret store. The Hub clears it from the clipboard after 60 seconds and never saves it. -- **Rotate** replaces the secret; **Revoke** turns the key off for good. -- Keys are for servers. The API refuses them from a browser (`400 api_key_in_browser`). - -See the [API key tutorial]({{ '/tutorials/api-keys/' | relative_url }}). - -## Webhooks - -[Webhooks]({{ '/screens/whk-01/' | relative_url }}) send events (access granted or revoked, users suspended, devices registered and more) to your HTTPS endpoint, signed with a secret. [Deliveries]({{ '/screens/whk-03/' | relative_url }}) shows every attempt; you can redeliver one. - -A webhook's **payload version** (a date such as `2026-10-05`) fixes the shape of the events it receives. It's separate from the API version header. -{: .important } - -## Tenant - -Your [Tenant]({{ '/screens/bil-04/' | relative_url }}) is where your apps' data lives. Every Tenant starts on shared infrastructure; support handles moves to dedicated infrastructure through tier-change requests. diff --git a/docs/guides/index.md b/docs/guides/index.md deleted file mode 100644 index 25035bb..0000000 --- a/docs/guides/index.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -layout: default -title: Guides -nav_order: 3 -has_children: true -permalink: /guides/ ---- - -# Guides -{: .no_toc } - -How each area of the Hub works and how its pieces fit together. For step-by-step tasks, see [Tutorials]({{ '/tutorials/' | relative_url }}). -{: .fs-6 .fw-300 } - -Three ideas run through every area: - -1. **Is the app on for this person?** Each person has an access status per app: Active, Scheduled, Disabled, Expired, Revoked or None. The access switch turns it on and off. -2. **If it's on, what can they do?** Roles grant permissions. -3. **Where does access come from?** Several paths can give one person access to one app: platform-managed ones (`free` apps, and a Standard `subscription`) and grants (`purchase`, `trial`, `admin_grant`, `org_seat`). The Hub shows the resolved status first and the paths one click away. Platform-managed access can only be turned off and back on, never edited or deleted. diff --git a/docs/guides/personal.md b/docs/guides/personal.md deleted file mode 100644 index 8b3bcaf..0000000 --- a/docs/guides/personal.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -layout: default -title: Your apps, inbox and account -parent: Guides -nav_order: 1 -permalink: /guides/personal/ ---- - -# Your apps, inbox and account -{: .no_toc } - -1. TOC -{:toc} - -## Home: your apps - -[Home]({{ '/screens/app-01/' | relative_url }}) shows every app you can use and its access status. Select an app for [its details]({{ '/screens/app-02/' | relative_url }}); **Open** launches it with a short-lived app token, so you don't sign in again. Apps you no longer have are under [Past apps]({{ '/screens/app-05/' | relative_url }}). - -## Inbox - -The [Inbox]({{ '/screens/ntf-01/' | relative_url }}) holds every notification sent to you by any app, plus Substratal's security and account alerts, for 90 days. It's always complete, even when a desktop notification didn't appear. - -**On Linux and Windows**, notifications arrive while the app is running: it checks for new ones every minute and shows a desktop notification. There's no push service for Linux desktops, so if the app is closed you'll see them next time you open it. On iOS, macOS, Android and the Web they're pushed. -{: .note } - -## Organizations - -[Organizations]({{ '/screens/org-01/' | relative_url }}) are teams. Organization admins invite members, grant apps to the whole organization or to chosen members, and can pay for Standard for the members they choose. - -## Plan & Billing - -Everyone starts on **Free**. **Standard** is $6 per person per month and unlocks every standard app. You buy it for yourself in [Plan & Billing]({{ '/screens/bil-01/' | relative_url }}), or an organization buys it for you. Payment always happens on Stripe's page in your browser; when you come back, the Hub waits a few seconds for the change to arrive and then shows your new plan. - -## Account and security - -[Account]({{ '/screens/acc-01/' | relative_url }}) has your profile. [Security]({{ '/screens/acc-04/' | relative_url }}) has your password, two-factor authentication and the list of places you're signed in, where you can sign other sessions out. [Privacy]({{ '/screens/acc-07/' | relative_url }}) lets you export your data or delete your account. - -## Settings and themes - -[Settings]({{ '/screens/set-01/' | relative_url }}) holds your language, time zone, notifications and appearance. Pick **Match system**, **Light** or **Dark**, and one of five light and five dark themes. The defaults are **Moss Paper** (light) and **Forest Floor** (dark). If your desktop uses high contrast, the Hub follows it instead of the theme. diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 95ca14c..0000000 --- a/docs/index.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -layout: default -title: Home -nav_order: 1 -permalink: / ---- - -# Substratal Hub help -{: .fs-9 } - -How to use the Substratal Hub: launch your apps, read your notifications, manage your plan and organizations, and, if you're staff or an app developer, administer users, access, roles, applications, API keys and webhooks. -{: .fs-6 .fw-300 } - -[Get started]({{ '/getting-started/' | relative_url }}){: .btn .btn-primary .mr-2 } [Find a screen]({{ '/screens/' | relative_url }}){: .btn .mr-2 } [API reference]({{ '/api/' | relative_url }}){: .btn } - - -## One help center for every Hub app - -The Hub is the same product on **Linux, macOS, Windows, iOS/iPadOS, Android and the Web**: same screens, same screen IDs, same routes, same words. So this help center serves all of them. - -- Every screen has a page at **`/screens//`**, for example [USR-01 Users]({{ '/screens/usr-01/' | relative_url }}). Each app's **Help** button and F1 open the page for the screen you're on. -- Where an app behaves differently on one platform (keyboard shortcuts, how notifications arrive), the page says so. -- Search (top of the page) covers every guide, tutorial, FAQ and screen. - -## Where to start - -| I want to… | Go to | -|---|---| -| Install the app and sign in | [Getting started]({{ '/getting-started/' | relative_url }}) | -| Understand a screen I'm looking at | [Screens]({{ '/screens/' | relative_url }}) or press F1 in the app | -| Do a common task step by step | [Tutorials]({{ '/tutorials/' | relative_url }}) | -| Learn an area (people, access, apps, keys) | [Guides]({{ '/guides/' | relative_url }}) | -| Look up a shortcut, status or permission | [Reference]({{ '/reference/' | relative_url }}) | -| Integrate with the Platform API | [API]({{ '/api/' | relative_url }}): versioning, authentication, the OpenAPI reference and every operation | -| Get a quick answer | [FAQ]({{ '/faq/' | relative_url }}) | - -## The Platform API version is a header - -Every Hub app talks to `https://api.substratalapps.com` and names the API version in a **`Substratal-Version: 1`** request header. There's no `/v1` in any URL any more; `/v1/...` paths return `404`. See [API versioning]({{ '/api/versioning/' | relative_url }}). -{: .important } diff --git a/docs/reference/cli.md b/docs/reference/cli.md deleted file mode 100644 index 720bb11..0000000 --- a/docs/reference/cli.md +++ /dev/null @@ -1,156 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "substratalctl" -parent: Reference -nav_order: 4 ---- - -# substratalctl command reference -{: .no_toc } - -`substratalctl` is the Hub's command-line companion. It shares the Linux app's core, so it sends the same `Substratal-Version` header, signs in the same way and gets the same permission errors. See the [CLI tutorial]({{ '/tutorials/cli/' | relative_url }}). -{: .fs-6 .fw-300 } - -## `substratalctl` - -Command-line client for the Substratal Apps Platform API - -**Usage:** `substratalctl [OPTIONS] ` - -###### **Subcommands:** - -* `health` — Check that the API is up (`GET /health`) -* `version` — Show this client's version, the API version it sends, and the base URL -* `login` — Sign in with email and password (and a TOTP code if two-factor is on) -* `logout` — Sign out and forget the saved session -* `whoami` — Show the signed-in user and their platform permissions -* `users` — Manage users -* `screens` — List the Hub's screens (IDs, routes, who can use them) -* `route` — Resolve a route or `substratal://` link to its screen -* `help-url` — Print the help-center URL for a screen ID, e.g. `USR-01` - -###### **Options:** - -* `--api ` — API environment: `production`, `local` (the devserver on :8080) or a base URL - - Default value: `production` -* `--no-keyring` — Don't use the Secret Service; you sign in again each run -* `--json` — Print JSON instead of a table - - - -## `substratalctl health` - -Check that the API is up (`GET /health`) - -**Usage:** `substratalctl health` - - - -## `substratalctl version` - -Show this client's version, the API version it sends, and the base URL - -**Usage:** `substratalctl version` - - - -## `substratalctl login` - -Sign in with email and password (and a TOTP code if two-factor is on) - -**Usage:** `substratalctl login [OPTIONS] --email ` - -###### **Options:** - -* `--email ` -* `--password-env ` — Read the password from this environment variable instead of prompting - - - -## `substratalctl logout` - -Sign out and forget the saved session - -**Usage:** `substratalctl logout` - - - -## `substratalctl whoami` - -Show the signed-in user and their platform permissions - -**Usage:** `substratalctl whoami` - - - -## `substratalctl users` - -Manage users - -**Usage:** `substratalctl users ` - -###### **Subcommands:** - -* `list` — List users (`GET /users`); needs `users.list` - - - -## `substratalctl users list` - -List users (`GET /users`); needs `users.list` - -**Usage:** `substratalctl users list [OPTIONS]` - -###### **Options:** - -* `-q`, `--query ` — Search by name, email or `usr_` ID prefix -* `--limit ` - - Default value: `50` - - - -## `substratalctl screens` - -List the Hub's screens (IDs, routes, who can use them) - -**Usage:** `substratalctl screens [OPTIONS]` - -###### **Options:** - -* `--all` — Include web-only screens (the Console) - - - -## `substratalctl route` - -Resolve a route or `substratal://` link to its screen - -**Usage:** `substratalctl route ` - -###### **Arguments:** - -* `` - - - -## `substratalctl help-url` - -Print the help-center URL for a screen ID, e.g. `USR-01` - -**Usage:** `substratalctl help-url ` - -###### **Arguments:** - -* `` - - - -


- - - This document was generated automatically by - clap-markdown. - diff --git a/docs/reference/errors.md b/docs/reference/errors.md deleted file mode 100644 index 6d05304..0000000 --- a/docs/reference/errors.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -layout: default -title: Error messages -parent: Reference -nav_order: 5 -permalink: /reference/errors/ ---- - -# Error messages -{: .no_toc } - -What the Hub's error messages mean and what to do. Every error has a **Details** section with the API's error code and a request ID; quote the request ID when asking for help. -{: .fs-6 .fw-300 } - -| Message | Code | What to do | -|---|---|---| -| Email or password is incorrect. | `invalid_credentials` | Check both. Use **Forgot password?** if needed. | -| That code didn't work. | `invalid_mfa_code` | Use the current code from your authenticator, or a recovery code. | -| This account is suspended. | `user_suspended` | Ask an administrator. | -| This test account has expired. | `test_user_expired` | Test accounts last 24 hours unless extended. | -| Your session ended. Sign in again. | `unauthenticated`, `session_revoked` | Sign in again; you return to where you were. | -| You don't have permission to do this. | `forbidden` | Ask an administrator for the permission named in Details. | -| Someone else changed this while you were editing. | `version_conflict` | Choose **Reload** to see their change, or **Re-apply** yours on top. | -| Managed by Substratal — you can only turn it off or back on. | `entitlement_platform_managed` | Free and Standard access can't be edited, only switched. | -| You're doing that too often. | `rate_limited` | Wait for the countdown. | -| Substratal is temporarily unavailable. | `service_unavailable` | Reading retries by itself; try changes again shortly. | -| Something went wrong in the app. | `api_version_required`, `method_not_allowed` | A bug in the app. Update it; report it with the request ID if it continues. | -| Update required | `unsupported_api_version` | Your app's API version has been retired. Update the app. See [Versioning]({{ '/api/versioning/' | relative_url }}). | - -The full list of API error codes is in the spec's [Conventions → Errors](https://compositecode.github.io/substratalapps.com/api-reference/conventions/). diff --git a/docs/reference/index.md b/docs/reference/index.md deleted file mode 100644 index 90d64be..0000000 --- a/docs/reference/index.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -layout: default -title: Reference -nav_order: 6 -has_children: true -permalink: /reference/ ---- - -# Reference -{: .no_toc } - -Look-up pages. Most are generated from the app's code and the specs on every change, so they always match the app. -{: .fs-6 .fw-300 } diff --git a/docs/reference/linking.md b/docs/reference/linking.md deleted file mode 100644 index bda7e61..0000000 --- a/docs/reference/linking.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -layout: default -title: Linking help from an app -parent: Reference -nav_order: 6 -permalink: /reference/linking/ ---- - -# Linking help from an app -{: .no_toc } - -For the teams building each Hub app. This help center is the documentation home for all of them: link to it instead of writing help into the app. -{: .fs-6 .fw-300 } - -## Per-screen help URLs - -Every screen ID has a stable page: - -```text -https://compositecode.github.io/substratalapps-linux/screens// -``` - -For example `USR-01` → `/screens/usr-01/`. Wire each app's Help button (and F1, ⌘? or the platform equivalent) to the page for the visible screen. The Linux app does this in `substratal_core::config::help_url`. - -## Machine-readable index - -[`/screens.json`]({{ '/screens.json' | relative_url }}) lists every screen with its title, route, kind, phase, who can use it, the platforms it's on, and its help URL. Use it to build an in-app help search or to check that every screen you ship has a page. - -## Keeping it current - -Screen pages are generated from the specs package (`linux/SCREENS.md` and `00-shared/SCREEN-SPECS.md`). When the specs change, the Linux repo's **Specs sync** workflow regenerates them, so a new screen gets a help page without anyone writing one. Platform-specific notes (shortcuts, controls) appear on the page where they differ. See [How these docs stay current]({{ '/contributing/' | relative_url }}). - -## If the help center moves - -If it gets its own domain (for example `help.substratalapps.com`), the paths stay the same. Keep the base URL in one constant in each app. diff --git a/docs/reference/navigation.md b/docs/reference/navigation.md deleted file mode 100644 index 57772b3..0000000 --- a/docs/reference/navigation.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "Navigation and permissions" -parent: Reference -nav_order: 2 ---- - -# Navigation and permissions -{: .no_toc } - -What each kind of person sees in the sidebar. The app hides what you can never use, and the server checks every action again, so this is a guide, not a guarantee. -{: .fs-6 .fw-300 } - -| Section › Item | Screen | End user | App owner | Support | Billing admin | Superadmin | -|---|---|:-:|:-:|:-:|:-:|:-:| -| Personal › Home | [APP-01]({{ '/screens/app-01/' | relative_url }}) | ✓ | ✓ | ✓ | ✓ | ✓ | -| Personal › Inbox | [NTF-01]({{ '/screens/ntf-01/' | relative_url }}) | ✓ | ✓ | ✓ | ✓ | ✓ | -| Personal › Organizations | [ORG-01]({{ '/screens/org-01/' | relative_url }}) | ✓ | ✓ | ✓ | ✓ | ✓ | -| Personal › Account | [ACC-01]({{ '/screens/acc-01/' | relative_url }}) | ✓ | ✓ | ✓ | ✓ | ✓ | -| Personal › Plan & Billing | [BIL-01]({{ '/screens/bil-01/' | relative_url }}) | ✓ | ✓ | ✓ | ✓ | ✓ | -| Personal › Settings | [SET-01]({{ '/screens/set-01/' | relative_url }}) | ✓ | ✓ | ✓ | ✓ | ✓ | -| Administration › Users | [USR-01]({{ '/screens/usr-01/' | relative_url }}) | | | ✓ | ✓ | ✓ | -| Administration › Access | [ACX-05]({{ '/screens/acx-05/' | relative_url }}) | | ✓ | ✓ | | ✓ | -| Administration › Roles | [ROL-01]({{ '/screens/rol-01/' | relative_url }}) | | | ✓ | ✓ | ✓ | -| Administration › Audit Log | [AUD-01]({{ '/screens/aud-01/' | relative_url }}) | | | ✓ | ✓ | ✓ | -| Administration › Send notification | [NTF-06]({{ '/screens/ntf-06/' | relative_url }}) | | | | | ✓ | -| Developer › Applications | [APL-01]({{ '/screens/apl-01/' | relative_url }}) | | ✓ | | | ✓ | -| Developer › API Keys | [KEY-01]({{ '/screens/key-01/' | relative_url }}) | | ✓ | | | ✓ | -| Developer › Webhooks | [WHK-01]({{ '/screens/whk-01/' | relative_url }}) | | ✓ | | | ✓ | -| Developer › Tenant | [BIL-04]({{ '/screens/bil-04/' | relative_url }}) | | ✓ | ✓ | | ✓ | - -The web Hub also has an **Operations** section (the Console) and **Reports** and **Integrations** items. They're web-only for now; see [Screens]({{ '/screens/' | relative_url }}). - -## Platform permissions - -| Permission | Typical holder | -|---|---| -| `users.list` | Support, Billing admin, Superadmin | -| `users.manage` | Superadmin | -| `entitlements.manage` | Support, Superadmin | -| `applications.manage` | Superadmin | -| `roles.manage` | Superadmin | -| `organizations.manage` | Superadmin | -| `billing.manage` | Support, Billing admin, Superadmin | -| `billing.refund` | Billing admin, Superadmin | -| `audit.view` | Support, Billing admin, Superadmin | -| `webhooks.manage` | Superadmin | -| `api_keys.manage` | Superadmin | -| `notifications.send` | Superadmin | -| `tenants.manage` | Support, Superadmin | diff --git a/docs/reference/shortcuts.md b/docs/reference/shortcuts.md deleted file mode 100644 index 4b6923e..0000000 --- a/docs/reference/shortcuts.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "Keyboard shortcuts" -parent: Reference -nav_order: 1 ---- - -# Keyboard shortcuts -{: .no_toc } - -Shortcuts in Substratal for Linux. Every shortcut has a visible equivalent: a button, menu item or the command palette. Press **Ctrl+?** in the app to see this list. -{: .fs-6 .fw-300 } - - -## General - -| Keys | Does | -|---|---| -| Ctrl+K | Command palette | -| Ctrl+F | Search | -| F1 | Help for this screen | -| Ctrl+, | Preferences | -| Ctrl+? | Keyboard shortcuts | -| Ctrl+W | Close window | -| Ctrl+Q | Quit | - -## Navigation - -| Keys | Does | -|---|---| -| Alt+1 | Home | -| Alt+2 | Inbox | -| Alt+3 | Organizations | -| Alt+4 | Account | -| Alt+5 | Plan & Billing | -| Alt+6 | Settings | -| Alt+← | Back | -| F9 | Show or hide the sidebar | - -## Editing - -| Keys | Does | -|---|---| -| Ctrl+N | New (in the current list) | -| Ctrl+R or F5 | Refresh | -| Ctrl+Z | Undo an access change | -| Delete | Delete or turn off the selection (asks first) | -| Ctrl+Shift+U | Mark all as read (Inbox) | diff --git a/docs/reference/statuses.md b/docs/reference/statuses.md deleted file mode 100644 index d9f9c23..0000000 --- a/docs/reference/statuses.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "Status meanings" -parent: Reference -nav_order: 3 ---- - -# Status meanings -{: .no_toc } - -Every status badge in every Hub app shows an icon and a label, never colour alone. They mean the same thing everywhere. -{: .fs-6 .fw-300 } - -| Status | Label | Kind | -|---|---|---| -| `active` | Active | success | -| `deleted` | Deleted | critical | -| `disabled` | Turned off | warning | -| `expired` | Expired | neutral | -| `important` | Important | critical | -| `invited` | Invited | accent | -| `none` | No access | neutral | -| `pending` | Pending | info | -| `revoked` | Revoked | critical | -| `scheduled` | Scheduled | info | -| `suspended` | Suspended | warning | -| `unread` | Unread | accent | diff --git a/docs/screens.json b/docs/screens.json deleted file mode 100644 index 2a4300a..0000000 --- a/docs/screens.json +++ /dev/null @@ -1,1133 +0,0 @@ -[ - { - "archetype": "auth", - "help": "https://compositecode.github.io/substratalapps-linux/screens/auth-01/", - "id": "AUTH-01", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Exchange email + password for a session, or start an MFA challenge.", - "route": "/sign-in", - "title": "Sign in", - "who": "Unauthenticated" - }, - { - "archetype": "auth", - "help": "https://compositecode.github.io/substratalapps-linux/screens/auth-02/", - "id": "AUTH-02", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Complete sign-in with a TOTP code or a recovery code.", - "route": "/sign-in/mfa", - "title": "Two-factor challenge", - "who": "Holder of mfa_token" - }, - { - "archetype": "auth", - "help": "https://compositecode.github.io/substratalapps-linux/screens/auth-03/", - "id": "AUTH-03", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Self-service signup with email and password.", - "route": "/sign-up", - "title": "Create account", - "who": "Unauthenticated" - }, - { - "archetype": "auth", - "help": "https://compositecode.github.io/substratalapps-linux/screens/auth-04/", - "id": "AUTH-04", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Tell the user to check email; complete verification from the emailed link.", - "route": "/verify-email", - "title": "Verify your email", - "who": "Signed in (unverified) or token holder" - }, - { - "archetype": "auth", - "help": "https://compositecode.github.io/substratalapps-linux/screens/auth-05/", - "id": "AUTH-05", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Request a password-reset email.", - "route": "/forgot-password", - "title": "Forgot password", - "who": "Unauthenticated" - }, - { - "archetype": "auth", - "help": "https://compositecode.github.io/substratalapps-linux/screens/auth-06/", - "id": "AUTH-06", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Choose a new password from the emailed reset link.", - "route": "/reset-password", - "title": "Set a new password", - "who": "Reset-token holder" - }, - { - "archetype": "auth", - "help": "https://compositecode.github.io/substratalapps-linux/screens/auth-07/", - "id": "AUTH-07", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Activate an account an admin created: set password and sign in.", - "route": "/invite", - "title": "Accept invitation", - "who": "Invitation-token holder" - }, - { - "archetype": "settings", - "help": "https://compositecode.github.io/substratalapps-linux/screens/acc-01/", - "id": "ACC-01", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "View and edit the signed-in user's global profile.", - "route": "/account", - "title": "Account & profile", - "who": "Self" - }, - { - "archetype": "form", - "help": "https://compositecode.github.io/substratalapps-linux/screens/acc-02/", - "id": "ACC-02", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Start an email change; the new address must be verified.", - "route": "/account/email", - "title": "Change email", - "who": "Self" - }, - { - "archetype": "form", - "help": "https://compositecode.github.io/substratalapps-linux/screens/acc-03/", - "id": "ACC-03", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Change password knowing the current one.", - "route": "/account/security/password", - "title": "Change password", - "who": "Self" - }, - { - "archetype": "settings", - "help": "https://compositecode.github.io/substratalapps-linux/screens/acc-04/", - "id": "ACC-04", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Password, two-factor and active sessions in one place.", - "route": "/account/security", - "title": "Security", - "who": "Self" - }, - { - "archetype": "wizard", - "help": "https://compositecode.github.io/substratalapps-linux/screens/acc-05/", - "id": "ACC-05", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Enroll an authenticator app (TOTP) and save recovery codes.", - "route": "/account/security/2fa/enable", - "title": "Turn on two-factor", - "who": "Self" - }, - { - "archetype": "confirm", - "help": "https://compositecode.github.io/substratalapps-linux/screens/acc-06/", - "id": "ACC-06", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Disable TOTP with a current code or a recovery code.", - "route": "/account/security/2fa/disable", - "title": "Turn off two-factor", - "who": "Self" - }, - { - "archetype": "settings", - "help": "https://compositecode.github.io/substratalapps-linux/screens/acc-07/", - "id": "ACC-07", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Export personal data and delete the account.", - "route": "/account/privacy", - "title": "Privacy", - "who": "Self" - }, - { - "archetype": "confirm", - "help": "https://compositecode.github.io/substratalapps-linux/screens/acc-08/", - "id": "ACC-08", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Request erasure: deactivate now, erase after 7 days.", - "route": "/account/privacy/delete", - "title": "Delete account", - "who": "Self" - }, - { - "archetype": "grid", - "help": "https://compositecode.github.io/substratalapps-linux/screens/app-01/", - "id": "APP-01", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "See every app you can use, its access status, and launch it.", - "route": "/", - "title": "Home — My Apps", - "who": "Signed in" - }, - { - "archetype": "popover", - "help": "https://compositecode.github.io/substratalapps-linux/screens/app-02/", - "id": "APP-02", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Explain an app's access status and how the user has access.", - "route": "/apps/:appId", - "title": "App info", - "who": "Signed in" - }, - { - "archetype": "handoff", - "help": "https://compositecode.github.io/substratalapps-linux/screens/app-03/", - "id": "APP-03", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Mint an app token (or authorization code) and open the app.", - "route": "/apps/:appId/launch", - "title": "Launch app", - "who": "Active entitlement" - }, - { - "archetype": "form", - "help": "https://compositecode.github.io/substratalapps-linux/screens/app-04/", - "id": "APP-04", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "Per-app display handle and custom profile fields.", - "route": "/apps/:appId/profile", - "title": "App profile", - "who": "Self, active entitlement" - }, - { - "archetype": "list", - "help": "https://compositecode.github.io/substratalapps-linux/screens/app-05/", - "id": "APP-05", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Apps whose access was revoked or expired.", - "route": "/apps/past", - "title": "Past apps", - "who": "Signed in" - }, - { - "archetype": "settings", - "help": "https://compositecode.github.io/substratalapps-linux/screens/set-01/", - "id": "SET-01", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Preferences that apply to every app.", - "route": "/settings", - "title": "Settings — Global", - "who": "Self" - }, - { - "archetype": "settings", - "help": "https://compositecode.github.io/substratalapps-linux/screens/set-02/", - "id": "SET-02", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "Override global settings and set app-specific preferences from the app's settings_schema.", - "route": "/settings/apps/:appId", - "title": "Settings — Per app", - "who": "Self, active entitlement" - }, - { - "archetype": "list", - "help": "https://compositecode.github.io/substratalapps-linux/screens/org-01/", - "id": "ORG-01", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Organizations you belong to, plus pending invitations.", - "route": "/orgs", - "title": "Organizations", - "who": "Signed in (all orgs with organizations.manage)" - }, - { - "archetype": "form", - "help": "https://compositecode.github.io/substratalapps-linux/screens/org-02/", - "id": "ORG-02", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Create an organization; creator becomes org admin.", - "route": "/orgs/new", - "title": "New organization", - "who": "Any active user" - }, - { - "archetype": "detail", - "help": "https://compositecode.github.io/substratalapps-linux/screens/org-03/", - "id": "ORG-03", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "See and manage members and pending invitations.", - "route": "/orgs/:orgId/members", - "title": "Organization — Members", - "who": "Member (read), org_admin / organizations.manage (write)" - }, - { - "archetype": "form", - "help": "https://compositecode.github.io/substratalapps-linux/screens/org-04/", - "id": "ORG-04", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Invite people by email (or add an existing user, staff only).", - "route": "/orgs/:orgId/members/invite", - "title": "Invite member", - "who": "org_admin; organizations.manage may add by user_id" - }, - { - "archetype": "confirm", - "help": "https://compositecode.github.io/substratalapps-linux/screens/org-05/", - "id": "ORG-05", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Change a member's role or remove them.", - "route": "/orgs/:orgId/members/:userId", - "title": "Member actions", - "who": "org_admin / organizations.manage" - }, - { - "archetype": "tab", - "help": "https://compositecode.github.io/substratalapps-linux/screens/org-06/", - "id": "ORG-06", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "Org-wide app grants and who they include.", - "route": "/orgs/:orgId/apps", - "title": "Organization — Apps & seats", - "who": "Member (read), org_admin (write)" - }, - { - "archetype": "form", - "help": "https://compositecode.github.io/substratalapps-linux/screens/org-07/", - "id": "ORG-07", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "Create an org-wide grant or change who it includes.", - "route": "/orgs/:orgId/apps/grant", - "title": "Grant app to organization / member scope", - "who": "entitlements.manage or app owner; scope edits by org_admin" - }, - { - "archetype": "settings", - "help": "https://compositecode.github.io/substratalapps-linux/screens/org-08/", - "id": "ORG-08", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Rename, leave, set up SCIM provisioning, or (staff) suspend the organization.", - "route": "/orgs/:orgId/settings", - "title": "Organization — Settings", - "who": "org_admin (rename, SCIM), organizations.manage (suspend, SCIM), any member (leave)" - }, - { - "archetype": "confirm", - "help": "https://compositecode.github.io/substratalapps-linux/screens/org-09/", - "id": "ORG-09", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Accept or decline an organization invitation.", - "route": "/orgs/:orgId/invitation", - "title": "Invitation", - "who": "Invited user" - }, - { - "archetype": "table", - "help": "https://compositecode.github.io/substratalapps-linux/screens/usr-01/", - "id": "USR-01", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Find and manage user accounts.", - "route": "/admin/users", - "title": "Users", - "who": "users.list (write actions need users.manage)" - }, - { - "archetype": "form", - "help": "https://compositecode.github.io/substratalapps-linux/screens/usr-02/", - "id": "USR-02", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Create a user and send an invitation (or create active).", - "route": "/admin/users/new", - "title": "Invite user", - "who": "users.manage" - }, - { - "archetype": "detail", - "help": "https://compositecode.github.io/substratalapps-linux/screens/usr-03/", - "id": "USR-03", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "One user's account state and administrative actions.", - "route": "/admin/users/:userId", - "title": "User — Overview", - "who": "users.list (read), users.manage (write)" - }, - { - "archetype": "tab", - "help": "https://compositecode.github.io/substratalapps-linux/screens/usr-04/", - "id": "USR-04", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "See and revoke a user's sessions.", - "route": "/admin/users/:userId/sessions", - "title": "User — Sessions", - "who": "users.manage" - }, - { - "archetype": "confirm", - "help": "https://compositecode.github.io/substratalapps-linux/screens/usr-05/", - "id": "USR-05", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Suspend an account (signs them out everywhere).", - "route": "/admin/users/:userId/suspend", - "title": "Suspend user", - "who": "users.manage" - }, - { - "archetype": "tab", - "help": "https://compositecode.github.io/substratalapps-linux/screens/acx-01/", - "id": "ACX-01", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Turn apps on or off for one person; see every path that grants access.", - "route": "/admin/users/:userId/access", - "title": "User — Access", - "who": "users.list (read), entitlements.manage or app owner (write)" - }, - { - "archetype": "form", - "help": "https://compositecode.github.io/substratalapps-linux/screens/acx-02/", - "id": "ACX-02", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Give a person access to an app.", - "route": "/admin/users/:userId/access/grant", - "title": "Grant access", - "who": "entitlements.manage or app owner (own app)" - }, - { - "archetype": "confirm", - "help": "https://compositecode.github.io/substratalapps-linux/screens/acx-03/", - "id": "ACX-03", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Confirm turning off access, or edit term/source.", - "route": "/admin/entitlements/:entId", - "title": "Turn off / edit access", - "who": "entitlements.manage or app owner" - }, - { - "archetype": "popover", - "help": "https://compositecode.github.io/substratalapps-linux/screens/acx-04/", - "id": "ACX-04", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Explain exactly why a person can or can't use an app.", - "route": "/admin/users/:userId/apps/:appId/why", - "title": "Access check (Why?)", - "who": "users.list or app owner" - }, - { - "archetype": "table", - "help": "https://compositecode.github.io/substratalapps-linux/screens/acx-05/", - "id": "ACX-05", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Every grant across users: audit expirations, trials, revocations.", - "route": "/admin/access", - "title": "Access (all)", - "who": "entitlements.manage or app owner (confined to own apps)" - }, - { - "archetype": "list", - "help": "https://compositecode.github.io/substratalapps-linux/screens/rol-01/", - "id": "ROL-01", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Platform and app roles.", - "route": "/admin/roles", - "title": "Roles", - "who": "users.list (read), roles.manage (write)" - }, - { - "archetype": "detail", - "help": "https://compositecode.github.io/substratalapps-linux/screens/rol-02/", - "id": "ROL-02", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "View or edit a role's permissions.", - "route": "/admin/roles/:roleId", - "title": "Role detail", - "who": "users.list (read), roles.manage (write)" - }, - { - "archetype": "form", - "help": "https://compositecode.github.io/substratalapps-linux/screens/rol-03/", - "id": "ROL-03", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Define a custom role.", - "route": "/admin/roles/new", - "title": "New role", - "who": "roles.manage" - }, - { - "archetype": "tab", - "help": "https://compositecode.github.io/substratalapps-linux/screens/rol-04/", - "id": "ROL-04", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Assign and remove roles for a user.", - "route": "/admin/users/:userId/roles", - "title": "User — Roles", - "who": "users.list (read), roles.manage (write)" - }, - { - "archetype": "list", - "help": "https://compositecode.github.io/substratalapps-linux/screens/apl-01/", - "id": "APL-01", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Applications you own or administer.", - "route": "/dev/apps", - "title": "Applications", - "who": "Any (catalog), owner/applications.manage (manage)" - }, - { - "archetype": "wizard", - "help": "https://compositecode.github.io/substratalapps-linux/screens/apl-02/", - "id": "APL-02", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Register a new application.", - "route": "/dev/apps/new", - "title": "Register application", - "who": "applications.manage, or anyone with a verified email (self-service)" - }, - { - "archetype": "detail", - "help": "https://compositecode.github.io/substratalapps-linux/screens/apl-03/", - "id": "APL-03", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Configure an application.", - "route": "/dev/apps/:appId", - "title": "Application — Overview", - "who": "Owner (configure), applications.manage (anything)" - }, - { - "archetype": "tab", - "help": "https://compositecode.github.io/substratalapps-linux/screens/apl-04/", - "id": "APL-04", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "Declare per-user settings the app stores in Substratal.", - "route": "/dev/apps/:appId/schema", - "title": "Settings schema", - "who": "Owner / applications.manage" - }, - { - "archetype": "tab", - "help": "https://compositecode.github.io/substratalapps-linux/screens/apl-05/", - "id": "APL-05", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "Declare app permissions, available app roles and the default role.", - "route": "/dev/apps/:appId/roles", - "title": "Permissions & roles", - "who": "Owner / applications.manage" - }, - { - "archetype": "form", - "help": "https://compositecode.github.io/substratalapps-linux/screens/apl-06/", - "id": "APL-06", - "phase": "Phase 3", - "platforms": [ - "all" - ], - "purpose": "Staff moderation of an application.", - "route": "/dev/apps/:appId/review", - "title": "Review", - "who": "applications.manage" - }, - { - "archetype": "tab", - "help": "https://compositecode.github.io/substratalapps-linux/screens/apl-07/", - "id": "APL-07", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "Configure APNs, FCM and Web Push for an Application so it can send push notifications.", - "route": "/dev/apps/:appId/push", - "title": "Push credentials", - "who": "Owner or applications.manage with a User token (write); app key / owner (read)" - }, - { - "archetype": "table", - "help": "https://compositecode.github.io/substratalapps-linux/screens/key-01/", - "id": "KEY-01", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Service and agent credentials.", - "route": "/dev/keys", - "title": "API keys", - "who": "api_keys.manage or app owner" - }, - { - "archetype": "form", - "help": "https://compositecode.github.io/substratalapps-linux/screens/key-02/", - "id": "KEY-02", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Create a key with least-privilege permissions.", - "route": "/dev/keys/new", - "title": "Create API key", - "who": "api_keys.manage or app owner (app-scoped)" - }, - { - "archetype": "secret", - "help": "https://compositecode.github.io/substratalapps-linux/screens/key-03/", - "id": "KEY-03", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Show a new API key secret, webhook signing secret, or recovery codes exactly once.", - "route": "(modal)", - "title": "Secret reveal", - "who": "Creator" - }, - { - "archetype": "detail", - "help": "https://compositecode.github.io/substratalapps-linux/screens/key-04/", - "id": "KEY-04", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Edit, rotate or revoke a key.", - "route": "/dev/keys/:keyId", - "title": "API key detail", - "who": "api_keys.manage or app owner" - }, - { - "archetype": "list", - "help": "https://compositecode.github.io/substratalapps-linux/screens/whk-01/", - "id": "WHK-01", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "Endpoints that receive access-change events.", - "route": "/dev/webhooks", - "title": "Webhooks", - "who": "webhooks.manage or app owner" - }, - { - "archetype": "form", - "help": "https://compositecode.github.io/substratalapps-linux/screens/whk-02/", - "id": "WHK-02", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "Subscribe a URL to events.", - "route": "/dev/webhooks/new", - "title": "Add / edit webhook", - "who": "webhooks.manage or app owner" - }, - { - "archetype": "detail", - "help": "https://compositecode.github.io/substratalapps-linux/screens/whk-03/", - "id": "WHK-03", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "Delivery history, testing and secret rotation.", - "route": "/dev/webhooks/:hookId", - "title": "Webhook — Deliveries", - "who": "webhooks.manage or app owner" - }, - { - "archetype": "popover", - "help": "https://compositecode.github.io/substratalapps-linux/screens/whk-04/", - "id": "WHK-04", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "One delivery attempt in detail.", - "route": "/dev/webhooks/:hookId/deliveries/:deliveryId", - "title": "Delivery detail", - "who": "webhooks.manage or app owner" - }, - { - "archetype": "settings", - "help": "https://compositecode.github.io/substratalapps-linux/screens/bil-01/", - "id": "BIL-01", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "Free or Standard: what covers you, your own subscription, upgrade and manage billing.", - "route": "/billing", - "title": "My plan", - "who": "Self; billing.manage can view any User's (read-only)" - }, - { - "archetype": "tab", - "help": "https://compositecode.github.io/substratalapps-linux/screens/bil-02/", - "id": "BIL-02", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "An organization's Standard subscription and which members it covers.", - "route": "/orgs/:orgId/subscription", - "title": "Organization subscription", - "who": "org_admin (manage); billing.manage (read + portal)" - }, - { - "archetype": "handoff", - "help": "https://compositecode.github.io/substratalapps-linux/screens/bil-03/", - "id": "BIL-03", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "Hand off to Stripe in the browser and confirm the result on return.", - "route": "/billing/return", - "title": "Checkout / billing portal", - "who": "Self (individual); org_admin (org checkout); org_admin or billing.manage (org portal)" - }, - { - "archetype": "detail", - "help": "https://compositecode.github.io/substratalapps-linux/screens/bil-04/", - "id": "BIL-04", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "Where an Application owner's data lives, and support-run tier changes. Tenants aren't billed.", - "route": "/dev/tenant", - "title": "Tenant & infrastructure", - "who": "Tenant owner (read), tenants.manage (write)" - }, - { - "archetype": "table", - "help": "https://compositecode.github.io/substratalapps-linux/screens/aud-01/", - "id": "AUD-01", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Who changed what, when.", - "route": "/admin/audit", - "title": "Audit log", - "who": "audit.view" - }, - { - "archetype": "popover", - "help": "https://compositecode.github.io/substratalapps-linux/screens/aud-02/", - "id": "AUD-02", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "One event with before/after diff.", - "route": "/admin/audit/:eventId", - "title": "Audit event", - "who": "audit.view" - }, - { - "archetype": "list", - "help": "https://compositecode.github.io/substratalapps-linux/screens/ntf-01/", - "id": "NTF-01", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "Every notification the person has received — from every app and from Substratal — with read and archived state.", - "route": "/inbox", - "title": "Inbox", - "who": "Self" - }, - { - "archetype": "popover", - "help": "https://compositecode.github.io/substratalapps-linux/screens/ntf-02/", - "id": "NTF-02", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "One notification in full, with where it leads and how its push went.", - "route": "/inbox/:notificationId", - "title": "Notification", - "who": "Self (users.list via NTF-05)" - }, - { - "archetype": "settings", - "help": "https://compositecode.github.io/substratalapps-linux/screens/ntf-03/", - "id": "NTF-03", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "Where push notifications reach this person, and controls for this device.", - "route": "/account/devices", - "title": "Devices & notifications", - "who": "Self" - }, - { - "archetype": "wizard", - "help": "https://compositecode.github.io/substratalapps-linux/screens/ntf-04/", - "id": "NTF-04", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "Explain, request the OS permission, and register this install for push.", - "route": "/account/devices/enable", - "title": "Turn on notifications", - "who": "Self" - }, - { - "archetype": "tab", - "help": "https://compositecode.github.io/substratalapps-linux/screens/ntf-05/", - "id": "NTF-05", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "Support view of a person's inbox and push devices for investigations.", - "route": "/admin/users/:userId/notifications", - "title": "User — Notifications & devices", - "who": "users.list (read), users.manage (delete devices)" - }, - { - "archetype": "form", - "help": "https://compositecode.github.io/substratalapps-linux/screens/ntf-06/", - "id": "NTF-06", - "phase": "Phase 2", - "platforms": [ - "all" - ], - "purpose": "Staff tool: send one notification to one person on behalf of an Application.", - "route": "/admin/notifications/new", - "title": "Send notification", - "who": "platform notifications.send" - }, - { - "archetype": "overlay", - "help": "https://compositecode.github.io/substratalapps-linux/screens/glb-01/", - "id": "GLB-01", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Jump anywhere and run any action by typing.", - "route": "(overlay)", - "title": "Command palette", - "who": "Signed in" - }, - { - "archetype": "hub", - "help": "https://compositecode.github.io/substratalapps-linux/screens/glb-02/", - "id": "GLB-02", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "Compact-layout landing list for the Administration and Developer sections.", - "route": "/manage", - "title": "Manage hub", - "who": "Any admin or developer permission" - }, - { - "archetype": "overlay", - "help": "https://compositecode.github.io/substratalapps-linux/screens/glb-03/", - "id": "GLB-03", - "phase": "MVP", - "platforms": [ - "all" - ], - "purpose": "App-wide banners and blocking states.", - "route": "(global)", - "title": "System states", - "who": "—" - }, - { - "archetype": "detail", - "help": "https://compositecode.github.io/substratalapps-linux/screens/ops-01/", - "id": "OPS-01", - "phase": "Console M3 (web only)", - "platforms": [ - "web" - ], - "purpose": "Is production healthy right now, and is anything costing or failing?", - "route": "/ops", - "title": "Overview", - "who": "operations.view (superadmin until G10)" - }, - { - "archetype": "detail", - "help": "https://compositecode.github.io/substratalapps-linux/screens/ops-02/", - "id": "OPS-02", - "phase": "Console M3 (web only)", - "platforms": [ - "web" - ], - "purpose": "Trends and SLO tracking.", - "route": "/ops/traffic", - "title": "Traffic & latency", - "who": "operations.view" - }, - { - "archetype": "table", - "help": "https://compositecode.github.io/substratalapps-linux/screens/ops-03/", - "id": "OPS-03", - "phase": "Console M3 (web only)", - "platforms": [ - "web" - ], - "purpose": "Find what happened to one request, user or key.", - "route": "/ops/logs", - "title": "Logs", - "who": "operations.view" - }, - { - "archetype": "table", - "help": "https://compositecode.github.io/substratalapps-linux/screens/sec-01/", - "id": "SEC-01", - "phase": "Console M3 (web only)", - "platforms": [ - "web" - ], - "purpose": "Spot attacks and lockouts.", - "route": "/ops/security", - "title": "Security events", - "who": "operations.view" - }, - { - "archetype": "table", - "help": "https://compositecode.github.io/substratalapps-linux/screens/ops-04/", - "id": "OPS-04", - "phase": "Console M3 (web only)", - "platforms": [ - "web" - ], - "purpose": "What's firing, what fired, and who gets told.", - "route": "/ops/alarms", - "title": "Alarms", - "who": "operations.view" - }, - { - "archetype": "table", - "help": "https://compositecode.github.io/substratalapps-linux/screens/ops-05/", - "id": "OPS-05", - "phase": "Console M3 (web only)", - "platforms": [ - "web" - ], - "purpose": "Are the scheduled jobs and deliveries keeping up?", - "route": "/ops/jobs", - "title": "Jobs & delivery", - "who": "operations.view" - }, - { - "archetype": "table", - "help": "https://compositecode.github.io/substratalapps-linux/screens/ops-06/", - "id": "OPS-06", - "phase": "Console M3 (web only)", - "platforms": [ - "web" - ], - "purpose": "What's deployed.", - "route": "/ops/releases", - "title": "Releases", - "who": "operations.view" - }, - { - "archetype": "detail", - "help": "https://compositecode.github.io/substratalapps-linux/screens/ops-07/", - "id": "OPS-07", - "phase": "Console M3 (web only)", - "platforms": [ - "web" - ], - "purpose": "Spend against the budgets.", - "route": "/ops/cost", - "title": "Cost", - "who": "operations.view" - }, - { - "archetype": "settings", - "help": "https://compositecode.github.io/substratalapps-linux/screens/cfg-01/", - "id": "CFG-01", - "phase": "Console M3 (web only)", - "platforms": [ - "web" - ], - "purpose": "What production is actually running with.", - "route": "/ops/config", - "title": "Configuration", - "who": "operations.view" - }, - { - "archetype": "settings", - "help": "https://compositecode.github.io/substratalapps-linux/screens/cfg-02/", - "id": "CFG-02", - "phase": "Console M4 (web only)", - "platforms": [ - "web" - ], - "purpose": "See, and later change, the client feature flags (CLIENT-ARCHITECTURE §10).", - "route": "/ops/config/flags", - "title": "Feature flags", - "who": "operations.view (read), operations.manage (write, after G10)" - }, - { - "archetype": "detail", - "help": "https://compositecode.github.io/substratalapps-linux/screens/rpt-01/", - "id": "RPT-01", - "phase": "Console M4 (web only)", - "platforms": [ - "web" - ], - "purpose": "Growth, access, usage, subscribers and delivery over time.", - "route": "/admin/reports", - "title": "Reports", - "who": "users.list (platform reports); an Application owner sees only their own app" - }, - { - "archetype": "list", - "help": "https://compositecode.github.io/substratalapps-linux/screens/int-01/", - "id": "INT-01", - "phase": "Console M2 (web only)", - "platforms": [ - "web" - ], - "purpose": "Every way to connect Substratal to other tools, in one place.", - "route": "/dev/integrations", - "title": "Integrations", - "who": "webhooks.manage or api_keys.manage" - } -] diff --git a/docs/screens/acc-01.md b/docs/screens/acc-01.md deleted file mode 100644 index abf4c5f..0000000 --- a/docs/screens/acc-01.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ACC-01 Account & profile" -permalink: /screens/acc-01/ -nav_exclude: true -screen_id: ACC-01 ---- - -# Account & profile -{: .no_toc } - -View and edit the signed-in user's global profile. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ACC-01` | -| **Route** | `/account` · deep link `substratal://account` | -| **Where** | Personal › Account | -| **Who can use it** | Self | -| **Kind** | settings | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwPreferencesPage` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Avatar | image-picker | Profile.avatar_url | camera / photo library / file / paste; client uploads to app asset storage then PATCHes URL* | GtkFileDialog (portal) | -| Display name | text-field | Profile.display_name | required; ≤ 100; save on blur/Return | AdwEntryRow | -| Email | row → ACC-02 | User.email + email_verified + pending_email | shows "Pending: {pending_email}" when set | AdwActionRow activatable | -| User ID | id-chip | User.id | copy | GtkLabel .monospace + copy | -| Contact email | email-field | Profile.contact_email | optional | AdwEntryRow input-purpose=email | -| Contact phone | phone-field | Profile.contact_phone | optional; E.164 formatting by locale | AdwEntryRow input-purpose=phone | -| Plan | row → BIL-01 | User.plan free\ | standard + plan_sources | AdwActionRow activatable | -| Member since / last sign-in | caption | User.created_at, last_login_at | locale formatted | GtkLabel .caption .dim-label | -| Sign out | button | POST /auth/logout | always visible at the bottom of Account (mobile) / account menu (desktop) | GtkButton | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Edit field | `PATCH /users/me/profile (If-Match)` | Save per field; 409 version_conflict → reload prompt; 422 → field error. | -| Sign out | `POST /auth/logout` | best effort; always wipe local credentials and cache → AUTH-01 | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES -- *The API stores `avatar_url` only. Upload target is a client-side decision (spec gap — see OPEN-QUESTIONS). - -## For developers - -Loads: `GET /users/me` · `GET /users/me/profile` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/acc-02.md b/docs/screens/acc-02.md deleted file mode 100644 index 6024902..0000000 --- a/docs/screens/acc-02.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ACC-02 Change email" -permalink: /screens/acc-02/ -nav_exclude: true -screen_id: ACC-02 ---- - -# Change email -{: .no_toc } - -Start an email change; the new address must be verified. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ACC-02` | -| **Route** | `/account/email` · deep link `substratal://account/email` | -| **Where** | Personal › Account | -| **Who can use it** | Self | -| **Kind** | form | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| New email | email-field | body.email | required; differs from current | AdwEntryRow input-purpose=email | -| Save | primary-button | PATCH /users/me | | GtkButton .suggested-action .pill | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Save | `PATCH /users/me {email}` | 200 → User.pending_email set; show AUTH-04-style "check your new inbox". 409 not_unique → "That email is already in use." | - diff --git a/docs/screens/acc-03.md b/docs/screens/acc-03.md deleted file mode 100644 index d1465d2..0000000 --- a/docs/screens/acc-03.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ACC-03 Change password" -permalink: /screens/acc-03/ -nav_exclude: true -screen_id: ACC-03 ---- - -# Change password -{: .no_toc } - -Change password knowing the current one. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ACC-03` | -| **Route** | `/account/security/password` · deep link `substratal://account/security/password` | -| **Where** | Personal › Account | -| **Who can use it** | Self | -| **Kind** | form | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Current password | password-field | body.current_password | autofill current-password | AdwPasswordEntryRow | -| New password | password-field | body.new_password | strength meter; autofill new-password | AdwPasswordEntryRow | -| Save | primary-button | POST /users/me/password | | GtkButton .suggested-action .pill | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Save | `POST /users/me/password` | 204 → toast "Password changed". 401 → "Current password is incorrect." 422 → policy errors. | - diff --git a/docs/screens/acc-04.md b/docs/screens/acc-04.md deleted file mode 100644 index 29c913c..0000000 --- a/docs/screens/acc-04.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ACC-04 Security" -permalink: /screens/acc-04/ -nav_exclude: true -screen_id: ACC-04 ---- - -# Security -{: .no_toc } - -Password, two-factor and active sessions in one place. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ACC-04` | -| **Route** | `/account/security` · deep link `substratal://account/security` | -| **Where** | Personal › Account | -| **Who can use it** | Self | -| **Kind** | settings | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwPreferencesPage` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Password | row → ACC-03 | | "Change…" | AdwActionRow activatable | -| Two-factor authentication | toggle | User.mfa_enabled | on → ACC-05; off → ACC-06 | AdwSwitchRow | -| Sessions | list | Session[]: user_agent → device name/icon, ip_address → approximate location*, last_seen_at, current | current session marked "This device" and not revocable here | GtkListBox .boxed-list | -| Sign out | row-button | DELETE /users/me/sessions/{sessionId} | per non-current row | suffix GtkButton .flat | -| Sign out of all other sessions | destructive-button | POST /users/me/sessions/revoke-all {keep_current: true} | confirm first; also stops push to those sessions' devices | GtkButton .destructive-action | -| Devices & notifications | row → NTF-03 | count of active devices | | AdwActionRow activatable | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Revoke one | `DELETE /users/me/sessions/{sessionId}` | Optimistic remove; 404 → already gone, refresh. | -| Sign out everywhere else | `POST /users/me/sessions/revoke-all {keep_current: true}` | 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. | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES -- *IP → city lookup is client-side/optional; never show the raw IP to non-staff unless the user expands details. - -## For developers - -Loads: `GET /users/me` · `GET /users/me/sessions` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/acc-05.md b/docs/screens/acc-05.md deleted file mode 100644 index 339d6f9..0000000 --- a/docs/screens/acc-05.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ACC-05 Turn on two-factor" -permalink: /screens/acc-05/ -nav_exclude: true -screen_id: ACC-05 ---- - -# Turn on two-factor -{: .no_toc } - -Enroll an authenticator app (TOTP) and save recovery codes. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ACC-05` | -| **Route** | `/account/security/2fa/enable` · deep link `substratal://account/security/2fa/enable` | -| **Where** | Personal › Account | -| **Who can use it** | Self | -| **Kind** | wizard | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwDialog` with `AdwNavigationView` pages | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Step 1 — Scan | qr + secret | POST /users/me/mfa/totp → otpauth_uri, secret | QR, "Copy setup key", "Open in authenticator" (otpauth:// link on mobile) | GtkPicture | -| Step 2 — Confirm | otp-field | body.code | 6 digits | AdwEntryRow input-purpose=digits | -| Step 3 — Recovery codes | secret-list | confirm response: 10 recovery codes | Copy all · Save as file · Print · Share (mobile); "I've saved these" checkbox gates Done | SecretReveal + GtkFileDialog/print | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Start | `POST /users/me/mfa/totp` | 409 → already enabled; close. | -| Confirm | `POST /users/me/mfa/totp/confirm` | 200 → step 3. 401 → wrong code. | - -## Good to know - -- **Loading, empty and error states:** Cancel before confirm leaves MFA off (no server cleanup needed) - diff --git a/docs/screens/acc-06.md b/docs/screens/acc-06.md deleted file mode 100644 index 5255fe1..0000000 --- a/docs/screens/acc-06.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ACC-06 Turn off two-factor" -permalink: /screens/acc-06/ -nav_exclude: true -screen_id: ACC-06 ---- - -# Turn off two-factor -{: .no_toc } - -Disable TOTP with a current code or a recovery code. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ACC-06` | -| **Route** | `/account/security/2fa/disable` · deep link `substratal://account/security/2fa/disable` | -| **Where** | Personal › Account | -| **Who can use it** | Self | -| **Kind** | confirm | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwAlertDialog` (`extra-child` for fields) | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Code or recovery code | otp-field / text-field | body.code \ | body.recovery_code | AdwEntryRow input-purpose=digits | -| Turn off | destructive-button | POST /users/me/mfa/totp/disable | | GtkButton .destructive-action | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Turn off | `POST /users/me/mfa/totp/disable` | 204 → toggle off, toast. 401 → wrong code. 429 → countdown. | - diff --git a/docs/screens/acc-07.md b/docs/screens/acc-07.md deleted file mode 100644 index 5405991..0000000 --- a/docs/screens/acc-07.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ACC-07 Privacy" -permalink: /screens/acc-07/ -nav_exclude: true -screen_id: ACC-07 ---- - -# Privacy -{: .no_toc } - -Export personal data and delete the account. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ACC-07` | -| **Route** | `/account/privacy` · deep link `substratal://account/privacy` | -| **Where** | Personal › Account | -| **Who can use it** | Self | -| **Kind** | settings | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwPreferencesPage` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Download my data | button | GET /users/me/export | saves `substratal-export-{date}.json` via save dialog / share sheet / download | GtkButton | -| Delete account | destructive-row → ACC-08 | | | AdwButtonRow .destructive-action | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Download | `GET /users/me/export` | 200 → save file. 429 → "You can export again in {n} minutes." | - diff --git a/docs/screens/acc-08.md b/docs/screens/acc-08.md deleted file mode 100644 index bc744ee..0000000 --- a/docs/screens/acc-08.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ACC-08 Delete account" -permalink: /screens/acc-08/ -nav_exclude: true -screen_id: ACC-08 ---- - -# Delete account -{: .no_toc } - -Request erasure: deactivate now, erase after 7 days. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ACC-08` | -| **Route** | `/account/privacy/delete` · deep link `substratal://account/privacy/delete` | -| **Where** | Personal › Account | -| **Who can use it** | Self | -| **Kind** | confirm | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwAlertDialog` (`extra-child` for fields) | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| What happens | text | | list: signed out everywhere; apps lose access; erased on {date}; support can cancel within 7 days | GtkLabel | -| Reason | text-area | body.reason | optional | GtkTextView in .card | -| Type DELETE | text-field | client only | must equal localized confirm word | AdwEntryRow | -| Delete account | destructive-button | POST /users/me/erasure-requests | | GtkButton .destructive-action | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Delete account | `POST /users/me/erasure-requests` | 202 ErasureRequest → clear local data → AUTH-01 with "Your account is scheduled for deletion on {scheduled_for}." | - -## Good to know - -- 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). - diff --git a/docs/screens/acx-01.md b/docs/screens/acx-01.md deleted file mode 100644 index a7540d5..0000000 --- a/docs/screens/acx-01.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ACX-01 User — Access" -permalink: /screens/acx-01/ -nav_exclude: true -screen_id: ACX-01 ---- - -# User — Access -{: .no_toc } - -Turn apps on or off for one person; see every path that grants access. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ACX-01` | -| **Route** | `/admin/users/:userId/access` · deep link `substratal://admin/users/:userId/access` | -| **Where** | Administration › Users | -| **Who can use it** | users.list (read), entitlements.manage or app owner (write) | -| **Kind** | tab | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwViewStack` page | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Rows (one per app) | list | resolved status per app + paths (personal Entitlement, org grants) | StatusBadge + AccessSwitch on the personal path | GtkListBox .boxed-list | -| Paths disclosure | expandable | Entitlement.granted_via, source, subscription_id, organization_id, member_decision, starts_at/ends_at, disabled_reason | platform-managed paths (`free`, `subscription`) show a "Managed by Substratal" caption; developer grants and platform rows can coexist | AdwExpanderRow | -| Org-only access | caption + link | | switch replaced by "Via {org}" → ORG-06 | GtkLabel .caption .dim-label | -| Grant access | primary-button | → ACX-02 | | GtkButton .suggested-action .pill | -| Why? | button | → ACX-04 | | GtkButton | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Switch off | `→ ACX-03 confirm → PATCH /entitlements/{id} {status:disabled, disabled_reason}` | Optimistic with Undo (PATCH status:active) for 10 s | -| Switch on | `PATCH /entitlements/{id} {status:active}` | No confirm | -| Remove grant made in error | `DELETE /entitlements/{id}` | Only developer grants with no order_id and < 24 h old; never on `free`/`subscription` rows (409 entitlement_platform_managed); 409 otherwise | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES -- 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. - -## For developers - -Loads: `GET /users/{id}/entitlements?include_inactive=true` · `GET /applications` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/acx-02.md b/docs/screens/acx-02.md deleted file mode 100644 index edf98cd..0000000 --- a/docs/screens/acx-02.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ACX-02 Grant access" -permalink: /screens/acx-02/ -nav_exclude: true -screen_id: ACX-02 ---- - -# Grant access -{: .no_toc } - -Give a person access to an app. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ACX-02` | -| **Route** | `/admin/users/:userId/access/grant` · deep link `substratal://admin/users/:userId/access/grant` | -| **Where** | Administration › Users | -| **Who can use it** | entitlements.manage or app owner (own app) | -| **Kind** | form | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| App | select | body.application_id | app owners see only their apps | AdwComboRow | -| Type | radio-group | body.source purchase \ | trial \ | GtkCheckButton group | -| Order ID | text-field | body.order_id | required feel for purchase | AdwEntryRow | -| Starts | date-time | body.starts_at | default now | GtkCalendar in popover | -| Ends | date-time | body.ends_at | optional; required for trial | GtkCalendar in popover | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Grant | `POST /users/{id}/entitlements (Idempotency-Key, required)` | 201 → row Active. 409 entitlement_already_exists → "Already has access — view it". A platform-managed row doesn't block a grant (they coexist). | - diff --git a/docs/screens/acx-03.md b/docs/screens/acx-03.md deleted file mode 100644 index 555c13e..0000000 --- a/docs/screens/acx-03.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ACX-03 Turn off / edit access" -permalink: /screens/acx-03/ -nav_exclude: true -screen_id: ACX-03 ---- - -# Turn off / edit access -{: .no_toc } - -Confirm turning off access, or edit term/source. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ACX-03` | -| **Route** | `/admin/entitlements/:entId` · deep link `substratal://admin/entitlements/:entId` | -| **Who can use it** | entitlements.manage or app owner | -| **Kind** | confirm | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwAlertDialog` (`extra-child` for fields) | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Title | text | | "Turn off {app} for {name}?" | GtkLabel | -| Reason | text-field | body.disabled_reason | optional; audit | AdwEntryRow | -| Edit term (advanced) | disclosure | ends_at, source trial→purchase, order_id | hidden for platform-managed rows | AdwExpanderRow | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Turn off | `PATCH /entitlements/{id} {status:disabled, disabled_reason} (If-Match)` | 409 version_conflict → reload and re-apply | -| Save changes | `PATCH /entitlements/{id}` | | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /entitlements/{id}` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/acx-04.md b/docs/screens/acx-04.md deleted file mode 100644 index f9324b3..0000000 --- a/docs/screens/acx-04.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ACX-04 Access check (Why?)" -permalink: /screens/acx-04/ -nav_exclude: true -screen_id: ACX-04 ---- - -# Access check (Why?) -{: .no_toc } - -Explain exactly why a person can or can't use an app. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ACX-04` | -| **Route** | `/admin/users/:userId/apps/:appId/why` · deep link `substratal://admin/users/:userId/apps/:appId/why` | -| **Where** | Administration › Users | -| **Who can use it** | users.list or app owner | -| **Kind** | popover | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `GtkPopover` or `AdwOverlaySplitView` sidebar | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Verdict | banner | allowed | "Dana can use Timetrack" / "can't" + first failing reason (user_status, entitlement_status) | AdwBanner | -| Checks | checklist | user_status, entitlement_status, access_paths[], roles, effective_permissions | each with pass/fail icon | GtkListBox with status icons | -| Computed at | caption | computed_at | | GtkLabel .caption .dim-label | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /users/{id}/apps/{appId}/effective-permissions` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/acx-05.md b/docs/screens/acx-05.md deleted file mode 100644 index 1d98d10..0000000 --- a/docs/screens/acx-05.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ACX-05 Access (all)" -permalink: /screens/acx-05/ -nav_exclude: true -screen_id: ACX-05 ---- - -# Access (all) -{: .no_toc } - -Every grant across users: audit expirations, trials, revocations. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ACX-05` | -| **Route** | `/admin/access` · deep link `substratal://admin/access` | -| **Where** | Administration › Access | -| **Who can use it** | entitlements.manage or app owner (confined to own apps) | -| **Kind** | table | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `GtkColumnView` (.data-table) in a card | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Filters | chips/menus | app, status, source, ends before (Expiring in 7/30 days presets) | | GtkToggleButton pills | -| Columns | table | user, app, status, source (incl. free / subscription), subscription_id, starts_at, ends_at, order_id, granted_via | source filter defaults to developer grants; "Show platform-managed" chip adds free/subscription rows | GtkColumnView .data-table | -| Multi-select | selection | | bulk Turn off (confirm, one PATCH per row, progress + per-row result) | AdwComboRow | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Open row | `→ ACX-01 for that user` | | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /entitlements?application_id&status&source&user_id&organization_id&order_id&ends_before&include_inactive` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/apl-01.md b/docs/screens/apl-01.md deleted file mode 100644 index 4b779ea..0000000 --- a/docs/screens/apl-01.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "APL-01 Applications" -permalink: /screens/apl-01/ -nav_exclude: true -screen_id: APL-01 ---- - -# Applications -{: .no_toc } - -Applications you own or administer. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `APL-01` | -| **Route** | `/dev/apps` · deep link `substratal://dev/apps` | -| **Where** | Developer › Applications | -| **Who can use it** | Any (catalog), owner/applications.manage (manage) | -| **Kind** | list | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Nested `AdwNavigationSplitView` list pane | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Rows | list | ApplicationSummary: icon, name, slug, visibility, review_status | | GtkListBox .boxed-list | -| Register app | primary-button | → APL-02 | applications.manage, or anyone with a verified email (self-service, flag `app_registration_self_service`); unverified → disabled with "Verify your email to register apps" | GtkButton .suggested-action .pill | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /applications?owned=true (staff: all, filter review_status)` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/apl-02.md b/docs/screens/apl-02.md deleted file mode 100644 index 6b265e1..0000000 --- a/docs/screens/apl-02.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "APL-02 Register application" -permalink: /screens/apl-02/ -nav_exclude: true -screen_id: APL-02 ---- - -# Register application -{: .no_toc } - -Register a new application. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `APL-02` | -| **Route** | `/dev/apps/new` · deep link `substratal://dev/apps/new` | -| **Where** | Developer › Applications | -| **Who can use it** | applications.manage, or anyone with a verified email (self-service) | -| **Kind** | wizard | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwDialog` with `AdwNavigationView` pages | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Name | text-field | name | | AdwEntryRow | -| Slug | text-field | slug | ^[a-z][a-z0-9-]{1,63}$; 'platform' reserved; immutable — warn | AdwEntryRow | -| Launch URL | url-field | launch_url | https | AdwEntryRow input-purpose=url | -| Redirect URIs | url-list | redirect_uris | | AdwPreferencesGroup of AdwEntryRow + add button | -| Visibility | radio-group | visibility public \ | invite_only \ | GtkCheckButton group | -| Access tier | radio-group | access_tier free \ | standard (default standard) | GtkCheckButton group | -| Owner | select | owner_user_id \ | owner_organization_id | AdwComboRow | -| Icon | image-picker | icon_url | | GtkFileDialog (portal) | -| Support URL | url-field | support_url | | AdwEntryRow input-purpose=url | -| Email from name | text-field | email_from_name | | AdwEntryRow | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Register | `POST /applications (Idempotency-Key)` | 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). | - diff --git a/docs/screens/apl-03.md b/docs/screens/apl-03.md deleted file mode 100644 index 86a87e4..0000000 --- a/docs/screens/apl-03.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "APL-03 Application — Overview" -permalink: /screens/apl-03/ -nav_exclude: true -screen_id: APL-03 ---- - -# Application — Overview -{: .no_toc } - -Configure an application. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `APL-03` | -| **Route** | `/dev/apps/:appId` · deep link `substratal://dev/apps/:appId` | -| **Where** | Developer › Applications | -| **Who can use it** | Owner (configure), applications.manage (anything) | -| **Kind** | detail | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Content pane; `AdwViewSwitcher`; header-bar actions | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Header | icon, name, id-chip, review StatusBadge, "Third-party" chip when `self_registered` | | | GtkLabel .monospace + copy | -| Tabs | tabs | Overview · Settings schema · Permissions & roles · Push · Keys · Webhooks · Access · Activity | Push = APL-07; Keys/Webhooks/Access = KEY-01/WHK-01/ACX-05 filtered to app | AdwViewSwitcher / AdwInlineViewSwitcher | -| Fields | settings rows | name, description, launch_url, redirect_uris, support_url, email_from_name, icon_url (owner); visibility, access_tier (applications.manage) | slug read-only with lock | Adw*Rows | -| Platform alerts | toggle | receives_platform_notifications | 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) | AdwSwitchRow | -| Review notes | banner | review_status rejected/suspended + review_notes | | AdwBanner | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Save field | `PATCH /applications/{id} (If-Match)` | 422 → field errors | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /applications/{id}` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/apl-04.md b/docs/screens/apl-04.md deleted file mode 100644 index f0c294c..0000000 --- a/docs/screens/apl-04.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "APL-04 Settings schema" -permalink: /screens/apl-04/ -nav_exclude: true -screen_id: APL-04 ---- - -# Settings schema -{: .no_toc } - -Declare per-user settings the app stores in Substratal. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `APL-04` | -| **Route** | `/dev/apps/:appId/schema` · deep link `substratal://dev/apps/:appId/schema` | -| **Where** | Developer › Applications | -| **Who can use it** | Owner / applications.manage | -| **Kind** | tab | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | `AdwViewStack` page | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Properties | list | settings_schema.properties: key, type, enum, default, min/max, maxLength | add/edit via form | GtkListBox .boxed-list | -| JSON editor | code-editor | settings_schema | JSON validation; diff before save | GtkSourceView (JSON) | -| Preview | rendered SET-02 form | | shows exactly what users will see | SchemaForm preview | -| Stats | caption | settings_schema_stats | | GtkLabel .caption .dim-label | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Save | `PATCH /applications/{id} {settings_schema}` | 422 → schema rule errors | - diff --git a/docs/screens/apl-05.md b/docs/screens/apl-05.md deleted file mode 100644 index b6d3400..0000000 --- a/docs/screens/apl-05.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "APL-05 Permissions & roles" -permalink: /screens/apl-05/ -nav_exclude: true -screen_id: APL-05 ---- - -# Permissions & roles -{: .no_toc } - -Declare app permissions, available app roles and the default role. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `APL-05` | -| **Route** | `/dev/apps/:appId/roles` · deep link `substratal://dev/apps/:appId/roles` | -| **Where** | Developer › Applications | -| **Who can use it** | Owner / applications.manage | -| **Kind** | tab | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | `AdwViewStack` page | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Permissions | editable list | permissions[] {key, description} | key app..* | GtkColumnView .data-table | -| Available app roles | token list | available_app_roles (≤ 20) | | custom entry with pills | -| Default app role | select | default_app_role | held implicitly by everyone with access | AdwComboRow | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Save | `PATCH /applications/{id}` | | - diff --git a/docs/screens/apl-06.md b/docs/screens/apl-06.md deleted file mode 100644 index 2c7aeaf..0000000 --- a/docs/screens/apl-06.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "APL-06 Review" -permalink: /screens/apl-06/ -nav_exclude: true -screen_id: APL-06 ---- - -# Review -{: .no_toc } - -Staff moderation of an application. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `APL-06` | -| **Route** | `/dev/apps/:appId/review` · deep link `substratal://dev/apps/:appId/review` | -| **Where** | Developer › Applications | -| **Who can use it** | applications.manage | -| **Kind** | form | -| **Available in** | All Hub apps (Phase 3) | -| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Review status | radio-group | review_status | | GtkCheckButton group | -| Notes | text-area | review_notes | visible to owner | GtkTextView in .card | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Save | `PATCH /applications/{id} {review_status, review_notes}` | | - diff --git a/docs/screens/apl-07.md b/docs/screens/apl-07.md deleted file mode 100644 index c458493..0000000 --- a/docs/screens/apl-07.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "APL-07 Push credentials" -permalink: /screens/apl-07/ -nav_exclude: true -screen_id: APL-07 ---- - -# Push credentials -{: .no_toc } - -Configure APNs, FCM and Web Push for an Application so it can send push notifications. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `APL-07` | -| **Route** | `/dev/apps/:appId/push` · deep link `substratal://dev/apps/:appId/push` | -| **Where** | Developer › Applications | -| **Who can use it** | Owner or applications.manage with a User token (write); app key / owner (read) | -| **Kind** | tab | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | `AdwViewStack` page | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Providers | list | PushCredential per provider apns \ | fcm \ | GtkListBox .boxed-list | -| Add APNs | form | POST body: provider apns, team_id (10), key_id (10), topic (bundle id), private_key (.p8) | file picker / drop / paste for the .p8; key never shown again | AdwDialog form | -| Add FCM | form | POST body: provider fcm, service_account (JSON file) | drop/pick the downloaded JSON; validate shape client-side | AdwDialog form | -| Add Web Push | form | POST body: provider web_push, subject (mailto:/https:) | platform generates VAPID keys; show vapid_public_key with Copy for the web client | AdwDialog form | -| Rotate / fix | form | PATCH /applications/{id}/push-credentials/{provider} {key material \ | status: active} | AdwDialog form | -| Remove | destructive-button | DELETE /applications/{id}/push-credentials/{provider} | confirm; web_push removal deactivates every browser subscription | GtkButton .destructive-action | -| Platform alerts | toggle | Application.receives_platform_notifications | mirror of APL-03 toggle | AdwSwitchRow | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Add | `POST /applications/{id}/push-credentials` | 201; 409 push_credential_exists → Edit; 422 invalid_push_credential → field error; 400 user_token_required never happens in the Hub (User token) | -| Fetch one | `GET /applications/{id}/push-credentials/{provider}` | on Edit | -| Save | `PATCH /applications/{id}/push-credentials/{provider}` | 409 invalid_status_transition | -| Remove | `DELETE /applications/{id}/push-credentials/{provider}` | 204 | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES -- 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). - -## For developers - -Loads: `GET /applications/{id}/push-credentials` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/app-01.md b/docs/screens/app-01.md deleted file mode 100644 index 6b9c1ac..0000000 --- a/docs/screens/app-01.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "APP-01 Home — My Apps" -permalink: /screens/app-01/ -nav_exclude: true -screen_id: APP-01 ---- - -# Home — My Apps -{: .no_toc } - -See every app you can use, its access status, and launch it. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `APP-01` | -| **Route** | `/` · deep link `substratal://` | -| **Where** | Personal › Home | -| **Who can use it** | Signed in | -| **Kind** | grid | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Content page, `GtkFlowBox` in `AdwClamp` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Search | search | client filter on name/description | | GtkSearchEntry | -| Grid / List | segmented | client preference | persisted locally | linked GtkToggleButtons | -| Invitation banner | banner | Organization.my_membership_status = pending | "{org} invited you…" → ORG-09 | AdwBanner | -| Verify-email banner | banner | User.email_verified = false | → AUTH-04 | AdwBanner | -| Your apps | tile-grid | join Application × resolved entitlement per app | tile = icon, name, StatusBadge, helper text (COPY); order: recently launched, then A–Z; pin to top (local) | GtkFlowBox .card children | -| Included with Standard | tile-grid | standard apps with status none while User.plan = free | badge "Standard"; tap → upsell sheet → BIL-01 ("Get every app for $6/month") | GtkFlowBox .card children | -| Available | tile-grid | other public apps with status none | "Get access" → app support/launch page | GtkFlowBox .card children | -| Inbox bell | toolbar button + count | NotificationUnreadCount.unread_count | → NTF-01 | AdwHeaderBar buttons | -| Past apps | row → APP-05 | count of revoked/expired | | AdwActionRow activatable | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Open tile (active) | `→ APP-03` | Launch | -| Open tile (other status) | `→ APP-02` | Explain status | -| Context menu / long-press | | Open · App settings (SET-02) · App profile (APP-04) · Pin · Copy app ID | - -## Good to know - -- **Loading, empty and error 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 -- 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. - -## For developers - -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)` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/app-02.md b/docs/screens/app-02.md deleted file mode 100644 index e26be31..0000000 --- a/docs/screens/app-02.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "APP-02 App info" -permalink: /screens/app-02/ -nav_exclude: true -screen_id: APP-02 ---- - -# App info -{: .no_toc } - -Explain an app's access status and how the user has access. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `APP-02` | -| **Route** | `/apps/:appId` · deep link `substratal://apps/:appId` | -| **Who can use it** | Signed in | -| **Kind** | popover | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `GtkPopover` or `AdwOverlaySplitView` sidebar | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Header | icon + name + StatusBadge | Application, EffectivePermissions.entitlement_status | | GtkImage + labels + badge | -| Description | text | Application.description | | GtkLabel | -| Access paths | list | EffectivePermissions.access_paths[]: source, status, organization_id, member_decision | "Free", "Standard · your subscription", "Standard · via Northwind", "Personal · Granted by admin", "Via Northwind (seat)"; member_decision excluded explained | GtkListBox .boxed-list | -| Tier | badge | Application.access_tier | "Free" or "Standard" | StatusBadge (.status-badge box) | -| Your roles | chips | EffectivePermissions.roles | Phase 2 | GtkToggleButton pills | -| Primary action | button | | Active → Open; none + standard tier + plan free → Upgrade to Standard (BIL-01); none+public → Get access; disabled/expired/revoked → Contact support (support_url) | GtkButton | -| Notification settings | row → SET-02 | resolved notifications.push for this app | "Push notifications: On/Off" | AdwActionRow activatable | -| App settings / App profile | rows | → SET-02, APP-04 | only when active | AdwActionRows | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /applications/{appId}` · `GET /users/me/apps/{appId}/effective-permissions` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/app-03.md b/docs/screens/app-03.md deleted file mode 100644 index ab6923d..0000000 --- a/docs/screens/app-03.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "APP-03 Launch app" -permalink: /screens/app-03/ -nav_exclude: true -screen_id: APP-03 ---- - -# Launch app -{: .no_toc } - -Mint an app token (or authorization code) and open the app. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `APP-03` | -| **Route** | `/apps/:appId/launch` · deep link `substratal://apps/:appId/launch` | -| **Who can use it** | Active entitlement | -| **Kind** | handoff | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `GtkUriLauncher` (OpenURI portal) | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Progress | inline spinner on tile | | ≤ 1 s target | GtkSpinner on tile | -| Organization picker | menu | body.organization_id | only when the user has access via >1 org and the app needs org context | GtkMenuButton | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Embedded launch | `POST /auth/app-tokens {application_id, organization_id?}` | 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. | -| Hosted launch (Phase 3) | `POST /auth/oauth/authorization-codes (PKCE S256)` | 201 → open redirect_uri?code=…&state=… in system browser / auth session | - -## Good to know - -- Record launch time locally for "recently launched" ordering. - diff --git a/docs/screens/app-04.md b/docs/screens/app-04.md deleted file mode 100644 index 2154729..0000000 --- a/docs/screens/app-04.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "APP-04 App profile" -permalink: /screens/app-04/ -nav_exclude: true -screen_id: APP-04 ---- - -# App profile -{: .no_toc } - -Per-app display handle and custom profile fields. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `APP-04` | -| **Route** | `/apps/:appId/profile` · deep link `substratal://apps/:appId/profile` | -| **Who can use it** | Self, active entitlement | -| **Kind** | form | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Display handle | text-field | AppProfile.display_handle | optional; shows effective_display_name as placeholder | AdwEntryRow | -| Custom fields | key/value editor | AppProfile.custom | only keys the app documents; null removes a key | AdwPreferencesGroup of AdwEntryRow pairs | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Save | `PATCH /users/me/apps/{appId}/profile (If-Match)` | | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /users/me/apps/{appId}/profile` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/app-05.md b/docs/screens/app-05.md deleted file mode 100644 index 44f9850..0000000 --- a/docs/screens/app-05.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "APP-05 Past apps" -permalink: /screens/app-05/ -nav_exclude: true -screen_id: APP-05 ---- - -# Past apps -{: .no_toc } - -Apps whose access was revoked or expired. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `APP-05` | -| **Route** | `/apps/past` · deep link `substratal://apps/past` | -| **Who can use it** | Signed in | -| **Kind** | list | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Nested `AdwNavigationSplitView` list pane | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Rows | list | Application name/icon, status, ended/revoked date, source | tap → APP-02 | GtkListBox .boxed-list | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /users/me/entitlements?include_inactive=true&status=revoked,expired` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/aud-01.md b/docs/screens/aud-01.md deleted file mode 100644 index 715e39d..0000000 --- a/docs/screens/aud-01.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "AUD-01 Audit log" -permalink: /screens/aud-01/ -nav_exclude: true -screen_id: AUD-01 ---- - -# Audit log -{: .no_toc } - -Who changed what, when. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `AUD-01` | -| **Route** | `/admin/audit` · deep link `substratal://admin/audit` | -| **Where** | Administration › Audit Log | -| **Who can use it** | audit.view | -| **Kind** | table | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `GtkColumnView` (.data-table) in a card | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Filters | search + menus | time range (presets 1h/24h/7d/custom), actor type, action (grouped by prefix, wildcard), resource | filters encoded in URL/state for sharing | GtkSearchEntry | -| Rows | table | timestamp, actor (type icon + name/id), action, target (type + link), app/org | relative time with absolute on hover/long-press | GtkColumnView .data-table | -| Inspector | side panel | → AUD-02 | desktop: trailing pane; compact: push | AdwOverlaySplitView | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES -- 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. - -## For developers - -Loads: `GET /audit-events?since&until&actor_type&actor_id&action&target_type&target_id&target_user_id&application_id&organization_id&tenant_id` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/aud-02.md b/docs/screens/aud-02.md deleted file mode 100644 index 10c1e2f..0000000 --- a/docs/screens/aud-02.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "AUD-02 Audit event" -permalink: /screens/aud-02/ -nav_exclude: true -screen_id: AUD-02 ---- - -# Audit event -{: .no_toc } - -One event with before/after diff. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `AUD-02` | -| **Route** | `/admin/audit/:eventId` · deep link `substratal://admin/audit/:eventId` | -| **Where** | Administration › Audit Log | -| **Who can use it** | audit.view | -| **Kind** | popover | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `GtkPopover` or `AdwOverlaySplitView` sidebar | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Facts | key-value | action, actor, target, target_user_id, application_id, organization_id, tenant_id, request_id, timestamp | each id links to its screen | AdwActionRow subtitle | -| Diff | diff view | before / after | JSON diff, changed keys highlighted | GtkSourceView diff | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /audit-events/{id}` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/auth-01.md b/docs/screens/auth-01.md deleted file mode 100644 index bd67f48..0000000 --- a/docs/screens/auth-01.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "AUTH-01 Sign in" -permalink: /screens/auth-01/ -nav_exclude: true -screen_id: AUTH-01 ---- - -# Sign in -{: .no_toc } - -Exchange email + password for a session, or start an MFA challenge. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `AUTH-01` | -| **Route** | `/sign-in` · deep link `substratal://sign-in` | -| **Who can use it** | Unauthenticated | -| **Kind** | auth | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Separate `AdwWindow` with `AdwClamp` | - -**How you get here:** App launch with no stored session; Session expired / revoked (preserves `?next=` destination); Sign out - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Email | email-field | body.email | required; RFC 5322; trim; autofill username | AdwEntryRow input-purpose=email | -| Password | password-field | body.password | required; reveal toggle; autofill current-password | AdwPasswordEntryRow | -| Sign in | primary-button | POST /auth/login | disabled until both fields non-empty | GtkButton .suggested-action .pill | -| Forgot password? | link | → AUTH-05 | | GtkButton .flat / GtkLinkButton | -| Create an account | link | → AUTH-03 | | GtkButton .flat / GtkLinkButton | -| Continue with SSO | button | POST /auth/sso/{provider}/callback | hidden behind flag `sso` (API returns 501 today) | GtkButton | -| Sign in with a passkey | button | — | hidden behind flag `passkeys`; reserved for when the API supports WebAuthn | GtkButton | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Sign in | `POST /auth/login` | 200 AuthSession → store tokens → go to `next` or APP-01. 200 MfaChallenge → AUTH-02 with mfa_token. | -| — | | 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. | - -## Good to know - -- **Loading, empty and error states:** Submitting: button spinner, fields read-only; Locked out (5 failures / 15 min): explain wait time -- `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. - diff --git a/docs/screens/auth-02.md b/docs/screens/auth-02.md deleted file mode 100644 index 217b1b7..0000000 --- a/docs/screens/auth-02.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "AUTH-02 Two-factor challenge" -permalink: /screens/auth-02/ -nav_exclude: true -screen_id: AUTH-02 ---- - -# Two-factor challenge -{: .no_toc } - -Complete sign-in with a TOTP code or a recovery code. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `AUTH-02` | -| **Route** | `/sign-in/mfa` · deep link `substratal://sign-in/mfa` | -| **Who can use it** | Holder of mfa_token | -| **Kind** | auth | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Separate `AdwWindow` with `AdwClamp` | - -**How you get here:** AUTH-01 returned MfaChallenge - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Code | otp-field | body.code | 6 digits; paste fills all; auto-submit on 6th digit; OS one-time-code autofill | AdwEntryRow input-purpose=digits | -| Use a recovery code | link | swaps to Recovery code field | only if `methods` includes recovery_code | GtkButton .flat / GtkLinkButton | -| Recovery code | text-field | body.recovery_code | format xxxx-xxxx; case-insensitive | AdwEntryRow | -| Expiry countdown | caption | MfaChallenge.expires_in | announce at 60 s and 10 s | GtkLabel .caption .dim-label | -| Verify | primary-button | POST /auth/mfa/verify | | GtkButton .suggested-action .pill | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Verify | `POST /auth/mfa/verify` | 200 → same as AUTH-01 success. 401 → "That code didn't work." clear field. Expired → back to AUTH-01 with message. 429 → countdown. | - -## Good to know - -- **Loading, empty and error states:** Challenge expired - diff --git a/docs/screens/auth-03.md b/docs/screens/auth-03.md deleted file mode 100644 index aa6e338..0000000 --- a/docs/screens/auth-03.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "AUTH-03 Create account" -permalink: /screens/auth-03/ -nav_exclude: true -screen_id: AUTH-03 ---- - -# Create account -{: .no_toc } - -Self-service signup with email and password. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `AUTH-03` | -| **Route** | `/sign-up` · deep link `substratal://sign-up` | -| **Who can use it** | Unauthenticated | -| **Kind** | auth | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Separate `AdwWindow` with `AdwClamp` | - -**How you get here:** AUTH-01 link; Marketing/app deep link with optional `application_id` - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Display name | text-field | body.display_name | optional; ≤ 100 | AdwEntryRow | -| Email | email-field | body.email | required | AdwEntryRow input-purpose=email | -| Password | password-field | body.password | required; strength meter; autofill new-password; show policy hint | AdwPasswordEntryRow | -| Create account | primary-button | POST /auth/signup | | GtkButton .suggested-action .pill | -| Terms / Privacy | links | external | | GtkLinkButton | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Create account | `POST /auth/signup` | 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. | - -## Good to know - -- 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. - diff --git a/docs/screens/auth-04.md b/docs/screens/auth-04.md deleted file mode 100644 index 136753a..0000000 --- a/docs/screens/auth-04.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "AUTH-04 Verify your email" -permalink: /screens/auth-04/ -nav_exclude: true -screen_id: AUTH-04 ---- - -# Verify your email -{: .no_toc } - -Tell the user to check email; complete verification from the emailed link. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `AUTH-04` | -| **Route** | `/verify-email` · deep link `substratal://verify-email` | -| **Who can use it** | Signed in (unverified) or token holder | -| **Kind** | auth | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Separate `AdwWindow` with `AdwClamp` | - -**How you get here:** After signup; Persistent banner on APP-01 while `email_verified` is false; Deep link `/verify-email?token=…` - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Instruction | text | User.email | "We sent a link to {email}." | GtkLabel | -| Resend email | button | POST /auth/email/verify/resend | 30 s cooldown after each send | GtkButton | -| Continue to Substratal | button | → APP-01 | unverified users may continue | GtkButton | -| Change email | link | → ACC-02 | | GtkButton .flat / GtkLinkButton | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Deep link opened | `POST /auth/email/verify {token}` | 204 → success state, refresh User. 400 → "This link has expired." + Resend. | -| Resend email | `POST /auth/email/verify/resend` | Always 202 → toast "If that address needs verifying, we sent a new link." | - diff --git a/docs/screens/auth-05.md b/docs/screens/auth-05.md deleted file mode 100644 index ae2ff51..0000000 --- a/docs/screens/auth-05.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "AUTH-05 Forgot password" -permalink: /screens/auth-05/ -nav_exclude: true -screen_id: AUTH-05 ---- - -# Forgot password -{: .no_toc } - -Request a password-reset email. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `AUTH-05` | -| **Route** | `/forgot-password` · deep link `substratal://forgot-password` | -| **Who can use it** | Unauthenticated | -| **Kind** | auth | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Separate `AdwWindow` with `AdwClamp` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Email | email-field | body.email | prefilled from AUTH-01 | AdwEntryRow input-purpose=email | -| Send reset link | primary-button | POST /auth/password/forgot | | GtkButton .suggested-action .pill | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Send reset link | `POST /auth/password/forgot` | Always 202 → confirmation "If an account exists for {email}, we sent a reset link." 429 → countdown. | - diff --git a/docs/screens/auth-06.md b/docs/screens/auth-06.md deleted file mode 100644 index e340cec..0000000 --- a/docs/screens/auth-06.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "AUTH-06 Set a new password" -permalink: /screens/auth-06/ -nav_exclude: true -screen_id: AUTH-06 ---- - -# Set a new password -{: .no_toc } - -Choose a new password from the emailed reset link. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `AUTH-06` | -| **Route** | `/reset-password` · deep link `substratal://reset-password` | -| **Who can use it** | Reset-token holder | -| **Kind** | auth | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Separate `AdwWindow` with `AdwClamp` | - -**How you get here:** Deep link `/reset-password?token=…` - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| New password | password-field | body.new_password | strength meter; autofill new-password | AdwPasswordEntryRow | -| Confirm password | password-field | client only | must match | AdwPasswordEntryRow | -| Notice | caption | | "You'll be signed out on all devices." | GtkLabel .caption .dim-label | -| Save password | primary-button | POST /auth/password/reset | | GtkButton .suggested-action .pill | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Save password | `POST /auth/password/reset` | 204 → clear local session → AUTH-01 with success banner. 400 → expired link + link to AUTH-05. 422 → field errors. | - diff --git a/docs/screens/auth-07.md b/docs/screens/auth-07.md deleted file mode 100644 index cb4f803..0000000 --- a/docs/screens/auth-07.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "AUTH-07 Accept invitation" -permalink: /screens/auth-07/ -nav_exclude: true -screen_id: AUTH-07 ---- - -# Accept invitation -{: .no_toc } - -Activate an account an admin created: set password and sign in. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `AUTH-07` | -| **Route** | `/invite` · deep link `substratal://invite` | -| **Who can use it** | Invitation-token holder | -| **Kind** | auth | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Separate `AdwWindow` with `AdwClamp` | - -**How you get here:** Deep link `/invite?token=…` from the invitation email - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Display name | text-field | body.display_name | optional | AdwEntryRow | -| Password | password-field | body.password | strength meter | AdwPasswordEntryRow | -| Accept and continue | primary-button | POST /auth/invitations/accept | | GtkButton .suggested-action .pill | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Accept and continue | `POST /auth/invitations/accept` | 200 AuthSession → APP-01. 400 → "This invitation is no longer valid. Ask your admin to resend it." | - diff --git a/docs/screens/bil-01.md b/docs/screens/bil-01.md deleted file mode 100644 index 99ee2c6..0000000 --- a/docs/screens/bil-01.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "BIL-01 My plan" -permalink: /screens/bil-01/ -nav_exclude: true -screen_id: BIL-01 ---- - -# My plan -{: .no_toc } - -Free or Standard: what covers you, your own subscription, upgrade and manage billing. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `BIL-01` | -| **Route** | `/billing` · deep link `substratal://billing` | -| **Where** | Personal › Plan & Billing | -| **Who can use it** | Self; billing.manage can view any User's (read-only) | -| **Kind** | settings | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | `AdwPreferencesPage` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Plan card | card | UserSubscription.plan free\ | standard | .card box | -| Covered by | list | plan_sources[]: {type user\ | organization, id} | GtkListBox .boxed-list | -| Double coverage tip | banner | plan_sources has both user and organization | "Your organization already covers you. You can cancel your own subscription in Manage billing." (no automatic refund) | AdwBanner | -| Your subscription | key-value | Subscription.status, current_period_end, cancel_at_period_end, unit_amount, currency | status badge via status-semantics; past_due → warning banner (Stripe retries ~3 weeks) | AdwActionRow subtitle | -| Upgrade to Standard | primary-button | → BIL-03 checkout | 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 | GtkButton .suggested-action .pill | -| Manage billing | button | → BIL-03 portal | when the user has completed a checkout (own subscription exists and isn't incomplete) | GtkButton | -| What Standard includes | link | standard apps from GET /applications?access_tier=standard | count + list | GtkButton .flat / GtkLinkButton | - -## Good to know - -- **Loading, empty and error 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." -- 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. - -## For developers - -Loads: `GET /users/me/subscription` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/bil-02.md b/docs/screens/bil-02.md deleted file mode 100644 index a7b1eda..0000000 --- a/docs/screens/bil-02.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "BIL-02 Organization subscription" -permalink: /screens/bil-02/ -nav_exclude: true -screen_id: BIL-02 ---- - -# Organization subscription -{: .no_toc } - -An organization's Standard subscription and which members it covers. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `BIL-02` | -| **Route** | `/orgs/:orgId/subscription` · deep link `substratal://orgs/:orgId/subscription` | -| **Where** | Personal › Organizations | -| **Who can use it** | org_admin (manage); billing.manage (read + portal) | -| **Kind** | tab | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | `AdwViewStack` page | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Plan card | card | OrganizationSubscription.plan + Subscription.status, current_period_end, cancel_at_period_end | | .card box | -| Billed users | key-value | quantity.current (live), quantity.last_synced + last_synced_at, unit_amount | "31 covered · 30 billed on next invoice (synced daily)" | AdwActionRow subtitle | -| Who's covered | radio-group | member_scope all_members \ | allowlist \ | GtkCheckButton group | -| Members | member-picker | member_overrides (active members only) | visible for allowlist/denylist | AdwDialog with GtkSearchEntry list | -| Subscribe the organization | primary-button | → BIL-03 org checkout (member_scope optional) | org_admin; when no subscription or not in force | GtkButton .suggested-action .pill | -| Manage billing | button | → BIL-03 org portal | org_admin or billing.manage | GtkButton | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Save coverage | `PATCH /organizations/{id}/subscription {member_scope, member_overrides} (If-Match)` | effective immediately (access.granted/revoked); billed from next daily sync. 422 member_override_not_a_member → mark rows. 404 subscription_not_found → show Subscribe. | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES -- Members gained or lost take effect at once; say so before saving. - -## For developers - -Loads: `GET /organizations/{id}/subscription` · `GET /organizations/{id}/members (for the member picker)` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/bil-03.md b/docs/screens/bil-03.md deleted file mode 100644 index e72853e..0000000 --- a/docs/screens/bil-03.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "BIL-03 Checkout / billing portal" -permalink: /screens/bil-03/ -nav_exclude: true -screen_id: BIL-03 ---- - -# Checkout / billing portal -{: .no_toc } - -Hand off to Stripe in the browser and confirm the result on return. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `BIL-03` | -| **Route** | `/billing/return` · deep link `substratal://billing/return` | -| **Where** | Personal › Plan & Billing | -| **Who can use it** | Self (individual); org_admin (org checkout); org_admin or billing.manage (org portal) | -| **Kind** | handoff | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | `GtkUriLauncher` (OpenURI portal) | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Upgrade (individual) | `POST /users/me/billing/checkout-sessions {plan:standard, success_url, cancel_url} (Idempotency-Key)` | 201 {url, expires_at} → open url externally. 409 subscription_exists → open portal instead. 409 test_mode_billing_unavailable / 503 billing_unavailable → explain. | -| Manage billing (individual) | `POST /users/me/billing/portal-sessions {return_url}` | 201 → open url externally. 409 no_billing_account → hide button. | -| Subscribe organization | `POST /organizations/{id}/billing/checkout-sessions {plan:standard, success_url, cancel_url, member_scope?, member_overrides?} (Idempotency-Key)` | as individual | -| Manage organization billing | `POST /organizations/{id}/billing/portal-sessions {return_url}` | as individual | -| Return | `deep link /billing/return?checkout=success\` | cancelled (or /orgs/:orgId/subscription?…)` | - -## Good to know - -- success_url / cancel_url / return_url must be https: use https://dashboard.substratalapps.com/billing/return… (Universal/App Links open the native app). - diff --git a/docs/screens/bil-04.md b/docs/screens/bil-04.md deleted file mode 100644 index cbe209f..0000000 --- a/docs/screens/bil-04.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "BIL-04 Tenant & infrastructure" -permalink: /screens/bil-04/ -nav_exclude: true -screen_id: BIL-04 ---- - -# Tenant & infrastructure -{: .no_toc } - -Where an Application owner's data lives, and support-run tier changes. Tenants aren't billed. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `BIL-04` | -| **Route** | `/dev/tenant` · deep link `substratal://dev/tenant` | -| **Where** | Developer › Tenant | -| **Who can use it** | Tenant owner (read), tenants.manage (write) | -| **Kind** | detail | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | Content pane; `AdwViewSwitcher`; header-bar actions | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Facts | key-value | Tenant: tier, region, status, owner, application_count | status migrating → banner, writes may 503 | AdwActionRow subtitle | -| Tier change requests | list | TenantTierChangeRequest: from→requested tier/region, status, scheduled_for, reason, notes | | GtkListBox .boxed-list | -| Request change | button | → form: requested_tier, requested_region, reason | tenants.manage only; owners see "isolated and dedicated-region tiers come with business pricing — contact sales" | GtkButton | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Open request | `POST /tenants/{id}/tier-change-requests` | 422/409 errors | -| Open request detail | `GET /tenants/{id}/tier-change-requests/{requestId}` | polls every 30 s while visible | -| Advance request | `PATCH /tenants/{id}/tier-change-requests/{requestId} {status, scheduled_for, notes} (If-Match)` | staff only; status scheduled → in_progress → completed, or cancelled | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /tenants` · `GET /tenants/{id}` · `GET /tenants/{id}/tier-change-requests` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/cfg-01.md b/docs/screens/cfg-01.md deleted file mode 100644 index 42cf657..0000000 --- a/docs/screens/cfg-01.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "CFG-01 Configuration" -permalink: /screens/cfg-01/ -nav_exclude: true -screen_id: CFG-01 ---- - -# Configuration -{: .no_toc } - -What production is actually running with. -{: .fs-6 .fw-300 } - -This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). -{: .note } - -| | | -|---|---| -| **Screen ID** | `CFG-01` | -| **Route** | `/ops/config` | -| **Where** | Operations › Configuration | -| **Who can use it** | operations.view | -| **Kind** | settings | -| **Available in** | Web Hub only (Console M3 (web only)) | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | -|---|---|---|---| -| Per function | key-value groups | non-secret settings from an allow-list: URLs, CORS origins, backends, log level | secrets never leave the gateway | -| Policy | key-value | rate-limit defaults, token lifetimes, password policy, API version served | each with its source (Terraform variable or code constant) | - -## Good to know - -- Read-only. Terraform-owned values say "Change this in the API repo" and link to the variable. - -## For developers - -Loads: `GET /ops/config` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/cfg-02.md b/docs/screens/cfg-02.md deleted file mode 100644 index f69ea99..0000000 --- a/docs/screens/cfg-02.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "CFG-02 Feature flags" -permalink: /screens/cfg-02/ -nav_exclude: true -screen_id: CFG-02 ---- - -# Feature flags -{: .no_toc } - -See, and later change, the client feature flags (CLIENT-ARCHITECTURE §10). -{: .fs-6 .fw-300 } - -This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). -{: .note } - -| | | -|---|---| -| **Screen ID** | `CFG-02` | -| **Route** | `/ops/config/flags` | -| **Who can use it** | operations.view (read), operations.manage (write, after G10) | -| **Kind** | settings | -| **Available in** | Web Hub only (Console M4 (web only)) | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | -|---|---|---|---| -| Flags | toggle list | every flag in CLIENT-ARCHITECTURE §10 | read-only until G11; then editable and audited | -| Platform settings | settings rows | signup open, read-only maintenance mode, per-key rate-limit overrides ([G12 #49](https://github.com/CompositeCode/substratalapps.com/issues/49)) | hidden until the API has them | - -## For developers - -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 - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/glb-01.md b/docs/screens/glb-01.md deleted file mode 100644 index 58a6566..0000000 --- a/docs/screens/glb-01.md +++ /dev/null @@ -1,30 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "GLB-01 Command palette" -permalink: /screens/glb-01/ -nav_exclude: true -screen_id: GLB-01 ---- - -# Command palette -{: .no_toc } - -Jump anywhere and run any action by typing. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `GLB-01` | -| **Who can use it** | Signed in | -| **Kind** | overlay | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Popover / overlay | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Query | search | | Cmd/Ctrl+K (desktop), search button (touch) | GtkSearchEntry | -| Results | grouped list | Navigation, Apps (launch), Users (GET /users?q), Orgs, Inbox (recent notifications), Actions (Invite user, Create key, Upgrade to Standard, Mark all read…) | permission-filtered; recent items first; ID prefixes resolve directly | GtkListBox headers | - diff --git a/docs/screens/glb-02.md b/docs/screens/glb-02.md deleted file mode 100644 index 166047f..0000000 --- a/docs/screens/glb-02.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "GLB-02 Manage hub" -permalink: /screens/glb-02/ -nav_exclude: true -screen_id: GLB-02 ---- - -# Manage hub -{: .no_toc } - -Compact-layout landing list for the Administration and Developer sections. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `GLB-02` | -| **Route** | `/manage` · deep link `substratal://manage` | -| **Who can use it** | Any admin or developer permission | -| **Kind** | hub | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Collapsed sidebar page | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Administration | list | Users, Access, Roles, Audit Log, Send notification — those the caller may open, each with a live count | section hidden when no rights | GtkListBox .boxed-list | -| Developer | list | Applications, API Keys, Webhooks, Tenant — with counts and health (unhealthy webhooks, failed push credentials) | section hidden when no rights | GtkListBox .boxed-list | - diff --git a/docs/screens/glb-03.md b/docs/screens/glb-03.md deleted file mode 100644 index c9ae682..0000000 --- a/docs/screens/glb-03.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "GLB-03 System states" -permalink: /screens/glb-03/ -nav_exclude: true -screen_id: GLB-03 ---- - -# System states -{: .no_toc } - -App-wide banners and blocking states. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `GLB-03` | -| **Who can use it** | — | -| **Kind** | overlay | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Popover / overlay | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Offline | banner | network status | reads from cache, writes disabled with explanation | AdwBanner | -| Maintenance / tenant migrating | banner | 503 service_unavailable + Retry-After | countdown, auto-retry reads | AdwBanner | -| Session ended | modal → AUTH-01 | 401 after failed refresh | preserve destination | AdwAlertDialog → AUTH-01 | -| Forbidden | inline state | 403 | COPY | GtkSpinner on tile | -| Not built yet | inline state | 501 not_implemented (Notifications today) | hide the feature behind its flag; never show an error to end users | GtkSpinner on tile | -| Update available | banner | `Deprecation` + `Sunset` response headers on this build's API version | once per day; shows the sunset date; "Update" opens the store / updater (Web: reload); dismissible until 14 days before `Sunset` | AdwBanner | -| Update required | blocking screen | 400 unsupported_api_version (`details.supported`) | 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 | AdwStatusPage window | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Silent token refresh | `POST /auth/token/refresh` | single-flight; rotate refresh token atomically (CLIENT-ARCHITECTURE §2) | -| Connectivity probe | `GET /health` | used after offline/503 to decide when to clear the banner (exempt from `Substratal-Version`) | -| Forced sign-out | `POST /auth/logout` | on session_revoked or user request; the server deactivates this session's push devices | -| Badge refresh | `GET /users/me/notifications/unread-count` | on launch, foreground, after a push arrives, and every 60 s while visible (Windows/Linux: also in background) → app icon/Dock/taskbar/PWA badge | - -## Good to know - -- `POST /auth/oauth/token` is called by the launched Application (PKCE exchange), never by the Hub. - diff --git a/docs/screens/index.md b/docs/screens/index.md deleted file mode 100644 index 84b0dbe..0000000 --- a/docs/screens/index.md +++ /dev/null @@ -1,179 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "Screens" -nav_order: 5 -has_children: false -permalink: /screens/ ---- - -# Screens -{: .no_toc } - -Every screen in the Substratal Hub, with the same ID, route and behavior in all six apps (Linux, macOS, Windows, iOS/iPadOS, Android and Web). Each has its own help page at `/screens//`, which every app links to from its Help button or **F1**. -{: .fs-6 .fw-300 } - -Generated from the [specs package](https://github.com/CompositeCode/substratalapps-applications-specs) at `cebf679`. A machine-readable list is at [`screens.json`]({{ '/screens.json' | relative_url }}). - - -## D1 · Sign-in & Identity - -| ID | Screen | Route | Who | Apps | -|---|---|---|---|---| -| [AUTH-01]({{ '/screens/auth-01/' | relative_url }}) | Sign in | `/sign-in` | Unauthenticated | All Hub apps | -| [AUTH-02]({{ '/screens/auth-02/' | relative_url }}) | Two-factor challenge | `/sign-in/mfa` | Holder of mfa_token | All Hub apps | -| [AUTH-03]({{ '/screens/auth-03/' | relative_url }}) | Create account | `/sign-up` | Unauthenticated | All Hub apps | -| [AUTH-04]({{ '/screens/auth-04/' | relative_url }}) | Verify your email | `/verify-email` | Signed in (unverified) or token holder | All Hub apps | -| [AUTH-05]({{ '/screens/auth-05/' | relative_url }}) | Forgot password | `/forgot-password` | Unauthenticated | All Hub apps | -| [AUTH-06]({{ '/screens/auth-06/' | relative_url }}) | Set a new password | `/reset-password` | Reset-token holder | All Hub apps | -| [AUTH-07]({{ '/screens/auth-07/' | relative_url }}) | Accept invitation | `/invite` | Invitation-token holder | All Hub apps | - -## D2 · Account & Security - -| ID | Screen | Route | Who | Apps | -|---|---|---|---|---| -| [ACC-01]({{ '/screens/acc-01/' | relative_url }}) | Account & profile | `/account` | Self | All Hub apps | -| [ACC-02]({{ '/screens/acc-02/' | relative_url }}) | Change email | `/account/email` | Self | All Hub apps | -| [ACC-03]({{ '/screens/acc-03/' | relative_url }}) | Change password | `/account/security/password` | Self | All Hub apps | -| [ACC-04]({{ '/screens/acc-04/' | relative_url }}) | Security | `/account/security` | Self | All Hub apps | -| [ACC-05]({{ '/screens/acc-05/' | relative_url }}) | Turn on two-factor | `/account/security/2fa/enable` | Self | All Hub apps | -| [ACC-06]({{ '/screens/acc-06/' | relative_url }}) | Turn off two-factor | `/account/security/2fa/disable` | Self | All Hub apps | -| [ACC-07]({{ '/screens/acc-07/' | relative_url }}) | Privacy | `/account/privacy` | Self | All Hub apps | -| [ACC-08]({{ '/screens/acc-08/' | relative_url }}) | Delete account | `/account/privacy/delete` | Self | All Hub apps | - -## D3 · My Apps - -| ID | Screen | Route | Who | Apps | -|---|---|---|---|---| -| [APP-01]({{ '/screens/app-01/' | relative_url }}) | Home — My Apps | `/` | Signed in | All Hub apps | -| [APP-02]({{ '/screens/app-02/' | relative_url }}) | App info | `/apps/:appId` | Signed in | All Hub apps | -| [APP-03]({{ '/screens/app-03/' | relative_url }}) | Launch app | `/apps/:appId/launch` | Active entitlement | All Hub apps | -| [APP-04]({{ '/screens/app-04/' | relative_url }}) | App profile | `/apps/:appId/profile` | Self, active entitlement | All Hub apps | -| [APP-05]({{ '/screens/app-05/' | relative_url }}) | Past apps | `/apps/past` | Signed in | All Hub apps | - -## D4 · Settings - -| ID | Screen | Route | Who | Apps | -|---|---|---|---|---| -| [SET-01]({{ '/screens/set-01/' | relative_url }}) | Settings — Global | `/settings` | Self | All Hub apps | -| [SET-02]({{ '/screens/set-02/' | relative_url }}) | Settings — Per app | `/settings/apps/:appId` | Self, active entitlement | All Hub apps | - -## D5 · Organizations - -| ID | Screen | Route | Who | Apps | -|---|---|---|---|---| -| [ORG-01]({{ '/screens/org-01/' | relative_url }}) | Organizations | `/orgs` | Signed in (all orgs with organizations.manage) | All Hub apps | -| [ORG-02]({{ '/screens/org-02/' | relative_url }}) | New organization | `/orgs/new` | Any active user | All Hub apps | -| [ORG-03]({{ '/screens/org-03/' | relative_url }}) | Organization — Members | `/orgs/:orgId/members` | Member (read), org_admin / organizations.manage (write) | All Hub apps | -| [ORG-04]({{ '/screens/org-04/' | relative_url }}) | Invite member | `/orgs/:orgId/members/invite` | org_admin; organizations.manage may add by user_id | All Hub apps | -| [ORG-05]({{ '/screens/org-05/' | relative_url }}) | Member actions | `/orgs/:orgId/members/:userId` | org_admin / organizations.manage | All Hub apps | -| [ORG-06]({{ '/screens/org-06/' | relative_url }}) | Organization — Apps & seats | `/orgs/:orgId/apps` | Member (read), org_admin (write) | All Hub apps | -| [ORG-07]({{ '/screens/org-07/' | relative_url }}) | Grant app to organization / member scope | `/orgs/:orgId/apps/grant` | entitlements.manage or app owner; scope edits by org_admin | All Hub apps | -| [ORG-08]({{ '/screens/org-08/' | relative_url }}) | Organization — Settings | `/orgs/:orgId/settings` | org_admin (rename, SCIM), organizations.manage (suspend, SCIM), any member (leave) | All Hub apps | -| [ORG-09]({{ '/screens/org-09/' | relative_url }}) | Invitation | `/orgs/:orgId/invitation` | Invited user | All Hub apps | - -## D6 · Users - -| ID | Screen | Route | Who | Apps | -|---|---|---|---|---| -| [USR-01]({{ '/screens/usr-01/' | relative_url }}) | Users | `/admin/users` | users.list (write actions need users.manage) | All Hub apps | -| [USR-02]({{ '/screens/usr-02/' | relative_url }}) | Invite user | `/admin/users/new` | users.manage | All Hub apps | -| [USR-03]({{ '/screens/usr-03/' | relative_url }}) | User — Overview | `/admin/users/:userId` | users.list (read), users.manage (write) | All Hub apps | -| [USR-04]({{ '/screens/usr-04/' | relative_url }}) | User — Sessions | `/admin/users/:userId/sessions` | users.manage | All Hub apps | -| [USR-05]({{ '/screens/usr-05/' | relative_url }}) | Suspend user | `/admin/users/:userId/suspend` | users.manage | All Hub apps | - -## D7 · Access - -| ID | Screen | Route | Who | Apps | -|---|---|---|---|---| -| [ACX-01]({{ '/screens/acx-01/' | relative_url }}) | User — Access | `/admin/users/:userId/access` | users.list (read), entitlements.manage or app owner (write) | All Hub apps | -| [ACX-02]({{ '/screens/acx-02/' | relative_url }}) | Grant access | `/admin/users/:userId/access/grant` | entitlements.manage or app owner (own app) | All Hub apps | -| [ACX-03]({{ '/screens/acx-03/' | relative_url }}) | Turn off / edit access | `/admin/entitlements/:entId` | entitlements.manage or app owner | All Hub apps | -| [ACX-04]({{ '/screens/acx-04/' | relative_url }}) | Access check (Why?) | `/admin/users/:userId/apps/:appId/why` | users.list or app owner | All Hub apps | -| [ACX-05]({{ '/screens/acx-05/' | relative_url }}) | Access (all) | `/admin/access` | entitlements.manage or app owner (confined to own apps) | All Hub apps | - -## D8 · Roles & Permissions - -| ID | Screen | Route | Who | Apps | -|---|---|---|---|---| -| [ROL-01]({{ '/screens/rol-01/' | relative_url }}) | Roles | `/admin/roles` | users.list (read), roles.manage (write) | All Hub apps | -| [ROL-02]({{ '/screens/rol-02/' | relative_url }}) | Role detail | `/admin/roles/:roleId` | users.list (read), roles.manage (write) | All Hub apps | -| [ROL-03]({{ '/screens/rol-03/' | relative_url }}) | New role | `/admin/roles/new` | roles.manage | All Hub apps | -| [ROL-04]({{ '/screens/rol-04/' | relative_url }}) | User — Roles | `/admin/users/:userId/roles` | users.list (read), roles.manage (write) | All Hub apps | - -## D9 · Applications - -| ID | Screen | Route | Who | Apps | -|---|---|---|---|---| -| [APL-01]({{ '/screens/apl-01/' | relative_url }}) | Applications | `/dev/apps` | Any (catalog), owner/applications.manage (manage) | All Hub apps | -| [APL-02]({{ '/screens/apl-02/' | relative_url }}) | Register application | `/dev/apps/new` | applications.manage, or anyone with a verified email (self-service) | All Hub apps | -| [APL-03]({{ '/screens/apl-03/' | relative_url }}) | Application — Overview | `/dev/apps/:appId` | Owner (configure), applications.manage (anything) | All Hub apps | -| [APL-04]({{ '/screens/apl-04/' | relative_url }}) | Settings schema | `/dev/apps/:appId/schema` | Owner / applications.manage | All Hub apps | -| [APL-05]({{ '/screens/apl-05/' | relative_url }}) | Permissions & roles | `/dev/apps/:appId/roles` | Owner / applications.manage | All Hub apps | -| [APL-06]({{ '/screens/apl-06/' | relative_url }}) | Review | `/dev/apps/:appId/review` | applications.manage | All Hub apps | -| [APL-07]({{ '/screens/apl-07/' | relative_url }}) | Push credentials | `/dev/apps/:appId/push` | Owner or applications.manage with a User token (write); app key / owner (read) | All Hub apps | - -## D10 · Developer Credentials - -| ID | Screen | Route | Who | Apps | -|---|---|---|---|---| -| [KEY-01]({{ '/screens/key-01/' | relative_url }}) | API keys | `/dev/keys` | api_keys.manage or app owner | All Hub apps | -| [KEY-02]({{ '/screens/key-02/' | relative_url }}) | Create API key | `/dev/keys/new` | api_keys.manage or app owner (app-scoped) | All Hub apps | -| [KEY-03]({{ '/screens/key-03/' | relative_url }}) | Secret reveal | `(modal)` | Creator | All Hub apps | -| [KEY-04]({{ '/screens/key-04/' | relative_url }}) | API key detail | `/dev/keys/:keyId` | api_keys.manage or app owner | All Hub apps | -| [WHK-01]({{ '/screens/whk-01/' | relative_url }}) | Webhooks | `/dev/webhooks` | webhooks.manage or app owner | All Hub apps | -| [WHK-02]({{ '/screens/whk-02/' | relative_url }}) | Add / edit webhook | `/dev/webhooks/new` | webhooks.manage or app owner | All Hub apps | -| [WHK-03]({{ '/screens/whk-03/' | relative_url }}) | Webhook — Deliveries | `/dev/webhooks/:hookId` | webhooks.manage or app owner | All Hub apps | -| [WHK-04]({{ '/screens/whk-04/' | relative_url }}) | Delivery detail | `/dev/webhooks/:hookId/deliveries/:deliveryId` | webhooks.manage or app owner | All Hub apps | - -## D11 · Plan & Billing - -| ID | Screen | Route | Who | Apps | -|---|---|---|---|---| -| [BIL-01]({{ '/screens/bil-01/' | relative_url }}) | My plan | `/billing` | Self; billing.manage can view any User's (read-only) | All Hub apps | -| [BIL-02]({{ '/screens/bil-02/' | relative_url }}) | Organization subscription | `/orgs/:orgId/subscription` | org_admin (manage); billing.manage (read + portal) | All Hub apps | -| [BIL-03]({{ '/screens/bil-03/' | relative_url }}) | Checkout / billing portal | `/billing/return` | Self (individual); org_admin (org checkout); org_admin or billing.manage (org portal) | All Hub apps | -| [BIL-04]({{ '/screens/bil-04/' | relative_url }}) | Tenant & infrastructure | `/dev/tenant` | Tenant owner (read), tenants.manage (write) | All Hub apps | - -## D12 · Audit Log - -| ID | Screen | Route | Who | Apps | -|---|---|---|---|---| -| [AUD-01]({{ '/screens/aud-01/' | relative_url }}) | Audit log | `/admin/audit` | audit.view | All Hub apps | -| [AUD-02]({{ '/screens/aud-02/' | relative_url }}) | Audit event | `/admin/audit/:eventId` | audit.view | All Hub apps | - -## D13 · Notifications - -| ID | Screen | Route | Who | Apps | -|---|---|---|---|---| -| [NTF-01]({{ '/screens/ntf-01/' | relative_url }}) | Inbox | `/inbox` | Self | All Hub apps | -| [NTF-02]({{ '/screens/ntf-02/' | relative_url }}) | Notification | `/inbox/:notificationId` | Self (users.list via NTF-05) | All Hub apps | -| [NTF-03]({{ '/screens/ntf-03/' | relative_url }}) | Devices & notifications | `/account/devices` | Self | All Hub apps | -| [NTF-04]({{ '/screens/ntf-04/' | relative_url }}) | Turn on notifications | `/account/devices/enable` | Self | All Hub apps | -| [NTF-05]({{ '/screens/ntf-05/' | relative_url }}) | User — Notifications & devices | `/admin/users/:userId/notifications` | users.list (read), users.manage (delete devices) | All Hub apps | -| [NTF-06]({{ '/screens/ntf-06/' | relative_url }}) | Send notification | `/admin/notifications/new` | platform notifications.send | All Hub apps | - -## G · Global / Shell - -| ID | Screen | Route | Who | Apps | -|---|---|---|---|---| -| [GLB-01]({{ '/screens/glb-01/' | relative_url }}) | Command palette | `(overlay)` | Signed in | All Hub apps | -| [GLB-02]({{ '/screens/glb-02/' | relative_url }}) | Manage hub | `/manage` | Any admin or developer permission | All Hub apps | -| [GLB-03]({{ '/screens/glb-03/' | relative_url }}) | System states | `(global)` | — | All Hub apps | - -## C · Console - -| ID | Screen | Route | Who | Apps | -|---|---|---|---|---| -| [OPS-01]({{ '/screens/ops-01/' | relative_url }}) | Overview | `/ops` | operations.view (superadmin until G10) | Web Hub only | -| [OPS-02]({{ '/screens/ops-02/' | relative_url }}) | Traffic & latency | `/ops/traffic` | operations.view | Web Hub only | -| [OPS-03]({{ '/screens/ops-03/' | relative_url }}) | Logs | `/ops/logs` | operations.view | Web Hub only | -| [SEC-01]({{ '/screens/sec-01/' | relative_url }}) | Security events | `/ops/security` | operations.view | Web Hub only | -| [OPS-04]({{ '/screens/ops-04/' | relative_url }}) | Alarms | `/ops/alarms` | operations.view | Web Hub only | -| [OPS-05]({{ '/screens/ops-05/' | relative_url }}) | Jobs & delivery | `/ops/jobs` | operations.view | Web Hub only | -| [OPS-06]({{ '/screens/ops-06/' | relative_url }}) | Releases | `/ops/releases` | operations.view | Web Hub only | -| [OPS-07]({{ '/screens/ops-07/' | relative_url }}) | Cost | `/ops/cost` | operations.view | Web Hub only | -| [CFG-01]({{ '/screens/cfg-01/' | relative_url }}) | Configuration | `/ops/config` | operations.view | Web Hub only | -| [CFG-02]({{ '/screens/cfg-02/' | relative_url }}) | Feature flags | `/ops/config/flags` | operations.view (read), operations.manage (write, after G10) | Web Hub only | -| [RPT-01]({{ '/screens/rpt-01/' | relative_url }}) | Reports | `/admin/reports` | users.list (platform reports); an Application owner sees only their own app | Web Hub only | -| [INT-01]({{ '/screens/int-01/' | relative_url }}) | Integrations | `/dev/integrations` | webhooks.manage or api_keys.manage | Web Hub only | diff --git a/docs/screens/int-01.md b/docs/screens/int-01.md deleted file mode 100644 index 3d695ab..0000000 --- a/docs/screens/int-01.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "INT-01 Integrations" -permalink: /screens/int-01/ -nav_exclude: true -screen_id: INT-01 ---- - -# Integrations -{: .no_toc } - -Every way to connect Substratal to other tools, in one place. -{: .fs-6 .fw-300 } - -This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). -{: .note } - -| | | -|---|---| -| **Screen ID** | `INT-01` | -| **Route** | `/dev/integrations` | -| **Where** | Developer › Integrations | -| **Who can use it** | webhooks.manage or api_keys.manage | -| **Kind** | list | -| **Available in** | Web Hub only (Console M2 (web only)) | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | -|---|---|---|---| -| Platform webhooks | list | → WHK-01 filtered to `scope: platform` | for SIEM, warehouse or chat tools | -| Agent and service keys | list | → KEY-01 filtered by `intended_use` | | -| MCP server | instructions | `https://api.substratalapps.com/mcp` with a restricted agent key | copy button | -| Alarm destinations | list | → OPS-04 subscribers; chat delivery via AWS Chatbot | Terraform-owned: link to the variable | -| Stripe | key-value | portal links; `billing_unavailable` state | | -| Log export | text | a CloudWatch subscription filter, configured in Terraform | documentation only | - -## For developers - -Loads: `GET /webhooks?scope=platform` · `GET /api-keys` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/key-01.md b/docs/screens/key-01.md deleted file mode 100644 index 9364f0e..0000000 --- a/docs/screens/key-01.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "KEY-01 API keys" -permalink: /screens/key-01/ -nav_exclude: true -screen_id: KEY-01 ---- - -# API keys -{: .no_toc } - -Service and agent credentials. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `KEY-01` | -| **Route** | `/dev/keys` · deep link `substratal://dev/keys` | -| **Where** | Developer › API Keys | -| **Who can use it** | api_keys.manage or app owner | -| **Kind** | table | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `GtkColumnView` (.data-table) in a card | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Filters | chips | mode live\ | test, intended_use, include revoked | GtkToggleButton pills | -| Columns | table | name, scope, secret_hint, mode, intended_use, restrict_destructive, last_used_at, expires_at, status(revoked_at) | | GtkColumnView .data-table | -| Create key | primary-button | → KEY-02 | | GtkButton .suggested-action .pill | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /api-keys?scope&mode&intended_use&include_inactive` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/key-02.md b/docs/screens/key-02.md deleted file mode 100644 index 5df9206..0000000 --- a/docs/screens/key-02.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "KEY-02 Create API key" -permalink: /screens/key-02/ -nav_exclude: true -screen_id: KEY-02 ---- - -# Create API key -{: .no_toc } - -Create a key with least-privilege permissions. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `KEY-02` | -| **Route** | `/dev/keys/new` · deep link `substratal://dev/keys/new` | -| **Where** | Developer › API Keys | -| **Who can use it** | api_keys.manage or app owner (app-scoped) | -| **Kind** | form | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Name | text-field | name | | AdwEntryRow | -| Scope | select | scope platform \ | application | AdwComboRow | -| Mode | segmented | mode live \ | test | linked GtkToggleButtons | -| Used by | radio-group | intended_use service \ | agent | GtkCheckButton group | -| Block destructive operations | toggle | restrict_destructive | | AdwSwitchRow | -| Permissions | checkbox-list | permissions | filtered by scope via GET /permissions; app-scoped keys offer the five app-confined platform permissions incl. notifications.send | AdwPreferencesGroup of check rows | -| Expires | date-field | expires_at | optional; presets 30/90/365 days | GtkCalendar in popover | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Create | `POST /api-keys` | 201 ApiKeyWithSecret → KEY-03 | - diff --git a/docs/screens/key-03.md b/docs/screens/key-03.md deleted file mode 100644 index f4790d2..0000000 --- a/docs/screens/key-03.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "KEY-03 Secret reveal" -permalink: /screens/key-03/ -nav_exclude: true -screen_id: KEY-03 ---- - -# Secret reveal -{: .no_toc } - -Show a new API key secret, webhook signing secret, or recovery codes exactly once. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `KEY-03` | -| **Who can use it** | Creator | -| **Kind** | secret | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwDialog` `can-close=false` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Secret | monospace selectable text | secret | never logged, never persisted, excluded from screenshots where the OS allows | GtkLabel selectable .monospace | -| Copy | button | clipboard (sensitive) | auto-clear after 60 s where supported | GtkButton | -| Acknowledge | checkbox | client | gates Done; modal not dismissible until checked | GtkCheckButton | - diff --git a/docs/screens/key-04.md b/docs/screens/key-04.md deleted file mode 100644 index 325e96e..0000000 --- a/docs/screens/key-04.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "KEY-04 API key detail" -permalink: /screens/key-04/ -nav_exclude: true -screen_id: KEY-04 ---- - -# API key detail -{: .no_toc } - -Edit, rotate or revoke a key. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `KEY-04` | -| **Route** | `/dev/keys/:keyId` · deep link `substratal://dev/keys/:keyId` | -| **Where** | Developer › API Keys | -| **Who can use it** | api_keys.manage or app owner | -| **Kind** | detail | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Content pane; `AdwViewSwitcher`; header-bar actions | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Fields | settings rows | name, permissions, restrict_destructive (editable); scope, mode, intended_use, expires_at, created_by, last_used_at (read-only) | | Adw*Rows | -| Rotate | button | POST /api-keys/{id}/rotate | confirm: no overlap | GtkButton | -| Revoke | destructive-button | DELETE /api-keys/{id} | confirm | GtkButton .destructive-action | -| Recent activity | list | audit events by this key | | GtkListBox .boxed-list | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Save | `PATCH /api-keys/{id}` | | -| Rotate | `POST …/rotate` | 200 → KEY-03 | -| Revoke | `DELETE /api-keys/{id}` | 204 → row shows Revoked | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /api-keys/{id}` · `GET /audit-events?actor_id={id}` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ntf-01.md b/docs/screens/ntf-01.md deleted file mode 100644 index a5abf57..0000000 --- a/docs/screens/ntf-01.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "NTF-01 Inbox" -permalink: /screens/ntf-01/ -nav_exclude: true -screen_id: NTF-01 ---- - -# Inbox -{: .no_toc } - -Every notification the person has received — from every app and from Substratal — with read and archived state. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `NTF-01` | -| **Route** | `/inbox` · deep link `substratal://inbox` | -| **Where** | Personal › Inbox | -| **Who can use it** | Self | -| **Kind** | list | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | Nested `AdwNavigationSplitView` list pane | - -**How you get here:** Bell in the toolbar / Inbox in the sidebar (desktop) / Inbox tab (compact); Tapping a push; Badges (app icon, Dock/taskbar, PWA) - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Filter | segmented | Unread · All · Archived (state) | default All (unarchived) | linked GtkToggleButtons | -| App filter | menu | application_id, source=platform ("Substratal") | per-app unread counts from unread-count.applications[] | GtkMenuButton | -| Rows | list | Notification: app icon (or Substratal mark for source=platform), title, body (2 lines), category, priority high → "Important" marker, created_at relative, unread dot | grouped Today / Yesterday / Earlier; high priority security.* rows show a shield icon | GtkListBox .boxed-list | -| Mark all as read | button | POST /users/me/notifications/mark-read {before: newest loaded created_at, application_id?} | respects current app filter; uses `before` so items arriving now aren't swept | GtkButton | -| Multi-select | selection | | bulk Mark read / Mark unread / Archive (one PATCH per row) | AdwComboRow | -| Row actions | row-button | PATCH /users/me/notifications/{notificationId} {state} | swipe/hover: Read ⇄ Unread, Archive | suffix GtkButton .flat | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Open row | `→ NTF-02 and PATCH {state:read}` | optimistic; badge decrements | -| Archive | `PATCH /users/me/notifications/{notificationId} {state:archived}` | toast with Undo (PATCH state:read) | -| Mark read / unread | `PATCH /users/me/notifications/{notificationId} {state}` | | - -## Good to know - -- **Loading, empty and error 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) -- 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. - -## For developers - -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)` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ntf-02.md b/docs/screens/ntf-02.md deleted file mode 100644 index ba3679a..0000000 --- a/docs/screens/ntf-02.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "NTF-02 Notification" -permalink: /screens/ntf-02/ -nav_exclude: true -screen_id: NTF-02 ---- - -# Notification -{: .no_toc } - -One notification in full, with where it leads and how its push went. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `NTF-02` | -| **Route** | `/inbox/:notificationId` · deep link `substratal://inbox/:notificationId` | -| **Where** | Personal › Inbox | -| **Who can use it** | Self (users.list via NTF-05) | -| **Kind** | popover | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | `GtkPopover` or `AdwOverlaySplitView` sidebar | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Header | icon + app name + time | application_id / source, created_at | | GtkImage + labels + badge | -| Title & body | text | title, body | full text | GtkLabel | -| Open | primary-button | action_url | Hub route → navigate in-app; https → open in browser / installed app (Universal/App Link); private scheme → hand to OS; hidden when null | GtkButton .suggested-action .pill | -| Contextual actions | buttons | by category | 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) | GtkButton | -| Delivery | disclosure | push.status, devices_targeted/sent/failed, skip_reason, deliveries[] (30 days) | plain-language: "Pushed to 2 devices", "Not pushed — notifications are off for this app" | AdwExpanderRow | -| Mark unread / Archive | toolbar | PATCH {state} | | AdwHeaderBar buttons | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Opened | `PATCH /users/me/notifications/{notificationId} {state:read}` | on open if unread | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /users/me/notifications/{notificationId}` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ntf-03.md b/docs/screens/ntf-03.md deleted file mode 100644 index f4553f9..0000000 --- a/docs/screens/ntf-03.md +++ /dev/null @@ -1,53 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "NTF-03 Devices & notifications" -permalink: /screens/ntf-03/ -nav_exclude: true -screen_id: NTF-03 ---- - -# Devices & notifications -{: .no_toc } - -Where push notifications reach this person, and controls for this device. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `NTF-03` | -| **Route** | `/account/devices` · deep link `substratal://account/devices` | -| **Where** | Personal › Account | -| **Who can use it** | Self | -| **Kind** | settings | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | `AdwPreferencesPage` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| This device | card | Hub Device for this install (matched by stored device id) | status, "Notifications are on" / off / blocked by OS; button Turn on (→ NTF-04) or Turn off | .card box | -| Push notifications (all apps) | toggle | Settings.notifications.push | same control as SET-01 | AdwSwitchRow | -| Devices | list | Device: name, application_id (app icon), provider, environment, status, inactive_reason, last_registered_at, last_delivery_at, token_hint | grouped by app; inactive rows dimmed with reason ("Signed out", "Not opened in 9 months"…) | GtkListBox .boxed-list | -| Rename | row-button | PATCH /users/me/devices/{deviceId} {name} | | suffix GtkButton .flat | -| Send test notification | row-button | POST /users/me/devices/{deviceId}/test | Hub devices only; shows provider result inline (sent / failed + error) within 10 s | suffix GtkButton .flat | -| Remove | row-button | DELETE /users/me/devices/{deviceId} | confirm; that install stops receiving pushes until it registers again | suffix GtkButton .flat | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Turn off on this device | `DELETE /users/me/devices/{deviceId}` | and stop re-registering until the user turns it back on | -| Test | `POST /users/me/devices/{deviceId}/test` | 429 → "Try again later (10 tests per hour)"; 409 push_not_configured → "Push isn't set up for this app yet" | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES -- Device tokens are never displayed (only token_hint). - -## For developers - -Loads: `GET /users/me/devices?include_inactive=true` · `GET /users/me/settings (notifications.push)` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ntf-04.md b/docs/screens/ntf-04.md deleted file mode 100644 index 31d6b9e..0000000 --- a/docs/screens/ntf-04.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "NTF-04 Turn on notifications" -permalink: /screens/ntf-04/ -nav_exclude: true -screen_id: NTF-04 ---- - -# Turn on notifications -{: .no_toc } - -Explain, request the OS permission, and register this install for push. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `NTF-04` | -| **Route** | `/account/devices/enable` · deep link `substratal://account/devices/enable` | -| **Where** | Personal › Account | -| **Who can use it** | Self | -| **Kind** | wizard | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | `AdwDialog` with `AdwNavigationView` pages | - -**How you get here:** 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 - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Primer | text + illustration | | what you'll get: security alerts, invitations, access changes; "You can change this anytime" | GtkLabel | -| Turn on | primary-button | OS permission prompt | platform API per PLATFORM-SPEC | GtkButton .suggested-action .pill | -| Not now | button | | snooze 14 days | GtkButton | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Permission granted | `POST /users/me/devices {application_id: app_hub, provider, token \` | subscription, environment (apns), name, app_version}` | -| Token rotated by OS | `PATCH /users/me/devices/{deviceId} {token \` | subscription}` | -| Permission denied | `—` | Settings shows "Blocked — Open system settings" deep link | - -## Good to know - -- 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). - diff --git a/docs/screens/ntf-05.md b/docs/screens/ntf-05.md deleted file mode 100644 index 922b023..0000000 --- a/docs/screens/ntf-05.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "NTF-05 User — Notifications & devices" -permalink: /screens/ntf-05/ -nav_exclude: true -screen_id: NTF-05 ---- - -# User — Notifications & devices -{: .no_toc } - -Support view of a person's inbox and push devices for investigations. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `NTF-05` | -| **Route** | `/admin/users/:userId/notifications` · deep link `substratal://admin/users/:userId/notifications` | -| **Where** | Administration › Users | -| **Who can use it** | users.list (read), users.manage (delete devices) | -| **Kind** | tab | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | `AdwViewStack` page | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Inbox | table | Notification: created_at, application/source, category, priority, title, state, push.status, skip_reason | read-only; never mark the user's items read | GtkColumnView .data-table | -| Delivery detail | side panel | deliveries[]: provider, status, attempts, error, sent_at | | AdwOverlaySplitView | -| Devices | table | Device: app, provider, environment, status, inactive_reason, name, app_version, last_registered_at, last_delivery_at | | GtkColumnView .data-table | -| Delete device | destructive-button | DELETE /users/{id}/devices/{deviceId} | users.manage; audited (device.deleted) | GtkButton .destructive-action | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -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}` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ntf-06.md b/docs/screens/ntf-06.md deleted file mode 100644 index 60bd3c0..0000000 --- a/docs/screens/ntf-06.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "NTF-06 Send notification" -permalink: /screens/ntf-06/ -nav_exclude: true -screen_id: NTF-06 ---- - -# Send notification -{: .no_toc } - -Staff tool: send one notification to one person on behalf of an Application. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `NTF-06` | -| **Route** | `/admin/notifications/new` · deep link `substratal://admin/notifications/new` | -| **Where** | Administration › Send notification | -| **Who can use it** | platform notifications.send | -| **Kind** | form | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Recipient | picker | user (GET /users?q) | must have active access to the chosen app | AdwDialog with GtkSearchEntry list | -| Application | select | body.application_id | required for platform senders | AdwComboRow | -| Category | text-field | body.category | ^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$; security./account./billing. reserved (422 reserved_category) | AdwEntryRow | -| Priority | segmented | body.priority normal \ | high | linked GtkToggleButtons | -| Title | text-field | body.title | 1–120 | AdwEntryRow | -| Body | text-area | body.body | ≤ 1,000 | GtkTextView in .card | -| Data | key/value editor | body.data | ≤ 20 string pairs, 2 KB; key `substratal` reserved | AdwPreferencesGroup of AdwEntryRow pairs | -| Open link | url-field | body.action_url | https or private scheme ≤ 2,048 | AdwEntryRow input-purpose=url | -| Advanced | disclosure | push (bool), badge, sound default\ | silent, collapse_key, ttl_seconds | AdwExpanderRow | -| Payload size | meters | client estimate of the 4 KB push payload | 422 notification_too_large → details.bytes/max | GtkLevelBar | -| Preview | card | lock-screen style preview per platform | warns: lock screens show title/body — keep sensitive content out | .card box | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Send | `POST /users/{id}/notifications (Idempotency-Key)` | 201 → result panel with push.status / skip_reason; 403 entitlement_required → recipient has no access; 429 scope sender\ | - diff --git a/docs/screens/ops-01.md b/docs/screens/ops-01.md deleted file mode 100644 index 3e233c6..0000000 --- a/docs/screens/ops-01.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "OPS-01 Overview" -permalink: /screens/ops-01/ -nav_exclude: true -screen_id: OPS-01 ---- - -# Overview -{: .no_toc } - -Is production healthy right now, and is anything costing or failing? -{: .fs-6 .fw-300 } - -This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). -{: .note } - -| | | -|---|---| -| **Screen ID** | `OPS-01` | -| **Route** | `/ops` | -| **Where** | Operations › Overview | -| **Who can use it** | operations.view (superadmin until G10) | -| **Kind** | detail | -| **Available in** | Web Hub only (Console M3 (web only)) | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | -|---|---|---|---| -| Health | StatusBadge | `GET /health` | Down → banner linking OPS-04 | -| Alarms | summary chips | firing / OK counts | any firing → sidebar badge on Operations | -| Traffic tiles | metric tiles | requests, 4xx rate, 5xx rate, p95 latency, Lambda errors and throttles, Aurora ACU, webhook DLQ depth | each tile → OPS-02 at that metric | -| Cost | progress bar | month-to-date vs warn and hard budgets | → OPS-07 | -| Recent security events | list | → SEC-01 | 5 newest | -| Recent admin activity | list | `GET /audit-events` | → AUD-01 | - -## Good to know - -- **Loading, empty and error 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." - -## For developers - -Loads: `GET /health` (API) · `GET /ops/metrics?panel=overview&range=1h` · `GET /ops/alarms` · `GET /ops/cost` · `GET /audit-events?limit=10` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ops-02.md b/docs/screens/ops-02.md deleted file mode 100644 index f665199..0000000 --- a/docs/screens/ops-02.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "OPS-02 Traffic & latency" -permalink: /screens/ops-02/ -nav_exclude: true -screen_id: OPS-02 ---- - -# Traffic & latency -{: .no_toc } - -Trends and SLO tracking. -{: .fs-6 .fw-300 } - -This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). -{: .note } - -| | | -|---|---| -| **Screen ID** | `OPS-02` | -| **Route** | `/ops/traffic` | -| **Where** | Operations › Traffic & latency | -| **Who can use it** | operations.view | -| **Kind** | detail | -| **Available in** | Web Hub only (Console M3 (web only)) | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | -|---|---|---|---| -| Range | segmented control | 1h · 24h · 7d · 30d | in the URL | -| Charts | time series | requests, 4xx, 5xx, p95 latency, throttles | accessible table view of each chart (ACCESSIBILITY) | -| SLOs | progress + burn rate | 99.9 % non-5xx; p95 < 150 ms on effective-permissions, app-tokens and oauth/token; p95 < 400 ms elsewhere | per-operation SLOs hidden until [G6 #43](https://github.com/CompositeCode/substratalapps.com/issues/43) gives per-operation data | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry - -## For developers - -Loads: `GET /ops/metrics?panel=traffic&range={1h|24h|7d|30d}` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ops-03.md b/docs/screens/ops-03.md deleted file mode 100644 index 971d4a8..0000000 --- a/docs/screens/ops-03.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "OPS-03 Logs" -permalink: /screens/ops-03/ -nav_exclude: true -screen_id: OPS-03 ---- - -# Logs -{: .no_toc } - -Find what happened to one request, user or key. -{: .fs-6 .fw-300 } - -This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). -{: .note } - -| | | -|---|---| -| **Screen ID** | `OPS-03` | -| **Route** | `/ops/logs` | -| **Where** | Operations › Logs | -| **Who can use it** | operations.view | -| **Kind** | table | -| **Available in** | Web Hub only (Console M3 (web only)) | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | -|---|---|---|---| -| Query | select + typed fields | a template from the gateway's catalog (by request ID, user, key, status, error code), never free text in v1 | `?request_id=` deep link prefills | -| Range | segmented control | default 24 h | longer ranges warn about query cost | -| Rows | table | timestamp, log group, level, message fields | request ID links to AUD-01 filtered and back | - -## Good to know - -- **Loading, empty and error states:** Loading: progress while the query runs; Empty: "No log lines match."; Error: inline with Retry - -## For developers - -Loads: `POST /ops/logs/query {template, params, range}` → `GET /ops/logs/query/{id}` (poll every 2 s until complete) - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ops-04.md b/docs/screens/ops-04.md deleted file mode 100644 index 65ce76b..0000000 --- a/docs/screens/ops-04.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "OPS-04 Alarms" -permalink: /screens/ops-04/ -nav_exclude: true -screen_id: OPS-04 ---- - -# Alarms -{: .no_toc } - -What's firing, what fired, and who gets told. -{: .fs-6 .fw-300 } - -This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). -{: .note } - -| | | -|---|---| -| **Screen ID** | `OPS-04` | -| **Route** | `/ops/alarms` | -| **Where** | Operations › Alarms | -| **Who can use it** | operations.view | -| **Kind** | table | -| **Available in** | Web Hub only (Console M3 (web only)) | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | -|---|---|---|---| -| Rows | table | alarm name, StatusBadge (OK · ALARM · INSUFFICIENT_DATA), since, threshold | firing first | -| History | popover | state changes for one alarm | links to the matching OPS-03 query | -| Subscribers | list | email subscriptions to the alarm topic, with pending confirmations | read-only in v1; changing them is Terraform | - -## For developers - -Loads: `GET /ops/alarms` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ops-05.md b/docs/screens/ops-05.md deleted file mode 100644 index 70b03e4..0000000 --- a/docs/screens/ops-05.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "OPS-05 Jobs & delivery" -permalink: /screens/ops-05/ -nav_exclude: true -screen_id: OPS-05 ---- - -# Jobs & delivery -{: .no_toc } - -Are the scheduled jobs and deliveries keeping up? -{: .fs-6 .fw-300 } - -This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). -{: .note } - -| | | -|---|---| -| **Screen ID** | `OPS-05` | -| **Route** | `/ops/jobs` | -| **Where** | Operations › Jobs & delivery | -| **Who can use it** | operations.view | -| **Kind** | table | -| **Available in** | Web Hub only (Console M3 (web only)) | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | -|---|---|---|---| -| Jobs | table | each scheduled job: schedule, last run, outcome, duration | outcome is inferred from logs until [G9 #46](https://github.com/CompositeCode/substratalapps.com/issues/46) records runs | -| Delivery | metric tiles | webhook and push queue depth, DLQ depth, failure rate | failures across subscriptions need [G8 #45](https://github.com/CompositeCode/substratalapps.com/issues/45); until then link WHK-03 per webhook | - -## For developers - -Loads: `GET /ops/jobs` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ops-06.md b/docs/screens/ops-06.md deleted file mode 100644 index 8fa3ede..0000000 --- a/docs/screens/ops-06.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "OPS-06 Releases" -permalink: /screens/ops-06/ -nav_exclude: true -screen_id: OPS-06 ---- - -# Releases -{: .no_toc } - -What's deployed. -{: .fs-6 .fw-300 } - -This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). -{: .note } - -| | | -|---|---| -| **Screen ID** | `OPS-06` | -| **Route** | `/ops/releases` | -| **Where** | Operations › Releases | -| **Who can use it** | operations.view | -| **Kind** | table | -| **Available in** | Web Hub only (Console M3 (web only)) | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | -|---|---|---|---| -| Functions | table | function, `live` alias → version → commit SHA, deployed at | SHA links to the commit | -| Docs | key-value | published spec version | | -| Links | link-buttons | release, deploy and rollback workflows | open GitHub; the console never deploys | - -## For developers - -Loads: `GET /ops/releases` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/ops-07.md b/docs/screens/ops-07.md deleted file mode 100644 index 897ef23..0000000 --- a/docs/screens/ops-07.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "OPS-07 Cost" -permalink: /screens/ops-07/ -nav_exclude: true -screen_id: OPS-07 ---- - -# Cost -{: .no_toc } - -Spend against the budgets. -{: .fs-6 .fw-300 } - -This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). -{: .note } - -| | | -|---|---| -| **Screen ID** | `OPS-07` | -| **Route** | `/ops/cost` | -| **Where** | Operations › Cost | -| **Who can use it** | operations.view | -| **Kind** | detail | -| **Available in** | Web Hub only (Console M3 (web only)) | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | -|---|---|---|---| -| Budget | progress bar | month-to-date and forecast vs warn and hard budgets | forecast over warn → warning banner | -| By service | bar chart + table | cost by AWS service, this month | | -| Anomalies | list | recent cost anomalies | | - -## Good to know - -- Show "Updated {time}" because the data is up to 6 hours old. - -## For developers - -Loads: `GET /ops/cost` (cached 6 h by the gateway) - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/org-01.md b/docs/screens/org-01.md deleted file mode 100644 index 34e4156..0000000 --- a/docs/screens/org-01.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ORG-01 Organizations" -permalink: /screens/org-01/ -nav_exclude: true -screen_id: ORG-01 ---- - -# Organizations -{: .no_toc } - -Organizations you belong to, plus pending invitations. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ORG-01` | -| **Route** | `/orgs` · deep link `substratal://orgs` | -| **Where** | Personal › Organizations | -| **Who can use it** | Signed in (all orgs with organizations.manage) | -| **Kind** | list | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Nested `AdwNavigationSplitView` list pane | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Pending invitations | section | my_membership_status = pending | Accept / Decline inline → ORG-09 | AdwPreferencesGroup | -| Rows | list | Organization: name, member_count, my_role, status | suspended orgs show badge | GtkListBox .boxed-list | -| Search | search | q | server-side when > 50 | GtkSearchEntry | -| New organization | primary-button | → ORG-02 | | GtkButton .suggested-action .pill | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /organizations` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/org-02.md b/docs/screens/org-02.md deleted file mode 100644 index d8ca722..0000000 --- a/docs/screens/org-02.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ORG-02 New organization" -permalink: /screens/org-02/ -nav_exclude: true -screen_id: ORG-02 ---- - -# New organization -{: .no_toc } - -Create an organization; creator becomes org admin. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ORG-02` | -| **Route** | `/orgs/new` · deep link `substratal://orgs/new` | -| **Where** | Personal › Organizations | -| **Who can use it** | Any active user | -| **Kind** | form | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Name | text-field | body.name | required; ≤ 100 | AdwEntryRow | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Create | `POST /organizations (Idempotency-Key)` | 201 → ORG-03. 429 → rate limited. | - diff --git a/docs/screens/org-03.md b/docs/screens/org-03.md deleted file mode 100644 index 6055e7a..0000000 --- a/docs/screens/org-03.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ORG-03 Organization — Members" -permalink: /screens/org-03/ -nav_exclude: true -screen_id: ORG-03 ---- - -# Organization — Members -{: .no_toc } - -See and manage members and pending invitations. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ORG-03` | -| **Route** | `/orgs/:orgId/members` · deep link `substratal://orgs/:orgId/members` | -| **Where** | Personal › Organizations | -| **Who can use it** | Member (read), org_admin / organizations.manage (write) | -| **Kind** | detail | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Content pane; `AdwViewSwitcher`; header-bar actions | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Header | avatar + name + id-chip + status | Organization | my_role shown as caption | GtkLabel .monospace + copy | -| Tabs | tabs | Members · Apps & seats · Subscription · Activity · Settings | Subscription tab = BIL-02 (org_admin / billing.manage only) | AdwViewSwitcher / AdwInlineViewSwitcher | -| Filter | chips | role, membership_status | | GtkToggleButton pills | -| Rows | list | OrganizationMember: display_name/email, role, membership_status, joined_at/invited_at | pending rows show Invited badge + invited date | GtkListBox .boxed-list | -| Invite | primary-button | → ORG-04 | org_admin only | GtkButton .suggested-action .pill | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Row menu | `→ ORG-05` | Change role · Remove · Withdraw invitation | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /organizations/{id}` · `GET /organizations/{id}/members` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/org-04.md b/docs/screens/org-04.md deleted file mode 100644 index d1e6ca3..0000000 --- a/docs/screens/org-04.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ORG-04 Invite member" -permalink: /screens/org-04/ -nav_exclude: true -screen_id: ORG-04 ---- - -# Invite member -{: .no_toc } - -Invite people by email (or add an existing user, staff only). -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ORG-04` | -| **Route** | `/orgs/:orgId/members/invite` · deep link `substratal://orgs/:orgId/members/invite` | -| **Where** | Personal › Organizations | -| **Who can use it** | org_admin; organizations.manage may add by user_id | -| **Kind** | form | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Emails | token-field | body.email (one request per address) | paste a list separated by commas/newlines | custom entry with pills | -| Role | radio-group | body.role org_admin \ | member | GtkCheckButton group | -| Add existing user | user-picker | body.user_id | organizations.manage only | AdwDialog with GtkSearchEntry list | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Send invitations | `POST /organizations/{id}/members × n` | Per-address results summary; 409 → "already a member" per address. Responses look identical whether or not the address has an account (by design). | - -## Good to know - -- Existing Users also receive an `account.organization_invitation` platform alert (NTF-01, and a push where the Hub has a device). - diff --git a/docs/screens/org-05.md b/docs/screens/org-05.md deleted file mode 100644 index b93eada..0000000 --- a/docs/screens/org-05.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ORG-05 Member actions" -permalink: /screens/org-05/ -nav_exclude: true -screen_id: ORG-05 ---- - -# Member actions -{: .no_toc } - -Change a member's role or remove them. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ORG-05` | -| **Route** | `/orgs/:orgId/members/:userId` · deep link `substratal://orgs/:orgId/members/:userId` | -| **Where** | Personal › Organizations | -| **Who can use it** | org_admin / organizations.manage | -| **Kind** | confirm | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwAlertDialog` (`extra-child` for fields) | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Role | radio-group | body.role | | GtkCheckButton group | -| Remove from organization | destructive-button | DELETE …/members/{userId} | COPY confirm | GtkButton .destructive-action | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Change role | `PATCH /organizations/{id}/members/{userId}` | 409 → last admin can't be demoted (explain). | -| Remove | `DELETE /organizations/{id}/members/{userId}` | 409 → last admin can't leave/remove. | - diff --git a/docs/screens/org-06.md b/docs/screens/org-06.md deleted file mode 100644 index 75d66bb..0000000 --- a/docs/screens/org-06.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ORG-06 Organization — Apps & seats" -permalink: /screens/org-06/ -nav_exclude: true -screen_id: ORG-06 ---- - -# Organization — Apps & seats -{: .no_toc } - -Org-wide app grants and who they include. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ORG-06` | -| **Route** | `/orgs/:orgId/apps` · deep link `substratal://orgs/:orgId/apps` | -| **Where** | Personal › Organizations | -| **Who can use it** | Member (read), org_admin (write) | -| **Kind** | tab | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | `AdwViewStack` page | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Rows | list | Entitlement (org): app, status, source (org_seat \ | subscription), member_scope, included_member_count, starts_at/ends_at | GtkListBox .boxed-list | -| Grant app | button | → ORG-07 | | GtkButton | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Toggle | `PATCH /entitlements/{id} {status}` | Off requires confirm (COPY). On platform-managed rows only active ⇄ disabled is allowed; anything else 409 entitlement_platform_managed. | -| Edit scope | `→ ORG-07 (org_seat) / BIL-02 (subscription)` | | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /organizations/{id}/entitlements` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/org-07.md b/docs/screens/org-07.md deleted file mode 100644 index 1f47356..0000000 --- a/docs/screens/org-07.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ORG-07 Grant app to organization / member scope" -permalink: /screens/org-07/ -nav_exclude: true -screen_id: ORG-07 ---- - -# Grant app to organization / member scope -{: .no_toc } - -Create an org-wide grant or change who it includes. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ORG-07` | -| **Route** | `/orgs/:orgId/apps/grant` · deep link `substratal://orgs/:orgId/apps/grant` | -| **Where** | Personal › Organizations | -| **Who can use it** | entitlements.manage or app owner; scope edits by org_admin | -| **Kind** | form | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| App | select | body.application_id | create only | AdwComboRow | -| Who gets access | radio-group | member_scope all_members \ | allowlist \ | GtkCheckButton group | -| Members | member-picker | member_overrides | visible for allowlist/denylist | AdwDialog with GtkSearchEntry list | -| Starts / Ends | date-fields | starts_at, ends_at | optional | GtkCalendar in popover | -| Order ID | text-field | order_id | optional; staff/app owner | AdwEntryRow | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Grant | `POST /organizations/{id}/entitlements (Idempotency-Key)` | 409 entitlement_already_exists → already granted (view it). 422 → field errors. | -| Save scope | `PATCH /entitlements/{id} {member_scope, member_overrides}` | | - diff --git a/docs/screens/org-08.md b/docs/screens/org-08.md deleted file mode 100644 index 7a85686..0000000 --- a/docs/screens/org-08.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ORG-08 Organization — Settings" -permalink: /screens/org-08/ -nav_exclude: true -screen_id: ORG-08 ---- - -# Organization — Settings -{: .no_toc } - -Rename, leave, set up SCIM provisioning, or (staff) suspend the organization. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ORG-08` | -| **Route** | `/orgs/:orgId/settings` · deep link `substratal://orgs/:orgId/settings` | -| **Where** | Personal › Organizations | -| **Who can use it** | org_admin (rename, SCIM), organizations.manage (suspend, SCIM), any member (leave) | -| **Kind** | settings | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwPreferencesPage` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Name | text-field | Organization.name | | AdwEntryRow | -| Leave organization | destructive-row | DELETE …/members/me | | AdwButtonRow .destructive-action | -| SCIM provisioning | section | GET …/scim-token → `created_at`, `created_by`, `last_used_at`, `base_url` | org_admin or organizations.manage; behind `scim` flag; 404 scim_token_not_found = "Not set up" | AdwPreferencesGroup | -| Set up / Rotate token | button | POST …/scim-token → token + base_url | rotate confirms first ("Your identity provider stops syncing until you paste the new token"); secret shown once (KEY-03 pattern) | AdwButtonRow | -| Turn off SCIM | destructive-row | DELETE …/scim-token | confirm; members already provisioned stay | AdwButtonRow .destructive-action | -| Suspend / Reactivate | destructive-row | PATCH {status, reason} | staff only; reason required | AdwButtonRow .destructive-action | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Rename | `PATCH /organizations/{id} {name}` | | -| Leave | `DELETE /organizations/{id}/members/me` | 409 last admin | -| Load SCIM status | `GET /organizations/{id}/scim-token` | 404 scim_token_not_found → "Not set up" with a Set up button | -| Create / rotate SCIM token | `POST /organizations/{id}/scim-token` | 201 → one-time secret sheet showing `base_url` and `token` with Copy; the previous token stops working at once | -| Turn off SCIM | `DELETE /organizations/{id}/scim-token` | 204 → "Not set up" | - -## Good to know - -- 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. - diff --git a/docs/screens/org-09.md b/docs/screens/org-09.md deleted file mode 100644 index 406ae32..0000000 --- a/docs/screens/org-09.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ORG-09 Invitation" -permalink: /screens/org-09/ -nav_exclude: true -screen_id: ORG-09 ---- - -# Invitation -{: .no_toc } - -Accept or decline an organization invitation. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ORG-09` | -| **Route** | `/orgs/:orgId/invitation` · deep link `substratal://orgs/:orgId/invitation` | -| **Where** | Personal › Organizations | -| **Who can use it** | Invited user | -| **Kind** | confirm | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwAlertDialog` (`extra-child` for fields) | - -**How you get here:** Invitation banner on APP-01; ORG-01 pending section; `account.organization_invitation` notification (NTF-01 / push action "Accept") - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Org name + inviter | text | Organization | | GtkLabel | -| Accept | primary-button | | | GtkButton .suggested-action .pill | -| Decline | button | | | GtkButton | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Accept | `POST /organizations/{id}/members/me/accept` | 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. | -| Decline | `DELETE /organizations/{id}/members/me` | | - diff --git a/docs/screens/rol-01.md b/docs/screens/rol-01.md deleted file mode 100644 index a547022..0000000 --- a/docs/screens/rol-01.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ROL-01 Roles" -permalink: /screens/rol-01/ -nav_exclude: true -screen_id: ROL-01 ---- - -# Roles -{: .no_toc } - -Platform and app roles. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ROL-01` | -| **Route** | `/admin/roles` · deep link `substratal://admin/roles` | -| **Where** | Administration › Roles | -| **Who can use it** | users.list (read), roles.manage (write) | -| **Kind** | list | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Nested `AdwNavigationSplitView` list pane | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Scope | segmented | scope platform \ | {application_id} | linked GtkToggleButtons | -| Rows | list | Role: name, scope, seed, assignment_count, description | seed → lock icon | GtkListBox .boxed-list | -| New role | primary-button | → ROL-03 | roles.manage | GtkButton .suggested-action .pill | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /roles?scope` · `GET /permissions (includes notifications.send)` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/rol-02.md b/docs/screens/rol-02.md deleted file mode 100644 index 37604f9..0000000 --- a/docs/screens/rol-02.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ROL-02 Role detail" -permalink: /screens/rol-02/ -nav_exclude: true -screen_id: ROL-02 ---- - -# Role detail -{: .no_toc } - -View or edit a role's permissions. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ROL-02` | -| **Route** | `/admin/roles/:roleId` · deep link `substratal://admin/roles/:roleId` | -| **Where** | Administration › Roles | -| **Who can use it** | users.list (read), roles.manage (write) | -| **Kind** | detail | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Content pane; `AdwViewSwitcher`; header-bar actions | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Header | name, id-chip, scope, assignment_count | | | GtkLabel .monospace + copy | -| Description | text-field | Role.description | read-only when seed | AdwEntryRow | -| Permissions | checkbox-list grouped by prefix | Role.permissions ⊂ Permission[].key | read-only when seed; show Permission.description | AdwPreferencesGroup of check rows | -| Holders | link | → USR-01 filtered by `GET /users?role_id={id}` | explicit assignments only; for an app's `default_app_role`, add "and everyone with access to {app}" → ACX-05 filtered | GtkButton .flat / GtkLinkButton | -| Delete role | destructive-button | DELETE /roles/{id} | only non-seed with assignment_count 0 | GtkButton .destructive-action | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Save | `PATCH /roles/{id} {description, permissions} (If-Match)` | | -| Delete | `DELETE /roles/{id}` | 409 → still assigned | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /roles/{id}` · `GET /permissions?scope` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/rol-03.md b/docs/screens/rol-03.md deleted file mode 100644 index 145c961..0000000 --- a/docs/screens/rol-03.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ROL-03 New role" -permalink: /screens/rol-03/ -nav_exclude: true -screen_id: ROL-03 ---- - -# New role -{: .no_toc } - -Define a custom role. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ROL-03` | -| **Route** | `/admin/roles/new` · deep link `substratal://admin/roles/new` | -| **Where** | Administration › Roles | -| **Who can use it** | roles.manage | -| **Kind** | form | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Name | text-field | body.name | ^[a-z][a-z0-9_]{1,40}$; live validation | AdwEntryRow | -| Scope | select | body.scope | platform or an application | AdwComboRow | -| Description | text-area | body.description | | GtkTextView in .card | -| Permissions | checkbox-list | body.permissions | ≥ 1 | AdwPreferencesGroup of check rows | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Create | `POST /roles` | 409 → name taken in scope | - diff --git a/docs/screens/rol-04.md b/docs/screens/rol-04.md deleted file mode 100644 index be183a1..0000000 --- a/docs/screens/rol-04.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "ROL-04 User — Roles" -permalink: /screens/rol-04/ -nav_exclude: true -screen_id: ROL-04 ---- - -# User — Roles -{: .no_toc } - -Assign and remove roles for a user. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `ROL-04` | -| **Route** | `/admin/users/:userId/roles` · deep link `substratal://admin/users/:userId/roles` | -| **Where** | Administration › Users | -| **Who can use it** | users.list (read), roles.manage (write) | -| **Kind** | tab | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwViewStack` page | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Rows | list | UserRoleAssignment: role, application_id, assigned_at, assigned_by, implicit | implicit → "Granted with app access", not removable | GtkListBox .boxed-list | -| Assign role | button → role picker | | | AdwDialog with GtkSearchEntry list | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Assign | `POST /users/{id}/roles/{roleId}` | idempotent; 200 already held | -| Remove | `DELETE /users/{id}/roles/{roleId}` | 409 → implicit role | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /users/{id}/roles` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/rpt-01.md b/docs/screens/rpt-01.md deleted file mode 100644 index 655ed93..0000000 --- a/docs/screens/rpt-01.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "RPT-01 Reports" -permalink: /screens/rpt-01/ -nav_exclude: true -screen_id: RPT-01 ---- - -# Reports -{: .no_toc } - -Growth, access, usage, subscribers and delivery over time. -{: .fs-6 .fw-300 } - -This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). -{: .note } - -| | | -|---|---| -| **Screen ID** | `RPT-01` | -| **Route** | `/admin/reports` | -| **Where** | Administration › Reports | -| **Who can use it** | users.list (platform reports); an Application owner sees only their own app | -| **Kind** | detail | -| **Available in** | Web Hub only (Console M4 (web only)) | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | -|---|---|---|---| -| Report | select | platform growth · access · usage · subscribers · delivery | subscribers needs [G20 #57](https://github.com/CompositeCode/substratalapps.com/issues/57) | -| Range | date range | | in the URL | -| Chart + table | chart, table | the report's series | accessible table view | -| Export | button | CSV of the loaded series | client-side | - -## Good to know - -- Revenue stays in Stripe; link to the Stripe dashboard instead of mirroring it. - -## For developers - -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. - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/sec-01.md b/docs/screens/sec-01.md deleted file mode 100644 index bc7b656..0000000 --- a/docs/screens/sec-01.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "SEC-01 Security events" -permalink: /screens/sec-01/ -nav_exclude: true -screen_id: SEC-01 ---- - -# Security events -{: .no_toc } - -Spot attacks and lockouts. -{: .fs-6 .fw-300 } - -This screen is part of the **web Hub's Console** and isn't in the Linux, macOS, Windows, iOS or Android apps yet (specs OPEN-QUESTIONS #27). -{: .note } - -| | | -|---|---| -| **Screen ID** | `SEC-01` | -| **Route** | `/ops/security` | -| **Where** | Operations › Security events | -| **Who can use it** | operations.view | -| **Kind** | table | -| **Available in** | Web Hub only (Console M3 (web only)) | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | -|---|---|---|---| -| Group by | segmented control | user · IP · key | | -| Rows | table | failed logins, lockouts, MFA challenges, refresh-token reuse, 403 denials, `destructive_operation_restricted`, `api_key_in_browser` | user → USR-03; key → KEY-04 | -| Actions | menu | → USR-05 Suspend · USR-04 Sign out everywhere · KEY-04 Revoke | each is the normal API call, audited with the person as actor | - -## Good to know - -- 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. - -## For developers - -Loads: `POST /ops/logs/query {template: security_events, range}` · `GET /ops/metrics?panel=security` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/set-01.md b/docs/screens/set-01.md deleted file mode 100644 index 26d31ee..0000000 --- a/docs/screens/set-01.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "SET-01 Settings — Global" -permalink: /screens/set-01/ -nav_exclude: true -screen_id: SET-01 ---- - -# Settings — Global -{: .no_toc } - -Preferences that apply to every app. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `SET-01` | -| **Route** | `/settings` · deep link `substratal://settings` | -| **Where** | Personal › Settings | -| **Who can use it** | Self | -| **Kind** | settings | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwPreferencesPage` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Language | select | Settings.locale | BCP 47; default = OS language; changing re-localizes the UI live | AdwComboRow | -| Time zone | searchable select | Settings.timezone | IANA; default = OS zone; "Use device time zone" option | AdwComboRow enable-search | -| Appearance | segmented | Settings.theme light/dark/system | Light · Dark · Match system (default). Applies instantly; Match system follows OS changes live. See THEMES.md | linked GtkToggleButtons | -| Light theme | swatch picker | `app_hub` setting `appearance.light_theme` | 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" | GtkFlowBox of swatch GtkCheckButtons (grouped) in an AdwPreferencesGroup | -| Dark theme | swatch picker | `app_hub` setting `appearance.dark_theme` | 5 themes: Night Soil, Deep Teal, Graphite Spore, **Forest Floor (default)**, Ember Clay. Same behaviour as Light theme | GtkFlowBox of swatch GtkCheckButtons (grouped) in an AdwPreferencesGroup | -| Push notifications | toggle | Settings.notifications.push (missing = on) | 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" | AdwSwitchRow | -| Email notifications | toggle | Settings.notifications.email | | AdwSwitchRow | -| SMS notifications | toggle | Settings.notifications.sms | disabled until Profile.contact_phone set | AdwSwitchRow | -| Share usage analytics | toggle | client preference | default per platform policy; see CLIENT-ARCHITECTURE §10 | AdwSwitchRow | -| Scope list | list | Global + each app with active access | → SET-02 (desktop: middle column) | GtkListBox .boxed-list | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Change any row | `PATCH /users/me/settings (If-Match)` | Optimistic; revert + toast on error. | -| Choose a light or dark theme | `PATCH /users/me/apps/app_hub/settings (If-Match)` with `appearance.light_theme` / `appearance.dark_theme` | Apply instantly, cache locally, then sync; 409 → refetch and re-apply; 422 → revert + toast "Couldn't save your theme. Try again." | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /users/me/settings` · `GET /users/me/apps/app_hub/settings` (theme choices) - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/set-02.md b/docs/screens/set-02.md deleted file mode 100644 index de6389d..0000000 --- a/docs/screens/set-02.md +++ /dev/null @@ -1,50 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "SET-02 Settings — Per app" -permalink: /screens/set-02/ -nav_exclude: true -screen_id: SET-02 ---- - -# Settings — Per app -{: .no_toc } - -Override global settings and set app-specific preferences from the app's settings_schema. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `SET-02` | -| **Route** | `/settings/apps/:appId` · deep link `substratal://settings/apps/:appId` | -| **Where** | Personal › Settings | -| **Who can use it** | Self, active entitlement | -| **Kind** | settings | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | `AdwPreferencesPage` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Schema form | generated rows | settings_schema properties × AppSettingsResolved.resolved | 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 | SchemaForm | -| Source caption | caption per row | AppSettingsResolved.sources[key] | app_override → "Changed for this app · Reset"; global → "Using your global setting"; app_default → "App default" | GtkLabel .caption .dim-label | -| Reset | link per overridden row | PATCH overrides {key:null} | | AdwActionRow activatable | -| Push notifications for this app | toggle | resolved notifications.push; override via overrides.notifications.push | source caption as other rows; Reset clears the override | AdwSwitchRow | -| Stale overrides | banner | AppSettingsResolved.stale_overrides | "Some saved settings no longer apply" → Clear | AdwBanner | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Change row | `PATCH /users/me/apps/{appId}/settings {overrides:{key:value}} (If-Match)` | 422 → field error from details (enum_mismatch, out_of_range, undeclared_key). | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /applications/{appId} (settings_schema)` · `GET /users/me/apps/{appId}/settings` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/usr-01.md b/docs/screens/usr-01.md deleted file mode 100644 index 50b5882..0000000 --- a/docs/screens/usr-01.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "USR-01 Users" -permalink: /screens/usr-01/ -nav_exclude: true -screen_id: USR-01 ---- - -# Users -{: .no_toc } - -Find and manage user accounts. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `USR-01` | -| **Route** | `/admin/users` · deep link `substratal://admin/users` | -| **Where** | Administration › Users | -| **Who can use it** | users.list (write actions need users.manage) | -| **Kind** | table | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `GtkColumnView` (.data-table) in a card | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Search | search | q (name, email, ID prefix usr_) | debounced 300 ms; exact ID jumps to USR-03 | GtkSearchEntry | -| Status filter | chips | status active \ | invited \ | GtkToggleButton pills | -| More filters | menu | application_id, organization_id, created range | | GtkMenuButton | -| Columns | table | display name + email, status, id, last_login_at, created_at | sortable client-side within loaded page; server order = created_at desc | GtkColumnView .data-table | -| Multi-select | selection | | bulk: Suspend, Resend invitation, Export CSV (client-side of loaded rows) | AdwComboRow | -| Invite user | primary-button | → USR-02 | users.manage | GtkButton .suggested-action .pill | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Open row | `→ USR-03` | | -| Row menu | | Suspend · Reactivate · Resend invitation · Copy ID · Copy email | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /users?limit=50&cursor&status&q&application_id&organization_id&created_after&created_before` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/usr-02.md b/docs/screens/usr-02.md deleted file mode 100644 index 6f821c4..0000000 --- a/docs/screens/usr-02.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "USR-02 Invite user" -permalink: /screens/usr-02/ -nav_exclude: true -screen_id: USR-02 ---- - -# Invite user -{: .no_toc } - -Create a user and send an invitation (or create active). -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `USR-02` | -| **Route** | `/admin/users/new` · deep link `substratal://admin/users/new` | -| **Where** | Administration › Users | -| **Who can use it** | users.manage | -| **Kind** | form | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Email | email-field | body.email | required | AdwEntryRow input-purpose=email | -| Display name | text-field | body.display_name | optional | AdwEntryRow | -| Send invitation email | toggle | body.send_invitation | default on | AdwSwitchRow | -| Create as active | toggle | body.status active\ | invited | AdwSwitchRow | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Invite | `POST /users (Idempotency-Key)` | 201 → USR-03. 409 → "A user with that email exists" + link. | - diff --git a/docs/screens/usr-03.md b/docs/screens/usr-03.md deleted file mode 100644 index 431ae0f..0000000 --- a/docs/screens/usr-03.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "USR-03 User — Overview" -permalink: /screens/usr-03/ -nav_exclude: true -screen_id: USR-03 ---- - -# User — Overview -{: .no_toc } - -One user's account state and administrative actions. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `USR-03` | -| **Route** | `/admin/users/:userId` · deep link `substratal://admin/users/:userId` | -| **Where** | Administration › Users | -| **Who can use it** | users.list (read), users.manage (write) | -| **Kind** | detail | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | Content pane; `AdwViewSwitcher`; header-bar actions | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Header | avatar, display name, email, id-chip, StatusBadge | User + Profile | | GtkLabel .monospace + copy | -| Tabs | tabs | Overview · Access · Roles · Sessions · Notifications · Activity | Activity = AUD-01 filtered target_user_id; Notifications = NTF-05 | AdwViewSwitcher / AdwInlineViewSwitcher | -| Facts | key-value list | 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") | plan_sources link to the org or to BIL-01 subscription view (billing.manage) | AdwActionRow subtitle | -| Actions | toolbar/menu | | Suspend (USR-05) · Reactivate · Resend invitation · Reset two-factor · Export data · Delete · Restore · Cancel scheduled erasure | GtkMenuButton | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Reactivate / Restore | `PATCH /users/{id} {status:active} (If-Match)` | | -| Resend invitation | `POST /users/{id}/invitation` | 202 toast; 409 → not in invited state | -| Reset two-factor | `DELETE /users/{id}/mfa/totp` | confirm | -| Export | `GET /users/{id}/export` | save file | -| Delete | `DELETE /users/{id}` | confirm | -| Cancel erasure | `DELETE /users/{id}/erasure-requests/current` | visible during 7-day window | -| Extend (test-mode) | `PATCH /users/{id} {expires_at} (If-Match)` | 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. | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /users/{id}` · `GET /users/{id}/profile` · `GET /users/{id}/subscription (billing.manage only)` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/usr-04.md b/docs/screens/usr-04.md deleted file mode 100644 index 7a7da87..0000000 --- a/docs/screens/usr-04.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "USR-04 User — Sessions" -permalink: /screens/usr-04/ -nav_exclude: true -screen_id: USR-04 ---- - -# User — Sessions -{: .no_toc } - -See and revoke a user's sessions. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `USR-04` | -| **Route** | `/admin/users/:userId/sessions` · deep link `substratal://admin/users/:userId/sessions` | -| **Where** | Administration › Users | -| **Who can use it** | users.manage | -| **Kind** | tab | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwViewStack` page | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Rows | list | Session: user_agent, ip_address, amr, created_at, last_seen_at, expires_at | staff see full IP | GtkListBox .boxed-list | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Revoke | `DELETE /users/{id}/sessions/{sessionId}` | | -| Sign out everywhere | `POST /users/{id}/sessions/revoke-all` | Confirm (COPY "Sign out everywhere (staff)"). 200 `{revoked}` → toast; the list empties. Their account stays active. | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /users/{id}/sessions` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/usr-05.md b/docs/screens/usr-05.md deleted file mode 100644 index 6fa922b..0000000 --- a/docs/screens/usr-05.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "USR-05 Suspend user" -permalink: /screens/usr-05/ -nav_exclude: true -screen_id: USR-05 ---- - -# Suspend user -{: .no_toc } - -Suspend an account (signs them out everywhere). -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `USR-05` | -| **Route** | `/admin/users/:userId/suspend` · deep link `substratal://admin/users/:userId/suspend` | -| **Where** | Administration › Users | -| **Who can use it** | users.manage | -| **Kind** | confirm | -| **Available in** | All Hub apps (MVP) | -| **On Linux** | `AdwAlertDialog` (`extra-child` for fields) | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Reason | text-area | body.reason | optional, written to audit | GtkTextView in .card | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Suspend | `POST /users/{id}/suspend` | 200 → badge Suspended. 409 → already suspended. | - -## Good to know - -- Suspension revokes sessions and deactivates every push device the user holds; say so in the confirm body. - diff --git a/docs/screens/whk-01.md b/docs/screens/whk-01.md deleted file mode 100644 index a56f1d7..0000000 --- a/docs/screens/whk-01.md +++ /dev/null @@ -1,41 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "WHK-01 Webhooks" -permalink: /screens/whk-01/ -nav_exclude: true -screen_id: WHK-01 ---- - -# Webhooks -{: .no_toc } - -Endpoints that receive access-change events. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `WHK-01` | -| **Route** | `/dev/webhooks` · deep link `substratal://dev/webhooks` | -| **Where** | Developer › Webhooks | -| **Who can use it** | webhooks.manage or app owner | -| **Kind** | list | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | Nested `AdwNavigationSplitView` list pane | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Rows | list | Webhook: url, events count, status healthy\ | unhealthy\ | GtkListBox .boxed-list | -| Add webhook | primary-button | → WHK-02 | | GtkButton .suggested-action .pill | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /webhooks?application_id` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/whk-02.md b/docs/screens/whk-02.md deleted file mode 100644 index 3c8a912..0000000 --- a/docs/screens/whk-02.md +++ /dev/null @@ -1,43 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "WHK-02 Add / edit webhook" -permalink: /screens/whk-02/ -nav_exclude: true -screen_id: WHK-02 ---- - -# Add / edit webhook -{: .no_toc } - -Subscribe a URL to events. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `WHK-02` | -| **Route** | `/dev/webhooks/new` · deep link `substratal://dev/webhooks/new` | -| **Where** | Developer › Webhooks | -| **Who can use it** | webhooks.manage or app owner | -| **Kind** | form | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | `AdwDialog` with `AdwPreferencesGroup` | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| URL | url-field | url | https required | AdwEntryRow input-purpose=url | -| Events | checkbox-list grouped | events (WebhookEvent enum: access.*, entitlement.*, role.*, user.*, organization.*, application.*, device.*, notification.push_failed, push_credential.failed, webhook.test) | presets: Access changes (access.granted, access.revoked) · Push health (notification.push_failed, push_credential.failed, device.deactivated) | AdwPreferencesGroup of check rows | -| Description | text-field | description | | AdwEntryRow | -| Scope | select | scope | | AdwComboRow | -| Payload version | select | api_version (a date) | edit only; the webhook payload version, independent of the `Substratal-Version` request header (CLIENT-ARCHITECTURE §4.1) | AdwComboRow | -| Enabled | toggle | status disabled ↔ healthy | edit only (re-enable) | AdwSwitchRow | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Create | `POST /webhooks` | 201 WebhookWithSecret → KEY-03 | -| Save | `PATCH /webhooks/{id}` | | - diff --git a/docs/screens/whk-03.md b/docs/screens/whk-03.md deleted file mode 100644 index 8c45359..0000000 --- a/docs/screens/whk-03.md +++ /dev/null @@ -1,51 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "WHK-03 Webhook — Deliveries" -permalink: /screens/whk-03/ -nav_exclude: true -screen_id: WHK-03 ---- - -# Webhook — Deliveries -{: .no_toc } - -Delivery history, testing and secret rotation. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `WHK-03` | -| **Route** | `/dev/webhooks/:hookId` · deep link `substratal://dev/webhooks/:hookId` | -| **Where** | Developer › Webhooks | -| **Who can use it** | webhooks.manage or app owner | -| **Kind** | detail | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | Content pane; `AdwViewSwitcher`; header-bar actions | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Header actions | toolbar | | Send test · Rotate secret · Edit · Delete | AdwHeaderBar buttons | -| Filter | chips | status pending\ | succeeded\ | GtkToggleButton pills | -| Rows | table | WebhookDelivery: status, response_status, event_type, event_id, attempt, duration_ms, created_at, next_retry_at, error | failed → Redeliver | GtkColumnView .data-table | - -## What happens when you act - -| Action | API call | Result | -|---|---|---| -| Send test | `POST /webhooks/{id}/test` | 202 → new pending row appears on refresh (poll 2 s × 5) | -| Rotate secret | `POST /webhooks/{id}/rotate-secret` | confirm (24 h overlap) → KEY-03 | -| Redeliver | `POST /webhooks/{id}/deliveries/{deliveryId}/redeliver` | 202 toast; same event_id | -| Delete | `DELETE /webhooks/{id}` | confirm | - -## Good to know - -- **Loading, empty and error states:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES - -## For developers - -Loads: `GET /webhooks/{id}` · `GET /webhooks/{id}/deliveries?status&since&event_id` - -Every call sends `Substratal-Version: 1` to `https://api.substratalapps.com` (no `/v1` in the path). See [API versioning]({{ '/api/versioning/' | relative_url }}) and the [API reference]({{ '/api/reference/' | relative_url }}). diff --git a/docs/screens/whk-04.md b/docs/screens/whk-04.md deleted file mode 100644 index bda540a..0000000 --- a/docs/screens/whk-04.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -# GENERATED by `cargo xtask gen-docs` from the code and the specs. Don't edit; edit the source and regenerate. -layout: default -title: "WHK-04 Delivery detail" -permalink: /screens/whk-04/ -nav_exclude: true -screen_id: WHK-04 ---- - -# Delivery detail -{: .no_toc } - -One delivery attempt in detail. -{: .fs-6 .fw-300 } - -| | | -|---|---| -| **Screen ID** | `WHK-04` | -| **Route** | `/dev/webhooks/:hookId/deliveries/:deliveryId` · deep link `substratal://dev/webhooks/:hookId/deliveries/:deliveryId` | -| **Where** | Developer › Webhooks | -| **Who can use it** | webhooks.manage or app owner | -| **Kind** | popover | -| **Available in** | All Hub apps (Phase 2) | -| **On Linux** | `GtkPopover` or `AdwOverlaySplitView` sidebar | - -## What's on this screen - -| Element | What it is | Shows / does | Rules | On Linux | -|---|---|---|---|---| -| Facts | key-value | all WebhookDelivery fields | | AdwActionRow subtitle | -| Redeliver | button | | | GtkButton | - diff --git a/docs/tutorials/api-keys.md b/docs/tutorials/api-keys.md deleted file mode 100644 index 46a9a00..0000000 --- a/docs/tutorials/api-keys.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -layout: default -title: Create, use and rotate an API key -parent: Tutorials -nav_order: 2 -permalink: /tutorials/api-keys/ ---- - -# Create, use and rotate an API key -{: .no_toc } - -**You need:** to own the app (for an app-scoped key) or hold `api_keys.manage`. - -## Create it - -1. **Developer › API Keys › New key** (Ctrl+N). -2. Name it after what will use it ("Notes backend, production"). -3. Pick the scope (your app, or platform), **live** or **test**, and **service** or **agent**. For an AI agent, keep **Restrict destructive actions** on. -4. Tick only the permissions it needs. -5. **Create**. The secret appears once. Copy it into your secret manager (for example AWS Secrets Manager), then confirm you've saved it. - -## Use it - -From your server, never from a browser: - -```sh -curl -s https://api.substratalapps.com/users/usr_123/apps/app_notes/effective-permissions \ - -H "Authorization: Bearer $SUBSTRATAL_KEY" \ - -H "Substratal-Version: 1" -``` - -## Rotate it - -Open the key and select **Rotate**. The new secret is shown once, and the old one stops working. Update your secret store first, then deploy. **Revoke** turns a key off for good. - -## Keep keys tidy - -The key list shows when each key was last used and when it expires. Revoke keys nobody uses. diff --git a/docs/tutorials/applications.md b/docs/tutorials/applications.md deleted file mode 100644 index 1ed5e64..0000000 --- a/docs/tutorials/applications.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -layout: default -title: Register, review and suspend an application -parent: Tutorials -nav_order: 3 -permalink: /tutorials/applications/ ---- - -# Register, review and suspend an application -{: .no_toc } - -## Register (developers) - -1. **Developer › Applications › Register app.** You need a verified email. -2. Give it a name, a slug, a launch URL and an icon. The registration is **Pending review**. -3. While you wait, create a **test** API key and build against test-mode users. - -## Review (staff with `applications.manage`) - -1. Open [Review]({{ '/screens/apl-06/' | relative_url }}). The queue is oldest first. -2. **Approve** to put the app in the catalog, or **Reject** with notes (required). The developer edits and resubmits. Every decision is in the audit log as `application.review_status_changed`. - -## Suspend - -If an approved app misbehaves, **Suspend** it from its review panel, with notes saying why. Suspension removes the app from the catalog, blocks new grants and stops new launches. People who already have access keep it, and open sessions aren't cut off; turning off existing people's access is a separate, deliberate step in [Access]({{ '/screens/acx-05/' | relative_url }}). **Reinstate** puts it back. - -Retiring or deleting an application isn't available yet: suspension is the off switch today. -{: .note } diff --git a/docs/tutorials/cli.md b/docs/tutorials/cli.md deleted file mode 100644 index ad08f85..0000000 --- a/docs/tutorials/cli.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -layout: default -title: Use substratalctl in scripts and CI -parent: Tutorials -nav_order: 5 -permalink: /tutorials/cli/ ---- - -# Use substratalctl in scripts and CI -{: .no_toc } - -`substratalctl` is the Hub's command-line companion. It shares the Linux app's code, so it behaves the same: same API version header, same sign-in, same errors. -{: .fs-6 .fw-300 } - -## Check the API - -```sh -$ substratalctl health -ok -$ substratalctl version -substratalctl 0.1.0 -API version Substratal-Version: 1 (header, not in the URL) -API base URL https://api.substratalapps.com/ -``` - -## Sign in - -```sh -substratalctl login --email you@example.com # prompts for the password, then a 2FA code if needed -substratalctl whoami -``` - -On a desktop the session goes into your keyring. On a server with no keyring, add `--no-keyring` and sign in within the same command run, or use an API key with `curl` instead. - -## Find a screen, or its help - -```sh -$ substratalctl route substratal://admin/users/usr_42 -USR-03 User — Overview (/admin/users/:userId) - userId = usr_42 - help: https://compositecode.github.io/substratalapps-linux/screens/usr-03/ -$ substratalctl screens --json | jq length -``` - -## Point at another environment - -`--api local` (or `SUBSTRATAL_API=local`) uses the API's devserver on `localhost:8080`. Any URL works too. - -Every command and flag is in the [reference]({{ '/reference/cli/' | relative_url }}). diff --git a/docs/tutorials/index.md b/docs/tutorials/index.md deleted file mode 100644 index b6c07f5..0000000 --- a/docs/tutorials/index.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -layout: default -title: Tutorials -nav_order: 4 -has_children: true -permalink: /tutorials/ ---- - -# Tutorials -{: .no_toc } - -Step-by-step walkthroughs of common tasks, in the app and against the API. -{: .fs-6 .fw-300 } diff --git a/docs/tutorials/invite-and-grant.md b/docs/tutorials/invite-and-grant.md deleted file mode 100644 index e079327..0000000 --- a/docs/tutorials/invite-and-grant.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -layout: default -title: Invite someone and give them an app -parent: Tutorials -nav_order: 1 -permalink: /tutorials/invite-and-grant/ ---- - -# Invite someone and give them an app -{: .no_toc } - -**You need:** `users.manage` to invite, and `entitlements.manage` (or owning the app) to grant access. - -1. Go to **Administration › Users** (or press Ctrl+K and type `users`). -2. Select **Invite user**, enter their email, and send. They appear with status **Invited** and get an email with a link that's valid for 7 days. -3. Open their row, then the **Access** tab, and select **Grant access**. -4. Choose the app and how they get it (an admin grant, a trial with an end date, …), then **Grant**. -5. The access switch shows **Active**, or **Scheduled** if you set a start date in the future. - -To take it away later, turn the switch off. You have 10 seconds to undo (Ctrl+Z). - -## The same with the API - -```sh -API=https://api.substratalapps.com -H=(-H "Authorization: Bearer $TOKEN" -H "Substratal-Version: 1" -H "Content-Type: application/json") - -# 1. Invite (Idempotency-Key makes a retry safe) -curl -s "${H[@]}" -H "Idempotency-Key: $(uuidgen)" \ - -d '{"email":"dana@example.com"}' "$API/users" - -# 2. Grant an app (Idempotency-Key is required here) -curl -s "${H[@]}" -H "Idempotency-Key: $(uuidgen)" \ - -d '{"application_id":"app_notes","source":"admin_grant"}' \ - "$API/users/usr_123/entitlements" -``` - -Request bodies are in the [API reference]({{ '/api/reference/' | relative_url }}) under `POST /users` and `POST /users/{id}/entitlements`. diff --git a/docs/tutorials/request-id.md b/docs/tutorials/request-id.md deleted file mode 100644 index 5422fe2..0000000 --- a/docs/tutorials/request-id.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -layout: default -title: Trace a problem with a request ID -parent: Tutorials -nav_order: 4 -permalink: /tutorials/request-id/ ---- - -# Trace a problem with a request ID -{: .no_toc } - -Every API response carries an `X-Request-Id` (`req_…`). When something fails, it's how support finds exactly what happened. - -1. When the Hub shows an error, open **Details**. It shows the error code and the request ID. -2. Copy the request ID and send it to support, or, with `audit.view`, search the [Audit log]({{ '/screens/aud-01/' | relative_url }}). Changes made by that request carry the same ID. -3. Operators can look the request up in the web Hub's [Logs]({{ '/screens/ops-03/' | relative_url }}). - -`substratalctl` prints the request ID on every failure: - -```text -$ substratalctl users list -substratalctl: 403 forbidden: … -request id: req_01M5MR798VSPN5PFEFT4XHNG7M -``` diff --git a/docs/versions/index.md b/docs/versions/index.md deleted file mode 100644 index ef2618b..0000000 --- a/docs/versions/index.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -layout: default -title: Docs versions -nav_order: 11 -permalink: /versions/ ---- - -# Versions -{: .no_toc } - -Every released version of this help center, including the API reference as it was then. Each time the site is published, it gets a new version, and a frozen, read-only copy of it is kept here. A link to an old version keeps showing exactly what that version said. -{: .fs-6 .fw-300 } - -This is the version of the **help center**. It is separate from the API's own version, the `Substratal-Version` header, which is covered in [API versioning](../api/versioning/). For what changed, see the [app changelog](../changelog/) and the [API changelog](../api/changelog/). - ---- - -{% assign current = site.data.versions.current -%} -{% if current -%} -| Version | Released | Change | -|---|---|---| -{% assign listed = site.data.versions.archive | unshift: current -%} -{% for v in listed -%} -| [v{{ v.version }}]({{ v.version }}/){% if forloop.first %} **(current)**{% endif %} | {{ v.released }} | {{ v.summary | default: "" | replace: "|", "\|" }}{% if v.commit %} ([`{{ v.commit | slice: 0, 7 }}`](https://github.com/CompositeCode/substratalapps-linux/commit/{{ v.commit }})){% endif %} | -{% endfor %} -{% else %} -This is a local build. Version numbers and the archive are assigned only when the site is built and published on GitHub. -{: .note } -{% endif %} diff --git a/planning/PREREQUISITES.md b/planning/PREREQUISITES.md index 0bcb447..1619229 100644 --- a/planning/PREREQUISITES.md +++ b/planning/PREREQUISITES.md @@ -17,7 +17,7 @@ The Platform API's version is a **request header, not part of the URL**. - 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 help center's [API versioning](../docs/api/versioning.md) page +- 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/...`. diff --git a/scripts/docs-build.rb b/scripts/docs-build.rb deleted file mode 100755 index ed5f29c..0000000 --- a/scripts/docs-build.rb +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Versioned build of the docs site. Run by .github/workflows/docs.yml on every -# push to main that touches docs/, and runnable locally the same way. -# -# scripts/docs-build.rb --archive DIR --out DIR [--bump patch|minor|major] -# [--commit SHA] [--summary TEXT] [--released YYYY-MM-DD] -# -# --archive is a checkout of the `docs-versions` branch (an empty dir is fine -# for the very first build). It holds manifest.yml plus one frozen static -# build per released version, at /. -# -# Each run: -# 1. Picks the next version: 1.0.0 for an empty archive, otherwise the -# newest archived version bumped by --bump (default patch). -# 2. Writes docs/_data/versions.yml, which the header badge and the -# Versions page read. -# 3. Builds this version as a frozen snapshot (baseurl /versions//, -# labelled archived) into // and records it in the manifest. -# 4. Builds the live site into --out, then copies every archived snapshot -# into /versions//. -# -# Committing the archive dir back to docs-versions and deploying --out are -# the caller's job (the workflow does both). - -require "date" -require "fileutils" -require "optparse" -require "tmpdir" -require "yaml" - -ROOT = File.expand_path("..", __dir__) -DOCS = File.join(ROOT, "docs") -DATA_FILE = File.join(DOCS, "_data", "versions.yml") -BUMPS = %w[patch minor major].freeze - -def abort_with(msg) - warn "docs-build: #{msg}" - exit 1 -end - -opts = { bump: "patch", released: Date.today.iso8601 } -OptionParser.new do |o| - o.on("--archive DIR") { |v| opts[:archive] = File.expand_path(v) } - o.on("--out DIR") { |v| opts[:out] = File.expand_path(v) } - o.on("--bump LEVEL", BUMPS) { |v| opts[:bump] = v } - o.on("--commit SHA") { |v| opts[:commit] = v } - o.on("--summary TEXT") { |v| opts[:summary] = v } - o.on("--released DATE") { |v| opts[:released] = v } -end.parse! -abort_with "--archive and --out are required" unless opts[:archive] && opts[:out] - -def next_version(latest, bump) - return "1.0.0" unless latest - - major, minor, patch = latest.split(".").map(&:to_i) - case bump - when "major" then "#{major + 1}.0.0" - when "minor" then "#{major}.#{minor + 1}.0" - else "#{major}.#{minor}.#{patch + 1}" - end -end - -def jekyll_build(dest, overlay = nil) - configs = ["_config.yml", overlay].compact.join(",") - Dir.chdir(DOCS) do - ok = system("bundle", "exec", "jekyll", "build", "--config", configs, "--destination", dest) - abort_with "jekyll build failed (#{dest})" unless ok - end -end - -FileUtils.mkdir_p(opts[:archive]) -manifest_path = File.join(opts[:archive], "manifest.yml") -manifest = File.exist?(manifest_path) ? (YAML.safe_load(File.read(manifest_path)) || {}) : {} -versions = manifest["versions"] || [] -versions.sort_by! { |v| Gem::Version.new(v["version"]) }.reverse! - -version = next_version(versions.first&.fetch("version"), opts[:bump]) -abort_with "#{opts[:archive]}/#{version} already exists" if File.exist?(File.join(opts[:archive], version)) - -current = { "version" => version, "released" => opts[:released] } -current["commit"] = opts[:commit] if opts[:commit] -current["summary"] = opts[:summary].lines.first.strip if opts[:summary] && !opts[:summary].strip.empty? - -FileUtils.mkdir_p(File.dirname(DATA_FILE)) -File.write(DATA_FILE, <<~HEAD + { "current" => current, "archive" => versions }.to_yaml.sub(/\A---\n/, "")) - # GENERATED by scripts/docs-build.rb on every GitHub build. Not committed; - # edits here are overwritten. The real history lives on the docs-versions - # branch (manifest.yml). -HEAD -puts "docs-build: building v#{version} (#{opts[:bump]} bump from #{versions.first&.fetch('version') || 'nothing'})" - -config = YAML.safe_load(File.read(File.join(DOCS, "_config.yml"))) - -Dir.mktmpdir("docs-build-") do |tmp| - # 1. Frozen snapshot of this version. - overlay = File.join(tmp, "_config.archive.yml") - snapshot = File.join(tmp, "snapshot") - File.write(overlay, { - "baseurl" => "#{config.fetch('baseurl')}/versions/#{version}", - "version_archive_build" => true, - # A snapshot never contains the archive itself. - "exclude" => (config["exclude"] || []) + ["versions"], - }.to_yaml) - jekyll_build(snapshot, overlay) - # Site-root-only files mean nothing inside a subdirectory. - %w[robots.txt sitemap.xml 404.html].each { |f| FileUtils.rm_f(File.join(snapshot, f)) } - FileUtils.cp_r(snapshot, File.join(opts[:archive], version)) -end - -versions.unshift(current) -File.write(manifest_path, <<~HEAD + { "versions" => versions }.to_yaml.sub(/\A---\n/, "")) - # Every released version of the docs site, newest first. Written by - # scripts/docs-build.rb on main's GitHub build; each entry has a frozen - # static copy at / on this branch, served at /versions//. -HEAD - -# 2. Live site, with every archived snapshot alongside it. -FileUtils.rm_rf(opts[:out]) -jekyll_build(opts[:out]) -versions.each do |v| - src = File.join(opts[:archive], v["version"]) - abort_with "manifest lists v#{v['version']} but #{src} is missing" unless File.directory?(src) - FileUtils.mkdir_p(File.join(opts[:out], "versions")) - FileUtils.cp_r(src, File.join(opts[:out], "versions", v["version"])) -end - -File.write(ENV["GITHUB_OUTPUT"], "version=#{version}\n", mode: "a") if ENV["GITHUB_OUTPUT"] -puts "docs-build: v#{version} built into #{opts[:out]} with #{versions.size} archived version(s)"

DNFA;3iytKH+jKa!MspO*)?Yk>1;q}$PM0)D%P{_P(Sdv|%&>LMWwIOj z+qaH)C1Ag&+dd`x`)6lP(e<9RNp$7hP*sE5qbKI9I4od zq2}!U)Cc|_dkAO*;OM=+nutfcS1NZdO1}GpKUa3at_+%>|GvCHZQd;ORW##d7jO>1 zYJ-7MKK`M%t2(2oaOpsFhi?xW!!VpL6u-L!swrCY%Mve; z@bsFr+jak7VSi6EGJ)>3;T)0l5#`<2zI82@=+&1iz1Dv3)La zDkaTv6B83p_<*Gik%g)rwUJx=`&0YZCe8dIQBuZoyXdx&*FMp4)K@x$Sho0$xYY9w zax|f4eqYD}DlD6&5e%aUD_A0N!W#^_4M=>+uKYL+44mgbl5t1$(A3Ok4!?Qb73?I6S4Yb0bv~e2b%6|ul9Zq;O8+`RIwMSafm!7NqRRH$IbG$)>g%hq?@SP|q z1w08-lo=z_QAwF6qAP1_!Gb}(y(o8I-oU`ni(w*Y3R5ksos<26o%!8vFzJDmMhqF(E|B#V2)kF77B8xdUnlc|jUl$Knh} z?yURUN))ot)YH@;wUBD3_IP!TsX!+P4Joni$S#=6qr&=Am_H%~#dt^CGBd*<|EpE- zGboHDDh?{r>kAXWrHFq1`hlnd<=t5uOL70e!*BjeZ8x=2_=PIn+(Z@o@ttxOeB-xT zS@LPK9{{}Jc6+=FHhx9LXte?yb|W}=pG=5lv=Oa!Ygbp-+#DfQF_MP7d4i}5}|w){y} zfkM+vj8_c%ub#!=``dzed!(P96mLe1U*odA$(_zo?16gPR=nyVp4xPrHqMs*RxD6h z?UI$11x#XuZHd40&m4Mr3DLu#o<&P!H9@>YGTInS%@&&}BLib|wB0>CPU_=ivd8uu z0J8904eW#YlYB(wgy(gd`7{}vJ#^)7?ibgWkE+UNCI2$t9VHmStBG+#?fc!UnKD!-vnXx2F-`efETPV%etc9`WE+G($N`L?2@wCKD@{yw42 z!@_FW^l_yJz1(_0W&1NuR#|Q?%3d&%&zHXnISRB^R;2DJT}27zl3n=^i48#av=GaFmifgC)vM?O%oV9N-(i zd?{3HVQi8{fPbi!wiOJOp$K?xn%d$sp{*In?aFK&`8ETJq8TWTEk+{*k7UVFe3Li9 zS+OyOj(Ugv8`>@Td7lS~P4U0a+{GmH86}1 z$S17)D1l1yw(7ksuU2tSP|p_`V{BEaEi5eT4EpPXQc@h05M&;C`fF7dKDKKpZ0~jD z%*0nS>{|YGD}2-~5%P!55E;4>cT1G~7Ps~6A3((uwN)F{gUl`=B;wD54L^!sL^_Z^ zeCb#S_l#6q2_9!*W9q}7@*A>xy8`_gDDI|kJT*2C_MuV^84la^*xPjo;UW6J`!VLT^-) z8%-P>usv_vtCT$Skzn8nfAn5B?v8rvx{tj5jHZnNv-JQ!PpJX|lY_A~Ylu!zhCatsz~iRWR0KZln7{%lo6C(7U_Vh*9}OEW8&l zaByBtm3|J!c~46*|C$6QJP=!pIn|`LS@WOV%Zn>ZgqUJx4gK<*lVt8>f>g3Mw_Q0O z`38Yc@TTWEzojrbVZQEGEOrM1r*Lvx$tO{g4$|t+@E0N&8T`SA`+anqI+sf~wvV52 zh%w^)*u;4(fxuNaQTQw$jemzSvhL4cpRe1#Gb*u_;L)hTNP0*dZokIuj8pM>UAwy) zd~ zhVEN!|7cASsy{8-RU#QJN3Vnb0GCrT98;BdUL=!xKHtBaDE>x(B+x1rZ~Ixd`7Tv_ zOlubuyF+~L;7Vf4ozuk0Ad}?fqE#OTW?`%EX2(2q33bBd{wVUYC*pqXb0TJ}PF4(j zym}q8J6f6-P8nSucCvz97q6#VF8gtdKJ7SmFu95tm7Y{FJa$y}3Ar ze2E;={)+{)eN1DD77yq8_@xJVl4#~Cuu#HXH!If4I)zu;bj*nb&J4NNqMkC6X)2F71X9Is{lD{Xc3P+=-eKz7JbSSC9v)?$^EZzz_STTpX3Nywb#l~^>=e_T}3G^J0Ai)c-qTNNyjQZ z>K796G@YEyEmdKYJaw84s`g5{HWH(< zW9k*;Rd2eN{|+r)aM>osOWL^hW@d$?d#9Sie|cU*wk&ktVM@O>Y)L0vgj*~x?_zP-8OcGNDUD4q|aB;@F`$vTIclN1GTf$mwo+H=?Q+fxL|R>wMXN z1RQDCDe(@&(%*PXJVq<#*W5qegidJZbFN7)clzA8MUv(2Om5v1u5D|IR30c}G%3NKPFMXx*7*S2Ac!55LcryPYcePKO_z zh3#K0c61o%?ZJygi+$*B&Wo{OxEYW8cb=D}cd8C2DI#2hn%l19Zpzps{HO1x02q_H zb^f5_UGr-VSp?zASx1SMA^Rr0ZL^v6K+gl&5k1B^W-(_N|Fs!~ifL-v8&;)0>c4L8 zCUij$T2QIUjW(`@(y`;>n52*Tya83)zS^5vDRg4|39+t}_jijPbemfIl@3>H+PMwz znF?;YN2j!NCVjBqZWaS4q{p4RtBaYoc5M=W90^cny6`IKmt%JzD>Ws#URY%eTHMWH zdZs=uSn+l{p*-wvh!i}B`v4z`LKwCcru$_-CuYMGOFp;luZZ4K z*(wiXW>tsS?&f1&x23T84R<43mkL#*J(4N$g5sMu$+As9CbSc;jz-^KBIwX?DXP;+ z@i6&Sjg2iVT*j_F%rV>S-v)nXmtV|lQOt*bL!+o#=~Yn~>hz2giOw%8i=r~ZsF)>a z2@XfIxK~A~69oxiVNmRoTRoNv95{IRk_ZXZtooyu<_!iy`iafC2G-WkEJOIS*Ju4P z#w{exOX#IV^EyGow-yT}%fQF-H1B?^uMZnD*mV@;Xu?QGH)d84hofx4?`c==6@<7D zgo1m~+?MkE;oGq3Ca2F{%WkREreB^OpA$ntt9A|=gcyZYLS(q3l{sc*hm-nB#=+bB zL(I{5c6qr%IeJVAu!xXG%$8}c)C;?SFHGY>T`eS3*in?}yK|Y&hoBwe@Y%?*W_?VK zk_W_8Q?RZVcv<3=Z6{JVF;bX3Z^B{@*6z?m#{50FACnkw*2KI&!B$I{-6Ix+Z0y!36TkxYZ1|Y zpFe;2;ElP+nx#k=_I~HZayW%EcTS&Kdqe`z<5L-=@vj5ZX32P(lNjnkst+-zBw0IUULA92h4G`lmGL9dF<>JrOLG> zYs$7wx)riFV$_%0Pk%pC)6>(_&>;V{F~uoTzQX;*r#pFG-;RecR|p|Q2`GMT#rcC{ zj@E3<%45A|h4CFxrWK0&v#gxBcMf?psCaEWV)4E@UUoa8mvCC{j9*_Mn0Hpz@Lr0lCMcn! z7xpDDp%}_j(Y1T4@w@k8cx>6N^pJ6iq5pzy(!~e$0ThYc@kQe1weRu3HQf8}k=?zm zj&2rDys}Sx9KBw9FCD+dbnEO2rm4%}3=ufz$0pM75H*!T&)%Iv6Aa|Zz_~@q1F3@P zk{yCD)sxml26yB1pV-fal1*HJRtm+m#?LSXS8bxq!KJ> zP#jHtgrV)oP<^zW&n>7T{G@$8=J|mS?nU5doi5BK$KPobXlVP+H5RT5RKZt`-1V&d z(g@&Nev&<%HC6IK&v|8~6eri$idtouL?ITRpWwjq^vBO<5qAh<(V+1aH90M;55Io9 zdO#c{7FI3fW}tF6zn>VZtn^U)Svqoy8yWX$el0$zp4d7DY&q6p|H1`Zj#ite_+kdu zeY6VY}B_eaq0Kg z2l>3Q6Yt&-`glI=K$7Q%@S?zE;FfsL7Mt#lMYX7{-d;7XUY!RJL9ty@shAQO!>dpVSqliDJl(x>m z>3|&M@^7Mkb{E_Ek6fddJ^XP(ISxl&Vz>UoRin05s!N)R+pL-s#+7pCCM`{8AYw0T z$8ot&9MBg$hrTqlJwL8HETdAcOe>i8+cct2=TY8WU@vk@Tie&$jcQlhSC0QW<{)Lm z)MR!SlZk$NME)%%Fl6DzAD1o6@nZNOCj=vxF$7sjs(dKkHbv^u!jtna3DZ1nbrrk& z%gt@Z%$~vIsu5SvlT?@z$p;^3(J;l3L)iAfi;@7LJ9su^BnH@{YQ@q5w#?g{Dp655 z7tNGQUG!FjWyi0kcn0Y3L4Em&rNY0~iw-Dnd{q|+`qwRt&^!+AOMvv0kUI1EKy6E{ zS*N{wfB{X_vr_O<8?c*7s%`SRqn_36j6mJ zb)xR5=Djf!W&Y;hL}K<_#V6rjS7NEM4$q$L)+9{hqU?1ojkx9QJ2Mk_QZMmsRIH`E z{J|V4a7{J)v82B7aY>-AZs%6V?sL5(1a(3+iPdVBn7b-mo5=JX1=aD;WP8H&yD#>P zp)V>pn5-uKXvQSy1=-~^gGe83)@X8pV-uQV$#?pO3WmC`r+6=lTDl3To*?q}@2^Wo zOa|WS*ND2ERl@##d{ECuF@C~O-^qTE3jmLNaCFwdK>OzYvE#V%m;Ij|y7WTdGGjc> z(th4*N@}|A{YEw8F04sPNHW>q*$Qm>TW*5j;!%4QHZ+2(Va#j$L!{8nPgby;otu4h zn4aB-NF)E!GJPTqb3c_5n{e5mU@K&s;dI228aWiCDH+-(tLzu$) z+-=2C%AC~UueUbmHW2IzPKwKZeGY4iam(Hq3(tl`v=99?S_UsQd>}tNo0RGJ|4yCHP49=L#|1>-Am-Dp^1lVgchh6y~+GT<)0n-q&qXlU}8Hv z^dQtojo*f~CCh($941C+v+fD|R$uukOuBxKRvZ{)Q{lO9GcHPrPJZrt(t=CB@1K0` zOK1LJIM#8A3M<0!aC~oS_uSu}apn_E8gXFaYM?C<^bXvXKi!^J_PEt1QotUiB! z{K6{UAk7Q$Lt4Jcl-6bBdwI?xt&_Y~47o>Nah{pZ{KfN)_zs(LQ08HKnLNdiDssKc zV_j&9Vx+lwzj*)mM;X(`19qo_{Os}$s2`78VDOX&+_XMpdej5nyp7XPY38421?FVd za|SF4QPT%T`Hc^szEgdgdX#LoYPcTfVYfe@;qq$tiZS-|tkL)E2V{+_5B;`sa9x&( z4!dOy>Sui&2OM=p{R;{6*=kjt4znjie9l)NG(l^%r_oYsRhzxXj@j#%{b#7;;|z&6 zErt85@#G9IKZ;b zW{6^~j~1^bz*VoAlo$jUkG@@Qnw*f~_v9P@p8@_O=HS(X_gYcc!uxvSz%SGbFS%ku z)llVDTlA`W+;f?=mSqfk&Vb+PM;)#R2e1t~hggJ@)Lf6~ax$|2whNyyf4mvyn5W+_ zsJFlnOY-!YPR5Ns&6#d?+nq)4Lnl??JOwNe<;#_uT=D%#m~(8-zpnFpJ`N_-L9mNc zg?uufl4YgOc#>q>>+HLeP<VD*@mQ!V(QV5H|{M`*2%e?z`_{HOs zlChwZj|NKUkid4(gEs(nbmpuKh+}ar>_Y+S2vx8EmjZ}`C@us69u5xOCI#04r>ecR zb!eRrlnsCd00~_n*z&`Q)5lETL5d040B$b2uR24}lk+^E$Vw4WDK%yxP_It2&O4lt z-eZk8R(tE|mHRG@x|kMo$JT1mNsey4%KE2;Y~oN-KghFP?Igw`W;h zs`M9?sknL0>#>*Tc{IhBK5M>|V0y+zz2=5L6=h}^+9i!DSm@H;Ge7 z*^RQis>--#UiL)b-lOydswLR)74_1}+`S>8a<6MGl_pVr~BLW7I^Uei&;@4MyB7jZMfR-zlvtM4v?PrhJ zR8)&ak-;n0K5vgWM z^`X_r2Odl(al`1qI(}z8462NGS1>WO6kk!6DMoPyxnR z)|1>Yvsl~@3?^5BKo~; zoNRXc&FY1XmV0v5hPP}~B^u$#jm2w}Mfj>e?#T(0zJZE&&9vJMbVDMY^(tPsdShH( zym=s8wLf9Tac(~-T@5rSMLa%ruU1DMaen}3Ccag^Im{boK=z_d)ImVdB_49Q;X1C% zfOI%H)>3`~ZJ9K@v)vY5uo~o=wdx_KXO)rHUi`gGOKWFeNG%G$I|vG|JSpjQ zr*h_dPfy|#+C8b3&wIC6O>U9z5f4u2`At595#B2XC_1j@0J%EqQUFEb+;U#6S`Ogt zoYeJUHS+~?>rXYx(YdHPf6dhnT{@uG*Am8~Blo8sAC@YYK>Mp=glq;>s7x>l3 zL{-4KEy;TgXTF_;syuSgVi)2>8Fb#sV)^9P-j0H=s{1?Sc)t>Bh1OX7J|-*FPd#=Q z{nc6pzVg*Y=HpLSOJ>+otV&l{V2X?^0WU(5M8D z>nOKU%kH|iDCiZEd|dy?<>)Xh5kP#2K8jA);%~D3HikwhLv^^i)_!JlnDj~W#fBi) z?S7E8(#VslTt=#~pn5evlDl7SnaSezer_#yd=))-T?Z~HZ@&1HJ;9Y&uWj%A!~%6x zm;Ff&bMmPAuhi>NKxOdlN5-@XJ+Ui3O*iye5Gu4Qto*vTmT$WEE8#O9z>G!CsiZ25 zaMy9henBCZ7R~Hz)gFzULL*p5N9Z~OjCFY2H=5X}!)j$<(Hhfm&iRWx5t9rvwHVb`M@7N*)~R>QMt z2byEt_s&NOUKmedxAOa&yhO^u?>6m;v$U~L;!A+zPv>p;wMP<%%_bP!E?N#&uz8F~ zxvNZ;)1QnS1`eAQc@*Ube+yBU!ACrog<5Bx(0ojEC(8{NjM}v}Z>0feZ@#77x}8!J z+t46$VR5_#GSc*>i7=Z*ycUt+Bu4drmFW_BAZ-n6mCe0cs1$6&_162Kt9#^8940I&E;Ih~C^UGfL8j}l;|bAvtq}oy>Ggzke>gj> zdteGfTB!S(yj6i2HTUOBmW0B;$Qy?5kNoc3={Sm zTsW57zt<`uh`nb}^(m%L_h5Z76y{$*iuO6GG*zQ?u{OA)X-Oa+Y%=Xn<#Eb$r9SrT zkGY#ej|JX_iKMEyY7=F-nXecDf1U7!yNxc&d`=`A)WtFn>*BZ}rX6^7P!VdxaP@9p z+hK1r!Cd}vlx;rhTRL}eQs6B7td1A?jh&G$n>**NpsWOe!xvPP^#;tA!mGG5@Jhz7 zZ@5~2xRPfJAH!(~N&wr2_1J4<#{^0_|2`uHlfL2MBhoHwn30b$BSZvu^*iZEnzsG< zR+IU*c2*>Th*uT!#~+TW-9EF_PBTnqh8}u8uZG%8S<3)H@d?a7&>cFV)-d1UPf}%= zYW;E1zsccll67|Wz7riJlot1e$GuBFi+;0o@kl11U&V};`;^#7O{?k&cF*NWN8 zRrz}gd%fqK?)ennzozS6Sd-A8NvWYyT%9tXApD`!0;tVKTWlh*iljb_#o8#`S}rUm zYs8zOT~Nz6U!J<}Ikd5O+mJd$zo~{&6PNn)FJ{a0Eo?5`K8St|S1kK=Ki1QDjWGES z3Qgwq<&7|~v<60t^1h5E3B0l_xg~!HZ(c@Bx!**%QR=+nw|ZQKex%ZCW~1xs&v<=O z=hca$`<3cuMgyc~1c}7*AAjAR<3(8degULTYq3QS=q9>=WY*qRl)q4pW<*-_ws&C> z?1Fb!lWfPJS~U2P6V0U8&_>vkEwM>&ShnX;CR1#Gzc{N9RYW7%u_}{4$$TVJlv~te z3ns81v3VS4`C;hy=s+#h<*v{%Eq3j)DGlOp>*@R{$X)7pwQa(qW}7`JZJuAKyndrU zj1E37cTd`gaz9i`+p-ELBUThjJOAzuj_|h;_7(lr8?_&ayH{wm^Q(wFR;E=RU86pV!HOTz{Db7Tji z9;UJ#Akk7#T!f09^#GAdSXgM;tb>*P1p)?1bN~Mt0pLYUwFZ)^?78*G2EzU@RT%48 z=izF6s;ujm7Q(o_PtDfjN{+=NKyHK#jvM)>#}-dl_3Wy|-8q`Df(RzTy=CUvYMXf2pQ4`%ZF`I$w)*LFL_I)TleWxTWTUu8$_ek36YX|QuK7uI zC~>=OT@9I>B^94%B0flXY3+8gzQ8oIM<*@8^--i5?LlKJROU0ysH&Qp z#rHM%ASMcmlICV%tVT8lh7gP|4NI1}A>!KrL0_T!fpJ^OJP|YdPsx%K&T#Mz>u>G` z-0<*s@BBpbzaVVh(K$`~@<}I3k|tAMh$(1jbaRFA!B&i^s##r%bsJC7MO3*nPJvw} z8CD@vcR0VQzgc6wZXJ9ahb-s5c!^Nb(gH=h-9f-g^gAP>!R*6I`$rxtoiOM~%Svot z3bL~9O5bR>`Dccv?Y^0-d`zh*JsJ+z25q|nC->i z1AihTL*kC6@pDp5$EL=UKV&pTqQ5Ms9Lj2Pv=j}xSQyY7LniRr2H?~-3X`myi^45T zp;A({B26reLkCy#u-hW!N@H$q2`~0z-|xWHB9w&>kA~Z6ig;wV3~7|iGfm;Lv6SgKNvY!0nVYTW8i&e>=40CySYAO;-CPLr*-o2udN4(mMiqgEiwk+cr z<~o-*`@QttvmY;H)v?&k)8W4@`XG}lPEEx!FOfGA=@k{3O~CQ_fOm$i(NOIo8>$nN zk`{~AqGx8D_NI0uFAoj|y1GD`RW9f$@Rt7nONMh%(E5jcilviR)^P3{dYi+Q`HS7e z%d}mrX1=bM3oP7>&a}l`*)^R%w;C;|`o+qBTH&Lr;rcncx2qNOgN0 zpC9g*+YaaMg`5$`ALvh--LI{h3F>ORQ*DTK3*ON6Yiza@*)?S;sjsyL-bEc`8@;%l zl<>daK*b+m*Inq5{Vx_kwi??+)b=(TQ}5Dhbj5vAs1nocarWSP)F*q7$f}qrD6j~pEGrocQ!6bmbJOfR zmoG}Is)_>ao**fFn*fMEzOU3@Ro9{~lRU=AA|q*bl;ajjQ=1LLLiiuAz*-50pC8-*tK>>cw{=B0^GIW*- z-Qi4XIlBMKwX9}Db90GjF|Ru#oEAE4sIu>y5M(|Q?s@?uQQ8PN+Htv2O0-LdA|iB& zB*pRSIw(T-3kSEfpb~(-(-}Je!mP9P_bquq9T{?vqRksJd7ke|BPy&3t|X)6#RJc< zZ~%u|B09r378uz(aKZN?X@E$jaLydIvsfF0gmB|&Oqt{Y5?H2gozAiWeF*%JN`XYe8^dKb0u>Y-j zV)HIMp&7-rKvO}XTgj1D>osA!eB!LY+Z&CyulBIQS)w`sVbvHQ18Q=6{r+wRq7of7jUlWxsDK|D{q5~#&rGH?DNvRQhpNBAqG>4 z)j&L9c3Xz3f}$;ZtTtzfR?W&CF_YT?(uZrxdh;T)U0IOp+wg0Ke5Bkf}VT$LGsn=ulnrtqWz3?BjWJUr~cZ- zUthSr?+`zVMSdnrkkS`!cjZs1@l~{8#g|NUjz-Vd9j4ZI!inv3fs?GU9q$$Ot_tTn zKy$+N6%GC*u_!t+Kh8Zd)*fp^4}Sx7FO9uWJ)U&xqurMEcs(?`X^;yP>~nXmb8?Rl zwEK;CFh3AQ$7RL3?JfZkmg&RsOgRGXnrd?iVz!U%bB*6L3LP}`j6`8+)B7oU8%g02 zSmY8Ig^V)wAr{|T+l=u(Nl*XJ$BA9vKv)v(fOS;!RM3OoRF9PTsp<4((95n9GR$4^ zDylxSzDJ~xSFAVE>Yu$V`j^=3g!3tS`@M2oqUj%jaN{WKxeXSyQA)co-6b4==PbtF;#Z87C(XRWQ>Sr|oo{G6EiQJAQ>0otjK zx{;O8u*jKqC&FT`K)#z=3a+6SL8#af7J+F?_I>W?DI;qr`U9iugT@A>QzX(xH60lCD=*`r8h zO<5Id5^8k4afdU{?f*WQVEtQ#KV#KIjG^;UHiL5Dn2;5s^>3iy-g==dUl*}CRSaT7 zkzb8@ShMdt{pN0aZJy*#nXV)}`r2PAmwqVRp|9m`RSXXhc?-lg#`!xsR!zeB_!-$( zehtXE0o*}d1tU;iV?@P7GPq4JeDM=P!)-24Zu;rT^p-lUkW@WC!!(M;#-;|*Oh(7< zcWrmhTCr`*HYfk3CgZ$XYM22|iYGIj3;5m`m<>PlBRINbgvQ&`!=I32KTzVmEE!~Z9YgJ(STH^XcBrfWRe_CjnxXz4c7 z=WW>w1ps|RitlFhjNwkKOAE*y20|0++zR(%^j_Dz#nu+RpF4DRw!8h54h*@*PI6IS zmT&S_3nuv z=VL37((F-_iytGqCZN+(G^C3F-Y(J0 zzz}3QQQM$lU)*0vo58qgj2KFpS>d;`t)Cv?7|7v$_#islo(gtq&x0F6?dA*8fIuV& zVzY%rm@VZ)CiovM#0}{MRQ7JY#0YM8Hf`sJm)QxzpU%!KbaL4t(}thRd;4fU*Lmkc z!u}9KRV_~K2^xF`xKU0oIj?I8Gi?t+6Nt7GIcRrZeP)A>P0XqiY!|aVF7U*d1}Udb z`HUJ0Cv#2h>*a#^IrFYNm4a?uTE0tA?#g*O+3q-sd9Nh+X}?nw_rZCRbELCjP&~df z&TduKRIoY27x@JwEpg=K^WM&Bw3vvS9@SBN?7~5MR$%jUi8~|#jlxUwI8G6aCWhqu$#`?vFJlSev;^Y<;E%bJo|P9y#gN1OzhBwf&Lw6tPBd(q2R z<2w-V`0)J|Vhr`n+Us0jH9L_~$qetic0*{3ecHrN!dd)Ap?1bIR?}mr229oX>rs|| zDNz9y8sASxvWAQrCkuD>x$c<&Dd90=z<%3B0kqYOW*m~MZ)l=ageN4bv6 zp=;xrUFmR8#sb+px2V;(lkpKIwdE$Y=w&-xKN{Jt@;Pf{+V(fFkn#P~#H5i^o=v*y zfXGr)-QP$q$Edn%5pRL+?wRT5MEo0`!GA)XfmO7!HRHsG&i^6_7PD&@(8u_MW0~KWc~T_s%?6yB=M?)9Km}j zD>NEdj14P*d{RGp)S*??#Y#hM6NXM_rQFZ+yLY7;D(kk=T(Q=3R_84&mVYz%XqnHk zR!-U?E!Z#b;)dm9tiNFHDyf%gH518@hqSKD7LDo3R{y+A4>x)eD@nLQ=VB_aAA1|) zVW4^p*i|d9FIv^=HJ)PkA1Z8_*>*Nnk@$eX8Ag&CZ|$D@PkJ*&yT=$z-cqb zmIx-_;xZ{>=7dTaO4$LZK$~Gskf!Ww299ARcZdTr9Pu76#=@SiWO}=&Y`Z>f-+5IF zjR%CXRn;&`zvC6xR}70?O^;hW3ks$;iDl(zlJpMo0?9ruBli9=Q9LI-4Vu4!em{iSQ`gj$T%(`kE3Ks)C;gxt2}8a>0D z5AsTk%l9XARm$u(>%vgpDg7v}y$6@~JGPT7ee8tC)^5-7t;((sy}Jt_mC@z(6Jeu! ztJ@-N<=@vL6mx!08jrRE1C-JwHQbKvV{7&bdswwkPcbVgPH!7(vD3|7|h2Rva@gi%p>-gD#NUM`IApu5QvL0j{4ExC)pp)WnZ)s+5 zxEfr~@xh0WAjoI^+DIpQt7ZB=0IBZP5W->=zgC2DIiT2=)gGn_!q4?A}&8zR8|&P_b4#{0pkzKVd=+tjhU*jye$GBnmz(1@5OL3oYNT zd#$yjcs_uwF1I%Yl(KP6T$Q66_9u0M-yHOXOcfBlQ?U{!im=HwjfQLXsv~^%G9$AA zLBHoqzv45){t}EpohXmrte#q*8a*~X_5%LkTxq<<%PF#SVq)UM)6<}_gKmvU@%Pe; z0f1pBO>%aAT<<$A^%e6AOp&NBs9{RJ)QaEn>oj?Tc&o1Wsc2!SAowLQh;T=tLGtdg zr!qy{Yg{^1MzXTmJO2vE!@%CexO1-}QW;MDOkUe51V{qW8qZ zeRSBXNwQ<~H;Fk4zNJy-v8NdjZ8JjmlqZMUo60?E1$C1#lR1b?RcWq^-#JnS*}gsUgz00aa-yp z^GeivDK)QRSR)y!(lZpbxXk%H)aGe5$dd56f4Sg75HKf|r=rNLH+a0rg{q-=x~8`R zRD#pXS^)(LI+Z%F!#EjOti3@X7?oKSKt@VF(tkux3#!)a@N=o}FnY%Nmk#TN%O4}v zsi>$}s(l%kJza)|hK(b?>nPmMCGP+IN!nBvi;Ie4;!M!Tg8~h}(q})OBJpjDzSg~c z^^Oq(45Olf`ZFGrspbbgPdyD46>aUSx?LguB=&{QHQwZEU7@Oc5HwKSOO*3p0b&ln zLt=fG5)xvDf8j(%Tzhn*X8_4H_;WUT0%k+B#Z#B>V3ZU=yJtnk5FYc~`f_zPZ6kB^ zq|UF&(9GgWgHEzroa^0yb0?ccKbpA~kv0=KuSu3h;&|r9<&PB*LzNWoRGJWzQKN60 zS6o&7m3h#e?iZ`JzcW+t>>B22D_c8R4ueX)`iU(Vl|&?aJ? z!%2C8VsgNI2!PBW1Ese{JvMEl%a8aa9PrqM%HO~0je`hx|8U@+wXh8Jhx0K{6EkiE zCv!Pl^~h2_x>0v#P8`**x*=MRx2M1&0|?_TJfJ(w~r25sp0{I#typi>B3q&;*0w;jvC$ z4@oF?HQ&l(>L9JwccbC}sF#F*Rv4dGZLW$Zrc_`8X0YbE{qSuVW!y}|JP$u#=lIxy z@G3E2@+17+Z6x0P3u$GlzPd2;A_J4f&krfrNp5p_kEPuO5KchqJbu8iT17f9+S5N5-8E`TrD`*} z@71z;kx?tF4x@fqx0#6`?Gza6fSKie-QKV0Q~aHbWTp82kCBI*r^NyBwZf{e4-E2q z9CPadV@PK*2{j@v(^oQ1c&pT7pY<@3>*KUYK;#S*1AM|?A#@7~2#zi+C?a;0KAc-^ zRWAx-Ak>xrx(G4XUg@vNOoXkeT4#_Hwhq-BkZV|aoIlwgKnJrX; zHMOH@(T-Z*4lxKI@ZPX3!s;BVW6m;9*N=pbL~uP?uwBYWYQ{tv;O_tMXgB>I7QlZ_ zC%JjwuE%u$uX10|xInXDs(?Njkwm6LG8avnm283 zGy>$iB(19zUHz7Zy3BAI{6MdClN#|hGMf7{aUh1n({Q+1b^!a~;o)Nq zG73sqzXec6o#$}_z||@Kj~CF_{=^m3ewfUJ;N9Vx&RP1u4T@NK5DoV zJ8!zDuI=%lVjkjS7&qmIzuB5$TGm}Ps2kW|9+OvJss&Sz&`#|o9~csQ)r?qIr;PFI zjFyDJPk(secmP!!D<`R9aW-6;eK$&`pkp)MQwf4~6A7^xkxrkcF~~N`Z&w(-P{3_h zQM@lOSH$|1^`@@>VIS0zD`-oRtXNw{VmDnJPZd^c)E0qt)77tw7_7V$w&kNXCc|^x zBqzU9pvGqPtR021f#u~aVtZr(&Kc-3r91aOBtuitO;FK@L!F^{C)Ic-nfj^Keju#zaLE?kEbZ*PbTQe=geB>=jSCzwyoBbl^tcQv>%k1S~lRcglkeN39##rE8x16 zvi?`KTl4-s1HYnv3DY+TT_#Gm!|~^5cU61Y5W5X4RI|^=4E*&AXV*`B3pXkXCKAf> zbwSad$hdQOEP@%2790a;DxPlihk@@0h0t)A#KPu=sk?Zn9O%vqwN)eeK`ycXseIm^ z1laSWfLXZxHyseG0*=CJ@>9eKI{)3RK2WxD<8ycRzB9Bg8VWMIx*Eed6Qj7YYBTTy zi`BZ><^mB0x27A5$-23%jZ4|QbRwy>tquHkv++?vQ?caFd7Eb~`8bd3m$>6yZ?~-C z6>fEQkm(SjJ2(@RQeZlandrIoaMq4&f1>IXOojusd@Laqx}08FQvzAZZ!WG@`g_Zi z#+@Teuexvg3wnphu%n_Wg3hD07TJ(!bE~xqBIC>6!@zXm7kY{=)3CL8qx9gLV%S1S+ znRS3VVQAz@cs~@9@O%k&Gu`zbo2eOsR}5C;G%wv0Z$Ok!8cy!{&}G8EqWD4*=1>Sf z1vDr*)5T|fg<`#5`jDgf0WS#4pWq zanVX)$rTL+Fv|k*tap%zw60&X5|#13F(I$}-*Z|!0y=yf8RqFTvt~P9=HB)H^=jWi?dL@SwQ3k?(_K5C{tdI>P$U_4R8DMR85R zd!=0tSdWtypBt5s;(#L4-b^g^2+>P9|SS= z5(SMU@W6k6zrjhwB34xbL49%0N4=I^5C0rGNQdzxFRmEJ$fArlsiG&RRH0#)Ybqgu zQBEE5WYlkmyp@DQl$cDSph)5UWd62KFyGYop&hMdW}>!1s2cm1Y9)5btt3f7*iM;q z^IAxPb0@Oj%p|jUvzRwq!FkKPyC^WUd1VGO%6fZKviPWVF?+`sz z4rr^%vc<3&p#F_Ca~MLwl@bXRK#)Qjrh>})hden3BD zWub=HWXgmDnz4}{^@(-lN1C#M{XzX5vy#=YLUbvT0uqENl8cw)a@9ZL33o4j=ark~ z-)L!xhz57^2!GJ}c7nTd{?TVZo+$X=EhVYN8P^{lj^RxTx9;KcF;$fwR|m!oZ3Uh^ zcB;!OqD;h@KpW*mln<%vA;b9To71P%tuN!|sv16Zowl`#eeI$8q{y(|s3fFevuF-L zcQfU`k*blDYka$be%Fa}6FvMy{;$`>@c8JvKbCUHCc~>Kf`K*!04N)P3uRGARA?w` zFCZ%+Fv;eY)=VWEB_$zFAvrK0A+FZPMtm8JRY@@<6&4nT1@dh@uY=N0;y>4YO?R%^ zK4)82(vYlG*I}eD=I3mZZ*9gZrUJ{x#gW2K1zj-(ed5ReMyW$mOsbGTtdcUCu(9w& z{f$V2rc#-vu*>tBujW^^x&??*LB1CaBMB{^`3Fm$nyzYEY|@`I=rv&R1)67=yDcUn zfh{6RR$RbP-ONB(Ow{RH5txuskl-KqNvY$difXqL@J;vvg6f(VkSs&tR5OU#ulDtq zCeopRqN7L{q8D@xtq!hG{$1geoAEtLD<>+BB36+mL9qZ!y_P~Pk3HM3wL)02QC+e# z5cB1|C#rG5!Jlxy@G!|R29Owfd|bf_<|O?`!T&S|J^w3FV(4-iJP8uFY8Xis>CP~65@k2)-8=W%LP+|~;a?sFX&`{C` z+qe3?$S;xhnHu4|W?xLGWHiYHG^vD%WQO)ZD@M3M6u43pI0;d~w7hi`32di_AUjlC zC?!9pjXXo$;ElL@Oo+A9NRRHpNn8TS-eV9G5rCBf`Hlq*JL6e_s2V5oQ;u2pnTJEWEhTxkpi$$L+RWJfa@pPLMjDtb)$KnZl zAt@{vpd%7^%0d$R5;*zFi)CQ&K)#&YbwcY`=_(X(NpW$H?E}D1sm8{~mrvkzCDszZ zT&f+8uX^#^3JLLad9Z_q3`%JMY$N45*by5cFxpJbX=id z@jOLw|9uMtf?$08*9{OPEs+_E<+{bfFCihJww4JBByTn;TOqjW!=dt9380<*`;=(^ zTe3i)PS)8N{(-?kN-8QQU__8lJI%@eDl-Ol|K}=@yi&nGf)l8OjwJ40zDw<$c@10LkM{U`i~BNdO-yDWx;1 z`H^Hwa9ia-;X&?ywX~qw>N)5xI5;>*jFHcuKJ8dmR93LQ-8Lm9Vf@qK$v@4AFFd$q zj+dR~N+ufucZdf)^5^2+$NxjZ?f>v3f!BkiC3KLPsTq&^RUrKOXKeS=DV5ew z1PmdzygwmdxwwK$6+}ct6ebm_dGy4@#FSD?|J`O*y9cuuJzT@>t$(+*1a8)isiWfQ z@v4;K3xMPEoJu}79bS)ALpr(mH>^NYpc||Tc9i5>dExaXKS@eRU}NJnI9YPIO(CV>;7EU7ygF~Rny*$M;^j5i z{>AqnmRI@@P67~!7cO7)KdMELZ;B-o2oVke7_G^Pdoba-kk!BC|aC3yDsIzo16Ml<|0^VScDlR z6g+}2vdJDuXQ#`z*}1ty*Cw)AyoqT^md3_2)vkJeB7S~;u7oT3f4W|wZf|cOn1ciD zPy6w4N&iM62dIdEdo@m%Tm*F75B`0k$})|Hi6>odlR|=>F^&|81frI1SGCoX>qA`xPadjn~@X z(qH7g{a)`SYMrAa5&w-+P01V;dUyJsyWRtsW!j@VWTh z?gDKsEiD^uX340ztN`BTm)O`h>z87owmX%}h9pJHBB$2%zH3Z4yX+%!xeV!d3`%Lb z^Wy*6L-d-;#BE|fK!KF=%06m*S$Y4TCUjwz`GMFeC_?cL`fO$edwGx4k zxYX6<+p*ZJK-_x4mcroj$IhoBS0?CJuu7jXFsCn_DsyvltE$Lq{TdQy!CFm90!l(c zKP?m$r%LOJv%#KUu(2i9*ABav7neRCEbHm%Wm}=5;nf>#XDf$9hMW8pxJtKRE>;rI z)HLNHUDj%9zx0UD$oRv$R&z;DN-9$1#I(WN;jXk9d7kbgnGYDWnJz9Z)Q&a}Whc{A zt5vTBh#|>kaDTzV15~Xm1FZ_xXFQL0heC%917-=avEF+ti%&=d0N`<8aCj2cdcAQq zMnLF*vO?#LizS8eR1DSw_Em+ct`6`4Jlj4-MZrk}i2OO;P{8D2tz5o2n8V+v0f4E? z$^mfHe{?{;nk6t$I>v+lxoe>HBAk!D;Zk9X{%NhP7gK_@EhF-QVwSHT!H$S7OeO`E(%UfGKA5>ICn08y#Jb9w3-9iqL5;Ptj z-k1)zTu@LrM&+Y(A|x|nm1kw2E<+6fH__6r)_KVF?~U&4?yTy0Xm`4&e%rKpvyD%R z!+Uu#x3sXd9J(XYyy4ba9~t>h|~Mwef|V7iHN$FumH?=OBr z4kjm2`^h8l`+>W}A6a&3+r62)_6fxATsq&CRLXH+5A1&OBJJ&Z;JYjFadEqqg+`^e zF>4dR$Io%!Y>hYT>gq~1s?r)~cE{k~PgE*Gy<^x7LfEHQul2M5xcet=j(2BE!;!x| zAqxtuFQ>B|Vr?#`r56X6`C6{FN~UhlJE4FHY27H`A7u9OJy><$#`5f$>d}9-SnWK$ z$*xvMUo7gi(hO{={H__VlOmLt)1 zX2PN(rwbkBdfDE<3Ns`*WKC$uDVl4=qN4Ij*O!EmBz-79bs}RWMeV`mA!-eb;ClUL z^<5!7@!>*ePtLml(Wr+krj54yb6Q!ceAq4)-3C*Z^j82MJM8=VvN+^ovn+$f<~?gy zUbWgx*Y1VMmh9^bqM}J*^MOnlSd#|z15bS>qesy(brWFKr+{6|2W&5SSzymc$YD=y zQd-K)oA-iw(OC&k50jCvImnkv(ZfNHWMY;EaN(HC#3jn@{%y|)nDw=`y2HTyLTr@L zY&7qi5m;ed=z0~Zq^StR6&4X8;Bga|m3=Kvy-qz!%+7Y*doCR)`ABttFxM99r38}` zB_%8Sc-~bd$6D9$`+T~NP^nnE#rrU_+O>&}C!NJ&O!-${-XEWP>taw!M{0WdFih7C z&TEH_k+vs|R&{=%mMX~e>g2MfCg#-(mZjEm1mvls+w%W-d+WC-!!>FYMNvtW5)n`l z5s)s)5mAtiL68RNmTpEwx9%}+k2~l3?*wXRL0nREV>8eocQASB=AuEa z{QD2b3!0S=*cOZMw%RocEAplfZm$NjdZ+9uB8{EUp^%jXKE2?AgY#*Lz+UxFrLO;vtRE1pPHaNL zJ0n*CS5Hr$&A83H&5vsQCTh+bOr$x$TDOdK`q+5hJ^{5(x!m0t5B zXF{jVO>bS84g*wBh`c3f&eHrTdr7MbyCAa80zoY8HkBxEmQVP0UQIDhcc6wgEG&!- zI{BxwV+3lk&=;8hg3Wp$3Ns>~Z_pqgN=tsHDyqtN zGw-9@$9O6#!dA77h0KPXfz{#2J9obozBAW&+|*W3U@p7%z#hVc88)s;f9t$G&<|p< zva)jJq@0Wb(Otr{VE^1pb{W+XpH-WytF_T<|1)Lp(^0}NoOh%fM2?zWd1Kr)96c?Mkg+*K; z=UcN4HZ}11xPtf2HRc^yx3f*x+14rfMJP<4@+lFgSv{VDt^%J8dZ;+^UCo-Kne zsgEHI9@is3d%_AKgBLO;a72CQfuB@GPj`1<0HFy^Grn6rUPI09N{RZ#tQ}f$^xie7 zkf|0)Nh!rAi<31-=mdYYr0iZ^&bKbPbSx%DCnbSh)GaAe*Q-%klRC8O)A5^{7rYO! zrWJ0h>+6=&ht9^vIR+IJD-sG>wXw}~u5PYR1ihY$ik{B-j%G@PtUdOZ{6X`kT;bh2 zyS}rPnJQ}q-ApbnZelvf<{^6gbY9a-{Ro0rkn#d*A0^$T%*Mt{k;l%4*?j%t>~=$5 z-Z$1#=qmFo)lnOH%__^27G2f3IxmaMm|{fuBQtC3fe8fcTyHf_iIQD=*=A>ZLuRtR zUXaZi{+wMyBS98P!Vt%t!&=}|UR}+nnPwxWWn}tl|94(NK@4wVxleIGdah=Td1h{* zq_2-ZaabzPQqM_YcX#(cJS3`kD?{~1Ee=lA6U;3SqbuAira%=HMJ-`iezY%da(E^f zW*~{JguUw;5B%aIm@$7VL zo6w!hoKaYu)EXrl`#H64vsXlOE^zQ}@JuAO?99z5;&Q!Gv0|m^@ngdKcN9L8=t(QE zYp*#nTa9Gh)6C4skoNwf(RFW#md~Au6DggzGH6; z&5Wa7fp17iNMt6dkfoiA%lRJG3DIbu*wbQjo~cA{;nb}2F!kr#N%rJqr1HRoGITVK z`Qy_Nk>fM_QX^exg{D zh?e7T4yRw@KNzAzeuc;K@5c`e43x34V$Zxm3~9#;*Ndf~ps07Ry?v`GG&uN^veNM6 z`2Jj3Vb7((J+H$+Ta>d9m*8`Db_E$|Z*`eAslTx-jXx+}(>jOYkD7d2V13NMWcfah z)O#_NFA>?-iv9hseJ`h{W_gkG73@$qo`)2)Zl`Vel@1GOnciYIsQIqtZwtDaO)buc zDCL{wv%%Rj3^F1kBk}PG#&~%yQJWT)hHWjsL$Q{?fn4j2vzg-8w99R^r3n39P3OM@ z{Z6)?Qn`wc7VgEkmk7HY3o21}pXJ^J&(Y{9-2S}HuH1fXe7yKIb)bM+Qc$K*}N948Zf<`Oq=K*>u#v>B3YP|i^tA#rtR-goc4PXujA@a2URyzfUSf?=S`N8 zZSifvD%1IVPm>5uRkg`;+a6iBe5!9Y9)ftZi_I!2SXKg^`g_ zvx6(`L)wItgaUZFQj1P|*sh?o)V&lbHa4~#xhB^Y8cMptqJo)H9UpkIJI9wy5!~Xv zjeTlrYPmvw4ztD8DQUUg`8S1?l=GEFxa1FIB)`FsSHe$QQ}OvicRW0r;zpG2Z=%Ph zNrUhkt>2M$UZ)int6@Zpjg7ThJOO^2(q=$v5E;|2X?vM{@%F6v$iz>?a|cvsHjyF_SB)SYMCy$5ic!1FrU%v|cs z;*06?XNO9s@0VzxGmtmz>}qhHM*{=hr>N!7mD##>Nt=&upJpq~-H}_q8v}#Q-y?0R zbo+0$p6wwt(Y;3i?E}5HzPP{b34-X_`Z8?y4l&)YShcUO`oGQ3&xb#ONc%2{LkLB9 zzDxyd^V|IS^XIij{SU6cVY`J)xa(tuYBv2_ykHfqR`U@h8V499SdRDT#*cuNg+df2 z1gIN&<`10Mb(f$Wq13#BUYk?h+>Gqn&3(iOxk(VIGlHQ6xoa{pBQ>HR0j8Okj=u5z zfv4T*#<{TqDY_?h4{M1PYIb`)c#8Jf3(s3Nq<0+i_ zq_xH`*-MC<1)7#3THLs_cF|m2%I!K?IC(G#o2W=WN2&;6p9^-hoDI=RD^E+)K~kw} z;6!v>8Je--qDax^1))>R!9ncTUy;kJv(7fMslpXrO?X;H);fC=G_X6j@V@ebz?Nx{ zL3NmwlWo0lN(pnYVa2K1yssLi`S%$G@5e!%X5vw22?9`r3~-?Zpl zS>N5}3$@(ni|eIZA9jyh=$`B6_Yys$TNs}h(|Kc)pX2vZjeT9Gt$*GT@mu7!?`^IA z-VuaUUusjprT%*@k3NJZe|f6)Kn0~j47*l(KGT-~D6uDp^ZqOnx%*pJD!fG|*BbR; zJ*;!u9zeN)cf$5oB>2;-;PZ{>(GXXZ{;KZvpLl~a+PS5Y!_!0aNs0GYDnyr3oZ);d zsGn1iR~-xtB&ou0+?Madda3zK`jeX!HDwx-{0*(m#VD812X}Q;i;If-XeydKw zLFjIqC*HN(VZ!mEK8W!yP{Y0P)!KT24hGMJd42fMVU<{8o(FTe6GFp_#`s*?cp!G> zDuQ9;LYfT`?T{<-?hBW#{;{eAa2XGgVd>$Df>B` zIr9`E)vHlaIwEeE({1ydupu^@Xu)Tk=|#t6U}=R>aUmSeXLU4|50MQF2sm)xro4au zU~xEes8rE}aswE_yg5Mp=S(suw|FcJwU!zMPt@1fn=?217@##UgVl0}_iXt>ck3N? z%&pC7$w^PHI!3z&X@||AxSl?ECkQ#$15}5BGz^*ax3$>^0HDk)fFE;_)M7UPX$33Q zn<{c)zTEdKHhd%Q09+3Llz?n`!6vfm6TM(!YC=M}>ujfJ)1A+npMyQ9r)K%4o2(F- zxg;PUZR#Nm*T)3rG8!A_T57DaP?G}_>CWMzoqnBB$PuZ|mE5o=0$u^~fAN#pg`BoN z%H&Q=#7sQiN~jlfb8rjG_>LvsadPr}_LIux(oG31V}f@FMQu1c6^5T8nqtD9)a-R! z&=l$upYM91Q2Z34Duf)?RSX z4o6eRA_GOsC!f7(N=n4(X1C9s@cmRl=aHELPX5!kmRfzM+M1gc(^dA) zT{*0qv#NTEt)W7bOucjEEBlUx*wy+y8sYMAWugxsvc<0bd2F`gQexJxJ0nl2x6D)b zYSd;L&J^m|wY9IG;rj8Fo0Fy9hn@XSrW_b_+DAy0`Dgo*dqNFzrHo-?BMm0HYzL3{ z-5zR}%$Ewj7IxSxx*b~F@i_3Olh4S=2$WI2rraFBjF^K%pUdz)ojR|;henK&3yDeJwiJblJgI>wc=*skD1GBF^wm2C)s--!i-AlG46s|z)k#Dw_0RHyweIbFnU=rg zU|^}{LbIbgFE6jc{$$iR!%Ie5QLmrN%GL34?t$RQ{p%6YvrQI*>vB(!KBs)G*yrpQ z$GJQ4yRFivbPJM5b{b;Gy{V2OZ}s(+f_+1TVXLoCE{)lOa%gTQ zVn*s|W-4PNq2Oe>ANII<(>ZaBSEZ62n^t6y1TtNV^ds#V8yxqRriyMSGaR3|+n+8^ zpZ}>X7UC<>IxYOlxPGMc1BQCTF0(gXMX5$b(@RjdO*Khst>7+j?_PJ&j;x#iD<*wH zY{4fGLB4+E5W+cXt2>@*UV6gBl&?MOjJY^V1^eOFnJ>?_TMU1{)A)lJ`7>pRNpiKA zD3+6zCL|%)SYIoC{Ve@ijkKhTlbz*orPOG7g3?uY6ecUSaCl@Q2WM&$5uPtp#99nT zG=dT0WzT$*EBAxzJzaPAn3mf!Cu1z*nVR8d&>U9X3xeAhWT8-6OMUNQ8PLnx52a3m zP<=D{;jdqzuc;3whtvW-*vB4)yV!3mumS+}Ec;fhNSFyMBQ7#_du!d}5>pJ&3eIfE zHxc_i9`YQ86sZk~X-8xuGfg0Ty0oYF($T}C=*s{cN@go9t$p` z)3S8BCDp|<$2B&i1-obM62TQl3JN-Eacs%;j_#0FJ^yMNreGpM;zjh$7@uTlq@Av` z?k3splEIxx=EHNcOlA!P6*{j0zEhI2Boop3o z!q}XJT~BX)xGU0dMk{zQpBGBjH$=Qt1Mvz~2!Wj*sdqUg$BlR+=I6ALKX&KhkEc_o zM#6^w{{1YWy7vc}ndF;ZnIEpPgK2j?$S(uoVCf?NzY>`{U_r&~4 zxSH*OOsV!CS0RnRB*95W69cN4x_0OcZ{X6k{+S z{tmh=0eY*$xeF?4oKdM?D<&><3;TFPDYoF*n19wTlYMVf{x?v{Hul&&Kb|!=(Ou!nO4GaiCXqE(=_z0ljT=M_u_9UKqqk zU!SbB6q0s27j}5$>c{_Z_v7B(0;6EnPKnM!FI}du`{NahxHBm@M<$7hsvsvkd_dWnfb z6Duvw_703@!*)@BZpVxeFYnC@Jy!!W3nwSX2YFfZrZsH)rw=0b?|cFPN8T$majEO9 zgm*7c)DrMm%+`y*&@xH$IwL0lJw7+lcz!J-lm?9Q%k*<|YZyI8(^k9y3OZa?>m#yX zqg&D>9Jl5BZ>o5{wEvD(l95x5?XoHxYGGuw2ABaLLtrYbR=ykAlRR07RT0!Sx)>WD zL(Xl378UgsDy~_O&rJ_h%LiWognw$3sv!@#KR|3)zi2+;w&o-|wt>!iZ zxkk96zJK+J!goLYHF*{wPegzUs;II0Vr(vN@<>aXq;C^Aewxbp#3@}ZtE?WRS3 zv+C_v0Z_W7)lSdj_OzUc^Z#K16%mdGd=9Ih)ip*P?M50zB#e#yN>alKZP*UHulhIV zEMo`e^A}H=V*@H!_E+X+@`?Cc9~K%kgrz2Z__ZXb)uY*@8B2T91bgR{=6xYF?`;G* zbUHzhht$Pr(+n*7(#0v^R!|pWX{l+BdRMhXouvC{_g+_c{GaTW*L+tWdp}Z|UUh@B zqi}>eO1t{?P~F-A(K*5AAp36rvp3x+RIw|Hw&w0t86SVuxz*atr-bPo zS$t7#F~^C$8`*allLeiOM|FuilEAZ&eZ}7q*AS{&hb$|rC>ELYU6y@NwtKF5JTpo! z#HcB$Su)$DfcQJ~Sue!fd{%+$3!POE_UD_M_8xip`6qkB;rQ?p9j;|tCoivKung}8 zrlX^y-xczta$PqeF;aXI-wyxzWOU@(xXC>^E1YOJo*T|&i7pr%n|=Lw0K3FgRF{v8 zj5#>k2Zw}l@$rCz{vI01|D>$E9G`VrmqkF}BKop$bLtT(y#bh&tt_vA#$!C8h8bVLK;VC-bEjL*JUcu^{{HsFm|siT1sGWBGRo3@C@?tvX6r z&UP073f9x2`u1&WE!|5+ee@V|rOV?HT3lRQ zkwp`sZVN5qlf2JFd9g9b%UTjb1&cBQi2pqNGKq7~!%fr4Hwd3)-W1)*{tt;r7OTy!;hp_Co57_dIczS5|rFx@lHDx3f|WdurnxVM%4-8^bfsn8&R7Gl&lBVC zgEVU${?~udb25mhJbv@&Jg~R{WR=b9CL%qjNHLUelqUrwg2% z!#kg$Z9*)kv238b0K}V)I!bir+is|#!+S6hc>C1mm&}qjnh{_;qR6^qZa@AIbr!ha z!XTI_?u*od7M2@BqGpLieAy0{gc=}`UUyZeB41EYsNNJWBIfK|gSLKS|-v?aQijfZu1*XFTd+T|4$l~ssq+R!$; z>aVCjoN`2RfW9(AMjfe^K@P!k8P82cjg0|c8euPVt#4)|yn0}^6(@FtxY^!haJtbk zxRtp-kr#{)A1=EFud^rAl<1t%FY5>>Pif``5Bw}jE6XeY(Q8BrX!Hj$Wo;D) z>+5S2(X{i{vvt`;eCpGUIB&RFf_qRMDQp>uYlhss_%tnmWy1T&s6H$MRwSVsiUjw4 z2YHQNMXm&P2X@KRXxCYOx?n-yIoEEmU9(Dqzy{-j1{z-WwatB5Qjh^7h}KgfeQAFY z%P)u@BU($5o@?8~#j(9C8v%d6_IzUuCACE}R8(&Jg;j?gJQwtrnMB3|W`dxztfcer zXJXcS@Exs0c5PQ}*_oKxOky17Nk(jKDdN||hbw$Gq@FMN7|-TwvidA_WO$X@-3vU0 zed|i+=AQ78j*kzM>PQDZ(9X@x<&2h-8_P<3T+rClXXm8K))?lOI;Xz2v7ue>)O`|d z&!t<_Snh-bwz|3j%eYQMRDrl=&!}7jkM-Up>K? z7}yY-*5`=4TJsO5%ktpo2%wZ55kk!MWvJCGM3LF;!jibQ`|2XYQz4-3W!4mrFuCO0 zZHSr0jq_9nbfZ^=Vyb4iVYldOnY7=bV!u=bH+H zYMpyhToQZ8YSh>gOMqFcfg2Y};c6dVtg}!84!fnakZeWfsj-PspcX%Oq*_T)R@+mQS)K))8Pxn7W~%3yFxY7C^f)TlYs_*|71O! zq+TJUGOfbNnjnNJM{OW5pT!$`Stj8_A8JDr5@sOO`~-i>A{phgHta5XjDmu4uinVH z6kh-lE4EfZGTBdP6*4}l+e2>>{SVTa$@RJiTV18&>* zCStF+hko}D`$&3eSIkUz2=Mamo+dk!CW1|x1IfHyT4Tb^C?4&3Eik9iZS)XuSmp4} zMuk^_86qIhecNKV^!gq+Jqr89oS7T!hKztGJt;A&$O)sFaUz?RUFCEmdPhizb3iV> zeChb}=TC=H3v;7s({6SG`poAbJQ5Ox^q4;(1q)ecb22jC362(&*bMz?&aaNCPH$eS zN>rm2ZXWcYA^>d%i&yUA#2{Ag?boza1T`hUi6FGm;rAeUg7&Y0dm(fpw!3}^oi`AR z`GVq*q2J{0c6DiKZ(ZgauY~JkVlO$`^b=KgUqRF7V!6oO(5Qz@EG)CLy!wDusRtR0 z>J59$DPf39g>=@fowB{XS(N`Z@oFR-CQz%RTbZdy_IQM;#9>cZ{i0`|>gAF58uue@01ehOwGl?fkfyNA%_DFI^EP7??yZa9Q&=g?$Ur#x=sb79?weLOJeJ( zAr(Vh+@>HRIc8SY_5&Tk2PMj03zGAn3r!&E6cLh%ufz8MRIb*|rq#1E%=%B_YQ z*TBqGxJh^l=d(g=EbN1X-{OazO(14jQb06IEkt9Bz9QBq5 z8=Tksl8Q@m|IvQ{O+YR`KRrI_R}mbpgn8|(k?Z!-<5#}}&+7V7-~46!|KPYqEv zpai$pcP*yt1mycQ9#c^fy#5s%sip=U?CzGAmVV8M)9iWnlE%N%Y})!NF7WCQ;x1aF zJXCgHyWV+?C;~u%5G9WMRxs&_jGc zmBMMx>~}+Oh@Oie8Xg>+Pupt6XJBl zA=22Z#{TsNM{qfwo68Qp$J$=K{eBC(whn5WTTwyM;ZK@WuETfSwN*ow?WnK>{1Se1 z71^wV=S~@%@+Uit@j*|lR8e#cObmf7;ctSIdDWZdn(qU;kU*V)L-^J^k+^AbQR@Rd z7J`MLQU{*6sKjPB_+FtDqgWMRaaN4C^6Z?1K`^A%GQr5DJ6x-dK9VEVUMD8#kH>|$ys7wYsu2J zp+X2F&*;u$-?#cJCmj4GPPIZClHpsv5GVr-c4;2|Ww7lORPN|fE-JgxzLoCk~4O}%`-pDZF#{X9Cstnvb$S~Y*RVi z`K;O8{JU*(3S5+-$OiW+Ki(KQO>)_Jit0jN1m|A6q)w2S2$Yv|!w{v_M!_*)VgzXQ zh6=xK-D(aE4So>Ey}VxxLz-?YatGOUAY_KvdM5{S#eDqgXXxI|iHckfw`U%6-@bjp z#Z`WB;flF|A*(8NeO9Orm87w1`thfBYHG9mrrvC4b)&1vyw(rNAg_bfW_4U{1NumO z8pm0lY9UVyS(7yYYw*x?ZX}eHlm~o9?f=69+?y`)AUajMj2(~}GVC+3C^KkIx5a!q zgB;c?nN2@5lx`+Kf;>8raiE;9M?DcW{Lz~6^Wgsa_2AvOkugAu@$9LvMzrvj?(4y+ zZVx)|Eo6O9E*8Q4RN`c`LoD}XDtv9d7@L)oLry`lCsLX!YEX$=USqFk9#&AGG0SJ= z2S%@bdBbvvJlBTk-t>SG_lQTJLlTfiA2DJfwxZ1|Xgq@zAzEnj_Y@uf)sG6R` z!@&qh4_LPSJEOWPZi|aQ7`+X0E1&IGJzSgmr`NY}z9n>c3ABLF>S?gSjNEj>{uc2M zqkZ%B98xVIF5dpTSqkv-V`#8L0@Cno(DcBYu+~>5xXA>1TTfR3RlEw{saKc92|sXy zJ+-cp&L@KW0adeYFCYQFqo+66TV zoEMp_HVf6rslfI#I!~T39-$>Td~YLUjDeL^#0zzc8MwiH)>DG|8(Z=>`$(QuaJxMe zY@TU?smaS%2il6*SA^$8nNRwi)z`T<knw=(U7eJ981$tWxqr{iIMB3uq(*`D7C z(O*hev(eEncpDqmxDUMy-w6#u50Qq6t_z**OPg-90Us3%AAtA1tJ*-0Oz8(f;@ zcBytUcJ4zO)so!PZLA(X`~|ljIHO%yV>UbS^mi$&u^3Hf_6m^+1ng=5+NIA-w6pGd z{Lloxds(JeLM$`Uo^~c3@(-k4Kbd~Ms}>(0Z;RaefR|P-;)Mx-BiW0-qI(Y{ghsiz zd2`!FDHWy?UJ}ch(Y;HZ0KDx3Y?-LrGDnkH6T+pUolr6H<*8hLsXIbm8h^^z;j7*8 zfTJ#!pIuAmYpOJ0AOODN)qd;VG3DS99aCAP`KV`^IMJy0&;(5)7>51rZ|{$C;?8~@ ziH~O#p7l=hDXK0r^QA^?3ubxz*ntex$J_Y$qMR{RIO4eUd_NW{V3d>25WpI3jquFR zhRm#}iClK$(^v&v{Coq3U-$s;wqB@p?C%=%cj{fkF8J3i+qx!XC;I7n9(iqySm@o6Ca^$@h|LY3QnZ^sL@uQj5`qXhvHd2qP0 z!LCC{%lH1B^7rdf$E_(TB_(ffX8X07=;GNLN93GQcU#BJV6Fad0e%`bKtV7L^pFJa5t6_#|LrYfeHqv&ea zB$uK%JL`pkfq}5Ft7I9#1zxi8j z!-bA#sJcX&JP7^b*OlSV=o^{`Kr#f32M)H`@_FX z^>gU{w%z5(4UZH`c2``xO4Eh%B0PtHhh$IAcRC^gSjDxE{cN*<;XmurP?Zn+{uyl` zC-CHwntcKk`xzOT$N-~@5*f?KvyV5C4J}ULp(~+kg4Sa$);m1lqjzAHVv3nwL*+ zs%)_jn;vdHSBVyIG#aLcMeE^$CmL z8TES!b@jSH&rzNE%SY7lU;8%;2aY;eyT5q?*(X-kx_jb#J4D zadGmDAFW=@9=4w|5pDA~I&Dk;1I?^gwy4#4F<$4Um@-mc-YSZ#O4C7%_Rc*ab~*+8 zZ=r;t8-qG7>ut!iMQ7ZfJ-yxGG`ym0Y^ql{aRWu6blivM`I$S&f!4)VfRlsne~of= z_<=lt)wJ_c2E1oxrbsrqORpl`)~NXmZzXs3|0PQr9GUC0JG?U5U{5|dIWr>t!o-Bk zA@0v>I=$4#xu~);_gq6Wn6Io(fnWV_NB}h!6~)5OMoCVttegQhca$O)*=QP6Kj6s1 zI*jEL9WQ1W@Z)JkJpZb0nQja4S#Y{6I#=c{Ym6{@PE<})mgw9KznckEm%k{CV^Gtu z=O}{*5%o@c@PY!T>xoY@cgnRVUcF8i!Od>D$qiMdAh2fv^LQX?C>;!@^CdG&p(yO)2Z7^j)}ogRan*X$3WRUh?%gH)C@r61PbuG@++!|& zfyl4u%2(GL(nNFjm%@rRSVi2IDiNBH({NtTkzGrg&X7l4#2l^4N~-G3=NnKHh1$9X z*G8`n+POzmR8a0}HBOnnrXML`3ytao7@<>< z46I&l4;=DWkY?KrHw~s76kPKR4?D+bjc-;6I*qvSZIb6(CGt^EVmlxzA~+tN04I#M z>r%`Al$CuLNp@a2*Ek2bc;w^G5SiBmH1026beI?$4yh|SJ$k-`w^pR-MTG4yrzpE^ zYwdM}_Vm6u&uLYDa9f>f!R7tQ<0&9)Jt#X+T*%M6x@mTrnEyV%9G?zh_@0ft)>!h# zO38!cm;Tt7n(JXvKU(BskFOu=HbKOtq-+&G-sext3se#zCyy@I=<6=rrp8Z`Cw^-W#wig2*9?v}3)7HaLZ|BQ$kpPa-tGTy9` z)YrcOojxGi>&?!|dEX@geJ39O$Ic5B#L`9wfk1}DwXfo2`?0m3M9^KtJUYbgV zH9d|bVdB`_^=p|_`rhEyu`i3MHR zl$sJRw$EUzGa{JId@A!Poe!^7ksfqvXLN##D=Wu*tq^|x@Irnb9&qUXIE(vbT(Y;S zk=)>Tt9~B`pccL0YikXjEkWxHQDJ+VI2bjL|4FC9<;8b@@vZ0ieVlfaLY5mGUcc_` zo8}b`rgFzJiQIky+6o#{sESrrdX6=qq=Sm%|6Cs9z^obIweW@7cmKo@emt--Ymtw(tVfyD!okMnG|?kxj_>Pg4bE*Z)<*{(D?2KPw}nrkbW2 zR2xjL?DeqEZbGYZamF0+kAl{7)-}kJ4k@?T`fdRJ`o12q-nVk_1;~V{)^YH@*$^4e z-TF3z>;3{K&x+$et_c78ufcZ}mRqg=*B`-;y7&J7{2QtNOM{p8-Ok<~#M`)t2w;c- ze2#;+d-L)>{(D3Lj@y53$6KqVt@WL?lLY+JQ@I3w3(&TJ>uvVY|3_xTUcKD{12FX7 zTtk&x#|(b4%k@MRsF#3vyX4w28PEv--P&k;;1mO{vBs;bMmiI~U^}2y8*S1YEKYRp z`TO@j|AwO|r93(UR5_nMjmHVT;^j3uYoQyOVnPhJ|JlPw`Z=m<`2VT6o zxBq+V6p#6?PVQv4Jbd_2yTKth(RqKq*>hvPvxWb)_7yj7)oi>yBOSzxNB=4-M=pW; z--|e4jYqeKKhuBK>Gx9hHkh<_^^Lug`&0$w^~VGRubNbqRg9T6LH4t%qarrFfwO4g zE$|2p2q)RdHTJ|BuurI@H3gS2Azd}eRluo)jy$Uo9IC%w#38EmQQIF8ND&K7Y!ef5gxqm)vanCGaf{I4^Dum8gWiqGBm zB>t~z z`HQP#^+4^Y0?K8V&!7L6KBFg(j?&W7!SlTZs7XZB!tat|w=tOw4n@?IbX_cv#RNVFe%ey~O5SShuLUxYmzJN1xsOZaeMXf=(w}xLfd%1(dir&OAPig?E|;V0agVC z5>3s^?#LX_fBEKH0&G4Ou9fy38mv0N#y*e&IVQqu1#Pfq4YoKf?I|z{s`}eEcC|i! z>9Aa4vyb^Fg~3s2`X6UnVWk9sk%CTRKLCm9P%Y(+{mz1q&=v23sLt+;!~H2ey{}I{ zRD)*Gd2e3qb^#C_AsVe#Ms7n|4o?JN7q4euvj|0UsW>Z~AykW^fs~Ua@>is(Bj{4S zi+v@3TvxezTI;ZBUW}}DT>Cr6_UXcb_HaTmK!%mOf~$`F_{gqi_sajs7}F(T(Vu{c zOb+z~(}OA#mO3t);Bww!r3e+EYj!x?`p{#+g0f?H@zPFH{-bmn0XY!RFF=yNSqWjf z*JsxwH^SMq1o%zR!T{5NmnYo;zvcg8?c0+>*fzhx=~&|kx0nK8`4DK9WMtGI#g1RG z!nEs*|KvS;_Dn1?CuEH|wZ&y?Z^>zI%!s8>LN>b|y9mVUL0MTZ=510j^P66}?w~#5 zH(G?s%Jpxy{kxTK$1`z}{|KyaM*m*|E0uaY*)}c2arN(^I3PZckB?2qoYBo2jUs60 z-JDHFb>!uL?-%cPux#uvw6NOsny@9MaJXd@7i$CIY<3}~j%zKAfbA zZYnFP)hOZ z;+h4ekN9^!t4sS6c8ln!s9xB=d&7JCzmZra5){+Z5V_n64!I`@a>++4iTt7@vV`C6lUddlBFMk20EPxq_w)g?XRbNy_ zOiT=v)?msr>Sqk%_Zt5|e}DF|GjJ21Pz|kH+9@EK7ZTAWBI-wa9fi;L?uYq6n`;Xh-GZzXE-*4F-fG%F9kbcLt9VC@&GVqq^CsuQP7 z0t&$2*9#pkd+b>OaFfJSPJci4qj(7(v87y(lQco zUB8vx;>1T>ku#_tUc=gS8m#pjyfo7n*iGa!^SVTfV;XmJyZz4J;k z≫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 z≫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