From 8658355432eebfda8ea21e90b4d656ed51c9fb1e Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Fri, 23 Oct 2026 16:16:04 -0700 Subject: [PATCH] Use the shared Hub help center instead of a help site in this repo The app's documentation moves to the Hub help center in substratalapps-applications-specs (help/), the one reference every Hub app links to. The macOS guide and troubleshooting pages went there in CompositeCode/substratalapps-applications-specs#2; the rest of this repo's Jekyll site duplicated pages that already exist there. - HelpLinks and SubstratalHelpBaseURL point at compositecode.github.io/substratalapps-applications-specs/help/; screen pages are screens/.html. The Help menu gains the macOS guide, Troubleshooting, What's New and API Versioning. - Removes help/, help.yml, help-build.rb and help-generate.rb; sync-openapi.sh vendors only the app's copy of the spec. - CI builds the help center and runs help-check.rb, which fails if any screen in the catalog, Help menu page or #fragment the app opens is missing. - docs-dispatch.yml sends app-docs-updated to the specs repo on every merge to main (needs SPECS_DOCS_TOKEN; otherwise the help center rebuilds daily). - README, CHANGELOG and PREREQUISITES updated. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 31 +- .github/workflows/docs-dispatch.yml | 37 + .github/workflows/help.yml | 129 - .github/workflows/upstream-sync.yml | 11 +- .gitignore | 13 - App/Navigation/HubCommands.swift | 6 +- App/Resources/Info.plist | 2 +- CHANGELOG.md | 4 +- Makefile | 15 +- .../SubstratalKit/Help/HelpLinks.swift | 46 +- .../SubstratalKitTests/NavigationTests.swift | 9 +- README.md | 26 +- docs/PREREQUISITES.md | 8 +- help/Gemfile | 7 - help/Gemfile.lock | 290 -- help/_config.yml | 64 - help/_includes/availability.html | 21 - help/_includes/head_custom.html | 16 - help/_includes/header_custom.html | 24 - help/_sass/color_schemes/substratal.scss | 21 - help/_sass/custom/custom.scss | 126 - help/_screen-notes/AUTH-01.md | 6 - help/_screen-notes/AUTH-02.md | 3 - help/_screen-notes/SET-01.md | 5 - help/_screen-notes/USR-01.md | 5 - help/api/index.md | 155 - help/api/openapi.yaml | 4446 ----------------- help/api/reference.md | 70 - help/api/versioning.md | 79 - help/concepts/applications-keys-webhooks.md | 39 - help/concepts/how-access-works.md | 57 - help/concepts/index.md | 20 - help/concepts/roles-and-permissions.md | 63 - help/concepts/test-and-live-mode.md | 24 - help/faq.md | 73 - help/getting-started/index.md | 69 - help/index.md | 49 - help/reference/index.md | 14 - help/reference/keyboard-shortcuts.md | 61 - help/reference/troubleshooting.md | 58 - help/screens/index.md | 26 - help/tutorials/assign-a-role.md | 39 - help/tutorials/create-an-api-key.md | 55 - help/tutorials/index.md | 24 - .../investigate-with-the-audit-log.md | 46 - help/tutorials/invite-a-user.md | 39 - help/tutorials/register-an-application.md | 39 - help/tutorials/turn-access-on-or-off.md | 52 - help/versions/index.md | 27 - project.yml | 4 +- scripts/help-build.rb | 136 - scripts/help-check.rb | 59 +- scripts/help-generate.rb | 182 - scripts/sync-openapi.sh | 17 +- 54 files changed, 158 insertions(+), 6789 deletions(-) create mode 100644 .github/workflows/docs-dispatch.yml delete mode 100644 .github/workflows/help.yml delete mode 100644 help/Gemfile delete mode 100644 help/Gemfile.lock delete mode 100644 help/_config.yml delete mode 100644 help/_includes/availability.html delete mode 100644 help/_includes/head_custom.html delete mode 100644 help/_includes/header_custom.html delete mode 100644 help/_sass/color_schemes/substratal.scss delete mode 100644 help/_sass/custom/custom.scss delete mode 100644 help/_screen-notes/AUTH-01.md delete mode 100644 help/_screen-notes/AUTH-02.md delete mode 100644 help/_screen-notes/SET-01.md delete mode 100644 help/_screen-notes/USR-01.md delete mode 100644 help/api/index.md delete mode 100644 help/api/openapi.yaml delete mode 100644 help/api/reference.md delete mode 100644 help/api/versioning.md delete mode 100644 help/concepts/applications-keys-webhooks.md delete mode 100644 help/concepts/how-access-works.md delete mode 100644 help/concepts/index.md delete mode 100644 help/concepts/roles-and-permissions.md delete mode 100644 help/concepts/test-and-live-mode.md delete mode 100644 help/faq.md delete mode 100644 help/getting-started/index.md delete mode 100644 help/index.md delete mode 100644 help/reference/index.md delete mode 100644 help/reference/keyboard-shortcuts.md delete mode 100644 help/reference/troubleshooting.md delete mode 100644 help/screens/index.md delete mode 100644 help/tutorials/assign-a-role.md delete mode 100644 help/tutorials/create-an-api-key.md delete mode 100644 help/tutorials/index.md delete mode 100644 help/tutorials/investigate-with-the-audit-log.md delete mode 100644 help/tutorials/invite-a-user.md delete mode 100644 help/tutorials/register-an-application.md delete mode 100644 help/tutorials/turn-access-on-or-off.md delete mode 100644 help/versions/index.md delete mode 100644 scripts/help-build.rb delete mode 100644 scripts/help-generate.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1b5bf8..6385057 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,7 @@ # Every push and pull request: the shared package's tests, an app build, and a -# full Help Center build with its link check, so a change that breaks the app -# or leaves a help page behind can't reach main. +# check that every Help link the app opens exists in a fresh build of the +# shared Hub help center, so a change that breaks the app or a help link can't +# reach main. name: CI on: @@ -50,20 +51,20 @@ jobs: -skipPackagePluginValidation -skipMacroValidation \ CODE_SIGNING_ALLOWED=NO build - help: - name: Build and check the Help Center + help-links: + name: Help links resolve in the Hub help center runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: ruby/setup-ruby@v1 + - uses: actions/checkout@v7 + with: + repository: CompositeCode/substratalapps-applications-specs + path: _specs + - uses: actions/setup-node@v7 with: - ruby-version: "3.2" - bundler-cache: true - working-directory: help - - name: Generate app-derived pages - run: ruby scripts/help-generate.rb --commit "$GITHUB_SHA" - - name: Build - working-directory: help - run: bundle exec jekyll build --destination _site - - name: Check every app help link and internal link - run: ruby scripts/help-check.rb help/_site + node-version: 22 + - name: Build the help center + working-directory: _specs + run: npm ci && npm run build + - name: Check every screen page and Help menu link + run: ruby scripts/help-check.rb _specs/_site/help diff --git a/.github/workflows/docs-dispatch.yml b/.github/workflows/docs-dispatch.yml new file mode 100644 index 0000000..b2e932c --- /dev/null +++ b/.github/workflows/docs-dispatch.yml @@ -0,0 +1,37 @@ +# Tells the shared Hub help center (CompositeCode/substratalapps-applications-specs) +# that this app changed, so it rebuilds and republishes straight away instead of +# waiting for its daily build. That help center is this app's documentation: +# every Help menu item and screen page the app opens lives there (HelpLinks.swift). +# +# Needs the secret SPECS_DOCS_TOKEN: a fine-grained token with Contents: read +# and write on substratalapps-applications-specs (see that repo's help/README.md +# → Automation). Without it, this job skips with a notice and the help center +# still picks changes up on its daily schedule. +name: Docs dispatch + +on: + push: + branches: [main] + paths: + - "App/**" + - "Packages/SubstratalKit/Sources/**" + - "CHANGELOG.md" + - "project.yml" + workflow_dispatch: + +jobs: + dispatch: + runs-on: ubuntu-latest + steps: + - name: Notify the help center + env: + GH_TOKEN: ${{ secrets.SPECS_DOCS_TOKEN }} + run: | + if [ -z "$GH_TOKEN" ]; then + echo "::notice::SPECS_DOCS_TOKEN isn't set; the help center will rebuild on its daily schedule." + exit 0 + fi + gh api repos/CompositeCode/substratalapps-applications-specs/dispatches \ + -f event_type=app-docs-updated \ + -F 'client_payload[app]=macos' \ + -F "client_payload[sha]=$GITHUB_SHA" diff --git a/.github/workflows/help.yml b/.github/workflows/help.yml deleted file mode 100644 index a3aa779..0000000 --- a/.github/workflows/help.yml +++ /dev/null @@ -1,129 +0,0 @@ -# Versioned build and deploy of the Help Center to GitHub Pages. -# -# Runs on every push to main that changes the app or its docs: the screen -# catalog, the screens that are built, the vendored openapi.yaml, the changelog -# or the help pages. Each run publishes a new docs version (patch by default; -# put [docs:minor] or [docs:major] in a commit subject since the last version -# to bump further). Its frozen snapshot is committed to the help-versions -# branch (never to main) and served at /versions//, labelled with the -# app version and Platform API version it describes. -# Same scheme as the API repository's docs.yml. -name: Help Center - -on: - push: - branches: [main] - paths: - - "App/**" - - "Packages/SubstratalKit/Sources/**" - - "help/**" - - "scripts/help-*.rb" - - "CHANGELOG.md" - - "project.yml" - - ".github/workflows/help.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 help-versions - pages: write - id-token: write - -# One release at a time, never cancelled midway: each run reads the archive the -# previous run wrote. -concurrency: - group: help-pages - cancel-in-progress: false - -jobs: - release: - runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deploy.outputs.page_url }} - steps: - - uses: actions/checkout@v7 - with: - fetch-depth: 0 - - - name: Check out version archive - run: | - if git ls-remote --exit-code --heads origin help-versions >/dev/null; then - git fetch --depth=1 origin +help-versions:refs/remotes/origin/help-versions - git worktree add _archive -B help-versions origin/help-versions - else - echo "No help-versions branch yet; starting the archive at 1.0.0." - git worktree add --orphan -b help-versions _archive - fi - - - uses: ruby/setup-ruby@v1 - with: - ruby-version: "3.2" - bundler-cache: true - working-directory: help - - - 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/help-build.rb \ - --archive _archive \ - --out _site \ - --bump "${{ steps.bump.outputs.level }}" \ - --commit "$GITHUB_SHA" \ - --summary "$(git log -1 --format=%s)" - - - uses: actions/configure-pages@v6 - - - uses: actions/upload-pages-artifact@v5 - with: - path: _site - - - id: deploy - uses: actions/deploy-pages@v5 - - # Only after a successful deploy: a failed deploy (Pages off, an outage) - # must not use up a version number in the archive. - - name: Commit snapshot to help-versions - working-directory: _archive - env: - VERSION: ${{ steps.build.outputs.version }} - run: | - if [ ! -f README.md ]; then - cat > README.md <<'EOF' - # help-versions - - Archive of every published version of the Help Center, written by - `.github/workflows/help.yml` on `main`. Don't edit by hand. - - - `manifest.yml`: every version, newest first, with the app and API version each describes. - - `/`: that version's frozen static build, served at /versions//. - EOF - 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 help v$VERSION from ${GITHUB_SHA::7}" - git push origin help-versions diff --git a/.github/workflows/upstream-sync.yml b/.github/workflows/upstream-sync.yml index 6c68cba..17104c1 100644 --- a/.github/workflows/upstream-sync.yml +++ b/.github/workflows/upstream-sync.yml @@ -1,12 +1,11 @@ -# Keeps the app and the Help Center in step with their two upstreams: +# Keeps the app in step with its two upstreams: # - the Platform API's openapi.yaml (CompositeCode/substratalapps.com, main = production) # - the macOS screen inventory (CompositeCode/substratalapps-applications-specs) # # When either changed, it opens (or updates) one pull request with the new # vendored spec and screen catalog. CI then has to build the app's generated -# client against the new spec and rebuild the Help Center before it can merge, -# and merging publishes the new docs through help.yml. Nothing reaches main -# unreviewed. +# client against the new spec, and check its Help links against the help +# center, before it can merge. Nothing reaches main unreviewed. # # Triggers: daily, by hand, or immediately from the upstream repos with a # repository_dispatch, for example as a last step of the API's deploy.yml: @@ -76,11 +75,11 @@ jobs: - Platform API \`openapi.yaml\`: **${API}** (from \`CompositeCode/substratalapps.com@main\`) - Screen catalog: re-imported from \`CompositeCode/substratalapps-applications-specs\` - CI rebuilds the generated client against the new spec and rebuilds the Help Center. Before merging: + CI rebuilds the generated client against the new spec and checks the app's Help links. Before merging: - Breaking changes ship as a new \`Substratal-Version\`. If \`components.parameters.ApiVersion\` gained a value, moving to it is a deliberate code change (\`APIVersion.current\`), not part of this PR. - Add a line under **Unreleased** in \`CHANGELOG.md\` if people will notice. - Merging publishes a new Help Center version. + Merging notifies the Hub help center (docs-dispatch.yml), which rebuilds. EOF ) if gh pr view "$branch" >/dev/null 2>&1; then diff --git a/.gitignore b/.gitignore index 83b88df..b76ea9a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,18 +12,5 @@ build/ .swiftpm/ Packages/**/Package.resolved.bak -# Help site -help/_site/ -help/.jekyll-cache/ -help/.sass-cache/ -help/vendor/ -help/.bundle/ -help/_data/versions.yml -help/_data/build.yml -help/_data/screens.json -help/screens/*.md -!help/screens/index.md -help/release-notes.md - # macOS .DS_Store diff --git a/App/Navigation/HubCommands.swift b/App/Navigation/HubCommands.swift index 8e9edf0..4667117 100644 --- a/App/Navigation/HubCommands.swift +++ b/App/Navigation/HubCommands.swift @@ -38,12 +38,14 @@ struct HubCommands: Commands { .disabled(navigation == nil) Divider() Button("Getting Started") { openURL(model.help.gettingStarted) } - Button("Tutorials") { openURL(model.help.tutorials) } + Button("Using the Hub on macOS") { openURL(model.help.macOSGuide) } Button("Keyboard Shortcuts") { openURL(model.help.shortcuts) } + Button("Troubleshooting") { openURL(model.help.troubleshooting) } Button("Frequently Asked Questions") { openURL(model.help.faq) } - Button("Release Notes") { openURL(model.help.releaseNotes) } + Button("What's New") { openURL(model.help.whatsNew) } Divider() Button("API Reference") { openURL(model.help.apiExplorer) } + Button("API Versioning") { openURL(model.help.apiVersioning) } Button("API Specification") { openURL(HelpLinks.apiSpecification) } } } diff --git a/App/Resources/Info.plist b/App/Resources/Info.plist index 2201da6..ab8dece 100644 --- a/App/Resources/Info.plist +++ b/App/Resources/Info.plist @@ -34,6 +34,6 @@ LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) SubstratalHelpBaseURL - https://compositecode.github.io/substratalapps-macos/ + https://compositecode.github.io/substratalapps-applications-specs/help/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ba99d..6f8e043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -Every release of Substratal Hub for macOS. The Help Center's Release Notes page is generated from this file. Add an entry under **Unreleased** with every change people will notice; the release workflow moves it under the version number. +Every release of Substratal Hub for macOS. The Hub help center's What's new page covers every Hub app; this file is the macOS app's own record. Add an entry under **Unreleased** with every change people will notice; the release workflow moves it under the version number. ## Unreleased @@ -11,7 +11,7 @@ Every release of Substratal Hub for macOS. The Help Center's Release Notes page - **Users** (USR-01): search the user directory by name, email or ID, filter by status, and load more as you scroll. - **Settings → Appearance**: Match System, Light or Dark, and any of the five light and five dark Substratal themes. - **Settings → Connection**: switch between production and a local API devserver. Each keeps its own session. -- Help menu: Help Center, help for the current screen, tutorials, keyboard shortcuts, FAQ, release notes, and the Platform API reference. +- Help menu: the Substratal Hub help center (shared by every Hub app), help for the current screen, the macOS guide, keyboard shortcuts, troubleshooting, FAQ, what's new, and the Platform API reference and versioning. - Update notices: a banner when this version of the app is deprecated by the API, and a stop with "Update required" when the API no longer supports it. ### Platform API diff --git a/Makefile b/Makefile index c0b5c10..d121c83 100644 --- a/Makefile +++ b/Makefile @@ -30,15 +30,12 @@ sync-api: ## Vendor the latest openapi.yaml from the API repo's main sync-screens: ## Re-import the screen catalog from ../substratalapps-applications-specs ruby scripts/import-screens.rb -help-build: ## Build the Help Center locally and check its links - ruby scripts/help-generate.rb - cd help && bundle install --quiet && bundle exec jekyll build - ruby scripts/help-check.rb help/_site +SPECS ?= ../substratalapps-applications-specs -help-serve: ## Serve the Help Center at http://localhost:4000/substratalapps-macos/ - ruby scripts/help-generate.rb - cd help && bundle install --quiet && bundle exec jekyll serve --livereload +help-check: ## Check the app's Help links against a fresh build of the Hub help center in SPECS + cd $(SPECS) && npm ci --silent && npm run build + ruby scripts/help-check.rb $(SPECS)/_site/help -check: test build help-build ## Everything CI runs +check: test build help-check ## Everything CI runs -.PHONY: help project build run test ui-test sync-api sync-screens help-build help-serve check +.PHONY: help project build run test ui-test sync-api sync-screens help-check check diff --git a/Packages/SubstratalKit/Sources/SubstratalKit/Help/HelpLinks.swift b/Packages/SubstratalKit/Sources/SubstratalKit/Help/HelpLinks.swift index 6282fdc..d5c95e2 100644 --- a/Packages/SubstratalKit/Sources/SubstratalKit/Help/HelpLinks.swift +++ b/Packages/SubstratalKit/Sources/SubstratalKit/Help/HelpLinks.swift @@ -1,15 +1,18 @@ import Foundation -/// Links into the Hub Help Center (the `help/` site in this repo, published to -/// GitHub Pages). Every screen has a page at `screens//`, generated from the -/// same `screens.json` the app navigates with, so a Help button can never point -/// at a page that doesn't exist. +/// Links into the Substratal Hub help center, the one documentation reference +/// every Hub app shares. It's built from the specs repository +/// (`CompositeCode/substratalapps-applications-specs`, `help/` and +/// `site/help.mjs`) and published at `…/substratalapps-applications-specs/help/`. +/// Every screen has a page at `screens/.html`, generated from the same +/// spec this app's screen catalog is imported from. CI checks that every link +/// here exists in a fresh build of that site (`scripts/help-check.rb`). public struct HelpLinks: Sendable { - public static let defaultBase = URL(string: "https://compositecode.github.io/substratalapps-macos/")! + public static let defaultBase = URL(string: "https://compositecode.github.io/substratalapps-applications-specs/help/")! public let base: URL - /// `base` comes from Info.plist `SubstratalHelpBaseURL` so a staging build can + /// `base` comes from Info.plist `SubstratalHelpBaseURL`, so a build can /// point at a preview of the site. public init(base: URL = HelpLinks.defaultBase) { self.base = base @@ -20,22 +23,27 @@ public struct HelpLinks: Sendable { self.init(base: configured ?? Self.defaultBase) } - public var home: URL { base } - public var gettingStarted: URL { page("getting-started/") } - public var tutorials: URL { page("tutorials/") } - public var faq: URL { page("faq/") } - public var shortcuts: URL { page("reference/keyboard-shortcuts/") } - public var apiReference: URL { page("api/") } - /// Swagger UI over the vendored openapi.yaml. - public var apiExplorer: URL { page("api/reference/") } - public var releaseNotes: URL { page("release-notes/") } - - public func screen(_ id: String) -> URL { page("screens/\(id.lowercased())/") } + public var home: URL { page("index.html") } + public var gettingStarted: URL { page("guides/getting-started.html") } + /// Shortcuts, windows, the Keychain and Settings → Connection. + public var macOSGuide: URL { page("guides/macos.html") } + public var shortcuts: URL { page("guides/macos.html", fragment: "keyboard-shortcuts") } + public var troubleshooting: URL { page("guides/troubleshooting.html") } + public var faq: URL { page("faq.html") } + public var whatsNew: URL { page("whats-new.html") } + public var apiQuickstart: URL { page("developers/quickstart.html") } + public var apiVersioning: URL { page("developers/api-versioning.html") } + /// Swagger UI for the API version this build speaks. + public var apiExplorer: URL { page("api/v\(APIVersion.headerValue)/index.html") } + + public func screen(_ id: String) -> URL { page("screens/\(id.lowercased()).html") } /// The API's own specification site (system of record for the contract). public static let apiSpecification = URL(string: "https://compositecode.github.io/substratalapps.com/")! - private func page(_ path: String) -> URL { - URL(string: path, relativeTo: base)!.absoluteURL + private func page(_ path: String, fragment: String? = nil) -> URL { + var components = URLComponents(url: URL(string: path, relativeTo: base)!.absoluteURL, resolvingAgainstBaseURL: true)! + components.fragment = fragment + return components.url! } } diff --git a/Packages/SubstratalKit/Tests/SubstratalKitTests/NavigationTests.swift b/Packages/SubstratalKit/Tests/SubstratalKitTests/NavigationTests.swift index d29b875..34eb9b9 100644 --- a/Packages/SubstratalKit/Tests/SubstratalKitTests/NavigationTests.swift +++ b/Packages/SubstratalKit/Tests/SubstratalKitTests/NavigationTests.swift @@ -68,10 +68,13 @@ struct NavigationTests { #expect(!noFlags.isVisible("flag:notifications")) } - @Test("Help links exist for every screen") + @Test("Help links point into the shared Hub help center") func helpLinks() { let help = HelpLinks() - #expect(help.screen("USR-01").absoluteString == "https://compositecode.github.io/substratalapps-macos/screens/usr-01/") - #expect(help.apiExplorer.absoluteString == "https://compositecode.github.io/substratalapps-macos/api/reference/") + let root = "https://compositecode.github.io/substratalapps-applications-specs/help/" + #expect(help.screen("USR-01").absoluteString == root + "screens/usr-01.html") + #expect(help.apiExplorer.absoluteString == root + "api/v1/index.html") + #expect(help.shortcuts.absoluteString == root + "guides/macos.html#keyboard-shortcuts") + #expect(help.home.absoluteString == root + "index.html") } } diff --git a/README.md b/README.md index 6b602f0..f46f4ec 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ The native macOS client for the [Substratal Apps Platform API](https://compositecode.github.io/substratalapps.com/): the Hub that staff and app developers use to run the platform (users, access, roles, Applications, API keys, webhooks, tenants and the audit log), plus every person's own apps, inbox, account and plan. - **UI spec:** [substratalapps-applications-specs → macOS](https://compositecode.github.io/substratalapps-applications-specs/) (75 screens, the source of truth for screens, copy and behavior) -- **Help Center:** [`help/`](help/), published to [compositecode.github.io/substratalapps-macos](https://compositecode.github.io/substratalapps-macos/) +- **Help:** the shared [Substratal Hub help center](https://compositecode.github.io/substratalapps-applications-specs/help/), the one documentation reference for every Hub app. Its source is `help/` in the [specs repo](https://github.com/CompositeCode/substratalapps-applications-specs); the macOS pages are [Using the Hub on macOS](https://compositecode.github.io/substratalapps-applications-specs/help/guides/macos.html) and [Troubleshooting](https://compositecode.github.io/substratalapps-applications-specs/help/guides/troubleshooting.html). - **Plans:** [docs/PLAN-management-app.md](docs/PLAN-management-app.md) · [docs/PLAN-api-gaps.md](docs/PLAN-api-gaps.md) · [docs/PREREQUISITES.md](docs/PREREQUISITES.md) ## Status @@ -31,7 +31,7 @@ GET https://api.substratalapps.com/users/me ← no /v1 Substratal-Version: 1 ``` -`/v1/…` paths return `404`. The version is the compile-time constant `APIVersion.current` in `SubstratalKit`, and `VersionMiddleware` adds the header to every request, so no call can forget it. Moving to a new API version is a code change, never configuration. See [help/api/versioning.md](help/api/versioning.md). +`/v1/…` paths return `404`. The version is the compile-time constant `APIVersion.current` in `SubstratalKit`, and `VersionMiddleware` adds the header to every request, so no call can forget it. Moving to a new API version is a code change, never configuration. See [API versioning](https://compositecode.github.io/substratalapps-applications-specs/help/developers/api-versioning.html) in the help center. ## Layout @@ -48,23 +48,22 @@ Substratal-Version: 1 │ ├── Sources/SubstratalAPI/ openapi.yaml → generated client (swift-openapi-generator, at build time) │ └── Sources/SubstratalKit/ API middleware and versioning, sessions + Keychain, Capabilities, │ screen catalog + routes, help links, theme, domain services -├── help/ Help Center (Jekyll + just-the-docs, the same stack as the API docs) -├── scripts/ sync-openapi.sh, import-screens.rb, help-generate/build/check.rb +├── scripts/ sync-openapi.sh, import-screens.rb, help-check.rb ├── docs/ Plans and the prerequisites check ├── project.yml XcodeGen definition (the .xcodeproj is generated, not committed) -└── .github/workflows/ ci.yml, help.yml, upstream-sync.yml +└── .github/workflows/ ci.yml, upstream-sync.yml, docs-dispatch.yml ``` ## Getting started -Requirements: Xcode 26 or later, macOS 15 or later, [XcodeGen](https://github.com/yonaskolb/XcodeGen) (`brew install xcodegen`), and Ruby with Bundler for the Help Center. +Requirements: Xcode 26 or later, macOS 15 or later, [XcodeGen](https://github.com/yonaskolb/XcodeGen) (`brew install xcodegen`), Ruby for the scripts, and Node.js to build the help center for `make help-check`. ```bash make project # generate SubstratalHub.xcodeproj make build # or open SubstratalHub.xcodeproj and run the SubstratalHub scheme make run # build and launch against production make test # SubstratalKit unit tests -make help-serve # Help Center at http://localhost:4000/substratalapps-macos/ +make help-check # check the app's Help links against ../substratalapps-applications-specs make check # everything CI runs ``` @@ -90,16 +89,19 @@ Two upstreams feed this repo, and both are vendored by script, never edited by h | File | From | Refresh | |---|---|---| -| `Packages/SubstratalKit/Sources/SubstratalAPI/openapi.yaml`, `help/api/openapi.yaml` | API repo `docs/openapi.yaml` on `main` | `make sync-api` | +| `Packages/SubstratalKit/Sources/SubstratalAPI/openapi.yaml` | API repo `docs/openapi.yaml` on `main` | `make sync-api` | | `Packages/SubstratalKit/Sources/SubstratalKit/Resources/screens.json` | Specs repo `macos/SCREENS.md` | `make sync-screens` | -The screen catalog drives the sidebar, menus, routes and deep links, and generates one Help Center page per screen. The app's help links and the site therefore can't disagree, and CI checks that they don't. +The screen catalog drives the sidebar, menus, routes and deep links. The help center generates its screen pages from the same spec, and CI checks that every screen and Help menu link the app opens exists there. ## Documentation workflow -- **Every change to the app** that reaches `main` publishes a new Help Center version (`help.yml`). Screen pages, keyboard shortcuts, "Coming soon" banners, release notes (from `CHANGELOG.md`) and the API reference are generated from the code and the vendored spec each time. -- **Every API change** is picked up by `upstream-sync.yml`, which runs daily, by hand, or on a `repository_dispatch` from the API repo. It opens a pull request that updates the generated client and the docs together. CI must build the app against the new spec before it can merge. -- **Every published version** is frozen at `/versions//`, labelled with the app version and Platform API version it describes. +The app's documentation is the shared Hub help center, in the specs repo, not here: + +- **Writing docs:** edit `help/` in [substratalapps-applications-specs](https://github.com/CompositeCode/substratalapps-applications-specs) (guides, tutorials, developer pages, FAQ, optional per-screen notes). See its `help/README.md`. Per-screen pages, the versioned API reference and What's new are generated there on every build. +- **When this app changes:** `docs-dispatch.yml` sends `app-docs-updated` to the specs repo on every merge to `main`, which rebuilds and republishes the help center. It needs the `SPECS_DOCS_TOKEN` secret; without it, the help center rebuilds daily. +- **When the API changes:** the help center rebuilds its API reference from the live `openapi.yaml` daily and on `api-contract-updated`. This repo's `upstream-sync.yml` opens a PR that updates the app's generated client. +- **Links can't break:** CI builds the help center and runs `scripts/help-check.rb`, so a screen without a help page, or a Help menu item pointing nowhere, fails the build. Update `CHANGELOG.md` under **Unreleased** with every change people will notice. diff --git a/docs/PREREQUISITES.md b/docs/PREREQUISITES.md index 37f16b6..505425f 100644 --- a/docs/PREREQUISITES.md +++ b/docs/PREREQUISITES.md @@ -20,7 +20,7 @@ Verified on production on 2026-10-23: | `GET /permissions` without the header | `400 api_version_required`, `details.supported: ["1"]` | | `GET /permissions` with `Substratal-Version: 1`, no token | `401 unauthenticated`, and the response echoes `substratal-version: 1` | -In this app: the base URL is the host root (`APIEnvironment.baseURL`). `APIVersion.current` is a compile-time constant, and `VersionMiddleware` stamps the header on every request, including the exempt ones, which is harmless. It also records `Deprecation`/`Sunset` and `400 unsupported_api_version` in `APIVersionMonitor`, which drives the GLB-03 banners. Unit tests cover each of these. The Help Center documents the scheme for integrators ([help/api/versioning.md](../help/api/versioning.md)). +In this app: the base URL is the host root (`APIEnvironment.baseURL`). `APIVersion.current` is a compile-time constant, and `VersionMiddleware` stamps the header on every request, including the exempt ones, which is harmless. It also records `Deprecation`/`Sunset` and `400 unsupported_api_version` in `APIVersionMonitor`, which drives the GLB-03 banners. Unit tests cover each of these. The Hub help center documents the scheme for integrators ([API versioning](https://compositecode.github.io/substratalapps-applications-specs/help/developers/api-versioning.html)). ## Development machine @@ -31,7 +31,8 @@ In this app: the base URL is the host root (`APIEnvironment.baseURL`). `APIVersi | Swift | ✅ | 6.4; the project builds in Swift 6 language mode with complete concurrency checking | | XcodeGen | ✅ | 2.46.0, installed with Homebrew during this check. The `.xcodeproj` is generated from `project.yml` and not committed. | | swift-openapi-generator | ✅ | 1.14.2 (SwiftPM). Generates and compiles the **whole** spec: 124 operations, about 53k lines. | -| Ruby + Bundler | ✅ | rbenv; `help/Gemfile` pins `github-pages` 232, the same as the API docs site | +| Ruby | ✅ | rbenv; for the `scripts/*.rb` tools | +| Node.js | ✅ | For building the shared help center locally (`make help-check`) | | Go | ⚠️ | 1.24.4 installed; the API needs 1.26, which `go` downloads automatically (`GOTOOLCHAIN=auto`). Only needed to run the API locally. | | Docker + Compose | ✅ | 20.10.20 / v2.12.0. Runs the API's Postgres, LocalStack and Mailpit. | | GitHub CLI | ✅ | Signed in as `Adron` | @@ -53,7 +54,8 @@ In this app: the base URL is the host root (`APIEnvironment.baseURL`). `APIVersi |---|---|---| | This repo's GitHub remote | ✅ | [`CompositeCode/substratalapps-macos`](https://github.com/CompositeCode/substratalapps-macos), **private**, created 2026-10-23; `main` pushed. | | GitHub Actions | ❌ **billing** | The first runs (2026-10-23) weren't started: "recent account payments have failed or your spending limit needs to be increased". Fix under the CompositeCode org's **Settings → Billing & plans**. Until then CI doesn't run; run `make check` locally. macOS runners use the most minutes, so set a spending limit that covers them. | -| GitHub Pages for the Help Center | ❌ **plan** | GitHub refuses Pages on this private repo with the org's current plan (`422`). Either make the repo public (like `substratalapps.com`), or move to a plan with private Pages. Then set **Settings → Pages → Source: GitHub Actions** and run the Help Center workflow. The app's Help menu already points at `https://compositecode.github.io/substratalapps-macos/` (`SubstratalHelpBaseURL` in `project.yml`); if the site ends up somewhere else, change it there. | +| Help center | ✅ | Not hosted here. The app's documentation lives in the shared **Hub help center** in the public specs repo, published at `https://compositecode.github.io/substratalapps-applications-specs/help/`. The macOS guide and troubleshooting pages were moved there in [specs PR #2](https://github.com/CompositeCode/substratalapps-applications-specs/pull/2). This repo's Pages, which the plan doesn't support for a private repo, isn't needed. | +| `SPECS_DOCS_TOKEN` secret | ❌ optional | Lets `docs-dispatch.yml` tell the help center to rebuild as soon as this app merges. Without it, the help center rebuilds daily. | | API repo `openapi.yaml` (raw, `main`) | ✅ | `200`. `scripts/sync-openapi.sh` vendors it; a scheduled workflow keeps it current. | | Specs repo | ✅ | Up to date (`cebf679`, targets API v1.8.0). `scripts/import-screens.rb` imports its 75 macOS screens. | diff --git a/help/Gemfile b/help/Gemfile deleted file mode 100644 index 6563f8c..0000000 --- a/help/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/help/Gemfile.lock b/help/Gemfile.lock deleted file mode 100644 index 6ae59e0..0000000 --- a/help/Gemfile.lock +++ /dev/null @@ -1,290 +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) - 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) - 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 - -DEPENDENCIES - github-pages (= 232) - -BUNDLED WITH - 2.4.10 diff --git a/help/_config.yml b/help/_config.yml deleted file mode 100644 index 484adea..0000000 --- a/help/_config.yml +++ /dev/null @@ -1,64 +0,0 @@ -title: Substratal Hub Help -description: >- - How to use Substratal Hub, the app for running the Substratal Apps platform - (people, access, roles, Applications, API keys, webhooks and the audit log), - and how to build against the Platform API it uses. - -# GitHub Pages project site for this repository. If the Help Center moves to a -# custom domain (help.substratalapps.com), change both, add a CNAME, and update -# SubstratalHelpBaseURL in project.yml so the app's Help links follow. -url: "https://compositecode.github.io" -baseurl: "/substratalapps-macos" - -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 -search_enabled: true -search: - heading_level: 3 - previews: 3 - preview_words_before: 5 - preview_words_after: 10 - button: true -heading_anchors: true -back_to_top: true -back_to_top_text: "Back to top" - -aux_links: - "API specification": "https://compositecode.github.io/substratalapps.com/" - "GitHub": "https://github.com/CompositeCode/substratalapps-macos" -aux_links_new_tab: true - -callouts_level: quiet -callouts: - note: - title: Note - color: blue - tip: - title: Tip - color: green - important: - title: Important - color: red - macos: - title: On macOS - color: grey-dk - -footer_content: >- - Substratal Hub Help. Generated from the app's own screen catalog and the - Platform API's openapi.yaml on every change to the app; see Versions for - every published version. - -exclude: - - Gemfile - - Gemfile.lock - - vendor - - node_modules - - README.md - - _screen-notes diff --git a/help/_includes/availability.html b/help/_includes/availability.html deleted file mode 100644 index e8515cd..0000000 --- a/help/_includes/availability.html +++ /dev/null @@ -1,21 +0,0 @@ -{%- comment -%} - Availability banner for tutorials and guides. A page lists the screens it - walks through in front matter (`screens: [USR-02, USR-03]`); any that this - version of the app hasn't built yet (per _data/build.yml, generated from - App/Screens/ScreenHost.swift) are named here, so a tutorial never claims a - step works when it doesn't yet. -{%- endcomment -%} -{%- assign built = site.data.build.built_screens -%} -{%- if page.screens and built -%} - {%- assign pending = "" | split: "" -%} - {%- for id in page.screens -%} - {%- unless built contains id -%}{%- assign pending = pending | push: id -%}{%- endunless -%} - {%- endfor -%} - {%- if pending.size > 0 -%} -
-

Coming soon in the macOS app: - {% for id in pending %}{{ id }}{% unless forloop.last %}, {% endunless %}{% endfor %}. - These steps describe the screens as specified. Until they're built, do this in the web Hub, which uses the same screens and routes.

-
- {%- endif -%} -{%- endif -%} diff --git a/help/_includes/head_custom.html b/help/_includes/head_custom.html deleted file mode 100644 index a31a349..0000000 --- a/help/_includes/head_custom.html +++ /dev/null @@ -1,16 +0,0 @@ - - diff --git a/help/_includes/header_custom.html b/help/_includes/header_custom.html deleted file mode 100644 index d913059..0000000 --- a/help/_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/help/_sass/color_schemes/substratal.scss b/help/_sass/color_schemes/substratal.scss deleted file mode 100644 index 32dc859..0000000 --- a/help/_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/help/_sass/custom/custom.scss b/help/_sass/custom/custom.scss deleted file mode 100644 index 6bea198..0000000 --- a/help/_sass/custom/custom.scss +++ /dev/null @@ -1,126 +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; } - } -} diff --git a/help/_screen-notes/AUTH-01.md b/help/_screen-notes/AUTH-01.md deleted file mode 100644 index ce534b4..0000000 --- a/help/_screen-notes/AUTH-01.md +++ /dev/null @@ -1,6 +0,0 @@ -- Enter your email and password and choose **Sign In** (or press Return). -- **Forgot password?** opens the reset page on the web. -- **Test-mode account** appears only when the app is connected to a local devserver: it signs in the test-mode account with that email. See [Test mode and live mode](../../concepts/test-and-live-mode/). -- The line at the bottom shows which API the app is connected to. - -If sign-in fails, see [Troubleshooting → Signing in](../../reference/troubleshooting/#signing-in). diff --git a/help/_screen-notes/AUTH-02.md b/help/_screen-notes/AUTH-02.md deleted file mode 100644 index a14871a..0000000 --- a/help/_screen-notes/AUTH-02.md +++ /dev/null @@ -1,3 +0,0 @@ -- Enter the 6-digit code from your authenticator app and choose **Verify**. -- **Use a recovery code** switches to one of the single-use codes you saved when you turned two-factor on. Each works once. -- **Back** returns to the sign-in form. The challenge expires after 5 minutes; after that, sign in again. diff --git a/help/_screen-notes/SET-01.md b/help/_screen-notes/SET-01.md deleted file mode 100644 index eb4e762..0000000 --- a/help/_screen-notes/SET-01.md +++ /dev/null @@ -1,5 +0,0 @@ -Open it from **Substratal Hub → Settings…** (⌘,) or from **Settings** in the sidebar. - -- **Appearance:** Match System follows macOS; Light and Dark override it for this app. -- **Light theme** and **Dark theme:** five of each. The defaults are Moss Paper and Forest Floor. When macOS uses Increase Contrast, the app uses system colours instead. -- **Connection** (Settings window only): production or a local API devserver. See [Getting Started](../../getting-started/#choose-which-api-the-app-talks-to). diff --git a/help/_screen-notes/USR-01.md b/help/_screen-notes/USR-01.md deleted file mode 100644 index 790af91..0000000 --- a/help/_screen-notes/USR-01.md +++ /dev/null @@ -1,5 +0,0 @@ -- **Search** matches name, email or a `usr_` ID. Searching starts at 3 characters and waits for you to stop typing. -- **Status** filters to Active, Invited, Suspended or Deleted. Deleted accounts are only listed for staff with `users.manage`. -- The list shows the newest accounts first. Choose **Load More** at the bottom to see older ones. -- Right-click selected rows and choose **Copy ID** to copy their IDs, one per line. -- **Invite User** needs `users.manage`. Without it the button is greyed out. See [Invite a user](../../tutorials/invite-a-user/). diff --git a/help/api/index.md b/help/api/index.md deleted file mode 100644 index 195e061..0000000 --- a/help/api/index.md +++ /dev/null @@ -1,155 +0,0 @@ ---- -layout: default -title: Platform API -nav_order: 8 -has_children: true -permalink: /api/ ---- - -# Platform API -{: .no_toc } - -The API every Hub app is built on, and that your Applications call too. This page is the quick guide; the [interactive reference](reference/) lists every endpoint, and the [API specification site](https://compositecode.github.io/substratalapps.com/) is the system of record. -{: .fs-6 .fw-300 } - -1. TOC -{: toc } - ---- - -## At a glance - -| | | -|---|---| -| Base URL | `{{ site.data.build.api_base_url | default: "https://api.substratalapps.com" }}`, no version segment in the path | -| Version | `Substratal-Version: {{ site.data.build.api_header_version | default: "1" }}` request header, **required** on every request (see [Versioning](versioning/)) | -| Specification | OpenAPI 3.1, spec version `{{ site.data.build.api_spec_version | default: "—" }}`, {{ site.data.build.api_operations | default: "—" }} operations: [interactive reference](reference/) · [download openapi.yaml]({{ '/api/openapi.yaml' | relative_url }}) | -| Authentication | `Authorization: Bearer `: a person's access token, or an API key | -| Format | JSON, UTF-8. Timestamps are ISO 8601 UTC with `Z`. IDs carry a type prefix (`usr_`, `app_`, `key_`, `req_`…). | - -This is the API version this version of the Help Center (and of the macOS app) was built against. Older ones are under [Versions](../versions/). - -## Your first request - -```bash -curl https://api.substratalapps.com/health -# {"status":"ok"} ← the only call that works without a version header or a token - -curl https://api.substratalapps.com/users/me \ - -H "Authorization: Bearer $TOKEN" \ - -H "Substratal-Version: 1" -``` - -Without the header you get: - -```json -{ "error": { "code": "api_version_required", "message": "This request requires a Substratal-Version header.", "details": { "supported": ["1"] } } } -``` - -{: .important } -Older examples used `https://api.substratalapps.com/v1/…`. Those paths now return `404 not_found`, with no redirect. Drop `/v1` and send the header instead. - -## Authentication - -| Credential | Looks like | Lives for | Use it for | -|---|---|---|---| -| **Access token** (a person) | JWT | 15 minutes | Apps acting for a signed-in person, like the Hub. Get one from `POST /auth/login` (plus `POST /auth/mfa/verify` if two-factor is on); renew it with `POST /auth/token/refresh`. | -| **Refresh token** | `rtk_…` | 30 days idle, 90 days at most; single use | Getting a new access token. Each refresh returns a new one; reusing an old one ends the session. | -| **API key** | `satk_live_…` / `satk_test_…` | Until revoked or its expiry | Servers, scripts and AI agents. Created in the Hub ([tutorial](../tutorials/create-an-api-key/)). Never from a browser. | -| **App token** | JWT, audience = your app | 5 minutes | What your Application receives when a person launches it; verify it against the JWKS. | - -Signing in: - -```bash -curl -X POST https://api.substratalapps.com/auth/login \ - -H "Substratal-Version: 1" -H "Content-Type: application/json" \ - -d '{"email":"dana@example.com","password":"…"}' -``` - -The response is either a session (`access_token`, `refresh_token`, `expires_in`) or, when two-factor is on, `{"mfa_required": true, "mfa_token": "…"}` to finish with `/auth/mfa/verify`. - -## Conventions you'll meet - -| Topic | In short | -|---|---| -| **Errors** | Always `{ "error": { "code", "message", "details" } }`. Branch on `code`, not `message`. | -| **Pagination** | `?limit=` (max 100) and `?cursor=`; responses carry `page.next_cursor` and `page.has_more`. | -| **Idempotency** | Send `Idempotency-Key: ` on creates. A retry with the same key returns the first result instead of creating twice. Some creates require it. | -| **Concurrency** | Reads return an `ETag`; send it back as `If-Match` on updates. If someone changed it first you get `409 version_conflict`. | -| **Rate limits** | 100 requests a minute per key or session by default, higher on hot paths. `RateLimit-*` headers show your budget; `429` comes with `Retry-After`. | -| **Request IDs** | Every response has `X-Request-Id: req_…`. Quote it when asking for help. | -| **Test mode** | Test keys and test users only ever see test data. | - -Full details: [Conventions](https://compositecode.github.io/substratalapps.com/api-reference/conventions/). - -## Examples - -### Check whether a person can use your app - -The call your backend makes on its hot path (300 requests a minute allowed): - -```bash -curl https://api.substratalapps.com/users/usr_01JAG.../apps/app_timetrack/effective-permissions \ - -H "Authorization: Bearer $SUBSTRATAL_API_KEY" \ - -H "Substratal-Version: 1" -``` - -```json -{ - "user_id": "usr_01JAG...", - "application_id": "app_timetrack", - "allowed": true, - "user_status": "active", - "entitlement_status": "active", - "access_paths": [{ "source": "purchase", "entitlement_id": "ent_01JAG...", "status": "active" }], - "roles": ["role_timetrack_admin"], - "effective_permissions": ["app.timetrack.export", "app.timetrack.manage_members"], - "computed_at": "2026-10-05T12:00:00Z" -} -``` - -`allowed` is the one field most callers need. `effective_permissions` is always empty when it's `false`. - -### Turn an app off for someone - -```bash -curl -X PATCH https://api.substratalapps.com/entitlements/ent_01JAG... \ - -H "Authorization: Bearer $TOKEN" -H "Substratal-Version: 1" \ - -H "If-Match: W/\"7\"" -H "Content-Type: application/json" \ - -d '{"status":"disabled","disabled_reason":"Billing dispute"}' -``` - -### From Swift (as the Hub does) - -The Hub generates its client from `openapi.yaml` with [swift-openapi-generator](https://github.com/apple/swift-openapi-generator) and adds the version header in one middleware, so no call can forget it: - -```swift -struct VersionMiddleware: ClientMiddleware { - func intercept(_ request: HTTPRequest, body: HTTPBody?, baseURL: URL, operationID: String, - next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?)) - async throws -> (HTTPResponse, HTTPBody?) { - var request = request - request.headerFields[HTTPField.Name("Substratal-Version")!] = "1" - return try await next(request, body, baseURL) - } -} - -let client = Client(serverURL: URL(string: "https://api.substratalapps.com")!, - transport: URLSessionTransport(), - middlewares: [VersionMiddleware(), AuthMiddleware(token: session.accessToken)]) -let page = try await client.users_list(query: .init(limit: 50), headers: .init(substratalVersion: ._1)) -``` - -See `Packages/SubstratalKit` in the [repository](https://github.com/CompositeCode/substratalapps-macos) for the complete version, with deprecation handling, token refresh and idempotency keys. - -### Other languages - -Generate a client from [`openapi.yaml`]({{ '/api/openapi.yaml' | relative_url }}): `openapi-typescript` + `openapi-fetch` (TypeScript), Kiota or NSwag (C#), `openapi-generator` (Kotlin, Python, Rust and others). Every operation has a stable `operationId`, and the version header is declared as the shared `ApiVersion` parameter, so generated clients know to send it. - -## Webhooks - -Your app should subscribe to at least `access.revoked` and `role.removed`, so it reacts to changes in the Hub immediately instead of polling. Deliveries are signed. A webhook's **payload version** (a date such as `2026-10-05`) is separate from `Substratal-Version`. See [Webhooks](https://compositecode.github.io/substratalapps.com/api-reference/webhooks/). - -## AI agents - -The platform runs an MCP server at `/mcp`. Give an agent its own API key with **Used by: Agent**, so destructive operations are blocked. See [MCP Server](https://compositecode.github.io/substratalapps.com/mcp-server/). diff --git a/help/api/openapi.yaml b/help/api/openapi.yaml deleted file mode 100644 index f36006d..0000000 --- a/help/api/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/help/api/reference.md b/help/api/reference.md deleted file mode 100644 index be0a360..0000000 --- a/help/api/reference.md +++ /dev/null @@ -1,70 +0,0 @@ ---- -layout: default -title: Interactive Reference -parent: Platform API -nav_order: 1 -permalink: /api/reference/ ---- - -# Interactive Reference -{: .no_toc } - -Every endpoint, parameter, request body and response in the Platform API (spec `{{ site.data.build.api_spec_version | default: "—" }}`, `Substratal-Version: {{ site.data.build.api_header_version | default: "1" }}`), rendered with Swagger UI from the same `openapi.yaml` the macOS app generates its client from. -{: .fs-6 .fw-300 } - -[Download openapi.yaml]({{ '/api/openapi.yaml' | relative_url }}){: .btn .btn-outline } [Versioning](../versioning/){: .btn .btn-outline } [Quick guide](../){: .btn .btn-outline } - -**Trying requests.** Production only accepts browser requests from first-party origins, so **Try it out** works against a local devserver, not production: - -1. Start the API repository's devserver with this site allowed: `CORS_ALLOWED_ORIGINS={{ site.url }} make run`. -2. Under **Servers**, choose **Local devserver**. -3. Choose **Authorize** and paste a person's access token (API keys are refused from browsers). - -Every request this page sends carries `Substratal-Version: {{ site.data.build.api_header_version | default: "1" }}`. For production, copy the request shown for each operation into `curl` or your own client. - -
- -
- - - - - diff --git a/help/api/versioning.md b/help/api/versioning.md deleted file mode 100644 index c16d135..0000000 --- a/help/api/versioning.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -layout: default -title: Versioning -parent: Platform API -nav_order: 2 -permalink: /api/versioning/ ---- - -# Versioning -{: .no_toc } - -How the Platform API is versioned, which version each Hub release uses, and how changes and deprecations reach you. -{: .fs-6 .fw-300 } - -1. TOC -{: toc } - ---- - -## The version is a header - -Every request names the API version it was written for: - -``` -GET https://api.substratalapps.com/users/me -Authorization: Bearer … -Substratal-Version: 1 -``` - -- **Required.** No header → `400 api_version_required`. An unknown value → `400 unsupported_api_version`. Both list the versions served in `details.supported`. There's no default, so the API never moves you to a new version without asking. -- **Not in the URL.** Paths are the same in every version. The old `/v1/…` paths return `404`. -- **Echoed.** Responses carry `Substratal-Version` with the version that served them. -- **Exempt:** `GET /health`, `POST /auth/oauth/token` (standard OAuth libraries can't add headers), `/.well-known/*`, and SCIM under `/scim/v2`. Sending the header to them is harmless. - -Versions served today: **{{ site.data.build.api_header_versions | join: ", " | default: "1" }}**. - -## What changes within a version, and what doesn't - -| Change | Ships as | What you need to do | -|---|---|---| -| New endpoint, new optional field, new enum value | The same version | Nothing, if your client ignores unknown fields and handles unknown enum values. | -| Removing or renaming a field, changing a type or a meaning | The next version number | Move to it within the deprecation window. | - -## Deprecation and sunset - -When a version is deprecated, every response to a request that names it carries: - -- `Deprecation` ([RFC 9745](https://www.rfc-editor.org/rfc/rfc9745)): it's deprecated, and since when. -- `Sunset` ([RFC 8594](https://www.rfc-editor.org/rfc/rfc8594)): the date it stops working, **at least 6 months** later. - -After the sunset date, requests naming it get `400 unsupported_api_version`. - -The Hub apps handle this for you: **Update available** with the date when they see `Deprecation`, and **Update required** (no API traffic) on `unsupported_api_version`. Do the same in your own clients: log the headers and alert on them. - -## Two version numbers, two meanings - -| Number | Example | What it is | -|---|---|---| -| `Substratal-Version` | `1` | The **contract**: what you send in the header. Changes only for breaking changes. | -| Spec version (`info.version`) | `{{ site.data.build.api_spec_version | default: "1.0.0-draft.9" }}` | The **document**: bumps with every change to `openapi.yaml`, additive ones included. | -| Webhook payload version | `2026-10-05` | The shape of webhook payloads for one subscription. Separate from both. | - -## Which version this documentation covers - -| | This version of the Help Center | -|---|---| -| Hub for macOS | {{ site.data.build.app_version | default: "local build" }} | -| `Substratal-Version` | {{ site.data.build.api_header_version | default: "1" }} | -| Spec version | {{ site.data.build.api_spec_version | default: "—" }} | -| Operations | {{ site.data.build.api_operations | default: "—" }} | - -Every published version of this site is frozen under [Versions](../../versions/), with the API version it described, so a link from an older app or an older integration still shows what applied then. What changed in the API itself, version by version, is in the API's [Changelog](https://compositecode.github.io/substratalapps.com/changelog/). - -## How this documentation stays current - -The Help Center is rebuilt and published automatically: - -- **When the app changes:** every merge to `main` that touches the app, its screen catalog, `CHANGELOG.md` or these pages publishes a new version. -- **When the API changes:** a scheduled job checks the API's `openapi.yaml` daily (and on request from the API repository). If it changed, it opens a pull request updating the app's generated client and this site together. CI must build the app against the new spec before it can merge, and merging publishes the docs. diff --git a/help/concepts/applications-keys-webhooks.md b/help/concepts/applications-keys-webhooks.md deleted file mode 100644 index 6a60ef0..0000000 --- a/help/concepts/applications-keys-webhooks.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -layout: default -title: Applications, keys and webhooks -parent: Concepts -nav_order: 3 -permalink: /concepts/applications-keys-webhooks/ ---- - -# Applications, keys and webhooks -{: .no_toc } - -How an app plugs into the platform. -{: .fs-6 .fw-300 } - -| Thing | What it is | Where in the Hub | -|---|---|---| -| **Application** | An app in the Substratal catalog. Has an owner (a person or an organization), a review status, its own permissions and roles, and a settings schema. | Developer → Applications | -| **Tenant** | Where an app's data lives. Shared by default; dedicated infrastructure on request. | Developer → Tenant | -| **API key** | A credential for code (a backend, a script, an AI agent) to call the Platform API. Scoped to one app or the whole platform, live or test, with only the permissions it needs. | Developer → API Keys | -| **Webhook** | A URL the platform calls when something happens (`access.revoked`, `role.removed`, `user.updated`…), signed so the app can trust it. | Developer → Webhooks | -| **Push credentials** | An app's APNs, FCM or Web Push keys, so the platform can send its notifications. | Applications → Push | - -## The review lifecycle - -```mermaid -stateDiagram-v2 - [*] --> pending_review: registered - pending_review --> approved - pending_review --> rejected: with a note - rejected --> pending_review: developer edits - approved --> suspended - suspended --> approved -``` - -Only **approved** apps appear in the catalog and can be given to people. - -## People vs. code - -People sign in to the Hub with their own account. Code uses API keys. An API key can never create or change API keys, so a leaked key can't make more of itself. Every change made with a key is recorded in the audit log under that key. diff --git a/help/concepts/how-access-works.md b/help/concepts/how-access-works.md deleted file mode 100644 index 9b86917..0000000 --- a/help/concepts/how-access-works.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -layout: default -title: How access works -parent: Concepts -nav_order: 1 -permalink: /concepts/how-access-works/ ---- - -# How access works -{: .no_toc } - -Two questions decide what a person can do in an app, always in this order. -{: .fs-6 .fw-300 } - -1. TOC -{: toc } - ---- - -## 1. Is the app on for them? - -That's the **entitlement**: the on/off switch between a person and an app. The Hub shows it as a status badge and a switch. - -| Status | Meaning | Can they use the app? | -|---|---|---| -| **Active** | On. | Yes | -| **Scheduled** | On, starting later. | Not yet | -| **Disabled** | Turned off by an admin. Reversible. | No | -| **Expired** | A trial or subscription ran out. | No | -| **Revoked** | The purchase itself was removed (refund, chargeback). | No | -| **None** | No access at all. | No | - -Turning an app off changes only this switch. Their roles, profile and settings in the app are kept, and come back when it's turned on again. - -## 2. If it's on, what can they do? - -That's their **roles**, each a set of **permissions**. See [Roles and permissions](../roles-and-permissions/). A role does nothing while the app is off for them. - -## Where access comes from - -A person can have several paths to the same app at once. The app is on if **any** path is on. - -| Path | Comes from | Can you edit it? | -|---|---|---| -| **Free** | Every account gets free apps. | Turn off and on only ("Managed by Substratal") | -| **Standard** | Their own $6/month subscription, or an organization's. | Turn off and on only | -| **Purchase**, **Trial**, **Admin grant** | Given by an admin or the app's developer. | Yes | -| **Organization seat** | An organization granted the app to its members. | By that organization's admins | - -Choose **Why?** on a person's Access tab to see every path and which one decides. - -{: .important } -Turning off one organization's seat never removes access the person has another way. The app stays on if another path is active. - -## How apps find out - -Apps don't work this out themselves. They ask the platform, or listen for webhooks such as `access.revoked`, so a change you make in the Hub reaches the app within minutes at most. diff --git a/help/concepts/index.md b/help/concepts/index.md deleted file mode 100644 index 94d37c3..0000000 --- a/help/concepts/index.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -layout: default -title: Concepts -nav_order: 4 -has_children: true -permalink: /concepts/ ---- - -# Concepts -{: .no_toc } - -The ideas every Hub screen is built on. Read these once and the rest of the app makes sense. -{: .fs-6 .fw-300 } - -- [How access works](how-access-works/): whether an app is on for a person, and why. -- [Roles and permissions](roles-and-permissions/): what a person can do, on the platform and inside each app. -- [Applications, keys and webhooks](applications-keys-webhooks/): how an app plugs into the platform. -- [Test mode and live mode](test-and-live-mode/): building safely next to real users. - -The full model is in the Platform API specification: [Access Control](https://compositecode.github.io/substratalapps.com/access-control/) and [Domain Model](https://compositecode.github.io/substratalapps.com/domain-model/). diff --git a/help/concepts/roles-and-permissions.md b/help/concepts/roles-and-permissions.md deleted file mode 100644 index aaa8115..0000000 --- a/help/concepts/roles-and-permissions.md +++ /dev/null @@ -1,63 +0,0 @@ ---- -layout: default -title: Roles and permissions -parent: Concepts -nav_order: 2 -permalink: /concepts/roles-and-permissions/ ---- - -# Roles and permissions -{: .no_toc } - -A permission is one thing a person can do. A role is a named set of permissions. You give people roles, not permissions. -{: .fs-6 .fw-300 } - -1. TOC -{: toc } - ---- - -## Two kinds of role - -- **Platform roles** govern the Hub itself: who can manage users, keys, the audit log, and so on. Staff hold these. -- **App roles** are defined by each app (for example `admin`, `editor`, `viewer` in TimeTrack) and grant only that app's permissions, named `app..`. - -The two never mix: a platform role never grants anything inside an app, and an app role never unlocks a Hub screen. - -## Built-in platform roles - -| Role | For | Can't be edited | -|---|---|---| -| `superadmin` | The platform owner. Everything. | ✓ | -| `support` | Helping users: see users, turn access on/off, read the audit log, tenants, billing | ✓ | -| `billing_admin` | Billing: subscriptions, refunds, the audit log, the user list | ✓ | -| `member` | Every account. Nothing beyond your own account. | ✓ | - -You can create more platform roles with any mix of the permissions below, but only with permissions you hold yourself. - -## Platform permissions and what they show you - -| Permission | Lets you | Hub sections it opens | -|---|---|---| -| `users.list` | See the user directory and roles | Users, Roles (read) | -| `users.manage` | Invite, edit, suspend and delete users | Users actions | -| `entitlements.manage` | Turn apps on and off for anyone | Access | -| `roles.manage` | Create roles and assign them | Roles actions | -| `applications.manage` | Register and review any app | Applications | -| `api_keys.manage` | Manage any API key | API Keys | -| `webhooks.manage` | Manage any webhook | Webhooks | -| `tenants.manage` | Manage tenants and tier changes | Tenant | -| `organizations.manage` | Manage any organization | Organizations | -| `billing.manage` | See and manage subscriptions | Plan & Billing (others) | -| `billing.refund` | Issue refunds | — | -| `audit.view` | Read the audit log | Audit Log | -| `notifications.send` | Send notifications to users | Send notification | - -**App owners** don't need any of these for their own apps: owning an app lets you manage its access, keys, webhooks and settings. - -{: .note } -The web Hub's Operations console will use `operations.view` and `operations.manage` once the API adds them. - -## Seeing a section but not a button - -If you can see a screen but an action is greyed out, you can read but not change. Hover over it for the reason, such as "Seed roles can't be edited" or "You're the last admin". diff --git a/help/concepts/test-and-live-mode.md b/help/concepts/test-and-live-mode.md deleted file mode 100644 index 854191e..0000000 --- a/help/concepts/test-and-live-mode.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -layout: default -title: Test mode and live mode -parent: Concepts -nav_order: 4 -permalink: /concepts/test-and-live-mode/ ---- - -# Test mode and live mode -{: .no_toc } - -Build and try things next to real users without touching them. -{: .fs-6 .fw-300 } - -Users, API keys, webhooks, devices, notifications and subscriptions are each either **live** or **test**. The two never see each other: - -- A **test** API key only sees test-mode users and data. -- A **live** key only sees live data. -- Test-mode users expire: 24 hours after they're created unless you choose a later time (up to 30 days). After that they can't sign in, and their data is removed after 30 days. - -In the Hub, test-mode rows are labelled **Test**. When the app is connected to the local devserver, the sign-in screen offers **Test-mode account**. - -{: .tip } -Test-mode subscriptions use Stripe's test mode and test cards, on a deployment that has Stripe test keys (such as the local devserver). Where there are none, the API says so (`test_mode_billing_unavailable`). diff --git a/help/faq.md b/help/faq.md deleted file mode 100644 index 9063b69..0000000 --- a/help/faq.md +++ /dev/null @@ -1,73 +0,0 @@ ---- -layout: default -title: FAQ -nav_order: 7 -permalink: /faq/ ---- - -# Frequently Asked Questions -{: .no_toc } - -1. TOC -{: toc } - ---- - -## Using the Hub - -### Why can't I see Administration or Developer in the sidebar? -{: #why-cant-i-see-administration } - -The sidebar only shows what your account can use. Administration needs a staff permission (such as `users.list` or `audit.view`) or owning an app; Developer needs owning an app or a developer permission. See [Roles and permissions](../concepts/roles-and-permissions/). - -### Does turning an app off delete anything? - -No. It flips one switch. Roles, profile and settings in the app are kept and come back when you turn it on again. See [How access works](../concepts/how-access-works/). - -### Someone still has access after I turned it off. Why? - -They probably have another path to the app, such as a Standard subscription or an organization's seats. Choose **Why?** on their Access tab to see every path. - -### Can I undo a change? - -Turning access on or off can be undone with **⌘Z** for 10 seconds. Other changes can't be undone, but every one is in the [Audit Log](../tutorials/investigate-with-the-audit-log/) with the before and after values. - -### Where is the Operations console (health, traffic, logs, alarms, cost)? - -In the web Hub only. It reads AWS monitoring data through a server the native apps can't reach without exposing that data publicly. The macOS app has everything else. - -### Can I use the Hub on more than one Mac? - -Yes. Sign in on each. Each Mac has its own session, listed under **Account → Security**, where you can end any of them. - -### Is my password stored on this Mac? - -No. The app stores only a session token, in the macOS Keychain, readable only by this app on this Mac. Signing out deletes it. - -## API keys - -### I lost an API key's secret. Can I see it again? - -No, secrets are shown once. Open the key and choose **Rotate** to issue a new secret; the old one stops working immediately. - -### Should I use an API key in a website or app that runs in the browser? - -No. The API refuses API keys sent from a browser (`400 api_key_in_browser`). Keys belong on servers. Browser apps sign people in instead. - -### What does "Block destructive operations" do? - -A key with it on can't turn access off, revoke, delete or suspend anything, whatever permissions it has. Keys for AI agents have it on by default. - -## API versions - -### Why don't the API's URLs have `/v1` in them anymore? - -The API version moved into a header: every request sends `Substratal-Version: 1`, and URLs stay the same across versions. Old `/v1/…` URLs return `404`. See [API versioning](../api/versioning/). - -### How do I know which API version this app uses? - -**Settings → Connection** shows it, and so does the [Platform API](../api/) page for each version of this Help Center. - -### Will the app stop working when the API changes? - -Not without warning. Additive changes don't affect it. A breaking change ships as a new API version, and the old one keeps working for at least 6 months. The app shows **Update available** with the date it will stop working. diff --git a/help/getting-started/index.md b/help/getting-started/index.md deleted file mode 100644 index ea3c03e..0000000 --- a/help/getting-started/index.md +++ /dev/null @@ -1,69 +0,0 @@ ---- -layout: default -title: Getting Started -nav_order: 2 -permalink: /getting-started/ ---- - -# Getting Started -{: .no_toc } - -Install Substratal Hub, sign in, and find your way around. -{: .fs-6 .fw-300 } - -1. TOC -{: toc } - ---- - -## What you need - -- A Mac running **macOS 15 Sequoia or later**. -- A Substratal account. Staff accounts are created by a superadmin, which sends you an invitation email. Developers can sign up from the web Hub. -- **Two-factor authentication**, if your account administers anything. Turn it on under **Account → Security** after you first sign in. - -## Install - -Download the latest build from the [Releases page](https://github.com/CompositeCode/substratalapps-macos/releases), open the disk image, and drag **Substratal Hub** to Applications. Builds are signed and notarized by Apple. Once installed, the app updates itself. - -{: .note } -Until the first notarized release is published, the app is built from source: see the repository's README. - -## Sign in - -1. Open Substratal Hub. The sign-in screen ([AUTH-01](../screens/auth-01/)) appears. -2. Enter your email and password, and choose **Sign In**. -3. If your account has two-factor authentication on, enter the 6-digit code from your authenticator app ([AUTH-02](../screens/auth-02/)). Lost your phone? Choose **Use a recovery code** and enter one of the codes you saved when you turned two-factor on. - -You stay signed in until you sign out, until 30 days pass without using the app, or for at most 90 days, whichever comes first. The app keeps your session in the macOS Keychain, on this Mac only. Signing out ends the session on the server too. - -## Find your way around - -The window has a **sidebar** on the left and the selected screen on the right. - -| Section | What's in it | Who sees it | -|---|---|---| -| **Personal** | Home (your apps), Inbox, Organizations, Account, Plan & Billing, Settings | Everyone | -| **Administration** | Users, Access, Roles, Audit Log, Send notification | Staff with the matching permission, and app owners (Access only) | -| **Developer** | Applications, API Keys, Webhooks, Tenant | App owners, and staff who manage them | - -You only see what your account can use. If a section or item is missing, your account doesn't have the permission it needs. See [Roles and permissions](../concepts/roles-and-permissions/). - -Every screen shows its ID (for example `USR-01`) under the title. Use **Help → Help for This Screen** (or the **?** button in the toolbar) to open its page here. - -{: .macos } -Press **⌘1** to **⌘6** for the Personal items, **⌥⌘1** to **⌥⌘4** for Administration and **⌃⌘1** to **⌃⌘4** for Developer. Right-click any sidebar item and choose **Open in New Window** to keep it alongside another screen. See [Keyboard Shortcuts](../reference/keyboard-shortcuts/). - -## Choose which API the app talks to - -Most people never change this. Developers working on the platform itself can point the app at a local copy of the API: - -1. Choose **Substratal Hub → Settings…** (⌘,) and open **Connection**. -2. Choose **Local devserver** and check the URL (`http://localhost:8080` by default, the API repository's `make run`). - -Switching signs you out. Production and the devserver each keep their own session, so a production token is never sent to a local server. - -## Next - -- [Tutorials](../tutorials/) for common jobs, step by step. -- [Concepts](../concepts/) to understand access, roles and Applications. diff --git a/help/index.md b/help/index.md deleted file mode 100644 index 8c13ad7..0000000 --- a/help/index.md +++ /dev/null @@ -1,49 +0,0 @@ ---- -layout: default -title: Home -nav_order: 1 -permalink: / ---- - -# Substratal Hub Help -{: .fs-9 } - -Run the Substratal Apps platform: the people, their access to each app, roles and permissions, Applications, API keys, webhooks and the audit trail behind `{{ site.data.build.api_base_url | default: "https://api.substratalapps.com" }}`. -{: .fs-6 .fw-300 } - -[Get started](getting-started/){: .btn .btn-primary .fs-5 .mb-4 .mb-md-0 .mr-2 } [Tutorials](tutorials/){: .btn .fs-5 .mb-4 .mb-md-0 .mr-2 } [API reference](api/){: .btn .fs-5 .mb-4 .mb-md-0 } - ---- - -## What's here - -| If you want to… | Go to | -|---|---| -| Install the app and sign in for the first time | [Getting Started](getting-started/) | -| Do a specific job, step by step (invite someone, turn an app off, create an API key) | [Tutorials](tutorials/) | -| Understand how access, roles and permissions fit together | [Concepts](concepts/) | -| Look up a screen you're on | [Screen Reference](screens/), or **Help → Help for This Screen** in the app | -| Find a keyboard shortcut | [Keyboard Shortcuts](reference/keyboard-shortcuts/) | -| Fix an error message | [Troubleshooting](reference/troubleshooting/) | -| Get a quick answer | [FAQ](faq/) | -| Build against the API the app uses | [Platform API](api/), with the [interactive reference](api/reference/) | -| See what changed | [Release Notes](release-notes/) and [Versions](versions/) | - -## One Hub, several apps - -Substratal Hub is one product with the same screens, names and routes on every platform: macOS (this app), the web Hub at `dashboard.substratalapps.com`, iOS, Android, Windows and Linux. This Help Center is written for all of them. Where a platform differs, the page says so: - -{: .macos } -Keyboard shortcuts, menus, multiple windows and the Keychain are macOS-specific. The web Hub adds the Operations console (health, traffic, logs, alarms, cost), which the native apps leave out. - -Each screen has an ID such as `USR-01`, shown under the window title. Every platform uses the same ID, and every ID has a page in the [Screen Reference](screens/), so a link from any Hub app lands on the right page. - -## This version - -| | | -|---|---| -| App version | {{ site.data.build.app_version | default: "local build" }} | -| Platform API | spec `{{ site.data.build.api_spec_version | default: "—" }}`, `Substratal-Version: {{ site.data.build.api_header_version | default: "1" }}`, {{ site.data.build.api_operations | default: "—" }} operations | -| Screens | {{ site.data.build.screens_built | default: "—" }} of {{ site.data.build.screens_total | default: "—" }} built | - -Older versions of this site are kept under [Versions](versions/), so a link always shows what that version said. diff --git a/help/reference/index.md b/help/reference/index.md deleted file mode 100644 index bde39c4..0000000 --- a/help/reference/index.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -layout: default -title: Reference -nav_order: 6 -has_children: true -permalink: /reference/ ---- - -# Reference -{: .no_toc } - -- [Keyboard Shortcuts](keyboard-shortcuts/) -- [Troubleshooting](troubleshooting/): error messages and what to do -- [Screen Reference](../screens/): every screen, by area diff --git a/help/reference/keyboard-shortcuts.md b/help/reference/keyboard-shortcuts.md deleted file mode 100644 index d388d44..0000000 --- a/help/reference/keyboard-shortcuts.md +++ /dev/null @@ -1,61 +0,0 @@ ---- -layout: default -title: Keyboard Shortcuts -parent: Reference -nav_order: 1 -permalink: /reference/keyboard-shortcuts/ ---- - -# Keyboard Shortcuts -{: .no_toc } - -Every shortcut is also in a menu, so you can always find it there. -{: .fs-6 .fw-300 } - -{: .macos } -These are the macOS app's shortcuts. The web Hub uses Ctrl in place of ⌘ on Windows and Linux. - -## Go to a section - -The **Go** menu. Generated from the app's own screen catalog, so it always matches the app. - -| Shortcut | Goes to | Section | -|---|---|---| -{% for screen in site.data.screens.screens -%} -{%- if screen.sidebar and screen.sidebar.shortcut -%} -{%- assign keys = screen.sidebar.shortcut | replace: "ctrl+", "⌃" | replace: "opt+", "⌥" | replace: "shift+", "⇧" | replace: "cmd+", "⌘" -%} -| **{{ keys }}** | [{{ screen.sidebar.label }}]({{ '/screens/' | append: screen.id | downcase | append: '/' | relative_url }}) | {{ screen.sidebar.section | capitalize }} | -{% endif -%} -{%- endfor %} - -## Everywhere - -| Shortcut | Action | Menu | -|---|---|---| -| **⌘K** | Command palette: find any user, app, key or event | Go | -| **⌘N** | New window | File | -| **⇧⌘N** | New item on this screen (Invite user, New role, Create key…) | File | -| **⌘F** | Search this list | Edit | -| **⌘R** | Refresh | View | -| **⌃⌘S** | Show or hide the sidebar | View | -| **⌥⌘I** | Show or hide the inspector | View | -| **⌘,** | Settings | Substratal Hub | -| **⌘?** | Substratal Hub Help | Help | - -## Lists and tables - -| Shortcut | Action | -|---|---| -| Click, **⇧**-click, **⌘**-click, **⌘A** | Select one, a range, several, or all | -| **Space** | Preview the selected row | -| **⌘C** | Copy the selected row's ID | -| **⌘⌫** | Remove, revoke or suspend the selection (asks first) | -| **⌘Z** | Undo turning access on or off (within 10 seconds) | -| **⇧⌘U** | Mark all as read (Inbox) | - -## Forms and sheets - -| Shortcut | Action | -|---|---| -| **⌘↩** | Submit | -| **Esc** | Cancel | diff --git a/help/reference/troubleshooting.md b/help/reference/troubleshooting.md deleted file mode 100644 index a7fbc75..0000000 --- a/help/reference/troubleshooting.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -layout: default -title: Troubleshooting -parent: Reference -nav_order: 2 -permalink: /reference/troubleshooting/ ---- - -# Troubleshooting -{: .no_toc } - -What an error means and what to do about it. -{: .fs-6 .fw-300 } - -1. TOC -{: toc } - ---- - -## Signing in - -| You see | Why | What to do | -|---|---|---| -| "That email and password don't match." | Wrong email or password. | Check both, or use **Forgot password?** | -| "Too many attempts." | Too many tries in a short time. | Wait a few minutes. | -| "This account is suspended." | An admin suspended the account. | Contact your Substratal admin. | -| "That code didn't work." | The two-factor code is wrong or expired (they change every 30 seconds). | Check your Mac's clock is set automatically, then try the current code. Or use a recovery code. | -| "This test account has expired." | Test-mode users stop working at their expiry time. | Create a new test user, or ask for the expiry to be extended. | -| You're asked to sign in again unexpectedly | Your session ended: you signed out elsewhere with **Sign out everywhere**, an admin ended it, 30 days passed without use, or 90 days passed since you signed in. | Sign in again. | - -## Update notices - -| You see | Why | What to do | -|---|---|---| -| **Update available.** This version stops working on a date. | The API is retiring the version this app was built for. You have at least 6 months. | Update the app (it updates itself, or download the latest release). | -| **Update required.** | The API no longer supports this version of the app. The app stops calling the API until it's updated. | Update the app. | - -## Anywhere in the app - -| You see | Code | What to do | -|---|---|---| -| A screen or button is missing | — | Your account doesn't have the permission. See [Roles and permissions](../../concepts/roles-and-permissions/). | -| "You don't have permission…" | `403` | Your permissions changed while you were working. The app refreshes them; ask an admin if you need more. | -| "Someone else changed this…" | `409 version_conflict` | Another person saved the same thing first. The app reloads it; make your change again. | -| "Too many requests." | `429` | The app slows down automatically. Wait a moment. | -| "Substratal is briefly unavailable." | `503` | Maintenance or an outage. The app retries; check again in a few minutes. | -| "Couldn't reach Substratal." | — | Check your network. If you use a VPN or proxy, check it allows `api.substratalapps.com`. | - -## Reporting a problem - -Choose **Help → Help for This Screen** to check you're on the right page, then report the screen ID (for example `USR-01`), what you did, and the time. Every API response carries a request ID (`req_…`) that support can look up. Please don't include passwords, tokens or API key secrets. - -## For developers - -API error codes, the error format and every status are in the API's [Conventions → Errors](https://compositecode.github.io/substratalapps.com/api-reference/conventions/#errors). The two version errors: - -- `400 api_version_required`: the request had no `Substratal-Version` header. Always a client bug. -- `400 unsupported_api_version`: the header names a version the API doesn't serve. `details.supported` lists the ones it does. diff --git a/help/screens/index.md b/help/screens/index.md deleted file mode 100644 index 0e1adba..0000000 --- a/help/screens/index.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -layout: default -title: Screen Reference -nav_order: 5 -has_children: true -permalink: /screens/ ---- - -# Screen Reference -{: .no_toc } - -Every screen in Substratal Hub, by area. Each has the same ID on every platform. In the app, the ID is under the window title, and **Help → Help for This Screen** opens its page here. -{: .fs-6 .fw-300 } - -These pages are generated from the app's screen catalog every time the app changes, so they always list exactly the screens the app has, and say which are built in this version. - -{% assign groups = site.data.screens.screens | group_by: "domain" -%} -| Area | Screens | -|---|---| -{% for group in groups -%} -{%- assign parts = group.name | split: " " -%} -{%- assign code = parts | first -%} -{%- assign name = group.name | remove_first: code | strip -%} -{%- assign slug = group.name | slugify -%} -| [{{ name }}]({{ '/screens/' | append: slug | append: '/' | relative_url }}) | {{ group.items.size }} | -{% endfor %} diff --git a/help/tutorials/assign-a-role.md b/help/tutorials/assign-a-role.md deleted file mode 100644 index 3d39db9..0000000 --- a/help/tutorials/assign-a-role.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -layout: default -title: Give someone a role -parent: Tutorials -nav_order: 3 -permalink: /tutorials/assign-a-role/ -screens: [USR-03, ROL-01, ROL-02, ROL-04] ---- - -# Give someone a role -{: .no_toc } - -Roles decide what a person can do: on the platform (for staff) or inside one app. -{: .fs-6 .fw-300 } - -{% include availability.html %} - -**You'll need:** `roles.manage`. For a platform role, you also need every permission the role grants: nobody can give out more than they hold. - -1. Open **Users** (⌥⌘1), select the person, and choose the **Roles** tab. -2. Choose **Assign role**, search for the role, and select it. -3. The role appears in the list straight away. Assigning a role they already hold does nothing. - -To remove a role, select it and press **⌘⌫**, or use its **Remove** button. Two kinds of role can't be removed: - -- **member**: every account holds this platform role. -- Roles marked **Granted with app access**: an app's default role, which comes with access to the app and goes when access is turned off. - -## See who holds a role - -Open **Roles** (⌥⌘3) and select the role. **People with this role** lists everyone it's assigned to. - -## Create a role - -1. In **Roles**, choose **New Role** (⇧⌘N). -2. Choose the scope: **Platform** for staff permissions, or an app for that app's own permissions. -3. Name it and tick its permissions. Choose **Create**. - -The built-in roles (`superadmin`, `support`, `billing_admin`, `member`) can't be edited. See [Roles and permissions](../concepts/roles-and-permissions/) for what each permission allows. diff --git a/help/tutorials/create-an-api-key.md b/help/tutorials/create-an-api-key.md deleted file mode 100644 index 766548e..0000000 --- a/help/tutorials/create-an-api-key.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -layout: default -title: Create an API key -parent: Tutorials -nav_order: 4 -permalink: /tutorials/create-an-api-key/ -screens: [KEY-01, KEY-02, KEY-03, KEY-04] ---- - -# Create an API key -{: .no_toc } - -Give a backend service, script or AI agent its own credential for the Platform API. -{: .fs-6 .fw-300 } - -{% include availability.html %} - -**You'll need:** to own the app the key is for, or `api_keys.manage` for platform keys. An API key can never create another key: only a person can. - -1. In the sidebar, under **Developer**, choose **API Keys** (⌃⌘2), then **Create Key** (⇧⌘N). -2. **Name** it after what will use it, such as `timetrack-backend`. -3. **Scope:** your app, or **Platform** (staff only). -4. **Mode:** **Live** for real users, **Test** for test-mode users only. You can't change this later. -5. **Used by:** - - **Service:** code you wrote, calling fixed endpoints. - - **Agent:** an AI agent or MCP client. This turns on **Block destructive operations**, so the key can't turn access off, delete or revoke anything, whatever its permissions. -6. Tick only the **Permissions** it needs. -7. Optionally set when it **Expires** (30, 90 or 365 days). -8. Choose **Create**. - -## Save the secret: you see it once - -The next screen shows the secret (`satk_live_…`). It is never shown again. - -1. Choose **Copy** and paste it straight into where it's stored, such as your service's secret manager. The clipboard clears itself after 60 seconds. -2. Tick **I've saved this secret**, then choose **Done**. - -{: .macos } -The secret window is hidden from screenshots and screen sharing while it's open. - -Lost it? Open the key and choose **Rotate**. That issues a new secret and stops the old one working at once. - -## Use the key - -```bash -curl https://api.substratalapps.com/users/usr_01JAG.../apps/app_timetrack/effective-permissions \ - -H "Authorization: Bearer satk_live_..." \ - -H "Substratal-Version: 1" -``` - -Every request needs the `Substratal-Version` header. See [Platform API](../../api/). - -## Keep keys tidy - -On **API Keys**, the list shows each key's last 4 characters, when it was last used and when it expires. Revoke keys nobody uses (**⌘⌫**). Revoking is permanent. diff --git a/help/tutorials/index.md b/help/tutorials/index.md deleted file mode 100644 index 252c625..0000000 --- a/help/tutorials/index.md +++ /dev/null @@ -1,24 +0,0 @@ ---- -layout: default -title: Tutorials -nav_order: 3 -has_children: true -permalink: /tutorials/ ---- - -# Tutorials -{: .no_toc } - -Common jobs, step by step. Each tutorial names the permission it needs and the screens it uses. -{: .fs-6 .fw-300 } - -| Tutorial | You'll need | -|---|---| -| [Invite a user](invite-a-user/) | `users.manage` | -| [Turn an app on or off for someone](turn-access-on-or-off/) | `entitlements.manage`, or owning the app | -| [Give someone a role](assign-a-role/) | `roles.manage` (and every permission the role grants) | -| [Create an API key](create-an-api-key/) | `api_keys.manage`, or owning the app | -| [Register an Application](register-an-application/) | a verified email address (self-service), or `applications.manage` | -| [Find out who changed something](investigate-with-the-audit-log/) | `audit.view` | - -Missing a section or button? Your account doesn't have the permission. See [Roles and permissions](../concepts/roles-and-permissions/). diff --git a/help/tutorials/investigate-with-the-audit-log.md b/help/tutorials/investigate-with-the-audit-log.md deleted file mode 100644 index 7ecc484..0000000 --- a/help/tutorials/investigate-with-the-audit-log.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -layout: default -title: Find out who changed something -parent: Tutorials -nav_order: 6 -permalink: /tutorials/investigate-with-the-audit-log/ -screens: [AUD-01, AUD-02, USR-03] ---- - -# Find out who changed something -{: .no_toc } - -Every change on the platform is recorded in the audit log: who did it, to what, and when. -{: .fs-6 .fw-300 } - -{% include availability.html %} - -**You'll need:** `audit.view` (the `superadmin`, `support` and `billing_admin` roles have it). - -1. Under **Administration**, choose **Audit Log** (⌥⌘4). -2. Narrow it down with the filters: - - **Actor:** a person, an API key, or the system (scheduled jobs). - - **Action:** for example `entitlement.disabled` or `api_key.revoked`. - - **Target:** the user, app, organization or key that was changed. - - **Time range.** -3. Select an event. The inspector (⌥⌘I) shows the before and after values. - -## Common questions - -| Question | Filter | -|---|---| -| Who turned off Dana's access? | Target = Dana's user, action `entitlement.disabled` | -| What did this API key do? | Actor = the key | -| What have I changed today? | Actor = you, time range today | -| What happened to this app? | App = the app | - -To see one person's history, open them in **Users** and choose the **Activity** tab. - -## Limits - -- The log covers the last year. Older events are archived and available through support. A banner tells you when your search reaches the start of what's available. -- Events can't be edited or deleted by anyone. -- To keep a copy, export the range you're looking at (staff with `audit.view`). - -{: .macos } -Drag an event out of the list to save it as a `.json` file. diff --git a/help/tutorials/invite-a-user.md b/help/tutorials/invite-a-user.md deleted file mode 100644 index fdf8b80..0000000 --- a/help/tutorials/invite-a-user.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -layout: default -title: Invite a user -parent: Tutorials -nav_order: 1 -permalink: /tutorials/invite-a-user/ -screens: [USR-01, USR-02, USR-03] ---- - -# Invite a user -{: .no_toc } - -Create an account for someone and email them an invitation. -{: .fs-6 .fw-300 } - -{% include availability.html %} - -**You'll need:** the `users.manage` permission (the `superadmin` role has it). - -1. In the sidebar, under **Administration**, choose **Users** (⌥⌘1). -2. Choose **Invite User** in the toolbar (or press ⇧⌘N). -3. Enter their **Email**. **Display name** is optional; they can change it later. -4. Leave **Send invitation email** on. They get a link to set a password and finish signing up. -5. Choose **Invite**. - -The new user opens with status **Invited**. When they accept, the status changes to **Active**. - -## Variations - -- **Create as active:** turn on **Create as active** to skip the invitation, for example for a service account that signs in another way. They still need to set a password through **Forgot password**. -- **Already exists:** if someone already has an account with that email, you'll see "A user with that email exists" with a link to their account. -- **Resend:** if the invitation expired or went to spam, open the user and choose **Resend invitation**. - -## What happens behind the scenes - -The app calls `POST /users` with an `Idempotency-Key`, so a retry after a network drop never creates two accounts. The invitation is recorded in the [Audit Log](investigate-with-the-audit-log/) as `user.created`. - -{: .note } -Invitation emails reach real inboxes only once the platform's email sending is out of the Amazon SES sandbox. Until then, only verified addresses receive them. diff --git a/help/tutorials/register-an-application.md b/help/tutorials/register-an-application.md deleted file mode 100644 index 268e053..0000000 --- a/help/tutorials/register-an-application.md +++ /dev/null @@ -1,39 +0,0 @@ ---- -layout: default -title: Register an Application -parent: Tutorials -nav_order: 5 -permalink: /tutorials/register-an-application/ -screens: [APL-01, APL-02, APL-03, APL-06] ---- - -# Register an Application -{: .no_toc } - -Add an app to the Substratal catalog so people can be given access to it. -{: .fs-6 .fw-300 } - -{% include availability.html %} - -**You'll need:** a verified email address. Staff with `applications.manage` can also register apps for others and review submissions. - -1. Under **Developer**, choose **Applications** (⌃⌘1), then **Register App** (⇧⌘N). -2. Fill in its **Name**, **Slug** (used in permission keys like `app.timetrack.export`; can't change later), **Description** and **Launch URL**. -3. Choose **Register**. - -Your app is created **pending review**. Until it's approved, only you and Substratal staff can see it, and nobody can be given access to it or launch it. You can still set it up: its permissions, roles, settings schema, API keys and webhooks. - -## Review (staff) - -Open **Applications** and filter by **Pending review**: that's the queue, oldest first. - -- **Approve** puts it in the catalog. -- **Reject** needs a note saying what to fix. The developer edits the app and it returns to the queue. -- **Suspend** (with a note) stops new grants and new sign-ins to an approved app, for example if its signing secret looks compromised. Existing access is left as it is, and **Approve** reinstates it. - -## Next steps for the app - -- Define its permissions and roles (**Permissions & roles** tab). -- [Create an API key](../create-an-api-key/) for its backend. -- Add a webhook so it hears `access.revoked` immediately. -- Add push credentials if it sends notifications. diff --git a/help/tutorials/turn-access-on-or-off.md b/help/tutorials/turn-access-on-or-off.md deleted file mode 100644 index a1e14c7..0000000 --- a/help/tutorials/turn-access-on-or-off.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -layout: default -title: Turn an app on or off for someone -parent: Tutorials -nav_order: 2 -permalink: /tutorials/turn-access-on-or-off/ -screens: [USR-03, ACX-01, ACX-02, ACX-03, ACX-04] ---- - -# Turn an app on or off for someone -{: .no_toc } - -Give a person access to an app, or switch it off, without touching their data. -{: .fs-6 .fw-300 } - -{% include availability.html %} - -**You'll need:** `entitlements.manage`, or to own the app. App owners only see their own apps. - -1. TOC -{: toc } - ---- - -## Turn access off - -1. Open **Users** (⌥⌘1) and select the person. -2. Choose the **Access** tab. Each app shows whether it's on for them, and why. -3. Flip the app's switch off. Add a **Reason** if you like; it's kept in the audit log. -4. Confirm **Turn off**. - -Their sessions in that app end within minutes: apps are told immediately through the `access.revoked` webhook, and app tokens last at most 5 minutes. - -{: .tip } -Changed your mind? Press **⌘Z** within 10 seconds to undo. - -Turning access off keeps everything else: their roles, profile and settings in that app are untouched, and come back exactly as they were when you turn it on again. See [How access works](../concepts/how-access-works/). - -## Give access - -1. On the person's **Access** tab, choose **Grant access**. -2. Pick the **App** and the **Type**: - - **Purchase:** they bought it outside Substratal. Add the **Order ID**. - - **Trial:** set when it **Ends**. - - **Admin grant:** you're giving it to them. -3. **Starts** defaults to now. Choose **Grant**. - -You can't grant **Free** or **Standard** access by hand. The platform gives free apps to everyone, and Standard apps to subscribers, automatically. Those rows show "Managed by Substratal": you can turn them off and back on, but not edit or delete them. - -## Find out why someone can or can't use an app - -On the **Access** tab, choose **Why?** next to an app. It shows every path the person has to the app (their own grant, a Standard subscription, or an organization's seats) and which one decides the result. This is the same answer the app itself gets from the API. diff --git a/help/versions/index.md b/help/versions/index.md deleted file mode 100644 index e478395..0000000 --- a/help/versions/index.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -layout: default -title: Versions -nav_order: 99 -permalink: /versions/ ---- - -# Versions -{: .no_toc } - -Every published version of this Help Center. Each publish gets a new version and a frozen, read-only copy here, so a link to an old version keeps showing exactly what it said, including which app version and which Platform API version it described. -{: .fs-6 .fw-300 } - -{% assign current = site.data.versions.current -%} -{% if current -%} -| Docs | Released | Hub for macOS | API spec | `Substratal-Version` | 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.app_version | default: "—" }} | {{ v.api_spec_version | default: "—" }} | {{ v.api_header_version | default: "—" }} | {{ v.summary | default: "" | replace: "|", "\|" }}{% if v.commit %} ([`{{ v.commit | slice: 0, 7 }}`](https://github.com/CompositeCode/substratalapps-macos/commit/{{ v.commit }})){% endif %} | -{% endfor %} -{% else %} -This is a local build. Version numbers and the archive are assigned only when the site is published from GitHub. -{: .note } -{% endif %} - -For what changed in the app, see [Release Notes](../release-notes/). For the API, see its [Changelog](https://compositecode.github.io/substratalapps.com/changelog/). diff --git a/project.yml b/project.yml index 8a35e42..d8b9026 100644 --- a/project.yml +++ b/project.yml @@ -54,8 +54,8 @@ targets: CFBundleShortVersionString: $(MARKETING_VERSION) CFBundleVersion: $(CURRENT_PROJECT_VERSION) LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET) - # Help Center root (help/ in this repo, on GitHub Pages). - SubstratalHelpBaseURL: https://compositecode.github.io/substratalapps-macos/ + # The shared Hub help center (specs repo, help/), which every Hub app links to. + SubstratalHelpBaseURL: https://compositecode.github.io/substratalapps-applications-specs/help/ CFBundleURLTypes: - CFBundleURLName: com.substratalapps.hub.deeplink CFBundleURLSchemes: [substratal] diff --git a/scripts/help-build.rb b/scripts/help-build.rb deleted file mode 100644 index 53cbb8f..0000000 --- a/scripts/help-build.rb +++ /dev/null @@ -1,136 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Versioned build of the Help Center. Run by .github/workflows/help.yml on every -# push to main that touches the app or the help site, and runnable locally the -# same way. Adapted from the API repository's scripts/docs-build.rb, so both -# sites version the same way. -# -# scripts/help-build.rb --archive DIR --out DIR [--bump patch|minor|major] -# [--commit SHA] [--summary TEXT] [--released YYYY-MM-DD] -# -# --archive is a checkout of the `help-versions` branch (an empty dir is fine for -# the first build): manifest.yml plus one frozen static build per version. -# -# Each run: -# 1. Generates the app-derived pages (scripts/help-generate.rb). -# 2. Picks the next docs version (1.0.0 for an empty archive, else bumped). -# 3. Writes help/_data/versions.yml for the header badge and Versions page; -# each entry records the app version and Platform API version it describes. -# 4. Builds this version as a frozen snapshot at //. -# 5. Builds the live site into --out with every snapshot under /versions//. -# -# Committing the archive back and deploying --out are the workflow's job. - -require "date" -require "fileutils" -require "optparse" -require "tmpdir" -require "yaml" - -ROOT = File.expand_path("..", __dir__) -HELP = File.join(ROOT, "help") -DATA_FILE = File.join(HELP, "_data", "versions.yml") -BUMPS = %w[patch minor major].freeze - -def abort_with(msg) - warn "help-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] - -generate = [File.join(ROOT, "scripts", "help-generate.rb")] -generate += ["--commit", opts[:commit]] if opts[:commit] -system("ruby", *generate) || abort_with("help-generate failed") -build = YAML.safe_load(File.read(File.join(HELP, "_data", "build.yml"))) - -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(HELP) 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], - "app_version" => build["app_version"], - "api_spec_version" => build["api_spec_version"], - "api_header_version" => build["api_header_version"], -} -current["commit"] = opts[:commit] if opts[:commit] -current["summary"] = opts[:summary].lines.first.strip if opts[:summary] && !opts[:summary].strip.empty? - -File.write(DATA_FILE, <<~HEAD + { "current" => current, "archive" => versions }.to_yaml.sub(/\A---\n/, "")) - # GENERATED by scripts/help-build.rb on every GitHub build. Not committed. - # The real history lives on the help-versions branch (manifest.yml). -HEAD -puts "help-build: building v#{version} (#{opts[:bump]} bump from #{versions.first&.fetch('version') || 'nothing'}); " \ - "app #{build['app_version']}, API #{build['api_spec_version']}" - -config = YAML.safe_load(File.read(File.join(HELP, "_config.yml"))) - -Dir.mktmpdir("help-build-") do |tmp| - 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, - "exclude" => (config["exclude"] || []) + ["versions"], - }.to_yaml) - jekyll_build(snapshot, overlay) - %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 Help Center, newest first. Written by - # scripts/help-build.rb on main's GitHub build; each entry has a frozen - # static copy at / on this branch, served at /versions//. -HEAD - -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 - -system("ruby", File.join(ROOT, "scripts", "help-check.rb"), opts[:out]) || abort_with("help-check failed") - -File.write(ENV["GITHUB_OUTPUT"], "version=#{version}\n", mode: "a") if ENV["GITHUB_OUTPUT"] -puts "help-build: v#{version} built into #{opts[:out]} with #{versions.size} archived version(s)" diff --git a/scripts/help-check.rb b/scripts/help-check.rb index 52f6df0..a03db74 100644 --- a/scripts/help-check.rb +++ b/scripts/help-check.rb @@ -1,57 +1,48 @@ #!/usr/bin/env ruby # frozen_string_literal: true -# Checks a built Help Center against the app, so the app can never link to a -# help page that doesn't exist. +# Checks the app's Help links against a build of the shared Hub help center +# (CompositeCode/substratalapps-applications-specs, `npm run build` → _site/help), +# so the app can never open a help page that doesn't exist. # -# scripts/help-check.rb SITE_DIR +# scripts/help-check.rb HELP_DIR e.g. ../substratalapps-applications-specs/_site/help # # Fails when: -# - a screen in the catalog has no page at screens// -# - a page HelpLinks.swift links to (page("…")) wasn't built -# - the vendored openapi.yaml wasn't published at api/openapi.yaml -# - an internal link in the built site points at a page that doesn't exist +# - a screen in this app's catalog has no page at screens/.html +# - a page HelpLinks.swift opens (page("…")) isn't in the build +# - a #fragment HelpLinks.swift uses isn't an id on that page require "json" -require "set" ROOT = File.expand_path("..", __dir__) -site = File.expand_path(ARGV[0] || File.join(ROOT, "help", "_site")) -abort "help-check: #{site} doesn't exist; build the site first" unless File.directory?(site) -baseurl = "/substratalapps-macos" +help = File.expand_path(ARGV[0] || File.join(ROOT, "..", "substratalapps-applications-specs", "_site", "help")) +abort "help-check: #{help} doesn't exist; build the specs site first (npm ci && npm run build)" unless File.directory?(help) problems = [] catalog = JSON.parse(File.read(File.join(ROOT, "Packages/SubstratalKit/Sources/SubstratalKit/Resources/screens.json"))) catalog.fetch("screens").each do |screen| - page = File.join(site, "screens", screen["id"].downcase, "index.html") - problems << "no help page for #{screen['id']} (expected screens/#{screen['id'].downcase}/)" unless File.exist?(page) + page = File.join(help, "screens", "#{screen['id'].downcase}.html") + problems << "no help page for #{screen['id']} (screens/#{screen['id'].downcase}.html)" unless File.exist?(page) end -help_links = File.read(File.join(ROOT, "Packages/SubstratalKit/Sources/SubstratalKit/Help/HelpLinks.swift")) -help_links.scan(/page\("([^"\\]+)"\)/).flatten.each do |path| - problems << "HelpLinks.swift links to #{path}, which wasn't built" unless File.exist?(File.join(site, path, "index.html")) -end - -problems << "api/openapi.yaml wasn't published" unless File.exist?(File.join(site, "api", "openapi.yaml")) - -# Internal links, skipping archived snapshots (each was checked when it was built). -pages = Dir.glob(File.join(site, "**", "*.html")).reject { |f| f.include?("/versions/") && !f.end_with?("versions/index.html") } -checked = Set.new -pages.each do |file| - File.read(file).scan(/href="(#{Regexp.escape(baseurl)}\/[^"#?]*)/).flatten.each do |href| - next if href.include?("/versions/") || checked.include?(href) - - checked << href - rel = href.delete_prefix(baseurl) - target = File.join(site, rel) - ok = File.file?(target) || File.exist?(File.join(target, "index.html")) - problems << "broken link #{href} (in #{file.delete_prefix(site)})" unless ok +source = File.read(File.join(ROOT, "Packages/SubstratalKit/Sources/SubstratalKit/Help/HelpLinks.swift")) +version = File.read(File.join(ROOT, "Packages/SubstratalKit/Sources/SubstratalKit/API/APIVersion.swift"))[/current: .*= \._(\d+)/, 1] +# screen(_:) builds screens/.html per screen; the catalog loop above covers it. +links = source.scan(/page\("([^"]+)"(?:, fragment: "([^"]+)")?\)/).reject { |path, _| path.include?('\(id') } +abort "help-check: no page(...) links found in HelpLinks.swift" if links.empty? +links.each do |path, fragment| + path = path.gsub('\(APIVersion.headerValue)', version.to_s) + file = File.join(help, path) + unless File.exist?(file) + problems << "HelpLinks.swift opens #{path}, which isn't in the help center" + next end + problems << "HelpLinks.swift opens #{path}##{fragment}, but that page has no id=\"#{fragment}\"" if fragment && !File.read(file).include?(%(id="#{fragment}")) end if problems.empty? - puts "help-check: ok (#{catalog['screens'].size} screens, #{checked.size} internal links)" + puts "help-check: ok (#{catalog['screens'].size} screens, #{links.size} help links)" else - warn problems.uniq.map { |p| "help-check: #{p}" } + warn problems.map { |p| "help-check: #{p}" } exit 1 end diff --git a/scripts/help-generate.rb b/scripts/help-generate.rb deleted file mode 100644 index 46e3495..0000000 --- a/scripts/help-generate.rb +++ /dev/null @@ -1,182 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# Generates the parts of the Help Center that come from the app itself, so the -# docs can't drift from the code. Run before every help build (help-build.rb -# and the CI workflows do), and locally before `jekyll serve`. -# -# scripts/help-generate.rb [--commit SHA] -# -# Reads: -# Packages/SubstratalKit/Sources/SubstratalKit/Resources/screens.json the screen catalog the app navigates with -# App/Screens/ScreenHost.swift which screens are built (vs. placeholders) -# help/api/openapi.yaml the Platform API contract (scripts/sync-openapi.sh) -# project.yml the app version -# CHANGELOG.md release notes -# help/_screen-notes/.md optional hand-written notes per screen -# -# Writes (all gitignored): -# help/_data/screens.json, help/_data/build.yml -# help/screens/.md and help/screens/.md one page per domain and per screen -# help/release-notes.md - -require "date" -require "json" -require "set" -require "optparse" -require "yaml" -require "fileutils" - -ROOT = File.expand_path("..", __dir__) -HELP = File.join(ROOT, "help") -CATALOG = File.join(ROOT, "Packages/SubstratalKit/Sources/SubstratalKit/Resources/screens.json") -HOST = File.join(ROOT, "App/Screens/ScreenHost.swift") -OPENAPI = File.join(HELP, "api", "openapi.yaml") - -opts = {} -OptionParser.new { |o| o.on("--commit SHA") { |v| opts[:commit] = v } }.parse! - -def slug(text) = text.downcase.gsub(/[^a-z0-9]+/, "-").gsub(/\A-|-\z/, "") - -def yaml_string(value) = value.to_s.to_json # JSON strings are valid YAML scalars - -catalog = JSON.parse(File.read(CATALOG)) -screens = catalog.fetch("screens") -built = File.read(HOST).scan(/case "([A-Z]+-\d+)"/).flatten.to_set - -# ── Build metadata ────────────────────────────────────────────────────────── -spec = YAML.safe_load(File.read(OPENAPI), permitted_classes: [Date, Time], aliases: true) -header_versions = spec.dig("components", "parameters", "ApiVersion", "schema", "enum") || [] -operations = spec.fetch("paths").sum { |_, item| item.keys.count { |k| %w[get post put patch delete].include?(k) } } -project = YAML.safe_load(File.read(File.join(ROOT, "project.yml"))) -build = { - "app_version" => project.dig("settings", "base", "MARKETING_VERSION").to_s, - "api_spec_version" => spec.dig("info", "version").to_s, - "api_header_versions" => header_versions.map(&:to_s), - "api_header_version" => header_versions.map(&:to_s).max_by(&:to_i), - "api_operations" => operations, - "api_base_url" => spec.fetch("servers").first.fetch("url"), - "screens_total" => screens.size, - "screens_built" => built.size, - "built_screens" => built.to_a.sort, - "ui_spec" => catalog["spec"], - "commit" => opts[:commit], -}.compact -FileUtils.mkdir_p(File.join(HELP, "_data")) -File.write(File.join(HELP, "_data", "build.yml"), "# GENERATED by scripts/help-generate.rb. Not committed.\n" + build.to_yaml.sub(/\A---\n/, "")) -FileUtils.cp(CATALOG, File.join(HELP, "_data", "screens.json")) - -# ── Screen reference ──────────────────────────────────────────────────────── -dir = File.join(HELP, "screens") -Dir.glob(File.join(dir, "*.md")).each { |f| File.delete(f) unless File.basename(f) == "index.md" } - -SECTION_TITLES = { "personal" => "Personal", "administration" => "Administration", "developer" => "Developer" }.freeze -KEY_GLYPHS = { "cmd" => "⌘", "opt" => "⌥", "ctrl" => "⌃", "shift" => "⇧" }.freeze - -def shortcut_glyphs(spec) - return nil unless spec - - parts = spec.split("+") - key = parts.pop - parts.sort_by { |p| %w[ctrl opt shift cmd].index(p) }.map { |p| KEY_GLYPHS.fetch(p) }.join + key.upcase -end - -domains = screens.group_by { |s| s["domain"] } -domains.each_with_index do |(domain, members), index| - code, name = domain.split(" ", 2) - File.write(File.join(dir, "#{slug(domain)}.md"), <<~MD) - --- - layout: default - title: #{yaml_string(name)} - parent: Screen Reference - nav_order: #{index + 1} - has_children: true - permalink: /screens/#{slug(domain)}/ - --- - - # #{name} - {: .no_toc } - - Domain #{code} · #{members.size} screens · #{members.count { |s| built.include?(s["id"]) }} built - {: .fs-5 .fw-300 } - - | Screen | What it's for | Status | - |---|---|---| - #{members.map { |s| "| [#{s['id']} #{s['title']}](#{'{{'} '/screens/#{s['id'].downcase}/' | relative_url #{'}}'}) | #{s['purpose'].gsub('|', '\\|')} | #{built.include?(s['id']) ? 'Available' : 'Coming soon'} |" }.join("\n")} - MD - - members.each_with_index do |s, i| - sidebar = s["sidebar"] - route = s["route"] - open_it = [] - if sidebar - open_it << "In the sidebar, under **#{SECTION_TITLES.fetch(sidebar['section'])}**, choose **#{sidebar['label']}**." - open_it << "Or press **#{shortcut_glyphs(sidebar['shortcut'])}** (Go menu)." if sidebar["shortcut"] - end - if route.start_with?("/") - open_it << "Link: `substratal://#{route.delete_prefix('/')}` opens it in the app; " \ - "`https://dashboard.substratalapps.com#{route}` opens it in the web Hub or, once Universal Links are set up, in the app." - end - notes_file = File.join(HELP, "_screen-notes", "#{s['id']}.md") - notes = File.exist?(notes_file) ? File.read(notes_file).strip : nil - status = built.include?(s["id"]) ? "Available in this version." : "Coming soon: specified, not built yet. The app shows a placeholder with a link back here." - - File.write(File.join(dir, "#{s['id'].downcase}.md"), <<~MD) - --- - layout: default - title: #{yaml_string("#{s['id']} #{s['title']}")} - parent: #{yaml_string(name)} - grand_parent: Screen Reference - nav_order: #{i + 1} - permalink: /screens/#{s['id'].downcase}/ - --- - - # #{s['title']} - {: .no_toc } - - #{s['purpose']} - {: .fs-6 .fw-300 } - - | | | - |---|---| - | Screen ID | `#{s['id']}` | - | Area | #{name} (#{code}) | - | Who can use it | #{s['audience'] || '—'} | - | Layout | #{s['archetype']} | - | Phase | #{s['phase']} | - | Route | `#{route}` | - | Status | #{status} | - - #{open_it.empty? ? '' : "## How to open it\n\n#{open_it.map { |l| "- #{l}" }.join("\n")}\n"} - #{notes ? "## Using this screen\n\n#{notes}\n" : ''} - ## See also - - - [#{name}](#{'{{'} '/screens/#{slug(domain)}/' | relative_url #{'}}'}): every screen in this area - - [Screen specification](https://compositecode.github.io/substratalapps-applications-specs/spec/macos/SCREENS.html): controls, data and API calls, for the people building it - MD - end -end - -# ── Release notes ─────────────────────────────────────────────────────────── -changelog = File.read(File.join(ROOT, "CHANGELOG.md")).sub(/\A# .*\n/, "") -File.write(File.join(HELP, "release-notes.md"), <<~MD) - --- - layout: default - title: Release Notes - nav_order: 90 - permalink: /release-notes/ - --- - - # Release Notes - {: .no_toc } - - What changed in each version of Substratal Hub for macOS. Generated from - [`CHANGELOG.md`](https://github.com/CompositeCode/substratalapps-macos/blob/main/CHANGELOG.md). - {: .fs-6 .fw-300 } - - #{changelog} -MD - -puts "help-generate: #{screens.size} screen pages (#{built.size} built) in #{domains.size} areas; " \ - "API #{build['api_spec_version']} (Substratal-Version #{build['api_header_versions'].join(', ')}, #{operations} operations); " \ - "app #{build['app_version']}" diff --git a/scripts/sync-openapi.sh b/scripts/sync-openapi.sh index e72f323..1302f2c 100755 --- a/scripts/sync-openapi.sh +++ b/scripts/sync-openapi.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash -# Vendors the Platform API's OpenAPI document into the app and the help site. +# Vendors the Platform API's OpenAPI document into the app (the input to +# swift-openapi-generator). The help center's API reference lives in the specs +# repository and fetches the contract itself. # # scripts/sync-openapi.sh [SOURCE] # # SOURCE is a path or URL to openapi.yaml. Default: the API repo's main branch, -# which is what production runs (main deploys on merge). The same file feeds: -# - Packages/SubstratalKit/Sources/SubstratalAPI/openapi.yaml (swift-openapi-generator input) -# - help/api/openapi.yaml (Swagger UI on the help site) +# which is what production runs (main deploys on merge). Writes +# Packages/SubstratalKit/Sources/SubstratalAPI/openapi.yaml. # Prints the spec's info.version. Exits 0 with "unchanged" when nothing moved, # so the scheduled workflow can decide whether to open a pull request. set -euo pipefail @@ -14,7 +15,6 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" SOURCE="${1:-https://raw.githubusercontent.com/CompositeCode/substratalapps.com/main/docs/openapi.yaml}" APP_COPY="$ROOT/Packages/SubstratalKit/Sources/SubstratalAPI/openapi.yaml" -HELP_COPY="$ROOT/help/api/openapi.yaml" tmp="$(mktemp)" trap 'rm -f "$tmp"' EXIT @@ -27,12 +27,11 @@ fi grep -q '^openapi: 3' "$tmp" || { echo "sync-openapi: $SOURCE is not an OpenAPI 3 document" >&2; exit 1; } version="$(awk '/^info:/{f=1} f && /^ version:/{gsub(/"/,"",$2); print $2; exit}' "$tmp")" -mkdir -p "$(dirname "$APP_COPY")" "$(dirname "$HELP_COPY")" -if [[ -f "$APP_COPY" ]] && cmp -s "$tmp" "$APP_COPY" && cmp -s "$tmp" "$HELP_COPY"; then +mkdir -p "$(dirname "$APP_COPY")" +if [[ -f "$APP_COPY" ]] && cmp -s "$tmp" "$APP_COPY"; then echo "unchanged $version" exit 0 fi cp "$tmp" "$APP_COPY" -cp "$tmp" "$HELP_COPY" -chmod 644 "$APP_COPY" "$HELP_COPY" +chmod 644 "$APP_COPY" echo "updated $version"