diff --git a/.github/workflows/contract-sync.yml b/.github/workflows/contract-sync.yml new file mode 100644 index 0000000..d8fdc9d --- /dev/null +++ b/.github/workflows/contract-sync.yml @@ -0,0 +1,45 @@ +name: API contract sync + +# Keeps the vendored API contract and the screen registry current, so the app's generated client, its routes +# and the help center all describe the same API. Opens a pull request when either one changed upstream. +on: + schedule: + - cron: "37 6 * * *" + repository_dispatch: + types: [api-contract-updated, specs-updated] + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + sync: + runs-on: macos-26 + steps: + - uses: actions/checkout@v5 + + - name: Fetch the latest published openapi.yaml + run: scripts/sync-openapi.sh + + - name: Regenerate the screen registry from the specs + run: | + git clone --depth 1 https://github.com/CompositeCode/substratalapps-applications-specs.git "$RUNNER_TEMP/specs" + scripts/generate-screen-ids.py "$RUNNER_TEMP/specs/substratal-application-specs" + + - name: Open a pull request if anything changed + env: + GH_TOKEN: ${{ github.token }} + run: | + if git diff --quiet -- api-contract Packages/SubstratalKit/Sources; then + echo "Contract and screens are current."; exit 0 + fi + branch="chore/contract-sync-$(date -u +%Y%m%d)" + version=$(python3 -c "import json;print(json.load(open('api-contract/CONTRACT.json'))['info_version'])") + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B "$branch" + git add api-contract Packages/SubstratalKit/Sources + git commit -m "Sync API contract ($version) and screen registry" + git push --force-with-lease origin "$branch" + gh pr create --fill --base main --head "$branch" || gh pr edit "$branch" --body "Updated by the API contract sync workflow." diff --git a/.github/workflows/docs-dispatch.yml b/.github/workflows/docs-dispatch.yml new file mode 100644 index 0000000..0d4ad61 --- /dev/null +++ b/.github/workflows/docs-dispatch.yml @@ -0,0 +1,32 @@ +name: Help center rebuild + +# The help center lives in CompositeCode/substratalapps-applications-specs and is shared by every Hub app. +# When this app's main branch changes, ask it to rebuild so the published help reflects what shipped. +# Needs the secret SPECS_DOCS_TOKEN: a fine-grained token with Contents: read and write on the specs repo. +on: + push: + branches: [main] + paths: + - "App/**" + - "Packages/**" + - "api-contract/**" + - "docs/**" + 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 "::warning::SPECS_DOCS_TOKEN isn't set, so the help center will pick this up on its daily build instead." + exit 0 + fi + gh api repos/CompositeCode/substratalapps-applications-specs/dispatches \ + -f event_type=app-docs-updated \ + -F "client_payload[app]=ios" \ + -F "client_payload[repository]=$GITHUB_REPOSITORY" \ + -F "client_payload[sha]=$GITHUB_SHA" diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml new file mode 100644 index 0000000..f64d2d2 --- /dev/null +++ b/.github/workflows/ios.yml @@ -0,0 +1,39 @@ +name: iOS + +# Builds and tests on every push and pull request. Also checks that the generated Xcode project matches +# project.yml, so nobody hand-edits project.pbxproj. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ios-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-and-test: + runs-on: macos-26 + timeout-minutes: 45 + steps: + - uses: actions/checkout@v5 + + - name: Select Xcode + run: sudo xcode-select -s /Applications/Xcode_27.0.app || xcodebuild -version + + - name: Project matches project.yml + run: | + brew install xcodegen + xcodegen generate --quiet + git diff --exit-code -- SubstratalHub.xcodeproj + + - name: SubstratalKit unit tests + working-directory: Packages/SubstratalKit + run: swift test + + - name: Build and UI smoke test + run: | + xcodebuild -project SubstratalHub.xcodeproj -scheme SubstratalHub -skipPackagePluginValidation \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro' \ + test -only-testing:SubstratalHubUITests diff --git a/App/AppEnvironment.swift b/App/AppEnvironment.swift new file mode 100644 index 0000000..7946fac --- /dev/null +++ b/App/AppEnvironment.swift @@ -0,0 +1,45 @@ +import SwiftUI +import SubstratalCore +import SubstratalUI + +/// Builds the object graph once per launch. Pass `-demo YES` (or pick "Demo data" on the sign-in screen +/// in debug builds) to run against fixtures with no network. +@MainActor +struct AppEnvironment { + let configuration: APIConfiguration + let session: SessionModel + let versionMonitor: APIVersionMonitor + let theme: ThemeStore + let navigator: Navigator + + static func launch() -> AppEnvironment { + let wantsDemo = UserDefaults.standard.bool(forKey: "demo") || ProcessInfo.processInfo.arguments.contains("-uiTesting") + return make(wantsDemo ? .demo : .production) + } + + static func make(_ environment: APIConfiguration.Environment) -> AppEnvironment { + let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0" + let configuration = APIConfiguration.make(environment, appVersion: appVersion, osVersion: UIDevice.current.systemVersion) + let monitor = APIVersionMonitor() + let session: SessionModel + switch environment { + case .demo: + session = SessionModel(service: DemoHubService(), tokens: nil) + case .production, .localDevserver: + let tokens = TokenStore() + session = SessionModel(service: LiveHubService(configuration: configuration, tokens: tokens, versionMonitor: monitor), tokens: tokens) + } + return AppEnvironment(configuration: configuration, session: session, versionMonitor: monitor, theme: ThemeStore(), navigator: Navigator()) + } +} + +private struct AppConfigurationKey: EnvironmentKey { + static let defaultValue = APIConfiguration.make(.demo, appVersion: "0", osVersion: "0") +} + +extension EnvironmentValues { + var appConfiguration: APIConfiguration { + get { self[AppConfigurationKey.self] } + set { self[AppConfigurationKey.self] = newValue } + } +} diff --git a/App/Features/Account/AccountView.swift b/App/Features/Account/AccountView.swift new file mode 100644 index 0000000..314d4e4 --- /dev/null +++ b/App/Features/Account/AccountView.swift @@ -0,0 +1,125 @@ +import SwiftUI +import SubstratalCore +import SubstratalUI + +/// ACC-01 Account & profile, plus the compact layout's home for Plan & Billing, Settings and Help. +struct AccountView: View { + @Environment(SessionModel.self) private var session + @Environment(\.appConfiguration) private var configuration + @Environment(\.openURL) private var openURL + @State private var confirmSignOutEverywhere = false + + var body: some View { + List { + if let user = session.user { + Section { + HStack(spacing: 14) { + Text(user.initials) + .font(.title3.weight(.semibold)) + .frame(width: 52, height: 52) + .background(.tint.opacity(0.15), in: Circle()) + VStack(alignment: .leading, spacing: 4) { + Text(user.email).font(.headline) + HStack { + StatusBadge(user.plan == "standard" ? .active : .none, label: user.plan == "standard" ? "Standard" : "Free") + if user.mfaEnabled { StatusBadge(.active, label: "Two-factor on") } + } + } + } + IDChip(user.id) + } + } + Section { + NavigationLink(value: Route(.acc04)) { Label("Security", systemImage: "lock.shield") } + NavigationLink(value: Route(.ntf03)) { Label("Devices & notifications", systemImage: "iphone.gen3") } + NavigationLink(value: Route(.acc07)) { Label("Privacy", systemImage: "hand.raised") } + } + Section { + NavigationLink(value: Route(.bil01)) { Label("Plan & Billing", systemImage: "creditcard") } + NavigationLink(value: Route(.set01)) { Label("Settings", systemImage: "gearshape") } + } + Section("Help & documentation") { + Button { openURL(HelpCenter.home) } label: { Label("Help center", systemImage: "questionmark.circle") } + Button { openURL(HelpCenter.tutorials) } label: { Label("Tutorials", systemImage: "graduationcap") } + Button { openURL(HelpCenter.faq) } label: { Label("FAQ", systemImage: "text.bubble") } + Button { openURL(HelpCenter.apiReference) } label: { Label("API reference (v\(APIConfiguration.apiVersion))", systemImage: "curlybraces") } + Button { openURL(HelpCenter.whatsNew) } label: { Label("What's new", systemImage: "sparkles") } + } + Section("About") { + LabeledContent("App version", value: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "–") + LabeledContent("API", value: configuration.baseURL.host() ?? "") + LabeledContent("API version (Substratal-Version)", value: APIConfiguration.apiVersion) + LabeledContent("Contract", value: SubstratalCore.contractVersion) + } + Section { + Button("Sign out", role: .destructive) { Task { await session.signOut() } } + Button("Sign out everywhere…", role: .destructive) { confirmSignOutEverywhere = true } + } + } + .navigationTitle("Account") + .confirmationDialog("Sign out on every device?", isPresented: $confirmSignOutEverywhere, titleVisibility: .visible) { + Button("Sign out everywhere", role: .destructive) { Task { await session.signOut(everywhere: true) } } + } message: { + Text("Every session you have, on every device, ends now.") + } + } +} + +/// SET-01 Settings — Global: appearance and theme choice (THEMES.md). Other settings rows come with the full screen. +struct AppearanceSettingsView: View { + @Environment(ThemeStore.self) private var theme + + var body: some View { + @Bindable var theme = theme + Form { + Section("Appearance") { + Picker("Appearance", selection: $theme.mode) { + Text("System").tag(ThemeMode.system) + Text("Light").tag(ThemeMode.light) + Text("Dark").tag(ThemeMode.dark) + } + .pickerStyle(.segmented) + } + Section("Light theme") { + ThemeSwatchGrid(options: LightTheme.allCases.map { ($0.id, $0.displayName, $0.palette) }, selection: theme.light.id) { + theme.light = LightTheme(rawValue: $0) ?? .default + } + } + Section("Dark theme") { + ThemeSwatchGrid(options: DarkTheme.allCases.map { ($0.id, $0.displayName, $0.palette) }, selection: theme.dark.id) { + theme.dark = DarkTheme(rawValue: $0) ?? .default + } + } + } + .navigationTitle("Settings") + } +} + +private struct ThemeSwatchGrid: View { + let options: [(id: String, name: String, palette: ThemePalette)] + let selection: String + let select: (String) -> Void + + var body: some View { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: 3), spacing: 10) { + ForEach(options, id: \.id) { option in + Button { select(option.id) } label: { + VStack(spacing: 6) { + RoundedRectangle(cornerRadius: 10) + .fill(option.palette.background) + .overlay(alignment: .bottomTrailing) { + Circle().fill(option.palette.accent).frame(width: 18, height: 18).padding(6) + } + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(option.id == selection ? option.palette.accent : .secondary.opacity(0.3), lineWidth: option.id == selection ? 3 : 1)) + .frame(height: 54) + Text(option.name).font(.caption).lineLimit(1) + } + } + .buttonStyle(.plain) + .accessibilityLabel(option.name) + .accessibilityAddTraits(option.id == selection ? .isSelected : []) + } + } + .padding(.vertical, 4) + } +} diff --git a/App/Features/Applications/ApplicationsView.swift b/App/Features/Applications/ApplicationsView.swift new file mode 100644 index 0000000..bc9f18f --- /dev/null +++ b/App/Features/Applications/ApplicationsView.swift @@ -0,0 +1,82 @@ +import SwiftUI +import SubstratalCore +import SubstratalUI + +/// APL-01 Applications. +struct ApplicationsView: View { + @Environment(SessionModel.self) private var session + @State private var state: LoadState<[ApplicationSummary]> = .idle + + var body: some View { + LoadStateView(state, retry: { Task { await load() } }) { apps in + List(apps) { app in + NavigationLink(value: Route(.apl03, ["appId": app.id])) { + HStack(spacing: 12) { + AppIconPlaceholder(name: app.name) + VStack(alignment: .leading, spacing: 3) { + Text(app.name).font(.body.weight(.medium)) + HStack(spacing: 6) { + StatusBadge(app.reviewStatus) + Text("\(app.visibility) · \(app.accessTier)").font(.caption).foregroundStyle(.secondary) + } + } + } + } + } + .refreshable { await load() } + } + .navigationTitle("Applications") + .toolbar { + ToolbarItem(placement: .primaryAction) { + NavigationLink(value: Route(.apl02)) { Label("Register app", systemImage: "plus") } + .disabled(!(session.user?.emailVerified ?? false)) + } + } + .task { await load() } + } + + private func load() async { + do { state = .loaded(try await session.service.applications(cursor: nil).items) } + catch let error as APIError { state = .failed(error) } catch {} + } +} + +/// APL-03 Application — Overview, with links to its tabs (settings schema, roles, push, keys, webhooks). +struct ApplicationDetailView: View { + let appID: String + @Environment(SessionModel.self) private var session + @State private var app: ApplicationSummary? + + var body: some View { + List { + if let app { + Section { + HStack(spacing: 12) { + AppIconPlaceholder(name: app.name) + VStack(alignment: .leading) { + Text(app.name).font(.title3.weight(.semibold)) + StatusBadge(app.reviewStatus) + } + } + if let summary = app.summary { Text(summary).foregroundStyle(.secondary) } + IDChip(app.id) + } + Section("Catalog") { + LabeledContent("Slug", value: app.slug) + LabeledContent("Visibility", value: app.visibility.capitalized) + LabeledContent("Access tier", value: app.accessTier.capitalized) + } + } + Section("Configure") { + NavigationLink(value: Route(.apl04, ["appId": appID])) { Label("Settings schema", systemImage: "slider.horizontal.3") } + NavigationLink(value: Route(.apl05, ["appId": appID])) { Label("Permissions & roles", systemImage: "checkmark.shield") } + NavigationLink(value: Route(.apl07, ["appId": appID])) { Label("Push credentials", systemImage: "bell.badge") } + if session.capabilities.canManageApplications { + NavigationLink(value: Route(.apl06, ["appId": appID])) { Label("Review", systemImage: "checkmark.seal") } + } + } + } + .navigationTitle(app?.name ?? "Application") + .task { app = try? await session.service.applications(cursor: nil).items.first { $0.id == appID } } + } +} diff --git a/App/Features/Audit/AuditLogView.swift b/App/Features/Audit/AuditLogView.swift new file mode 100644 index 0000000..e8475ad --- /dev/null +++ b/App/Features/Audit/AuditLogView.swift @@ -0,0 +1,78 @@ +import SwiftUI +import SubstratalCore +import SubstratalUI + +/// AUD-01 Audit log, newest first, grouped by day. AUD-02 opens as a sheet (popover archetype). +struct AuditLogView: View { + @Environment(SessionModel.self) private var session + @State private var state: LoadState<[AuditEntry]> = .idle + @State private var selected: AuditEntry? + + var body: some View { + LoadStateView(state, retry: { Task { await load() } }) { events in + List { + ForEach(groupedByDay(events), id: \.0) { day, items in + Section(day.formatted(date: .complete, time: .omitted)) { + ForEach(items) { event in + Button { selected = event } label: { AuditRow(event: event) } + .buttonStyle(.plain) + } + } + } + } + .refreshable { await load() } + } + .navigationTitle("Audit Log") + .sheet(item: $selected) { event in AuditEventSheet(event: event).presentationDetents([.medium, .large]) } + .task { await load() } + } + + private func groupedByDay(_ events: [AuditEntry]) -> [(Date, [AuditEntry])] { + Dictionary(grouping: events) { Calendar.current.startOfDay(for: $0.timestamp) } + .sorted { $0.key > $1.key } + } + + private func load() async { + do { state = .loaded(try await session.service.auditEvents(cursor: nil).items) } + catch let error as APIError { state = .failed(error) } catch {} + } +} + +private struct AuditRow: View { + let event: AuditEntry + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(event.action).font(.body.monospaced().weight(.medium)) + Spacer() + Text(event.timestamp, style: .time).font(.caption).foregroundStyle(.secondary) + } + Text("\(event.actorType == "api_key" ? "API key" : event.actorType.capitalized) \(event.actorID)") + .font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) + } + .contentShape(Rectangle()) + } +} + +/// AUD-02 Audit event. +struct AuditEventSheet: View { + let event: AuditEntry + + var body: some View { + NavigationStack { + List { + LabeledContent("Action", value: event.action) + LabeledContent("When") { Text(event.timestamp.formatted(date: .abbreviated, time: .standard)) } + LabeledContent("Actor", value: event.actorID) + if let target = event.targetID { LabeledContent("Target", value: "\(event.targetType ?? "") \(target)") } + if let app = event.applicationID { LabeledContent("Application", value: app) } + if let request = event.requestID { LabeledContent("Request ID", value: request) } + IDChip(event.id) + } + .font(.callout) + .navigationTitle("Audit event") + .navigationBarTitleDisplayMode(.inline) + } + } +} diff --git a/App/Features/Auth/SignInView.swift b/App/Features/Auth/SignInView.swift new file mode 100644 index 0000000..dbcb6a6 --- /dev/null +++ b/App/Features/Auth/SignInView.swift @@ -0,0 +1,121 @@ +import SwiftUI +import SubstratalCore +import SubstratalUI + +/// AUTH-01 Sign in. Embedded login is allowed for first-party apps (Trust Model); SSO and passkeys stay +/// hidden behind their flags until the API supports them (OPEN-QUESTIONS #4). +struct SignInView: View { + @Environment(SessionModel.self) private var session + @Environment(\.appConfiguration) private var configuration + @Environment(\.openURL) private var openURL + @Environment(\.palette) private var palette + @State private var email = "" + @State private var password = "" + @FocusState private var focus: Field? + + private enum Field { case email, password } + + var body: some View { + NavigationStack { + Form { + Section { + Image("Brand/BrandLockup") + .resizable().scaledToFit() + .frame(maxHeight: 56) + .frame(maxWidth: .infinity) + .padding(.top, 32) + .accessibilityLabel("Substratal Apps") + .listRowBackground(Color.clear) + } + Section { + TextField("Email", text: $email) + .textContentType(.username) + .keyboardType(.emailAddress) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .focused($focus, equals: .email) + .submitLabel(.next) + .onSubmit { focus = .password } + SecureField("Password", text: $password) + .textContentType(.password) + .focused($focus, equals: .password) + .submitLabel(.go) + .onSubmit(signIn) + } footer: { + if let error = session.lastError { + Label(error.userMessage, systemImage: "exclamationmark.circle").foregroundStyle(Color.danger) + } + } + Section { + Button(action: signIn) { + HStack { + Spacer() + if session.isWorking { ProgressView() } else { Text("Sign in").bold() } + Spacer() + } + } + .disabled(email.isEmpty || password.isEmpty || session.isWorking) + NavigationLink("Forgot password?", value: Route(.auth05)) + NavigationLink("Create an account", value: Route(.auth03)) + } + Section { + LabeledContent("Server", value: configuration.environment.displayName) + Button("Help signing in", systemImage: "questionmark.circle") { openURL(HelpCenter.url(for: .auth01)) } + } footer: { + Text("API \(configuration.baseURL.host() ?? "") · Substratal-Version \(APIConfiguration.apiVersion)") + .font(.caption.monospaced()) + } + } + .scrollContentBackground(.hidden) + .background(palette.background) + .navigationDestination(for: Route.self) { ScreenPlaceholder($0.screen, params: $0.params) } + } + } + + private func signIn() { + guard !email.isEmpty, !password.isEmpty else { return } + Task { await session.signIn(email: email, password: password) } + } +} + +/// AUTH-02 Two-factor challenge. +struct TwoFactorView: View { + @Environment(SessionModel.self) private var session + @State private var code = "" + @FocusState private var codeFocused: Bool + + var body: some View { + NavigationStack { + Form { + Section { + TextField("6-digit code", text: $code) + .textContentType(.oneTimeCode) + .keyboardType(.numberPad) + .font(.title2.monospacedDigit()) + .focused($codeFocused) + .onSubmit(verify) + } header: { + Text("Enter the code from your authenticator app") + } footer: { + if let error = session.lastError { + Label(error.userMessage, systemImage: "exclamationmark.circle").foregroundStyle(Color.danger) + } + } + Section { + Button("Verify", action: verify).disabled(code.count != 6 || session.isWorking) + Button("Use a recovery code instead") {} + .disabled(true) + } + } + .navigationTitle("Two-factor") + .onAppear { codeFocused = true } + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { session.cancelMFA() } } + } + } + } + + private func verify() { + Task { await session.verifyMFA(code: code) } + } +} diff --git a/App/Features/Home/HomeView.swift b/App/Features/Home/HomeView.swift new file mode 100644 index 0000000..7a68083 --- /dev/null +++ b/App/Features/Home/HomeView.swift @@ -0,0 +1,67 @@ +import SwiftUI +import SubstratalCore +import SubstratalUI + +/// APP-01 Home — My Apps. Internal apps (like the Hub itself) never show as tiles. +struct HomeView: View { + @Environment(SessionModel.self) private var session + @Environment(\.palette) private var palette + @State private var state: LoadState<[ApplicationSummary]> = .idle + + private let columns = [GridItem(.adaptive(minimum: 150), spacing: 12)] + + var body: some View { + LoadStateView(state, retry: load) { apps in + ScrollView { + if apps.isEmpty { + ContentUnavailableView("No apps yet", systemImage: "square.grid.2x2", description: Text("Apps you have access to appear here.")) + } else { + LazyVGrid(columns: columns, spacing: 12) { + ForEach(apps) { app in + NavigationLink(value: Route(.app02, ["appId": app.id])) { AppTile(app: app) } + .buttonStyle(.plain) + } + } + .padding() + } + } + .refreshable { await reload() } + } + .background(palette.background) + .navigationTitle("Home") + .task { if case .idle = state { load() } } + } + + private func load() { Task { await reload() } } + + private func reload() async { + if state.value == nil { state = .loading } + do { + let page = try await session.service.applications(cursor: nil) + state = .loaded(page.items.filter { $0.visibility != "internal" }) + } catch let error as APIError { + state = .failed(error) + } catch { + state = .failed(.transport(error.localizedDescription)) + } + } +} + +private struct AppTile: View { + let app: ApplicationSummary + @Environment(\.palette) private var palette + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + AppIconPlaceholder(name: app.name) + Text(app.name).font(.headline).foregroundStyle(palette.text) + Text(app.summary ?? "").font(.caption).foregroundStyle(palette.textSecondary).lineLimit(2, reservesSpace: true) + Text(app.accessTier == "free" ? "Free" : "Standard").font(.caption2.weight(.semibold)).foregroundStyle(palette.accent) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(palette.raised, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .accessibilityElement(children: .combine) + .accessibilityHint("Shows app info") + } +} diff --git a/App/Features/Inbox/InboxView.swift b/App/Features/Inbox/InboxView.swift new file mode 100644 index 0000000..a359c20 --- /dev/null +++ b/App/Features/Inbox/InboxView.swift @@ -0,0 +1,12 @@ +import SwiftUI +import SubstratalUI + +/// NTF-01 Inbox. Push and inbox are built in the API (v1.8.0); this screen lands with Phase 2. +struct InboxView: View { + var body: some View { + ContentUnavailableView("You're all caught up", + systemImage: "tray", + description: Text("Security alerts and notifications from your apps appear here.")) + .navigationTitle("Inbox") + } +} diff --git a/App/Features/Keys/APIKeysView.swift b/App/Features/Keys/APIKeysView.swift new file mode 100644 index 0000000..6a40582 --- /dev/null +++ b/App/Features/Keys/APIKeysView.swift @@ -0,0 +1,182 @@ +import SwiftUI +import SubstratalCore +import SubstratalUI + +/// KEY-01 API keys. The app never holds an API key: people manage keys with their own session, +/// and a new key's secret is shown once (KEY-03), then exists only where the person stored it. +struct APIKeysView: View { + @Environment(SessionModel.self) private var session + @State private var keys: LoadState<[APIKeySummary]> = .idle + @State private var includeInactive = false + @State private var creating = false + @State private var revealed: CreatedAPIKey? + @State private var pendingRevoke: APIKeySummary? + + var body: some View { + LoadStateView(keys, retry: { Task { await load() } }) { keys in + List { + Section { + Toggle("Show revoked and expired", isOn: $includeInactive) + } + Section { + ForEach(keys) { key in + APIKeyRow(key: key) + .swipeActions(edge: .trailing) { + if key.status == .active { + Button("Revoke", role: .destructive) { pendingRevoke = key } + } + } + } + } + } + .refreshable { await load() } + } + .navigationTitle("API Keys") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Button("Create key", systemImage: "plus") { creating = true } + } + } + .task(id: includeInactive) { await load() } + .sheet(isPresented: $creating) { + CreateAPIKeyView { created in + creating = false + revealed = created + } + } + .sheet(item: $revealed) { created in + SecretRevealSheet(title: created.key.name, secret: created.secret, + note: "Use it as a Bearer token from your server. API keys never belong in an app or a browser; the API refuses them there.") { + revealed = nil + Task { await load() } + } + } + .confirmationDialog("Revoke \(pendingRevoke?.name ?? "")?", isPresented: Binding(get: { pendingRevoke != nil }, set: { if !$0 { pendingRevoke = nil } }), titleVisibility: .visible, presenting: pendingRevoke) { key in + Button("Revoke key", role: .destructive) { + Task { + try? await session.service.revokeAPIKey(id: key.id) + await load() + } + } + } message: { _ in + Text("Anything using this key stops working immediately. This can't be undone.") + } + } + + private func load() async { + do { keys = .loaded(try await session.service.apiKeys(includeInactive: includeInactive, cursor: nil).items) } + catch let error as APIError { keys = .failed(error) } catch {} + } +} + +extension CreatedAPIKey: @retroactive Identifiable { + public var id: String { key.id } +} + +private struct APIKeyRow: View { + let key: APIKeySummary + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(key.name).font(.body.weight(.medium)) + if key.mode == "test" { StatusBadge(.scheduled, label: "TEST") } + if key.intendedUse == "agent" { StatusBadge(.pending, label: key.restrictDestructive ? "Agent · safe" : "Agent") } + } + HStack(spacing: 6) { + StatusBadge(key.status) + Text(key.scope == "platform" ? "Platform" : key.scope).font(.caption.monospaced()).foregroundStyle(.secondary) + if let hint = key.secretHint { Text(hint).font(.caption.monospaced()).foregroundStyle(.secondary) } + } + Group { + if let used = key.lastUsedAt { Text("Last used \(used, format: .relative(presentation: .named))") } else { Text("Never used") } + } + .font(.caption).foregroundStyle(.secondary) + } + } +} + +/// KEY-02 Create API key. +struct CreateAPIKeyView: View { + let onCreated: (CreatedAPIKey) -> Void + @Environment(SessionModel.self) private var session + @Environment(\.dismiss) private var dismiss + @State private var draft = NewAPIKey() + @State private var catalog: [String] = [] + @State private var expires = true + @State private var error: APIError? + @State private var saving = false + /// Generated when the form opens and reused on every retry (CLIENT-ARCHITECTURE §4). + @State private var idempotencyKey = UUID().uuidString + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Name", text: $draft.name, prompt: Text("billing-webhook-handler")) + .textInputAutocapitalization(.never) + TextField("Scope", text: $draft.scope, prompt: Text("platform or app_…")) + .textInputAutocapitalization(.never) + .font(.body.monospaced()) + } footer: { + Text("A platform key needs api_keys.manage, and can't hold more than you do. An app key can only reach its own Application.") + } + Section("Mode and use") { + Picker("Mode", selection: $draft.mode) { + Text("Live").tag("live") + Text("Test").tag("test") + } + Picker("Intended use", selection: $draft.intendedUse) { + Text("Service").tag("service") + Text("AI agent").tag("agent") + } + if draft.intendedUse == "agent" { + Label("Agent keys can't run destructive operations unless you turn that on later.", systemImage: "shield.lefthalf.filled") + .font(.footnote).foregroundStyle(.secondary) + } + } + Section("Expiry") { + Toggle("Expires", isOn: $expires) + if expires { + DatePicker("Expires on", selection: Binding(get: { draft.expiresAt ?? .now }, set: { draft.expiresAt = $0 }), + in: Date.now..., displayedComponents: .date) + } + } + Section("Permissions") { + ForEach(catalog, id: \.self) { permission in + Toggle(permission, isOn: Binding( + get: { draft.permissions.contains(permission) }, + set: { isOn in + if isOn { draft.permissions.insert(permission) } else { draft.permissions.remove(permission) } + } + )) + .font(.callout.monospaced()) + } + } + if let error { + Section { Label(error.userMessage, systemImage: "exclamationmark.circle").foregroundStyle(Color.danger) } + } + } + .navigationTitle("Create API key") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("Create", action: create) + .disabled(draft.name.isEmpty || draft.scope.isEmpty || draft.permissions.isEmpty || saving) + } + } + .task { catalog = (try? await session.service.permissionCatalog()) ?? [] } + } + } + + private func create() { + saving = true + if !expires { draft.expiresAt = nil } + Task { + defer { saving = false } + do { onCreated(try await session.service.createAPIKey(draft, idempotencyKey: idempotencyKey)) } + catch let failure as APIError { error = failure } catch {} + } + } +} diff --git a/App/Features/Roles/RolesView.swift b/App/Features/Roles/RolesView.swift new file mode 100644 index 0000000..84591ab --- /dev/null +++ b/App/Features/Roles/RolesView.swift @@ -0,0 +1,71 @@ +import SwiftUI +import SubstratalCore +import SubstratalUI + +/// ROL-01 Roles (platform scope in MVP). +struct RolesView: View { + @Environment(SessionModel.self) private var session + @State private var state: LoadState<[RoleSummary]> = .idle + + var body: some View { + LoadStateView(state, retry: { Task { await load() } }) { roles in + List(roles) { role in + NavigationLink(value: Route(.rol02, ["roleId": role.id])) { + VStack(alignment: .leading, spacing: 3) { + HStack { + Text(role.name).font(.body.weight(.medium)) + if role.seed { StatusBadge(.none, label: "Built-in") } + } + Text("\(role.permissions.count) permissions · \(role.assignmentCount) people") + .font(.caption).foregroundStyle(.secondary) + } + } + } + .refreshable { await load() } + } + .navigationTitle("Roles") + .toolbar { + ToolbarItem(placement: .primaryAction) { + NavigationLink(value: Route(.rol03)) { Label("New role", systemImage: "plus") } + .disabled(!session.capabilities.canManageRoles) + } + } + .task { await load() } + } + + private func load() async { + do { state = .loaded(try await session.service.roles(scope: "platform")) } + catch let error as APIError { state = .failed(error) } catch {} + } +} + +/// ROL-02 Role detail: the permission checklist. Built-in (seed) roles are read-only. +struct RoleDetailView: View { + let roleID: String + @Environment(SessionModel.self) private var session + @State private var role: RoleSummary? + + var body: some View { + List { + if let role { + Section { + LabeledContent("Scope", value: role.scope) + LabeledContent("People", value: "\(role.assignmentCount)") + if let description = role.description { Text(description).foregroundStyle(.secondary) } + IDChip(role.id) + } footer: { + if role.seed { Text("Seed roles can't be edited.") } + } + Section("Permissions") { + if role.permissions.isEmpty { Text("None").foregroundStyle(.secondary) } + ForEach(role.permissions, id: \.self) { Label($0, systemImage: "checkmark").font(.body.monospaced()) } + } + Section { + NavigationLink("People with this role", value: Route(.usr01, ["role_id": role.id])) + } + } + } + .navigationTitle(role?.name ?? "Role") + .task { role = try? await session.service.roles(scope: nil).first { $0.id == roleID } } + } +} diff --git a/App/Features/Users/UsersView.swift b/App/Features/Users/UsersView.swift new file mode 100644 index 0000000..e534dd3 --- /dev/null +++ b/App/Features/Users/UsersView.swift @@ -0,0 +1,182 @@ +import SwiftUI +import SubstratalCore +import SubstratalUI + +/// USR-01 Users: search, status filter, cursor pagination. +struct UsersView: View { + @Environment(SessionModel.self) private var session + @State private var query = UserQuery() + @State private var users: [UserSummary] = [] + @State private var nextCursor: String? + @State private var error: APIError? + @State private var loading = false + + private let statuses: [(String?, String)] = [(nil, "All"), ("active", "Active"), ("invited", "Invited"), ("suspended", "Suspended")] + + var body: some View { + List { + Section { + ScrollView(.horizontal, showsIndicators: false) { + HStack { + ForEach(statuses, id: \.1) { status, label in + Button(label) { query.status = status } + .buttonStyle(.bordered) + .tint(query.status == status ? .accentColor : .secondary) + } + } + } + .listRowInsets(EdgeInsets(top: 6, leading: 16, bottom: 6, trailing: 16)) + } + Section { + ForEach(users) { user in + NavigationLink(value: Route(.usr03, ["userId": user.id])) { UserRow(user: user) } + } + if nextCursor != nil { + ProgressView().frame(maxWidth: .infinity).task { await load(more: true) } + } + } + } + .overlay { + if let error, users.isEmpty { ErrorStateView(error) { Task { await load() } } } + else if !loading, users.isEmpty { ContentUnavailableView.search(text: query.text) } + } + .searchable(text: $query.text, prompt: "Email, name or usr_ ID") + .refreshable { await load() } + .task(id: query) { await load() } + .navigationTitle("Users") + .toolbar { + ToolbarItem(placement: .primaryAction) { + NavigationLink(value: Route(.usr02)) { Label("Invite user", systemImage: "person.badge.plus") } + .disabled(!session.capabilities.canManageUsers) + } + } + } + + private func load(more: Bool = false) async { + loading = true + defer { loading = false } + do { + let page = try await session.service.users(query, cursor: more ? nextCursor : nil) + users = more ? users + page.items : page.items + nextCursor = page.nextCursor + error = nil + } catch let failure as APIError { + error = failure + } catch {} + } +} + +struct UserRow: View { + let user: UserSummary + + var body: some View { + HStack(spacing: 12) { + Text(user.initials) + .font(.subheadline.weight(.semibold)) + .frame(width: 36, height: 36) + .background(.tint.opacity(0.15), in: Circle()) + VStack(alignment: .leading, spacing: 3) { + Text(user.email).lineLimit(1) + HStack(spacing: 6) { + StatusBadge(user.status) + if user.testMode { StatusBadge(.scheduled, label: "TEST") } + if let last = user.lastLoginAt { + Text(last, format: .relative(presentation: .named)).font(.caption).foregroundStyle(.secondary) + } + } + } + } + } +} + +/// USR-03 User — Overview, with the spec's tabs as a segmented control (Overview · Access · Roles · Sessions · Notifications). +struct UserDetailView: View { + let userID: String + @Environment(SessionModel.self) private var session + @State private var user: LoadState = .idle + @State private var access: [AccessSummary] = [] + @State private var tab: Tab = .overview + @State private var pendingTurnOff: AccessSummary? + + enum Tab: String, CaseIterable { case overview = "Overview", access = "Access", roles = "Roles", sessions = "Sessions", notifications = "Notifications" } + + var body: some View { + LoadStateView(user, retry: { Task { await load() } }) { user in + List { + Section { + UserRow(user: user) + IDChip(user.id) + Picker("Section", selection: $tab) { + ForEach(Tab.allCases, id: \.self) { Text($0.rawValue).tag($0) } + } + .pickerStyle(.segmented) + } + switch tab { + case .overview: overview(user) + case .access: accessSection + case .roles: link("Roles", .rol04) + case .sessions: link("Sessions", .usr04) + case .notifications: link("Notifications & devices", .ntf05) + } + } + .navigationTitle(user.email) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .secondaryAction) { + NavigationLink(value: Route(.usr05, ["userId": userID])) { Label("Suspend…", systemImage: "pause.circle") } + .disabled(!session.capabilities.canManageUsers) + } + } + } + .task { await load() } + .confirmationDialog("Turn off \(pendingTurnOff?.applicationName ?? "")?", isPresented: Binding(get: { pendingTurnOff != nil }, set: { if !$0 { pendingTurnOff = nil } }), titleVisibility: .visible, presenting: pendingTurnOff) { _ in + Button("Turn off", role: .destructive) { pendingTurnOff = nil } + Button("Cancel", role: .cancel) { pendingTurnOff = nil } + } message: { _ in + Text("They lose access right away. Their roles and settings are kept, so turning it back on restores everything.") + } + } + + @ViewBuilder private func overview(_ user: UserSummary) -> some View { + Section("Account") { + LabeledContent("Email verified", value: user.emailVerified ? "Yes" : "No") + LabeledContent("Two-factor", value: user.mfaEnabled ? "On" : "Off") + LabeledContent("Plan", value: (user.plan ?? "free").capitalized) + if let created = user.createdAt { LabeledContent("Joined") { Text(created, style: .date) } } + if let expires = user.expiresAt { LabeledContent("Test user expires") { Text(expires, style: .relative) } } + } + Section("Activity") { + NavigationLink(value: Route(.aud01, ["target_user_id": userID])) { Label("Audit trail", systemImage: "list.bullet.clipboard") } + } + } + + @ViewBuilder private var accessSection: some View { + Section { + ForEach(access) { row in + AccessRow(row) { on in if !on { pendingTurnOff = row } } + } + } header: { + Text("Apps") + } footer: { + Text("Status shows the resolved access. Tap an app for every path that grants it.") + } + Section { + NavigationLink(value: Route(.acx02, ["userId": userID])) { Label("Grant access", systemImage: "plus.circle") } + } + } + + private func link(_ title: String, _ screen: ScreenID) -> some View { + Section { NavigationLink(title, value: Route(screen, ["userId": userID])) } + } + + private func load() async { + do { + async let detail = session.service.user(id: userID) + async let rows = session.service.access(userID: userID) + user = .loaded(try await detail) + access = (try? await rows) ?? [] + } catch let error as APIError { + user = .failed(error) + } catch {} + } +} diff --git a/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json b/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..cd5f4f7 --- /dev/null +++ b/App/Resources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "red": "0x4A", + "green": "0x73", + "blue": "0x22", + "alpha": "1.000" + } + } + }, + { + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "red": "0xB8", + "green": "0xE2", + "blue": "0x6A", + "alpha": "1.000" + } + } + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024-dark.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024-dark.png new file mode 100644 index 0000000..4ec6049 Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024-dark.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024-tinted.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024-tinted.png new file mode 100644 index 0000000..9500d51 Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024-tinted.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..4e24171 Binary files /dev/null and b/App/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..7f452ce --- /dev/null +++ b/App/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,38 @@ +{ + "images": [ + { + "filename": "AppIcon-1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "AppIcon-1024-dark.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "tinted" + } + ], + "filename": "AppIcon-1024-tinted.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandIndigo.colorset/Contents.json b/App/Resources/Assets.xcassets/Brand/BrandIndigo.colorset/Contents.json new file mode 100644 index 0000000..7c2aa92 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandIndigo.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "red": "0x3D", + "green": "0x3B", + "blue": "0xB8", + "alpha": "1.000" + } + } + }, + { + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "red": "0x8E", + "green": "0x8C", + "blue": "0xFF", + "alpha": "1.000" + } + } + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandInk.colorset/Contents.json b/App/Resources/Assets.xcassets/Brand/BrandInk.colorset/Contents.json new file mode 100644 index 0000000..0669718 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandInk.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "red": "0x1A", + "green": "0x18", + "blue": "0x38", + "alpha": "1.000" + } + } + }, + { + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "red": "0xFF", + "green": "0xFF", + "blue": "0xFF", + "alpha": "1.000" + } + } + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandLime.colorset/Contents.json b/App/Resources/Assets.xcassets/Brand/BrandLime.colorset/Contents.json new file mode 100644 index 0000000..c363d55 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandLime.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "red": "0x8D", + "green": "0xBF", + "blue": "0x2E", + "alpha": "1.000" + } + } + }, + { + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "red": "0xC4", + "green": "0xEE", + "blue": "0x6C", + "alpha": "1.000" + } + } + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandLockup.imageset/BrandLockup-dark.svg b/App/Resources/Assets.xcassets/Brand/BrandLockup.imageset/BrandLockup-dark.svg new file mode 100644 index 0000000..711809c --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandLockup.imageset/BrandLockup-dark.svg @@ -0,0 +1 @@ +Substratal Apps \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandLockup.imageset/BrandLockup.svg b/App/Resources/Assets.xcassets/Brand/BrandLockup.imageset/BrandLockup.svg new file mode 100644 index 0000000..463c461 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandLockup.imageset/BrandLockup.svg @@ -0,0 +1 @@ +Substratal Apps \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandLockup.imageset/Contents.json b/App/Resources/Assets.xcassets/Brand/BrandLockup.imageset/Contents.json new file mode 100644 index 0000000..b2b1d85 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandLockup.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "BrandLockup.svg" + }, + { + "idiom": "universal", + "filename": "BrandLockup-dark.svg", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandLockupInline.imageset/BrandLockupInline-dark.svg b/App/Resources/Assets.xcassets/Brand/BrandLockupInline.imageset/BrandLockupInline-dark.svg new file mode 100644 index 0000000..b4bb340 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandLockupInline.imageset/BrandLockupInline-dark.svg @@ -0,0 +1 @@ +Substratal Apps \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandLockupInline.imageset/BrandLockupInline.svg b/App/Resources/Assets.xcassets/Brand/BrandLockupInline.imageset/BrandLockupInline.svg new file mode 100644 index 0000000..4aeb7ad --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandLockupInline.imageset/BrandLockupInline.svg @@ -0,0 +1 @@ +Substratal Apps \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandLockupInline.imageset/Contents.json b/App/Resources/Assets.xcassets/Brand/BrandLockupInline.imageset/Contents.json new file mode 100644 index 0000000..505ee03 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandLockupInline.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "BrandLockupInline.svg" + }, + { + "idiom": "universal", + "filename": "BrandLockupInline-dark.svg", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandLockupStacked.imageset/BrandLockupStacked-dark.svg b/App/Resources/Assets.xcassets/Brand/BrandLockupStacked.imageset/BrandLockupStacked-dark.svg new file mode 100644 index 0000000..9ff5981 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandLockupStacked.imageset/BrandLockupStacked-dark.svg @@ -0,0 +1 @@ +Substratal Apps \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandLockupStacked.imageset/BrandLockupStacked.svg b/App/Resources/Assets.xcassets/Brand/BrandLockupStacked.imageset/BrandLockupStacked.svg new file mode 100644 index 0000000..63c05f2 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandLockupStacked.imageset/BrandLockupStacked.svg @@ -0,0 +1 @@ +Substratal Apps \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandLockupStacked.imageset/Contents.json b/App/Resources/Assets.xcassets/Brand/BrandLockupStacked.imageset/Contents.json new file mode 100644 index 0000000..2c09120 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandLockupStacked.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "BrandLockupStacked.svg" + }, + { + "idiom": "universal", + "filename": "BrandLockupStacked-dark.svg", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandMark.imageset/Contents.json b/App/Resources/Assets.xcassets/Brand/BrandMark.imageset/Contents.json new file mode 100644 index 0000000..f64225d --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandMark.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "mark.svg" + }, + { + "idiom": "universal", + "filename": "mark-dark.svg", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandMark.imageset/mark-dark.svg b/App/Resources/Assets.xcassets/Brand/BrandMark.imageset/mark-dark.svg new file mode 100644 index 0000000..5115972 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandMark.imageset/mark-dark.svg @@ -0,0 +1 @@ +Substratal \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandMark.imageset/mark.svg b/App/Resources/Assets.xcassets/Brand/BrandMark.imageset/mark.svg new file mode 100644 index 0000000..d003aa3 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandMark.imageset/mark.svg @@ -0,0 +1 @@ +Substratal \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandMarkSmall.imageset/Contents.json b/App/Resources/Assets.xcassets/Brand/BrandMarkSmall.imageset/Contents.json new file mode 100644 index 0000000..ba6d183 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandMarkSmall.imageset/Contents.json @@ -0,0 +1,25 @@ +{ + "images": [ + { + "idiom": "universal", + "filename": "mark-small.svg" + }, + { + "idiom": "universal", + "filename": "mark-small-dark.svg", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ] + } + ], + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "preserves-vector-representation": true + } +} \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandMarkSmall.imageset/mark-small-dark.svg b/App/Resources/Assets.xcassets/Brand/BrandMarkSmall.imageset/mark-small-dark.svg new file mode 100644 index 0000000..61b4f76 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandMarkSmall.imageset/mark-small-dark.svg @@ -0,0 +1 @@ +Substratal \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandMarkSmall.imageset/mark-small.svg b/App/Resources/Assets.xcassets/Brand/BrandMarkSmall.imageset/mark-small.svg new file mode 100644 index 0000000..ca5b641 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandMarkSmall.imageset/mark-small.svg @@ -0,0 +1 @@ +Substratal \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/BrandPink.colorset/Contents.json b/App/Resources/Assets.xcassets/Brand/BrandPink.colorset/Contents.json new file mode 100644 index 0000000..d17be5c --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/BrandPink.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "red": "0xE0", + "green": "0x45", + "blue": "0x7B", + "alpha": "1.000" + } + } + }, + { + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "red": "0xFF", + "green": "0x86", + "blue": "0xAE", + "alpha": "1.000" + } + } + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/Contents.json b/App/Resources/Assets.xcassets/Brand/Contents.json new file mode 100644 index 0000000..00a27f1 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/Contents.json @@ -0,0 +1,9 @@ +{ + "info": { + "author": "xcode", + "version": 1 + }, + "properties": { + "provides-namespace": true + } +} \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/IconBackground.colorset/Contents.json b/App/Resources/Assets.xcassets/Brand/IconBackground.colorset/Contents.json new file mode 100644 index 0000000..0e242db --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/IconBackground.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "red": "0xE8", + "green": "0xE8", + "blue": "0xF7", + "alpha": "1.000" + } + } + }, + { + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "red": "0x15", + "green": "0x13", + "blue": "0x2E", + "alpha": "1.000" + } + } + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/WordmarkCoral.colorset/Contents.json b/App/Resources/Assets.xcassets/Brand/WordmarkCoral.colorset/Contents.json new file mode 100644 index 0000000..deec009 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/WordmarkCoral.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "red": "0xE2", + "green": "0x67", + "blue": "0x4A", + "alpha": "1.000" + } + } + }, + { + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "red": "0xFF", + "green": "0x9A", + "blue": "0x80", + "alpha": "1.000" + } + } + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/WordmarkSand.colorset/Contents.json b/App/Resources/Assets.xcassets/Brand/WordmarkSand.colorset/Contents.json new file mode 100644 index 0000000..c74d5e3 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/WordmarkSand.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "red": "0xC9", + "green": "0xA2", + "blue": "0x6A", + "alpha": "1.000" + } + } + }, + { + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "red": "0xEB", + "green": "0xCB", + "blue": "0x95", + "alpha": "1.000" + } + } + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Brand/WordmarkTeal.colorset/Contents.json b/App/Resources/Assets.xcassets/Brand/WordmarkTeal.colorset/Contents.json new file mode 100644 index 0000000..8828376 --- /dev/null +++ b/App/Resources/Assets.xcassets/Brand/WordmarkTeal.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "idiom": "universal", + "color": { + "color-space": "srgb", + "components": { + "red": "0x13", + "green": "0x7C", + "blue": "0x7A", + "alpha": "1.000" + } + } + }, + { + "idiom": "universal", + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "red": "0x55", + "green": "0xCF", + "blue": "0xC9", + "alpha": "1.000" + } + } + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/App/Resources/Assets.xcassets/Contents.json b/App/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..c47b5f2 --- /dev/null +++ b/App/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/App/RootView.swift b/App/RootView.swift new file mode 100644 index 0000000..51ec945 --- /dev/null +++ b/App/RootView.swift @@ -0,0 +1,57 @@ +import SwiftUI +import SubstratalCore +import SubstratalUI + +/// Auth gate: restoring → sign-in / two-factor → the shell. "Update required" blocks everything. +struct RootView: View { + @Environment(\.appConfiguration) private var configuration + @Environment(SessionModel.self) private var session + @Environment(APIVersionMonitor.self) private var versionMonitor + @Environment(Navigator.self) private var navigator + @Environment(\.scenePhase) private var scenePhase + + var body: some View { + Group { + if case .unsupported(let supported) = versionMonitor.state { + UpdateRequiredView(supported: supported) { + versionMonitor.reset() + Task { await session.refreshCapabilities() } + } + } else { + switch session.phase { + case .restoring: + ProgressView().controlSize(.large) + case .signedOut: + SignInView() + case .awaitingMFA: + TwoFactorView() + case .signedIn: + MainShell() + .safeAreaInset(edge: .top) { APIVersionBanner(versionMonitor.state) } + } + } + } + .animation(.default, value: session.phase) + .task { + await session.restore() + await applyDemoLaunchArguments() + } + .onChange(of: scenePhase) { _, phase in + if phase == .active, session.phase == .signedIn { Task { await session.refreshCapabilities() } } + } + } + + /// Demo-only conveniences for screenshots and UI tests: `-autoSignIn YES` skips AUTH-01/02, + /// and `-route /admin/users` opens a route as if it arrived as a deep link. + private func applyDemoLaunchArguments() async { + guard configuration.environment == .demo else { return } + let defaults = UserDefaults.standard + if defaults.bool(forKey: "autoSignIn"), session.phase == .signedOut { + await session.signIn(email: "adron@substratalapps.com", password: "demo") + await session.verifyMFA(code: "000000") + } + if let path = defaults.string(forKey: "route"), let route = Route.parse(path: path) { + navigator.open(route) + } + } +} diff --git a/App/Shell/HubSection.swift b/App/Shell/HubSection.swift new file mode 100644 index 0000000..19ef217 --- /dev/null +++ b/App/Shell/HubSection.swift @@ -0,0 +1,147 @@ +import SwiftUI +import SubstratalCore + +/// The navigation tree from INFORMATION-ARCHITECTURE §1: the same sections, order and icons on every platform. +enum HubSection: String, CaseIterable, Identifiable, Hashable, Codable { + // Personal + case home, inbox, organizations, account, billing, settings + // Administration + case users, access, roles, audit, sendNotification + // Developer + case applications, apiKeys, webhooks, tenant + // Compact-only hub (GLB-02) that holds Administration + Developer + case manage + + var id: String { rawValue } + + enum Group: String { case personal = "Personal", administration = "Administration", developer = "Developer" } + + var group: Group { + switch self { + case .home, .inbox, .organizations, .account, .billing, .settings, .manage: .personal + case .users, .access, .roles, .audit, .sendNotification: .administration + case .applications, .apiKeys, .webhooks, .tenant: .developer + } + } + + var title: String { + switch self { + case .home: "Home" + case .inbox: "Inbox" + case .organizations: "Organizations" + case .account: "Account" + case .billing: "Plan & Billing" + case .settings: "Settings" + case .users: "Users" + case .access: "Access" + case .roles: "Roles" + case .audit: "Audit Log" + case .sendNotification: "Send notification" + case .applications: "Applications" + case .apiKeys: "API Keys" + case .webhooks: "Webhooks" + case .tenant: "Tenant" + case .manage: "Manage" + } + } + + /// The iPhone tab bar's shorter label (PLATFORM-SPEC §2: Home · Inbox · Orgs · Manage · Account). + var compactTitle: String { self == .organizations ? "Orgs" : title } + + /// SF Symbols from INFORMATION-ARCHITECTURE §5. + var symbol: String { + switch self { + case .home: "square.grid.2x2" + case .inbox: "tray" + case .organizations: "person.3" + case .account: "person.crop.circle" + case .billing: "creditcard" + case .settings: "gearshape" + case .users: "person.2" + case .access: "switch.2" + case .roles: "checkmark.shield" + case .audit: "list.bullet.clipboard" + case .sendNotification: "bell.badge" + case .applications: "shippingbox" + case .apiKeys: "key" + case .webhooks: "arrow.triangle.branch" + case .tenant: "server.rack" + case .manage: "slider.horizontal.3" + } + } + + /// The screen at the root of this section's stack. + var root: ScreenID { + switch self { + case .home: .app01 + case .inbox: .ntf01 + case .organizations: .org01 + case .account: .acc01 + case .billing: .bil01 + case .settings: .set01 + case .users: .usr01 + case .access: .acx05 + case .roles: .rol01 + case .audit: .aud01 + case .sendNotification: .ntf06 + case .applications: .apl01 + case .apiKeys: .key01 + case .webhooks: .whk01 + case .tenant: .bil04 + case .manage: .glb02 + } + } + + /// INFORMATION-ARCHITECTURE §2 visibility rules. + func isVisible(for caps: Capabilities) -> Bool { + switch self { + case .home, .inbox, .organizations, .account, .billing, .settings: true + case .manage: caps.showsManage + case .users: caps.canListUsers + case .access: caps.canManageEntitlements || caps.ownsApps + case .roles: caps.canListUsers || caps.canManageRoles + case .audit: caps.canViewAudit + case .sendNotification: caps.canSendNotifications + case .applications: caps.showsDeveloper + case .apiKeys: caps.canManageAPIKeys || caps.ownsApps + case .webhooks: caps.canManageWebhooks || caps.ownsApps + case .tenant: caps.canManageTenants || !caps.ownedTenantIDs.isEmpty + } + } + + static let administration: [HubSection] = [.users, .access, .roles, .audit, .sendNotification] + static let developer: [HubSection] = [.applications, .apiKeys, .webhooks, .tenant] + /// iPhone: Home · Inbox · Orgs · Manage · Account (Plan & Billing and Settings live inside Account). + static let compactTabs: [HubSection] = [.home, .inbox, .organizations, .manage, .account] + /// iPad sidebar, Personal group. + static let personal: [HubSection] = [.home, .inbox, .organizations, .account, .billing, .settings] + + /// Which section a deep-linked screen belongs to, for picking the tab to push it onto. + static func owning(_ screen: ScreenID, compact: Bool) -> HubSection { + let route = screen.info.route + let section: HubSection = switch true { + case route == "/manage": .manage + case route.hasPrefix("/inbox"): .inbox + case route.hasPrefix("/orgs"): .organizations + case route.hasPrefix("/billing"): .billing + case route.hasPrefix("/settings"): .settings + case route.hasPrefix("/account"): .account + case route.hasPrefix("/admin/users"), route.hasPrefix("/admin/entitlements"): .users + case route.hasPrefix("/admin/access"): .access + case route.hasPrefix("/admin/roles"): .roles + case route.hasPrefix("/admin/audit"): .audit + case route.hasPrefix("/admin/notifications"): .sendNotification + case route.hasPrefix("/dev/apps"): .applications + case route.hasPrefix("/dev/keys"): .apiKeys + case route.hasPrefix("/dev/webhooks"): .webhooks + case route.hasPrefix("/dev/tenant"): .tenant + default: .home + } + guard compact else { return section } + switch section { + case .billing, .settings: return .account + case _ where section.group != .personal: return .manage + default: return section + } + } +} diff --git a/App/Shell/MainShell.swift b/App/Shell/MainShell.swift new file mode 100644 index 0000000..c5c4628 --- /dev/null +++ b/App/Shell/MainShell.swift @@ -0,0 +1,84 @@ +import SwiftUI +import SubstratalCore +import SubstratalUI + +/// iPhone: five tabs, Administration + Developer combined under Manage (GLB-02), Manage removed when the +/// person has no rights there. iPad (regular width, iOS 18+): a sidebar-adaptable TabView showing the full tree. +struct MainShell: View { + @Environment(SessionModel.self) private var session + @Environment(Navigator.self) private var navigator + @Environment(\.horizontalSizeClass) private var sizeClass + + var body: some View { + Group { + if sizeClass == .regular, #available(iOS 18.0, *) { + SidebarShell() + } else { + CompactShell() + } + } + .onAppear { navigator.shellAppeared(compact: sizeClass != .regular) } + .onDisappear { navigator.shellDisappeared() } + .onChange(of: sizeClass) { _, value in navigator.layoutChanged(compact: value != .regular) } + } +} + +private struct CompactShell: View { + @Environment(SessionModel.self) private var session + @Environment(Navigator.self) private var navigator + + var body: some View { + @Bindable var navigator = navigator + TabView(selection: $navigator.selection) { + ForEach(HubSection.compactTabs.filter { $0.isVisible(for: session.capabilities) }) { section in + SectionStack(section: section) + .tabItem { Label(section.compactTitle, systemImage: section.symbol) } + .tag(section) + } + } + } +} + +@available(iOS 18.0, *) +private struct SidebarShell: View { + @Environment(SessionModel.self) private var session + @Environment(Navigator.self) private var navigator + + var body: some View { + @Bindable var navigator = navigator + let caps = session.capabilities + TabView(selection: $navigator.selection) { + ForEach(HubSection.personal) { section in + Tab(section.title, systemImage: section.symbol, value: section) { SectionStack(section: section) } + } + if caps.showsAdministration { + TabSection("Administration") { + ForEach(HubSection.administration.filter { $0.isVisible(for: caps) }) { section in + Tab(section.title, systemImage: section.symbol, value: section) { SectionStack(section: section) } + } + } + } + if caps.showsDeveloper { + TabSection("Developer") { + ForEach(HubSection.developer.filter { $0.isVisible(for: caps) }) { section in + Tab(section.title, systemImage: section.symbol, value: section) { SectionStack(section: section) } + } + } + } + } + .tabViewStyle(.sidebarAdaptable) + } +} + +/// One NavigationStack per section, its path owned by the Navigator so deep links can rewrite it. +struct SectionStack: View { + let section: HubSection + @Environment(Navigator.self) private var navigator + + var body: some View { + NavigationStack(path: navigator.path(for: section)) { + ScreenView(route: Route(section.root)) + .navigationDestination(for: Route.self) { ScreenView(route: $0) } + } + } +} diff --git a/App/Shell/ManageHubView.swift b/App/Shell/ManageHubView.swift new file mode 100644 index 0000000..2ac433e --- /dev/null +++ b/App/Shell/ManageHubView.swift @@ -0,0 +1,32 @@ +import SwiftUI +import SubstratalCore +import SubstratalUI + +/// GLB-02 Manage hub: the compact layout's entry to Administration and Developer. +/// Sections the person can't use are hidden, never shown disabled. +struct ManageHubView: View { + @Environment(SessionModel.self) private var session + + var body: some View { + let caps = session.capabilities + List { + let admin = HubSection.administration.filter { $0.isVisible(for: caps) } + if !admin.isEmpty { + Section("Administration") { + ForEach(admin) { section in + NavigationLink(value: Route(section.root)) { Label(section.title, systemImage: section.symbol) } + } + } + } + let developer = HubSection.developer.filter { $0.isVisible(for: caps) } + if !developer.isEmpty { + Section("Developer") { + ForEach(developer) { section in + NavigationLink(value: Route(section.root)) { Label(section.title, systemImage: section.symbol) } + } + } + } + } + .navigationTitle("Manage") + } +} diff --git a/App/Shell/Navigator.swift b/App/Shell/Navigator.swift new file mode 100644 index 0000000..f6828c6 --- /dev/null +++ b/App/Shell/Navigator.swift @@ -0,0 +1,70 @@ +import SwiftUI +import SubstratalCore + +/// Owns the selected section and one navigation path per section, and routes deep links +/// (Universal Links, `substratal://`, notification `action_url`s) into them (INFORMATION-ARCHITECTURE §4). +@MainActor @Observable +final class Navigator { + var selection: HubSection = .home + var paths: [HubSection: [Route]] = [:] + var isCompact = true + /// A link that arrived before the shell existed (signed out, restoring); replayed by `shellAppeared`. + private(set) var pending: Route? + private var isShellReady = false + + func path(for section: HubSection) -> Binding<[Route]> { + Binding(get: { self.paths[section, default: []] }, set: { self.paths[section] = $0 }) + } + + func push(_ route: Route) { + paths[selection, default: []].append(route) + } + + func open(_ url: URL) { + guard let route = Route.parse(url) else { + UIApplication.shared.open(url) + return + } + open(route) + } + + /// Called by the shell once it knows its layout. Replays a deferred deep link. + func shellAppeared(compact: Bool) { + isShellReady = true + layoutChanged(compact: compact) + if let pending { + self.pending = nil + open(pending) + } + } + + func shellDisappeared() { isShellReady = false } + + /// Keeps the selection valid when an iPad moves between compact (tabs + Manage) and regular (sidebar). + func layoutChanged(compact: Bool) { + isCompact = compact + if compact, selection.group != .personal || selection == .billing || selection == .settings { + selection = HubSection.owning(selection.root, compact: true) + } else if !compact, selection == .manage { + selection = .users + } + } + + func open(_ route: Route) { + guard isShellReady else { + pending = route + return + } + let section = HubSection.owning(route.screen, compact: isCompact) + let inner = HubSection.owning(route.screen, compact: false) + selection = section + if route.screen == section.root { + paths[section] = [] + } else if section != inner, inner.root != route.screen { + // Compact: Administration/Developer screens sit under the Manage hub, behind their section's root. + paths[section] = [Route(inner.root), route] + } else { + paths[section] = [route] + } + } +} diff --git a/App/Shell/ScreenView.swift b/App/Shell/ScreenView.swift new file mode 100644 index 0000000..f305179 --- /dev/null +++ b/App/Shell/ScreenView.swift @@ -0,0 +1,46 @@ +import SwiftUI +import SubstratalCore +import SubstratalUI + +/// Maps a route to its screen. Screens that aren't built yet resolve to `ScreenPlaceholder`, +/// so all 75 routes and deep links work from the first build. Forbidden routes show the Forbidden state +/// rather than redirecting (INFORMATION-ARCHITECTURE §4). +struct ScreenView: View { + let route: Route + @Environment(SessionModel.self) private var session + @Environment(\.palette) private var palette + + var body: some View { + Group { + if session.capabilities.shows(route.screen) { + content + } else { + ErrorStateView(.forbidden(code: "forbidden")) + } + } + .scrollContentBackground(.hidden) + .background(palette.background) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { HelpButton(route.screen) } + } + } + + @ViewBuilder private var content: some View { + switch route.screen { + case .app01: HomeView() + case .ntf01: InboxView() + case .acc01: AccountView() + case .set01: AppearanceSettingsView() + case .glb02: ManageHubView() + case .usr01: UsersView() + case .usr03: UserDetailView(userID: route["userId"] ?? "") + case .rol01: RolesView() + case .rol02: RoleDetailView(roleID: route["roleId"] ?? "") + case .aud01: AuditLogView() + case .apl01: ApplicationsView() + case .apl03: ApplicationDetailView(appID: route["appId"] ?? "") + case .key01: APIKeysView() + default: ScreenPlaceholder(route.screen, params: route.params) + } + } +} diff --git a/App/SubstratalHubApp.swift b/App/SubstratalHubApp.swift new file mode 100644 index 0000000..c2ed591 --- /dev/null +++ b/App/SubstratalHubApp.swift @@ -0,0 +1,21 @@ +import SwiftUI +import SubstratalCore +import SubstratalUI + +@main +struct SubstratalHubApp: App { + @State private var environment = AppEnvironment.launch() + + var body: some Scene { + WindowGroup { + RootView() + .environment(environment.session) + .environment(environment.versionMonitor) + .environment(environment.theme) + .environment(environment.navigator) + .environment(\.appConfiguration, environment.configuration) + .substratalTheme(environment.theme) + .onOpenURL { environment.navigator.open($0) } + } + } +} diff --git a/App/Support/Info.plist b/App/Support/Info.plist new file mode 100644 index 0000000..1a26dad --- /dev/null +++ b/App/Support/Info.plist @@ -0,0 +1,66 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Substratal + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleURLTypes + + + CFBundleURLName + com.substratal.hub + CFBundleURLSchemes + + substratal + + + + CFBundleVersion + 1 + ITSAppUsesNonExemptEncryption + + LSApplicationCategoryType + public.app-category.productivity + NSFaceIDUsageDescription + Substratal uses Face ID to unlock administration and to confirm sensitive changes. + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UIColorName + Brand/IconBackground + UIImageName + Brand/BrandMark + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/App/Support/SubstratalHub.entitlements b/App/Support/SubstratalHub.entitlements new file mode 100644 index 0000000..5be9469 --- /dev/null +++ b/App/Support/SubstratalHub.entitlements @@ -0,0 +1,13 @@ + + + + + aps-environment + development + com.apple.developer.associated-domains + + applinks:dashboard.substratalapps.com + webcredentials:dashboard.substratalapps.com + + + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..46febc2 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ +# Common tasks. Everything here also runs in CI (.github/workflows/ios.yml). +SIM ?= iPhone 17 Pro +SPECS ?= ../substratalapps-applications-specs/substratal-application-specs +XCODEBUILD = xcodebuild -project SubstratalHub.xcodeproj -scheme SubstratalHub -skipPackagePluginValidation + +.PHONY: project build test test-kit test-ui contract screens run-demo check + +project: ## Regenerate SubstratalHub.xcodeproj from project.yml + xcodegen generate + +build: ## Build the app for the simulator + $(XCODEBUILD) -destination 'generic/platform=iOS Simulator' build + +test: test-kit test-ui + +test-kit: ## Unit tests for SubstratalKit (routing, versioning, models) + cd Packages/SubstratalKit && swift test + +test-ui: ## UI smoke test against demo data + $(XCODEBUILD) -destination 'platform=iOS Simulator,name=$(SIM)' test -only-testing:SubstratalHubUITests + +contract: ## Vendor the latest published openapi.yaml and re-derive the generator's copy + scripts/sync-openapi.sh + +screens: ## Regenerate ScreenID.swift from the specs (sibling checkout by default) + scripts/generate-screen-ids.py $(SPECS) + +run-demo: ## Launch in the booted simulator with demo data, signed in + xcrun simctl launch booted com.substratal.hub -demo YES -autoSignIn YES + +check: ## Fail if the project, contract or screen registry is out of date + xcodegen generate --quiet && git diff --exit-code -- SubstratalHub.xcodeproj + scripts/sync-openapi.sh >/dev/null && git diff --exit-code -- api-contract/openapi.yaml Packages/SubstratalKit/Sources/SubstratalAPI/openapi.yaml diff --git a/Packages/SubstratalKit/Package.resolved b/Packages/SubstratalKit/Package.resolved new file mode 100644 index 0000000..018d720 --- /dev/null +++ b/Packages/SubstratalKit/Package.resolved @@ -0,0 +1,96 @@ +{ + "originHash" : "fb0582aa2490cea4fb1fd47884fa427fbc4945c11b5ff5c33c36f607ebf0608f", + "pins" : [ + { + "identity" : "openapikit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattpolzin/OpenAPIKit", + "state" : { + "revision" : "5b72eb9a6b23b1fec439d9a0bf38f115400421f9", + "version" : "6.4.0" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "844289ac455816e193f1dbbe014f3c4dbe429410", + "version" : "1.7.4" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types", + "state" : { + "revision" : "bff4b6903cdc99dda49649dd52f46c11cfd3ed50", + "version" : "1.8.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-openapi-generator", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-generator", + "state" : { + "revision" : "164dfbcf1f78e2ddc719ab9d85c50d1157fa8d7d", + "version" : "1.14.2" + } + }, + { + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "6927ad435adf4d00fe55fc61b9a07a04673c79c8", + "version" : "1.13.3" + } + }, + { + "identity" : "swift-openapi-urlsession", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-urlsession", + "state" : { + "revision" : "c97e0f0187d95542b4a254f5bb4dc67f43bf73c9", + "version" : "1.4.0" + } + }, + { + "identity" : "yams", + "kind" : "remoteSourceControl", + "location" : "https://github.com/jpsim/Yams", + "state" : { + "revision" : "a27b21e0c81c5bf42049b897a62aaf387e80f279", + "version" : "6.2.2" + } + } + ], + "version" : 3 +} diff --git a/Packages/SubstratalKit/Package.swift b/Packages/SubstratalKit/Package.swift new file mode 100644 index 0000000..acba69a --- /dev/null +++ b/Packages/SubstratalKit/Package.swift @@ -0,0 +1,39 @@ +// swift-tools-version: 6.0 +// SubstratalKit: everything the Hub shares between iOS/iPadOS and (later) macOS. +// SubstratalAPI is generated from openapi.yaml at build time; SubstratalCore wraps it with the +// request conventions from CLIENT-ARCHITECTURE §4; SubstratalUI holds the theme and shared components. +import PackageDescription + +let package = Package( + name: "SubstratalKit", + platforms: [.iOS(.v17), .macOS(.v14)], + products: [ + .library(name: "SubstratalAPI", targets: ["SubstratalAPI"]), + .library(name: "SubstratalCore", targets: ["SubstratalCore"]), + .library(name: "SubstratalUI", targets: ["SubstratalUI"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-openapi-generator", from: "1.10.0"), + .package(url: "https://github.com/apple/swift-openapi-runtime", from: "1.8.0"), + .package(url: "https://github.com/apple/swift-openapi-urlsession", from: "1.1.0"), + .package(url: "https://github.com/apple/swift-http-types", from: "1.3.0"), + ], + targets: [ + .target( + name: "SubstratalAPI", + dependencies: [.product(name: "OpenAPIRuntime", package: "swift-openapi-runtime")], + plugins: [.plugin(name: "OpenAPIGenerator", package: "swift-openapi-generator")] + ), + .target( + name: "SubstratalCore", + dependencies: [ + "SubstratalAPI", + .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), + .product(name: "OpenAPIURLSession", package: "swift-openapi-urlsession"), + .product(name: "HTTPTypes", package: "swift-http-types"), + ] + ), + .target(name: "SubstratalUI", dependencies: ["SubstratalCore"]), + .testTarget(name: "SubstratalCoreTests", dependencies: ["SubstratalCore"]), + ] +) diff --git a/Packages/SubstratalKit/Sources/SubstratalAPI/SubstratalAPI.swift b/Packages/SubstratalKit/Sources/SubstratalAPI/SubstratalAPI.swift new file mode 100644 index 0000000..39ffa3e --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalAPI/SubstratalAPI.swift @@ -0,0 +1,8 @@ +// The generated client (Client, Operations, Components) is produced from openapi.yaml by the +// OpenAPIGenerator build plugin. This file only gives the target a hand-written anchor. +import OpenAPIRuntime + +/// The `info.version` of the openapi.yaml this module was generated from (see api-contract/CONTRACT.json). +public enum SubstratalAPIContract { + public static let openAPIDocumentVersion = "1.0.0-draft.9" +} diff --git a/Packages/SubstratalKit/Sources/SubstratalAPI/openapi-generator-config.yaml b/Packages/SubstratalKit/Sources/SubstratalAPI/openapi-generator-config.yaml new file mode 100644 index 0000000..0599e69 --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalAPI/openapi-generator-config.yaml @@ -0,0 +1,6 @@ +# Generated from the derived openapi.yaml next to this file (see scripts/sync-openapi.sh). Never edit generated code. +generate: + - types + - client +accessModifier: public +namingStrategy: idiomatic diff --git a/Packages/SubstratalKit/Sources/SubstratalAPI/openapi.yaml b/Packages/SubstratalKit/Sources/SubstratalAPI/openapi.yaml new file mode 100644 index 0000000..0446904 --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalAPI/openapi.yaml @@ -0,0 +1,6192 @@ +# DERIVED by scripts/derive-openapi.py from api-contract/openapi.yaml. Do not edit; run scripts/sync-openapi.sh. +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 + /auth/signup: + post: + operationId: auth.signup + parameters: [] + 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: [] + 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: [] + 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: [] + 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: [] + 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: [] + 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: [] + 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 + 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: [] + 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: [] + 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: [] + 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: [] + 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: [] + 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: + - 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: [] + 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: [] + 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: [] + 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: [] + 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: [] + 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/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: [] + 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: [] + 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: + 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/Limit' + - $ref: '#/components/parameters/Cursor' + - name: status + in: query + description: deleted is allowed for users.manage only + schema: + type: string + - 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/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 + 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: [] + 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/IfMatch' + requestBody: + content: + application/json: + schema: + type: object + properties: + email: + type: string + format: email + status: + type: string + 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: [] + 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: [] + 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: [] + 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: [] + 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: [] + 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: [] + 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' + /users/{id}/profile: + parameters: + - $ref: '#/components/parameters/UserIdOrMe' + get: + operationId: profiles.get + parameters: [] + 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/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: [] + 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/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: [] + 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/IfMatch' + requestBody: + content: + application/json: + schema: + type: object + properties: + display_handle: + type: string + 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: [] + 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/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: + get: + operationId: applications.list + tags: + - Applications + summary: List the catalog (filtered by visibility rules) + x-substratal-phase: mvp + parameters: + - $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/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: [] + 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/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' + /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/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/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - application_id + - source + properties: + application_id: + type: string + source: + type: string + order_id: + type: string + maxLength: 255 + starts_at: + type: string + format: date-time + ends_at: + type: string + 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/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: [] + 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/IfMatch' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + status: + $ref: '#/components/schemas/EntitlementStatus' + disabled_reason: + type: string + maxLength: 255 + ends_at: + type: string + format: date-time + source: + type: string + 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: [] + 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' + /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/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/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: [] + 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: [] + 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/IfMatch' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + description: + type: string + 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: [] + 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/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: [] + 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: [] + 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: [] + 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: + get: + operationId: organizations.list + tags: + - Organizations + summary: List your Organizations (all, with organizations.manage) + x-substratal-phase: phase2 + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: status + in: query + schema: + type: string + - 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/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: [] + 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/IfMatch' + requestBody: + content: + application/json: + schema: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 100 + status: + type: string + 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/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: [] + 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: [] + 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: [] + 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: [] + 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: [] + 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: [] + 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: [] + 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/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/IdempotencyKey' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - application_id + properties: + application_id: + type: string + order_id: + type: string + maxLength: 255 + starts_at: + type: string + format: date-time + ends_at: + type: string + 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' + /tenants: + get: + operationId: tenants.list + tags: + - Tenancy + summary: List Tenants (your own; all with tenants.manage) + x-substratal-phase: phase2 + parameters: + - $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: [] + 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/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: [] + 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 + 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: [] + 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/IfMatch' + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - status + properties: + status: + type: string + 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' + /users/{id}/subscription: + parameters: + - $ref: '#/components/parameters/UserIdOrMe' + get: + operationId: billing.getUserSubscription + parameters: [] + 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/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: [] + 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: [] + 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/IfMatch' + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + member_scope: + $ref: '#/components/schemas/EntitlementMemberScope' + member_overrides: + type: array + 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/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: [] + 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-events: + get: + operationId: audit.list + tags: + - Audit + summary: Query the audit log (hot window only) + x-substratal-phase: mvp + parameters: + - $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 + - 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: [] + 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/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: [] + responses: + '400': + $ref: '#/components/responses/BadRequest' + '200': + description: The export + content: + application/json: + schema: + $ref: '#/components/schemas/AuditExport' + '404': + $ref: '#/components/responses/NotFound' + /webhooks: + get: + operationId: webhooks.list + tags: + - Webhooks + summary: List subscriptions visible to the caller + x-substratal-phase: phase2 + parameters: + - $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: [] + 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: [] + 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: [] + 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 + 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: [] + 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: [] + 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: [] + 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/Limit' + - $ref: '#/components/parameters/Cursor' + - name: event_id + in: query + schema: + type: string + - name: status + in: query + schema: + type: string + - 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: [] + 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' + /applications/{id}/push-credentials: + parameters: + - name: id + in: path + required: true + schema: + type: string + get: + operationId: pushCredentials.list + parameters: [] + 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/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: [] + 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/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: [] + 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/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: [] + 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: [] + 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/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: [] + 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: [] + 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 + 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/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/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: + - 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: [] + 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: [] + 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/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: + get: + operationId: apiKeys.list + tags: + - API Keys + summary: List keys the caller may manage + x-substratal-phase: mvp + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + - name: scope + in: query + schema: + type: string + - name: mode + in: query + schema: + type: string + - name: intended_use + in: query + schema: + type: string + - 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: [] + 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: [] + 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: [] + 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: [] + 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: [] + 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 + 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 + error_description: + type: string + PageInfo: + type: object + required: + - has_more + properties: + next_cursor: + type: string + 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 + id: + type: string + Region: + type: string + AuthSession: + type: object + required: + - token_type + - access_token + - expires_in + - refresh_token + - session_id + - user_id + properties: + token_type: + type: string + 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 + expires_in: + type: integer + example: 300 + AppToken: + type: object + required: + - token_type + - app_token + - expires_in + - application_id + properties: + token_type: + type: string + 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 + 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 + 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 + UserStatus: + type: string + User: + type: object + properties: + id: + type: string + email: + type: string + format: email + email_verified: + type: boolean + pending_email: + type: string + format: email + status: + $ref: '#/components/schemas/UserStatus' + mfa_enabled: + type: boolean + signup_application_id: + type: string + test_mode: + type: boolean + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + last_login_at: + type: string + format: date-time + expires_at: + type: string + 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 + 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 + mfa_enabled: + type: boolean + last_used_at: + type: string + 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' + ProfileWrite: + type: object + properties: + display_name: + type: string + minLength: 1 + maxLength: 100 + avatar_url: + type: string + format: uri + maxLength: 2048 + contact_email: + type: string + format: email + contact_phone: + type: string + 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 + effective_display_name: + type: string + readOnly: true + custom: + type: object + updated_at: + type: string + format: date-time + SettingsWrite: + type: object + properties: + locale: + type: string + timezone: + type: string + example: America/Denver + theme: + type: string + 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 + overrides: + type: object + stale_overrides: + type: array + items: + type: string + updated_at: + type: string + format: date-time + ApplicationVisibility: + type: string + ApplicationReviewStatus: + type: string + 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 + 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 + icon_url: + type: string + 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 + maxLength: 2000 + icon_url: + type: string + format: uri + launch_url: + type: string + format: uri + redirect_uris: + type: array + maxItems: 10 + items: + type: string + support_url: + type: string + format: uri + email_from_name: + type: string + 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 + visibility: + $ref: '#/components/schemas/ApplicationVisibility' + access_tier: + $ref: '#/components/schemas/ApplicationAccessTier' + owner_user_id: + type: string + owner_organization_id: + type: string + review_status: + $ref: '#/components/schemas/ApplicationReviewStatus' + review_notes: + type: string + 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 + EntitlementStatus: + type: string + ResolvedEntitlementStatus: + type: string + description: 'scheduled: the only live path is a grant whose starts_at is in the future.' + EntitlementSource: + type: string + 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 + 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 + Entitlement: + type: object + properties: + id: + type: string + user_id: + type: string + organization_id: + type: string + application_id: + type: string + status: + $ref: '#/components/schemas/EntitlementStatus' + source: + $ref: '#/components/schemas/EntitlementSource' + order_id: + type: string + maxLength: 255 + description: Opaque developer-supplied billing reference + subscription_id: + type: string + readOnly: true + description: 'The subscription a source: subscription row comes from.' + starts_at: + type: string + format: date-time + ends_at: + type: string + format: date-time + disabled_reason: + type: string + member_scope: + $ref: '#/components/schemas/EntitlementMemberScope' + member_overrides: + type: array + 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' + 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 + 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 + assigned_at: + type: string + format: date-time + assigned_by: + $ref: '#/components/schemas/Actor' + 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 + member_decision: + type: string + roles: + type: array + items: + type: string + effective_permissions: + type: array + items: + type: string + computed_at: + type: string + format: date-time + Organization: + type: object + properties: + id: + type: string + name: + type: string + status: + type: string + 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 + OrganizationMember: + type: object + properties: + user_id: + type: string + email: + type: string + format: email + display_name: + type: string + 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 + format: date-time + OrganizationMembershipStatus: + type: string + OrganizationMemberPage: + allOf: + - $ref: '#/components/schemas/PageEnvelope' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/OrganizationMember' + TenantOwnerType: + type: string + TenantTier: + type: string + TenantStatus: + type: string + SubscriptionStatus: + type: string + 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 + owner_organization_id: + type: string + 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 + 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: + $ref: '#/components/schemas/Region' + status: + $ref: '#/components/schemas/TenantTierChangeRequestStatus' + reason: + type: string + notes: + type: string + scheduled_for: + type: string + format: date-time + requested_by: + type: string + created_at: + type: string + format: date-time + started_at: + type: string + format: date-time + completed_at: + type: string + format: date-time + TenantTierChangeRequestPage: + allOf: + - $ref: '#/components/schemas/PageEnvelope' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/TenantTierChangeRequest' + Plan: + type: string + description: Business and enterprise plans come later. + PlanSource: + type: object + required: + - type + - id + properties: + type: + type: string + 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 + user_id: + type: string + organization_id: + type: string + plan: + type: string + status: + $ref: '#/components/schemas/SubscriptionStatus' + current_period_start: + type: string + format: date-time + current_period_end: + type: string + format: date-time + cancel_at_period_end: + type: boolean + member_scope: + description: Organization subscriptions only. + allOf: + - $ref: '#/components/schemas/EntitlementMemberScope' + member_overrides: + type: array + 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 + last_synced_at: + type: string + 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: + description: The User's own individual subscription, if they've ever started one. + allOf: + - $ref: '#/components/schemas/Subscription' + OrganizationSubscription: + type: object + properties: + organization_id: + type: string + plan: + $ref: '#/components/schemas/Plan' + subscription: + $ref: '#/components/schemas/Subscription' + CheckoutSessionCreate: + type: object + required: + - plan + - success_url + - cancel_url + properties: + plan: + type: string + 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 + AuditAction: + type: string + AuditTargetType: + type: string + AuditExportRequest: + type: object + description: The same filters as GET /audit-events, as body fields, plus format. + properties: + format: + type: string + 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 + 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 + format: + type: string + filters: + type: object + description: The filters as requested, with target_user_id me resolved to the caller's id. + row_count: + type: integer + description: Set once ready. + download_url: + type: string + 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 + format: date-time + error: + type: string + 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 + 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 + 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 + application_id: + type: string + organization_id: + type: string + tenant_id: + type: string + before: + type: object + after: + type: object + request_id: + type: string + timestamp: + type: string + format: date-time + AuditEventPage: + allOf: + - $ref: '#/components/schemas/PageEnvelope' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/AuditEvent' + WebhookEvent: + type: string + AccessEventReason: + type: string + 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 + 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 + status: + type: string + api_version: + type: string + test_mode: + type: boolean + consecutive_failures: + type: integer + last_delivery_at: + type: string + 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 + response_status: + type: integer + duration_ms: + type: integer + error: + type: string + next_retry_at: + type: string + format: date-time + created_at: + type: string + format: date-time + PushProvider: + type: string + 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 + 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 + 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 + failed_at: + type: string + format: date-time + last_used_at: + type: string + 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 + DeviceInactiveReason: + type: string + 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 + description: apns only, required for it + name: + type: string + maxLength: 100 + app_version: + type: string + maxLength: 50 + DeviceUpdate: + type: object + properties: + token: + type: string + minLength: 1 + maxLength: 4096 + writeOnly: true + subscription: + $ref: '#/components/schemas/WebPushSubscription' + environment: + type: string + name: + type: string + maxLength: 100 + app_version: + type: string + 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 + token_hint: + type: string + example: …9f3a + description: The token itself is never returned. + name: + type: string + app_version: + type: string + session_id: + type: string + description: The registering session; null when an app key registered it. + status: + $ref: '#/components/schemas/DeviceStatus' + inactive_reason: + $ref: '#/components/schemas/DeviceInactiveReason' + last_registered_at: + type: string + format: date-time + last_delivery_at: + type: string + 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 + provider_status: + type: integer + description: The provider's HTTP status; null on a timeout or connection error. + error: + type: string + description: The provider's reason code, e.g. BadDeviceToken, UNREGISTERED, InvalidProviderToken, or timeout. + duration_ms: + type: integer + NotificationPriority: + type: string + NotificationSource: + type: string + NotificationState: + type: string + NotificationPushStatus: + type: string + NotificationSkipReason: + type: string + NotificationCreate: + type: object + required: + - title + - category + properties: + title: + type: string + minLength: 1 + maxLength: 120 + body: + type: string + 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 + maxLength: 2048 + badge: + type: integer + minimum: 0 + maximum: 99999 + sound: + type: string + description: Defaults to default for high priority, null (silent) for normal. + collapse_key: + type: string + 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: + $ref: '#/components/schemas/NotificationSkipReason' + 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 + 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 + data: + type: object + additionalProperties: + type: string + action_url: + type: string + badge: + type: integer + sound: + type: string + collapse_key: + type: string + ttl_seconds: + type: integer + push: + $ref: '#/components/schemas/NotificationPush' + state: + $ref: '#/components/schemas/NotificationState' + read_at: + type: string + format: date-time + archived_at: + type: string + format: date-time + sender: + type: object + required: + - type + - id + properties: + type: + type: string + 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 + description: null once the Device is deleted + provider: + $ref: '#/components/schemas/PushProvider' + status: + type: string + attempts: + type: integer + minimum: 0 + maximum: 4 + error: + type: string + sent_at: + type: string + 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: + - unread_count + properties: + application_id: + type: string + description: null for platform alerts + unread_count: + type: integer + minimum: 0 + 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 + description: Fixed at creation. + intended_use: + type: string + 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 + 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 + id: + type: string + last_used_at: + type: string + format: date-time + created_at: + type: string + format: date-time + revoked_at: + type: string + 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/Packages/SubstratalKit/Sources/SubstratalCore/Contract.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Contract.swift new file mode 100644 index 0000000..211d5c4 --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Contract.swift @@ -0,0 +1,5 @@ +import SubstratalAPI + +/// `info.version` of the openapi.yaml the client was generated from (api-contract/CONTRACT.json). +/// Distinct from `APIConfiguration.apiVersion`, the `Substratal-Version` header value. +public let contractVersion = SubstratalAPIContract.openAPIDocumentVersion diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Models/Capabilities.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Models/Capabilities.swift new file mode 100644 index 0000000..d8408d7 --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Models/Capabilities.swift @@ -0,0 +1,69 @@ +import Foundation + +/// What the signed-in person may see and do (CLIENT-ARCHITECTURE §5, INFORMATION-ARCHITECTURE §2). +/// Navigation and every action read only from this; the server is always the authority. +public struct Capabilities: Sendable, Hashable { + public var platformPermissions: Set + public var ownedApplicationIDs: [String] + public var ownedTenantIDs: [String] + public var adminOfOrganizationIDs: [String] + public var plan: String + + public init(platformPermissions: Set = [], ownedApplicationIDs: [String] = [], ownedTenantIDs: [String] = [], + adminOfOrganizationIDs: [String] = [], plan: String = "free") { + self.platformPermissions = platformPermissions + self.ownedApplicationIDs = ownedApplicationIDs + self.ownedTenantIDs = ownedTenantIDs + self.adminOfOrganizationIDs = adminOfOrganizationIDs + self.plan = plan + } + + public static let none = Capabilities() + + public var canListUsers: Bool { platformPermissions.contains("users.list") } + public var canManageUsers: Bool { platformPermissions.contains("users.manage") } + public var canManageEntitlements: Bool { platformPermissions.contains("entitlements.manage") } + public var canManageRoles: Bool { platformPermissions.contains("roles.manage") } + public var canViewAudit: Bool { platformPermissions.contains("audit.view") } + public var canManageApplications: Bool { platformPermissions.contains("applications.manage") } + public var canManageAPIKeys: Bool { platformPermissions.contains("api_keys.manage") } + public var canManageWebhooks: Bool { platformPermissions.contains("webhooks.manage") } + public var canManageTenants: Bool { platformPermissions.contains("tenants.manage") } + public var canManageBilling: Bool { platformPermissions.contains("billing.manage") } + public var canSendNotifications: Bool { platformPermissions.contains("notifications.send") } + public var ownsApps: Bool { !ownedApplicationIDs.isEmpty } + + // MARK: Section visibility (INFORMATION-ARCHITECTURE §2) + + public var showsAdministration: Bool { + canListUsers || canManageEntitlements || canManageRoles || canViewAudit || canSendNotifications || ownsApps + } + public var showsDeveloper: Bool { + ownsApps || !ownedTenantIDs.isEmpty || canManageApplications || canManageAPIKeys || canManageWebhooks || canManageTenants + } + /// The Manage tab is removed (not disabled) for people with no rights in either section. + public var showsManage: Bool { showsAdministration || showsDeveloper } + + public func shows(_ screen: ScreenID) -> Bool { + switch screen { + case .usr01, .usr02, .usr03, .usr04, .usr05: canListUsers + case .acx05: canManageEntitlements || ownsApps + case .rol01, .rol02, .rol04: canListUsers + case .rol03: canManageRoles + case .aud01, .aud02: canViewAudit + case .ntf06: canSendNotifications + case .apl01, .apl02, .apl03: showsDeveloper + case .key01, .key02, .key03, .key04: canManageAPIKeys || ownsApps + case .whk01, .whk02, .whk03, .whk04: canManageWebhooks || ownsApps + case .bil04: canManageTenants || !ownedTenantIDs.isEmpty + default: true + } + } + + /// Superadmin's full grant, used by demo mode and previews. + public static let superadmin = Capabilities( + platformPermissions: ["users.list", "users.manage", "entitlements.manage", "applications.manage", "roles.manage", + "organizations.manage", "billing.manage", "billing.refund", "audit.view", "webhooks.manage", + "api_keys.manage", "notifications.send", "tenants.manage"], + ownedApplicationIDs: ["app_hub"], ownedTenantIDs: ["tnt_system"], plan: "standard") +} diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Models/DomainModels.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Models/DomainModels.swift new file mode 100644 index 0000000..90b7c60 --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Models/DomainModels.swift @@ -0,0 +1,155 @@ +import Foundation + +/// A page from a cursor-paginated list (Conventions → Pagination). +public struct Page: Sendable { + public var items: [Item] + public var nextCursor: String? + public init(items: [Item], nextCursor: String?) { + self.items = items + self.nextCursor = nextCursor + } +} + +public struct UserSummary: Identifiable, Hashable, Sendable { + public var id: String + public var email: String + public var status: StatusKind + public var mfaEnabled: Bool + public var emailVerified: Bool + public var testMode: Bool + public var plan: String? + public var createdAt: Date? + public var lastLoginAt: Date? + public var expiresAt: Date? + + public init(id: String, email: String, status: StatusKind, mfaEnabled: Bool = false, emailVerified: Bool = true, + testMode: Bool = false, plan: String? = "free", createdAt: Date? = nil, lastLoginAt: Date? = nil, expiresAt: Date? = nil) { + self.id = id; self.email = email; self.status = status; self.mfaEnabled = mfaEnabled; self.emailVerified = emailVerified + self.testMode = testMode; self.plan = plan; self.createdAt = createdAt; self.lastLoginAt = lastLoginAt; self.expiresAt = expiresAt + } + + /// Initials for the avatar placeholder (avatar upload is OPEN-QUESTIONS #1). + public var initials: String { + let name = email.split(separator: "@").first ?? "" + let parts = name.split(whereSeparator: { $0 == "." || $0 == "_" || $0 == "-" || $0 == "+" }) + let letters = parts.prefix(2).compactMap(\.first).map(String.init).joined() + return (letters.isEmpty ? String(name.prefix(1)) : letters).uppercased() + } +} + +public struct ApplicationSummary: Identifiable, Hashable, Sendable { + public var id: String + public var slug: String + public var name: String + public var summary: String? + public var visibility: String + public var accessTier: String + public var reviewStatus: StatusKind + + public init(id: String, slug: String, name: String, summary: String?, visibility: String, accessTier: String, reviewStatus: StatusKind) { + self.id = id; self.slug = slug; self.name = name; self.summary = summary + self.visibility = visibility; self.accessTier = accessTier; self.reviewStatus = reviewStatus + } +} + +public struct APIKeySummary: Identifiable, Hashable, Sendable { + public var id: String + public var name: String + /// `platform` or an `application_id`. + public var scope: String + public var mode: String + public var intendedUse: String + public var restrictDestructive: Bool + public var permissions: [String] + public var secretHint: String? + public var lastUsedAt: Date? + public var expiresAt: Date? + public var revokedAt: Date? + public var createdAt: Date? + + public init(id: String, name: String, scope: String, mode: String, intendedUse: String, restrictDestructive: Bool, + permissions: [String], secretHint: String?, lastUsedAt: Date? = nil, expiresAt: Date? = nil, + revokedAt: Date? = nil, createdAt: Date? = nil) { + self.id = id; self.name = name; self.scope = scope; self.mode = mode; self.intendedUse = intendedUse + self.restrictDestructive = restrictDestructive; self.permissions = permissions; self.secretHint = secretHint + self.lastUsedAt = lastUsedAt; self.expiresAt = expiresAt; self.revokedAt = revokedAt; self.createdAt = createdAt + } + + public var status: StatusKind { + if revokedAt != nil { return .revoked } + if let expiresAt, expiresAt < .now { return .expired } + return .active + } +} + +/// A newly created key. `secret` exists only in memory, only until KEY-03 is acknowledged. +public struct CreatedAPIKey: Sendable, Hashable { + public var key: APIKeySummary + public var secret: String + public init(key: APIKeySummary, secret: String) { self.key = key; self.secret = secret } +} + +public struct NewAPIKey: Sendable, Hashable { + public var name = "" + public var scope = "platform" + public var permissions: Set = [] + public var mode = "live" + public var intendedUse = "service" + public var expiresAt: Date? = Calendar.current.date(byAdding: .year, value: 1, to: .now) + public init() {} +} + +public struct AuditEntry: Identifiable, Hashable, Sendable { + public var id: String + public var action: String + public var actorType: String + public var actorID: String + public var targetType: String? + public var targetID: String? + public var applicationID: String? + public var requestID: String? + public var timestamp: Date + + public init(id: String, action: String, actorType: String, actorID: String, targetType: String?, targetID: String?, + applicationID: String?, requestID: String?, timestamp: Date) { + self.id = id; self.action = action; self.actorType = actorType; self.actorID = actorID; self.targetType = targetType + self.targetID = targetID; self.applicationID = applicationID; self.requestID = requestID; self.timestamp = timestamp + } +} + +public struct RoleSummary: Identifiable, Hashable, Sendable { + public var id: String + public var name: String + public var scope: String + public var description: String? + public var permissions: [String] + public var seed: Bool + public var assignmentCount: Int + + public init(id: String, name: String, scope: String, description: String?, permissions: [String], seed: Bool, assignmentCount: Int) { + self.id = id; self.name = name; self.scope = scope; self.description = description + self.permissions = permissions; self.seed = seed; self.assignmentCount = assignmentCount + } +} + +/// One resolved row of a user's access to an Application (ACX-01): the status first, the paths one tap away. +public struct AccessSummary: Identifiable, Hashable, Sendable { + public var id: String { applicationID } + public var applicationID: String + public var applicationName: String + public var status: StatusKind + public var sources: [String] + public var platformManaged: Bool + + public init(applicationID: String, applicationName: String, status: StatusKind, sources: [String], platformManaged: Bool) { + self.applicationID = applicationID; self.applicationName = applicationName; self.status = status + self.sources = sources; self.platformManaged = platformManaged + } +} + +public struct UserQuery: Sendable, Hashable { + public var text = "" + public var status: String? + public var roleIDs: [String] = [] + public init() {} +} diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Models/StatusKind.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Models/StatusKind.swift new file mode 100644 index 0000000..d00984a --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Models/StatusKind.swift @@ -0,0 +1,96 @@ +import Foundation + +/// Status semantics shared by every Hub app (00-shared/status-semantics.json). +/// Every badge is icon + label; colour comes from `role`, never alone. +public enum StatusKind: Hashable, Sendable { + case active, scheduled, disabled, expired, revoked, none, invited, suspended, deleted, pending, important, unread + /// An enum value this build doesn't know. Enums grow without a version bump (NFR → Versioning). + case unknown(String) + + public enum Role: String, Sendable { case success, info, warning, critical, neutral, accent } + + public init(rawValue: String) { + switch rawValue { + case "active": self = .active + case "scheduled": self = .scheduled + case "disabled": self = .disabled + case "expired": self = .expired + case "revoked": self = .revoked + case "none": self = .none + case "invited": self = .invited + case "suspended": self = .suspended + case "deleted": self = .deleted + case "pending": self = .pending + case "important": self = .important + case "unread": self = .unread + default: self = .unknown(rawValue) + } + } + + public var label: String { + switch self { + case .active: "Active" + case .scheduled: "Scheduled" + case .disabled: "Turned off" + case .expired: "Expired" + case .revoked: "Revoked" + case .none: "No access" + case .invited: "Invited" + case .suspended: "Suspended" + case .deleted: "Deleted" + case .pending: "Pending" + case .important: "Important" + case .unread: "Unread" + case .unknown(let raw): raw.replacingOccurrences(of: "_", with: " ").capitalized + } + } + + public var role: Role { + switch self { + case .active: .success + case .scheduled, .pending: .info + case .disabled, .suspended: .warning + case .revoked, .deleted, .important: .critical + case .expired, .none, .unknown: .neutral + case .invited, .unread: .accent + } + } + + public var sfSymbol: String { + switch self { + case .active: "checkmark.circle.fill" + case .scheduled: "calendar" + case .disabled, .suspended: "pause.circle.fill" + case .expired: "clock" + case .revoked: "xmark.circle.fill" + case .none: "minus.circle" + case .invited: "envelope" + case .deleted: "trash" + case .pending: "hourglass" + case .important: "exclamationmark.circle" + case .unread: "circle.fill" + case .unknown: "questionmark.circle" + } + } + + // MARK: Mappings from API enums (status-semantics.json → mappings) + + public static func applicationReview(_ raw: String) -> StatusKind { + switch raw { + case "approved": .active + case "pending_review": .pending + case "rejected": .revoked + case "suspended": .suspended + default: .unknown(raw) + } + } + + public static func webhook(_ raw: String) -> StatusKind { + switch raw { + case "healthy": .active + case "unhealthy": .disabled + case "disabled": .expired + default: .unknown(raw) + } + } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Networking/APIConfiguration.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Networking/APIConfiguration.swift new file mode 100644 index 0000000..82b431c --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Networking/APIConfiguration.swift @@ -0,0 +1,49 @@ +import Foundation + +/// Where the Hub talks to and which API version it was built against. +/// +/// The Platform API serves every path at the host root. There is no `/v1` in any URL; +/// the version travels in the `Substratal-Version` request header instead +/// (CLIENT-ARCHITECTURE §4.1, API Conventions → Versioning). `/v1/…` paths return `404 not_found`. +public struct APIConfiguration: Sendable, Hashable { + /// The API version this build is written against. Pinned per build, never negotiated at runtime. + public static let apiVersion = "1" + /// The request and response header that carries the API version. + public static let versionHeader = "Substratal-Version" + + public enum Environment: String, Sendable, CaseIterable, Identifiable { + case production + case localDevserver + case demo + + public var id: String { rawValue } + public var displayName: String { + switch self { + case .production: "Production" + case .localDevserver: "Local devserver" + case .demo: "Demo data (offline)" + } + } + } + + public var environment: Environment + /// Host-root base URL, e.g. `https://api.substratalapps.com`. Never ends in a version segment. + public var baseURL: URL + /// Sent as `User-Agent` so Sessions (ACC-04, USR-04) can name this device. + public var userAgent: String + + public init(environment: Environment, baseURL: URL, userAgent: String) { + precondition(!baseURL.path.hasPrefix("/v1"), "The Platform API has no /v1 prefix; the version goes in the Substratal-Version header.") + self.environment = environment + self.baseURL = baseURL + self.userAgent = userAgent + } + + public static func make(_ environment: Environment, appVersion: String, osVersion: String) -> APIConfiguration { + let url: URL = switch environment { + case .production, .demo: URL(string: "https://api.substratalapps.com")! + case .localDevserver: URL(string: "http://localhost:8080")! + } + return APIConfiguration(environment: environment, baseURL: url, userAgent: "Substratal-iOS/\(appVersion) (iOS \(osVersion))") + } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Networking/APIError.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Networking/APIError.swift new file mode 100644 index 0000000..578a3fa --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Networking/APIError.swift @@ -0,0 +1,59 @@ +import Foundation + +/// The API's error envelope: `{"error": {"code", "message", "details"}}` (Conventions → Errors). +public struct APIErrorEnvelope: Decodable, Sendable { + public struct Body: Decodable, Sendable { + public let code: String + public let message: String + public let details: Details? + + /// `details.supported` on `api_version_required` / `unsupported_api_version`. + public var supportedVersions: [String]? { details?.supported } + } + + public struct Details: Decodable, Sendable { + public let supported: [String]? + public let fields: [FieldError]? + } + + public struct FieldError: Decodable, Sendable, Hashable { + public let field: String + public let code: String + } + + public let error: Body +} + +/// What screens see when a call fails. Copy for each case lives in COPY-AND-STATES. +public enum APIError: Error, Equatable, Sendable { + case unauthenticated + case forbidden(code: String) + case notFound + case conflict(code: String) + case validation(fields: [APIErrorEnvelope.FieldError]) + case rateLimited(retryAfter: TimeInterval?) + case notImplemented + case unavailable + case unsupportedAPIVersion + case mfaRequired(mfaToken: String) + case server(status: Int, code: String?, requestID: String?) + case transport(String) + + public var userMessage: String { + switch self { + case .unauthenticated: "Your session has ended. Sign in again." + case .forbidden: "You don't have permission to do that." + case .notFound: "That item doesn't exist, or you can't see it." + case .conflict(let code) where code == "version_conflict": "Someone else changed this while you were editing. Reload to see the latest." + case .conflict: "That change conflicts with the current state." + case .validation: "Some fields need attention." + case .rateLimited: "Too many requests. Try again in a moment." + case .notImplemented: "This feature isn't available yet." + case .unavailable: "Substratal is temporarily unavailable." + case .unsupportedAPIVersion: "Update the app to keep using Substratal." + case .mfaRequired: "Enter your two-factor code." + case .server: "Something went wrong on our side." + case .transport: "You're offline or the connection failed." + } + } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Networking/APIVersionMiddleware.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Networking/APIVersionMiddleware.swift new file mode 100644 index 0000000..ca08f89 --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Networking/APIVersionMiddleware.swift @@ -0,0 +1,63 @@ +import Foundation +import HTTPTypes +import OpenAPIRuntime + +extension HTTPField.Name { + static let substratalVersion = HTTPField.Name(APIConfiguration.versionHeader)! + static let deprecation = HTTPField.Name("Deprecation")! + static let sunset = HTTPField.Name("Sunset")! + static let requestID = HTTPField.Name("X-Request-Id")! + static let idempotencyKey = HTTPField.Name("Idempotency-Key")! + static let ifMatch = HTTPField.Name("If-Match")! +} + +/// Sends `Substratal-Version` on every request and watches responses for version signals. +/// +/// The generated client never sets the header itself: scripts/sync-openapi.sh strips the per-operation +/// `ApiVersion` parameter so this middleware is the single place the version is chosen. +/// Exempt endpoints (`GET /health`, `POST /auth/oauth/token`, `/.well-known/*`) accept the header harmlessly. +public struct APIVersionMiddleware: ClientMiddleware { + let monitor: APIVersionMonitor + + public init(monitor: APIVersionMonitor) { self.monitor = monitor } + + public 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[.substratalVersion] = APIConfiguration.apiVersion + let (response, responseBody) = try await next(request, body, baseURL) + + if let echo = response.headerFields[.substratalVersion] { + await monitor.recordEcho(echo) + } + if response.headerFields[.deprecation] != nil { + let sunset = response.headerFields[.sunset].flatMap(HTTPDate.parse) + await monitor.recordDeprecation(sunset: sunset) + } + guard response.status.code == 400, let responseBody else { return (response, responseBody) } + + // Read the (small) error body to tell `unsupported_api_version` apart, then hand it back unchanged. + let data = try await Data(collecting: responseBody, upTo: 64 * 1024) + if let envelope = try? JSONDecoder().decode(APIErrorEnvelope.self, from: data), + envelope.error.code == "unsupported_api_version" { + await monitor.recordUnsupported(supported: envelope.error.supportedVersions ?? []) + } + return (response, HTTPBody(data)) + } +} + +/// RFC 9110 IMF-fixdate, as used by the `Sunset` header. +enum HTTPDate { + static func parse(_ value: String) -> Date? { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.timeZone = TimeZone(identifier: "GMT") + formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz" + return formatter.date(from: value) + } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Networking/APIVersionMonitor.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Networking/APIVersionMonitor.swift new file mode 100644 index 0000000..5391c0a --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Networking/APIVersionMonitor.swift @@ -0,0 +1,38 @@ +import Foundation +import Observation + +/// What the server has told this build about its API version (CLIENT-ARCHITECTURE §4.1). +public enum APIVersionState: Equatable, Sendable { + /// The pinned version is current. + case current + /// The server sent `Deprecation` (RFC 9745) and usually `Sunset` (RFC 8594): show GLB-03 "Update available". + case deprecated(sunset: Date?) + /// The server answered `400 unsupported_api_version`: stop API traffic and show GLB-03 "Update required". + case unsupported(supported: [String]) +} + +/// Collects version signals from every response. The UI observes `state`. +@MainActor @Observable +public final class APIVersionMonitor { + public private(set) var state: APIVersionState = .current + /// A response echoed a different version than the one sent: a proxy or misconfigured environment. Logged only. + public private(set) var lastEchoMismatch: String? + + public init() {} + + public func recordDeprecation(sunset: Date?) { + if case .unsupported = state { return } + state = .deprecated(sunset: sunset) + } + + public func recordUnsupported(supported: [String]) { + state = .unsupported(supported: supported) + } + + public func recordEcho(_ value: String) { + if value != APIConfiguration.apiVersion { lastEchoMismatch = value } + } + + /// Called on foreground with one fresh request, so a server-side fix clears "Update required". + public func reset() { state = .current } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Networking/AuthMiddleware.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Networking/AuthMiddleware.swift new file mode 100644 index 0000000..926d51f --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Networking/AuthMiddleware.swift @@ -0,0 +1,32 @@ +import Foundation +import HTTPTypes +import OpenAPIRuntime + +/// Adds the Bearer token, and on `401` refreshes once (single-flight) and replays the request. +public struct AuthMiddleware: ClientMiddleware { + let tokens: TokenStore + + public init(tokens: TokenStore) { self.tokens = tokens } + + public func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + var authorized = request + if let token = try await tokens.accessToken() { + authorized.headerFields[.authorization] = "Bearer \(token)" + } + // Buffer the body so the request can be replayed after a refresh. + let buffered: HTTPBody? = if let body { HTTPBody(try await Data(collecting: body, upTo: 4 * 1024 * 1024)) } else { nil } + let (response, responseBody) = try await next(authorized, buffered, baseURL) + guard response.status == .unauthorized else { return (response, responseBody) } + + let fresh = try await tokens.refresh() + var retried = request + retried.headerFields[.authorization] = "Bearer \(fresh.accessToken)" + return try await next(retried, buffered, baseURL) + } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Networking/ErrorMappingMiddleware.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Networking/ErrorMappingMiddleware.swift new file mode 100644 index 0000000..546dbf6 --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Networking/ErrorMappingMiddleware.swift @@ -0,0 +1,48 @@ +import Foundation +import HTTPTypes +import OpenAPIRuntime + +/// Turns every non-2xx response into an `APIError`, decoded from the API's error envelope, +/// so services only ever handle the success case of a generated `Output`. +public struct ErrorMappingMiddleware: ClientMiddleware { + public init() {} + + public func intercept( + _ request: HTTPRequest, + body: HTTPBody?, + baseURL: URL, + operationID: String, + next: @Sendable (HTTPRequest, HTTPBody?, URL) async throws -> (HTTPResponse, HTTPBody?) + ) async throws -> (HTTPResponse, HTTPBody?) { + let (response, responseBody) = try await next(request, body, baseURL) + guard response.status.kind != .successful else { return (response, responseBody) } + + let data = if let responseBody { try await Data(collecting: responseBody, upTo: 256 * 1024) } else { Data() } + let envelope = try? JSONDecoder().decode(APIErrorEnvelope.self, from: data) + let code = envelope?.error.code + let requestID = response.headerFields[.requestID] + + switch response.status.code { + case 400 where code == "unsupported_api_version": throw APIError.unsupportedAPIVersion + case 401: throw APIError.unauthenticated + case 403: throw APIError.forbidden(code: code ?? "forbidden") + case 404: throw APIError.notFound + case 409: throw APIError.conflict(code: code ?? "conflict") + case 422: throw APIError.validation(fields: envelope?.error.details?.fields ?? []) + case 429: throw APIError.rateLimited(retryAfter: response.headerFields[.retryAfter].flatMap(TimeInterval.init)) + case 501: throw APIError.notImplemented + case 503: throw APIError.unavailable + default: throw APIError.server(status: response.status.code, code: code, requestID: requestID) + } + } +} + +/// Unwraps the generator's `ClientError` so callers see the `APIError` a middleware threw. +func unwrapping(_ body: () async throws -> T) async throws -> T { + do { + return try await body() + } catch let error as ClientError { + if let apiError = error.underlyingError as? APIError { throw apiError } + throw APIError.transport(error.underlyingError.localizedDescription) + } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Networking/RequestContextMiddleware.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Networking/RequestContextMiddleware.swift new file mode 100644 index 0000000..9c2d19a --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Networking/RequestContextMiddleware.swift @@ -0,0 +1,33 @@ +import Foundation +import HTTPTypes +import OpenAPIRuntime + +/// Per-request values a screen sets for the duration of one call, without threading them through every API. +public enum RequestContext { + /// Generated when a create form opens and reused on every retry of its submit (CLIENT-ARCHITECTURE §4). + @TaskLocal public static var idempotencyKey: String? + /// The `ETag` the screen loaded, sent as `If-Match` on PATCH/DELETE. + @TaskLocal public static var ifMatch: String? +} + +/// Adds `User-Agent`, `Accept-Language`, and any `RequestContext` values. +public struct RequestContextMiddleware: ClientMiddleware { + let userAgent: String + + public init(userAgent: String) { self.userAgent = userAgent } + + public 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[.userAgent] = userAgent + request.headerFields[.acceptLanguage] = Locale.preferredLanguages.prefix(3).joined(separator: ", ") + if let key = RequestContext.idempotencyKey { request.headerFields[.idempotencyKey] = key } + if let etag = RequestContext.ifMatch { request.headerFields[.ifMatch] = etag } + return try await next(request, body, baseURL) + } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Routing/HelpCenter.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Routing/HelpCenter.swift new file mode 100644 index 0000000..c8a4ce3 --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Routing/HelpCenter.swift @@ -0,0 +1,19 @@ +import Foundation + +/// Links into the shared Substratal Hub help center, published with the specs site so every Hub app +/// (web, iOS, Android, macOS, Windows, Linux) points at one documentation reference. +public enum HelpCenter { + public static let base = URL(string: "https://compositecode.github.io/substratalapps-applications-specs/help/")! + + public static var home: URL { base } + public static var faq: URL { base.appending(path: "faq.html") } + public static var tutorials: URL { base.appending(path: "tutorials/") } + /// The interactive API reference for the version this build sends in `Substratal-Version`. + public static var apiReference: URL { base.appending(path: "api/v\(APIConfiguration.apiVersion)/") } + public static var whatsNew: URL { base.appending(path: "whats-new.html") } + + /// Contextual help for one screen: what it's for, how to use it, and the related tutorials. + public static func url(for screen: ScreenID) -> URL { + base.appending(path: "screens/\(screen.rawValue.lowercased()).html") + } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Routing/Route.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Routing/Route.swift new file mode 100644 index 0000000..c178974 --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Routing/Route.swift @@ -0,0 +1,73 @@ +import Foundation + +/// A navigable destination: one screen plus the path parameters from its route (`/admin/users/:userId` → `userId`). +/// Hashable and Codable so `NavigationStack` paths persist in `@SceneStorage`. +public struct Route: Hashable, Codable, Sendable { + public var screen: ScreenID + public var params: [String: String] + + public init(_ screen: ScreenID, _ params: [String: String] = [:]) { + self.screen = screen + self.params = params + } + + public subscript(_ name: String) -> String? { params[name] } + + // MARK: Deep links (INFORMATION-ARCHITECTURE §4) + + /// Hosts whose paths are Hub routes. Universal Links arrive on the dashboard host. + public static let webHosts: Set = ["dashboard.substratalapps.com"] + public static let scheme = "substratal" + + /// Parses `substratal://admin/users/usr_123`, `https://dashboard.substratalapps.com/admin/users/usr_123`, + /// or a bare path. Returns nil for anything that isn't a Hub route (open those externally). + public static func parse(_ url: URL) -> Route? { + let path: String + if url.scheme == scheme { + path = "/" + [url.host, url.path].compactMap { $0 }.joined().trimmingCharacters(in: CharacterSet(charactersIn: "/")) + } else if let host = url.host, webHosts.contains(host) { + path = url.path.isEmpty ? "/" : url.path + } else if url.scheme == nil { + path = url.path + } else { + return nil + } + var route = parse(path: path) + if let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems { + for item in items { route?.params[item.name] = item.value } + } + return route + } + + /// Matches a path against every screen's route pattern. Literal segments win over parameters, + /// so `/admin/users/new` resolves to USR-02, not USR-03 with `userId = "new"`. + public static func parse(path: String) -> Route? { + let segments = path.split(separator: "/").map(String.init) + var best: (route: Route, literals: Int)? + for screen in ScreenID.allCases where screen.info.route.hasPrefix("/") { + let pattern = screen.info.route.split(separator: "/").map(String.init) + guard pattern.count == segments.count else { continue } + var params: [String: String] = [:] + var literals = 0 + var matched = true + for (p, s) in zip(pattern, segments) { + if p.hasPrefix(":") { params[String(p.dropFirst())] = s.removingPercentEncoding ?? s } + else if p == s { literals += 1 } + else { matched = false; break } + } + if matched, literals > (best?.literals ?? -1) { best = (Route(screen, params), literals) } + } + return best?.route + } + + /// The canonical path for this route, for sharing and for the web fallback. + public var path: String { + screen.info.route.split(separator: "/").map { segment in + segment.hasPrefix(":") ? (params[String(segment.dropFirst())] ?? String(segment)) : String(segment) + }.reduce("") { $0 + "/" + $1 }.ifEmpty("/") + } +} + +private extension String { + func ifEmpty(_ fallback: String) -> String { isEmpty ? fallback : self } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Routing/ScreenID.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Routing/ScreenID.swift new file mode 100644 index 0000000..dfef65e --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Routing/ScreenID.swift @@ -0,0 +1,176 @@ +// Generated from 00-shared/SCREEN-SPECS.md (spec targeting API v1.8.0) by scripts/generate-screen-ids.py. Do not edit by hand. +// The 12 C · Console screens (OPS-*, SEC-01, CFG-*, RPT-01, INT-01) are web-only; iOS skips them (PRODUCT-SPEC §2a, OPEN-QUESTIONS #27). +import Foundation + +/// Every Hub screen, with the canonical ID, route and archetype from the shared spec. +public enum ScreenID: String, CaseIterable, Identifiable, Sendable, Codable { + case auth01 = "AUTH-01" + case auth02 = "AUTH-02" + case auth03 = "AUTH-03" + case auth04 = "AUTH-04" + case auth05 = "AUTH-05" + case auth06 = "AUTH-06" + case auth07 = "AUTH-07" + case acc01 = "ACC-01" + case acc02 = "ACC-02" + case acc03 = "ACC-03" + case acc04 = "ACC-04" + case acc05 = "ACC-05" + case acc06 = "ACC-06" + case acc07 = "ACC-07" + case acc08 = "ACC-08" + case app01 = "APP-01" + case app02 = "APP-02" + case app03 = "APP-03" + case app04 = "APP-04" + case app05 = "APP-05" + case set01 = "SET-01" + case set02 = "SET-02" + case org01 = "ORG-01" + case org02 = "ORG-02" + case org03 = "ORG-03" + case org04 = "ORG-04" + case org05 = "ORG-05" + case org06 = "ORG-06" + case org07 = "ORG-07" + case org08 = "ORG-08" + case org09 = "ORG-09" + case usr01 = "USR-01" + case usr02 = "USR-02" + case usr03 = "USR-03" + case usr04 = "USR-04" + case usr05 = "USR-05" + case acx01 = "ACX-01" + case acx02 = "ACX-02" + case acx03 = "ACX-03" + case acx04 = "ACX-04" + case acx05 = "ACX-05" + case rol01 = "ROL-01" + case rol02 = "ROL-02" + case rol03 = "ROL-03" + case rol04 = "ROL-04" + case apl01 = "APL-01" + case apl02 = "APL-02" + case apl03 = "APL-03" + case apl04 = "APL-04" + case apl05 = "APL-05" + case apl06 = "APL-06" + case key01 = "KEY-01" + case key02 = "KEY-02" + case key03 = "KEY-03" + case key04 = "KEY-04" + case whk01 = "WHK-01" + case whk02 = "WHK-02" + case whk03 = "WHK-03" + case whk04 = "WHK-04" + case bil01 = "BIL-01" + case bil02 = "BIL-02" + case bil03 = "BIL-03" + case bil04 = "BIL-04" + case aud01 = "AUD-01" + case aud02 = "AUD-02" + case ntf01 = "NTF-01" + case ntf02 = "NTF-02" + case ntf03 = "NTF-03" + case ntf04 = "NTF-04" + case ntf05 = "NTF-05" + case ntf06 = "NTF-06" + case apl07 = "APL-07" + case glb01 = "GLB-01" + case glb02 = "GLB-02" + case glb03 = "GLB-03" + + public var id: String { rawValue } + + public struct Info: Sendable, Hashable { + public let title: String + public let domain: String + public let route: String + public let archetype: String + public let phase: String + /// The heading anchor in SCREEN-SPECS.md and in the help center. + public let anchor: String + } + + public var info: Info { + switch self { + case .auth01: Info(title: "Sign in", domain: "D1", route: "/sign-in", archetype: "auth", phase: "MVP", anchor: "auth-01--sign-in") + case .auth02: Info(title: "Two-factor challenge", domain: "D1", route: "/sign-in/mfa", archetype: "auth", phase: "MVP", anchor: "auth-02--two-factor-challenge") + case .auth03: Info(title: "Create account", domain: "D1", route: "/sign-up", archetype: "auth", phase: "MVP", anchor: "auth-03--create-account") + case .auth04: Info(title: "Verify your email", domain: "D1", route: "/verify-email", archetype: "auth", phase: "MVP", anchor: "auth-04--verify-your-email") + case .auth05: Info(title: "Forgot password", domain: "D1", route: "/forgot-password", archetype: "auth", phase: "MVP", anchor: "auth-05--forgot-password") + case .auth06: Info(title: "Set a new password", domain: "D1", route: "/reset-password", archetype: "auth", phase: "MVP", anchor: "auth-06--set-a-new-password") + case .auth07: Info(title: "Accept invitation", domain: "D1", route: "/invite", archetype: "auth", phase: "MVP", anchor: "auth-07--accept-invitation") + case .acc01: Info(title: "Account & profile", domain: "D2", route: "/account", archetype: "settings", phase: "MVP", anchor: "acc-01--account--profile") + case .acc02: Info(title: "Change email", domain: "D2", route: "/account/email", archetype: "form", phase: "MVP", anchor: "acc-02--change-email") + case .acc03: Info(title: "Change password", domain: "D2", route: "/account/security/password", archetype: "form", phase: "MVP", anchor: "acc-03--change-password") + case .acc04: Info(title: "Security", domain: "D2", route: "/account/security", archetype: "settings", phase: "MVP", anchor: "acc-04--security") + case .acc05: Info(title: "Turn on two-factor", domain: "D2", route: "/account/security/2fa/enable", archetype: "wizard", phase: "MVP", anchor: "acc-05--turn-on-two-factor") + case .acc06: Info(title: "Turn off two-factor", domain: "D2", route: "/account/security/2fa/disable", archetype: "confirm", phase: "MVP", anchor: "acc-06--turn-off-two-factor") + case .acc07: Info(title: "Privacy", domain: "D2", route: "/account/privacy", archetype: "settings", phase: "MVP", anchor: "acc-07--privacy") + case .acc08: Info(title: "Delete account", domain: "D2", route: "/account/privacy/delete", archetype: "confirm", phase: "MVP", anchor: "acc-08--delete-account") + case .app01: Info(title: "Home — My Apps", domain: "D3", route: "/", archetype: "grid", phase: "MVP", anchor: "app-01--home--my-apps") + case .app02: Info(title: "App info", domain: "D3", route: "/apps/:appId", archetype: "popover", phase: "MVP", anchor: "app-02--app-info") + case .app03: Info(title: "Launch app", domain: "D3", route: "/apps/:appId/launch", archetype: "handoff", phase: "MVP", anchor: "app-03--launch-app") + case .app04: Info(title: "App profile", domain: "D3", route: "/apps/:appId/profile", archetype: "form", phase: "Phase 2", anchor: "app-04--app-profile") + case .app05: Info(title: "Past apps", domain: "D3", route: "/apps/past", archetype: "list", phase: "MVP", anchor: "app-05--past-apps") + case .set01: Info(title: "Settings — Global", domain: "D4", route: "/settings", archetype: "settings", phase: "MVP", anchor: "set-01--settings--global") + case .set02: Info(title: "Settings — Per app", domain: "D4", route: "/settings/apps/:appId", archetype: "settings", phase: "Phase 2", anchor: "set-02--settings--per-app") + case .org01: Info(title: "Organizations", domain: "D5", route: "/orgs", archetype: "list", phase: "MVP", anchor: "org-01--organizations") + case .org02: Info(title: "New organization", domain: "D5", route: "/orgs/new", archetype: "form", phase: "MVP", anchor: "org-02--new-organization") + case .org03: Info(title: "Organization — Members", domain: "D5", route: "/orgs/:orgId/members", archetype: "detail", phase: "MVP", anchor: "org-03--organization--members") + case .org04: Info(title: "Invite member", domain: "D5", route: "/orgs/:orgId/members/invite", archetype: "form", phase: "MVP", anchor: "org-04--invite-member") + case .org05: Info(title: "Member actions", domain: "D5", route: "/orgs/:orgId/members/:userId", archetype: "confirm", phase: "MVP", anchor: "org-05--member-actions") + case .org06: Info(title: "Organization — Apps & seats", domain: "D5", route: "/orgs/:orgId/apps", archetype: "tab", phase: "Phase 2", anchor: "org-06--organization--apps--seats") + case .org07: Info(title: "Grant app to organization / member scope", domain: "D5", route: "/orgs/:orgId/apps/grant", archetype: "form", phase: "Phase 2", anchor: "org-07--grant-app-to-organization--member-scope") + case .org08: Info(title: "Organization — Settings", domain: "D5", route: "/orgs/:orgId/settings", archetype: "settings", phase: "MVP", anchor: "org-08--organization--settings") + case .org09: Info(title: "Invitation", domain: "D5", route: "/orgs/:orgId/invitation", archetype: "confirm", phase: "MVP", anchor: "org-09--invitation") + case .usr01: Info(title: "Users", domain: "D6", route: "/admin/users", archetype: "table", phase: "MVP", anchor: "usr-01--users") + case .usr02: Info(title: "Invite user", domain: "D6", route: "/admin/users/new", archetype: "form", phase: "MVP", anchor: "usr-02--invite-user") + case .usr03: Info(title: "User — Overview", domain: "D6", route: "/admin/users/:userId", archetype: "detail", phase: "MVP", anchor: "usr-03--user--overview") + case .usr04: Info(title: "User — Sessions", domain: "D6", route: "/admin/users/:userId/sessions", archetype: "tab", phase: "MVP", anchor: "usr-04--user--sessions") + case .usr05: Info(title: "Suspend user", domain: "D6", route: "/admin/users/:userId/suspend", archetype: "confirm", phase: "MVP", anchor: "usr-05--suspend-user") + case .acx01: Info(title: "User — Access", domain: "D7", route: "/admin/users/:userId/access", archetype: "tab", phase: "MVP", anchor: "acx-01--user--access") + case .acx02: Info(title: "Grant access", domain: "D7", route: "/admin/users/:userId/access/grant", archetype: "form", phase: "MVP", anchor: "acx-02--grant-access") + case .acx03: Info(title: "Turn off / edit access", domain: "D7", route: "/admin/entitlements/:entId", archetype: "confirm", phase: "MVP", anchor: "acx-03--turn-off--edit-access") + case .acx04: Info(title: "Access check (Why?)", domain: "D7", route: "/admin/users/:userId/apps/:appId/why", archetype: "popover", phase: "MVP", anchor: "acx-04--access-check-why") + case .acx05: Info(title: "Access (all)", domain: "D7", route: "/admin/access", archetype: "table", phase: "MVP", anchor: "acx-05--access-all") + case .rol01: Info(title: "Roles", domain: "D8", route: "/admin/roles", archetype: "list", phase: "MVP", anchor: "rol-01--roles") + case .rol02: Info(title: "Role detail", domain: "D8", route: "/admin/roles/:roleId", archetype: "detail", phase: "MVP", anchor: "rol-02--role-detail") + case .rol03: Info(title: "New role", domain: "D8", route: "/admin/roles/new", archetype: "form", phase: "MVP", anchor: "rol-03--new-role") + case .rol04: Info(title: "User — Roles", domain: "D8", route: "/admin/users/:userId/roles", archetype: "tab", phase: "MVP", anchor: "rol-04--user--roles") + case .apl01: Info(title: "Applications", domain: "D9", route: "/dev/apps", archetype: "list", phase: "MVP", anchor: "apl-01--applications") + case .apl02: Info(title: "Register application", domain: "D9", route: "/dev/apps/new", archetype: "wizard", phase: "MVP", anchor: "apl-02--register-application") + case .apl03: Info(title: "Application — Overview", domain: "D9", route: "/dev/apps/:appId", archetype: "detail", phase: "MVP", anchor: "apl-03--application--overview") + case .apl04: Info(title: "Settings schema", domain: "D9", route: "/dev/apps/:appId/schema", archetype: "tab", phase: "Phase 2", anchor: "apl-04--settings-schema") + case .apl05: Info(title: "Permissions & roles", domain: "D9", route: "/dev/apps/:appId/roles", archetype: "tab", phase: "Phase 2", anchor: "apl-05--permissions--roles") + case .apl06: Info(title: "Review", domain: "D9", route: "/dev/apps/:appId/review", archetype: "form", phase: "Phase 3", anchor: "apl-06--review") + case .key01: Info(title: "API keys", domain: "D10", route: "/dev/keys", archetype: "table", phase: "MVP", anchor: "key-01--api-keys") + case .key02: Info(title: "Create API key", domain: "D10", route: "/dev/keys/new", archetype: "form", phase: "MVP", anchor: "key-02--create-api-key") + case .key03: Info(title: "Secret reveal", domain: "D10", route: "(modal)", archetype: "secret", phase: "MVP", anchor: "key-03--secret-reveal") + case .key04: Info(title: "API key detail", domain: "D10", route: "/dev/keys/:keyId", archetype: "detail", phase: "MVP", anchor: "key-04--api-key-detail") + case .whk01: Info(title: "Webhooks", domain: "D10", route: "/dev/webhooks", archetype: "list", phase: "Phase 2", anchor: "whk-01--webhooks") + case .whk02: Info(title: "Add / edit webhook", domain: "D10", route: "/dev/webhooks/new", archetype: "form", phase: "Phase 2", anchor: "whk-02--add--edit-webhook") + case .whk03: Info(title: "Webhook — Deliveries", domain: "D10", route: "/dev/webhooks/:hookId", archetype: "detail", phase: "Phase 2", anchor: "whk-03--webhook--deliveries") + case .whk04: Info(title: "Delivery detail", domain: "D10", route: "/dev/webhooks/:hookId/deliveries/:deliveryId", archetype: "popover", phase: "Phase 2", anchor: "whk-04--delivery-detail") + case .bil01: Info(title: "My plan", domain: "D11", route: "/billing", archetype: "settings", phase: "Phase 2", anchor: "bil-01--my-plan") + case .bil02: Info(title: "Organization subscription", domain: "D11", route: "/orgs/:orgId/subscription", archetype: "tab", phase: "Phase 2", anchor: "bil-02--organization-subscription") + case .bil03: Info(title: "Checkout / billing portal", domain: "D11", route: "/billing/return", archetype: "handoff", phase: "Phase 2", anchor: "bil-03--checkout--billing-portal") + case .bil04: Info(title: "Tenant & infrastructure", domain: "D11", route: "/dev/tenant", archetype: "detail", phase: "Phase 2", anchor: "bil-04--tenant--infrastructure") + case .aud01: Info(title: "Audit log", domain: "D12", route: "/admin/audit", archetype: "table", phase: "MVP", anchor: "aud-01--audit-log") + case .aud02: Info(title: "Audit event", domain: "D12", route: "/admin/audit/:eventId", archetype: "popover", phase: "MVP", anchor: "aud-02--audit-event") + case .ntf01: Info(title: "Inbox", domain: "D13", route: "/inbox", archetype: "list", phase: "Phase 2", anchor: "ntf-01--inbox") + case .ntf02: Info(title: "Notification", domain: "D13", route: "/inbox/:notificationId", archetype: "popover", phase: "Phase 2", anchor: "ntf-02--notification") + case .ntf03: Info(title: "Devices & notifications", domain: "D13", route: "/account/devices", archetype: "settings", phase: "Phase 2", anchor: "ntf-03--devices--notifications") + case .ntf04: Info(title: "Turn on notifications", domain: "D13", route: "/account/devices/enable", archetype: "wizard", phase: "Phase 2", anchor: "ntf-04--turn-on-notifications") + case .ntf05: Info(title: "User — Notifications & devices", domain: "D13", route: "/admin/users/:userId/notifications", archetype: "tab", phase: "Phase 2", anchor: "ntf-05--user--notifications--devices") + case .ntf06: Info(title: "Send notification", domain: "D13", route: "/admin/notifications/new", archetype: "form", phase: "Phase 2", anchor: "ntf-06--send-notification") + case .apl07: Info(title: "Push credentials", domain: "D9", route: "/dev/apps/:appId/push", archetype: "tab", phase: "Phase 2", anchor: "apl-07--push-credentials") + case .glb01: Info(title: "Command palette", domain: "G", route: "(overlay)", archetype: "overlay", phase: "MVP", anchor: "glb-01--command-palette") + case .glb02: Info(title: "Manage hub", domain: "G", route: "/manage", archetype: "hub", phase: "MVP", anchor: "glb-02--manage-hub") + case .glb03: Info(title: "System states", domain: "G", route: "(global)", archetype: "overlay", phase: "MVP", anchor: "glb-03--system-states") + } + } + + public var title: String { info.title } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Services/DemoHubService.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Services/DemoHubService.swift new file mode 100644 index 0000000..8d598a0 --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Services/DemoHubService.swift @@ -0,0 +1,129 @@ +import Foundation + +/// Fixture-backed `HubService` for SwiftUI previews, UI tests and design review, with no network. +/// Data mirrors the examples in the API reference so screens look like they will against production. +public actor DemoHubService: HubService { + private var keys: [APIKeySummary] + private let now = Date() + + public init() { + keys = DemoHubService.seedKeys + } + + public func signIn(email: String, password: String) async throws -> SignInResult { + try await Task.sleep(for: .milliseconds(300)) + guard !password.isEmpty else { throw APIError.unauthenticated } + return .mfaRequired(mfaToken: "mfa_demo") + } + + public func verifyMFA(mfaToken: String, code: String) async throws -> SessionTokens { + guard code.count == 6 else { throw APIError.validation(fields: [.init(field: "code", code: "invalid_mfa_code")]) } + return SessionTokens(accessToken: "demo", expiresIn: 900, refreshToken: "rtk_demo", sessionID: "ses_demo", userID: "usr_01JAG0SYSTEM00000000000000") + } + + public func signOut(allSessions: Bool) async throws {} + + public func currentUser() async throws -> UserSummary { + UserSummary(id: "usr_01JAG0SYSTEM00000000000000", email: "adron@substratalapps.com", status: .active, mfaEnabled: true, plan: "standard") + } + + public func capabilities() async throws -> Capabilities { .superadmin } + + public func users(_ query: UserQuery, cursor: String?) async throws -> Page { + let filtered = Self.users.filter { user in + (query.text.isEmpty || user.email.localizedCaseInsensitiveContains(query.text) || user.id.contains(query.text)) + && (query.status == nil || user.status == StatusKind(rawValue: query.status!)) + } + return Page(items: filtered, nextCursor: nil) + } + + public func user(id: String) async throws -> UserSummary { + guard let user = Self.users.first(where: { $0.id == id }) else { throw APIError.notFound } + return user + } + + public func access(userID: String) async throws -> [AccessSummary] { + [ + AccessSummary(applicationID: "app_timetrack", applicationName: "TimeTrack", status: .active, sources: ["subscription", "admin_grant"], platformManaged: false), + AccessSummary(applicationID: "app_invoicer", applicationName: "Invoicer", status: .disabled, sources: ["purchase"], platformManaged: false), + AccessSummary(applicationID: "app_notes", applicationName: "Field Notes", status: .active, sources: ["free"], platformManaged: true), + AccessSummary(applicationID: "app_ledger", applicationName: "Ledger", status: .scheduled, sources: ["trial"], platformManaged: false), + ] + } + + public func applications(cursor: String?) async throws -> Page { + Page(items: Self.applications, nextCursor: nil) + } + + public func apiKeys(includeInactive: Bool, cursor: String?) async throws -> Page { + Page(items: includeInactive ? keys : keys.filter { $0.status == .active }, nextCursor: nil) + } + + public func createAPIKey(_ draft: NewAPIKey, idempotencyKey: String) async throws -> CreatedAPIKey { + let secret = "satk_\(draft.mode)_" + UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + let key = APIKeySummary(id: "key_" + UUID().uuidString.prefix(26).replacingOccurrences(of: "-", with: "").uppercased(), + name: draft.name, scope: draft.scope, mode: draft.mode, intendedUse: draft.intendedUse, + restrictDestructive: draft.intendedUse == "agent", permissions: draft.permissions.sorted(), + secretHint: "…" + secret.suffix(4), expiresAt: draft.expiresAt, createdAt: .now) + keys.insert(key, at: 0) + return CreatedAPIKey(key: key, secret: secret) + } + + public func revokeAPIKey(id: String) async throws { + guard let index = keys.firstIndex(where: { $0.id == id }) else { throw APIError.notFound } + keys[index].revokedAt = .now + } + + public func auditEvents(cursor: String?) async throws -> Page { + let actions = ["entitlement.disabled", "role.assigned", "api_key.created", "user.suspended", "application.updated", + "entitlement.granted", "user.sessions_revoked", "webhook.secret_rotated", "api_key.rotated", "user.created"] + let items = actions.enumerated().map { index, action in + AuditEntry(id: "evt_01JAG7X3P8QY110M9N807P6Q\(index)R", action: action, actorType: index % 3 == 0 ? "api_key" : "user", + actorID: index % 3 == 0 ? "key_01JAGE5F6G7H8J9K011M2N304P" : "usr_01JAG9STAFF000000000000000", + targetType: String(action.split(separator: ".").first ?? ""), targetID: "ent_01JAG9F4Q1W2E3R4T5Y6V7J809", + applicationID: index % 2 == 0 ? "app_invoicer" : nil, requestID: "req_7c1e9a2f4b\(index)", + timestamp: now.addingTimeInterval(TimeInterval(-index * 2_700))) + } + return Page(items: items, nextCursor: nil) + } + + public func roles(scope: String?) async throws -> [RoleSummary] { + [ + RoleSummary(id: "role_platform_superadmin", name: "superadmin", scope: "platform", description: "Every platform permission.", permissions: Capabilities.superadmin.platformPermissions.sorted(), seed: true, assignmentCount: 1), + RoleSummary(id: "role_platform_support", name: "support", scope: "platform", description: "Front-line support.", permissions: ["users.list", "entitlements.manage", "audit.view", "tenants.manage", "billing.manage"], seed: true, assignmentCount: 3), + RoleSummary(id: "role_platform_billing_admin", name: "billing_admin", scope: "platform", description: "Billing and refunds.", permissions: ["billing.manage", "billing.refund", "audit.view", "users.list"], seed: true, assignmentCount: 1), + RoleSummary(id: "role_platform_member", name: "member", scope: "platform", description: "Every account. Grants nothing beyond self-service.", permissions: [], seed: true, assignmentCount: 214), + RoleSummary(id: "role_platform_support_readonly", name: "support_readonly", scope: "platform", description: "Look, don't touch.", permissions: ["users.list", "audit.view"], seed: false, assignmentCount: 2), + ] + } + + public func permissionCatalog() async throws -> [String] { + Capabilities.superadmin.platformPermissions.sorted() + ["app.timetrack.export", "app.timetrack.manage_members", "app.invoicer.view", "app.invoicer.send"] + } + + // MARK: Fixtures + + static let users: [UserSummary] = [ + UserSummary(id: "usr_01JAG0SYSTEM00000000000000", email: "adron@substratalapps.com", status: .active, mfaEnabled: true, plan: "standard", createdAt: .now.addingTimeInterval(-86_400 * 40), lastLoginAt: .now.addingTimeInterval(-600)), + UserSummary(id: "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S", email: "jordan.lee@example.com", status: .active, mfaEnabled: true, plan: "standard", createdAt: .now.addingTimeInterval(-86_400 * 21), lastLoginAt: .now.addingTimeInterval(-7_200)), + UserSummary(id: "usr_01JAG4A1B2C3D4E5F6G7H8J9K0", email: "dana.okafor@example.com", status: .invited, emailVerified: false, createdAt: .now.addingTimeInterval(-86_400 * 2)), + UserSummary(id: "usr_01JAG5M6N7P8Q9R0S1T2V3W4X5", email: "sam.rivera@example.org", status: .suspended, createdAt: .now.addingTimeInterval(-86_400 * 90), lastLoginAt: .now.addingTimeInterval(-86_400 * 12)), + UserSummary(id: "usr_01JAG6Y7Z8A9B0C1D2E3F4G5H6", email: "priya.n@example.com", status: .active, plan: "free", createdAt: .now.addingTimeInterval(-86_400 * 5), lastLoginAt: .now.addingTimeInterval(-86_400)), + UserSummary(id: "usr_01JAG7J8K9L0M1N2P3Q4R5S6T7", email: "qa+1@example.com", status: .active, testMode: true, createdAt: .now.addingTimeInterval(-3_600), expiresAt: .now.addingTimeInterval(3 * 3_600)), + ] + + static let applications: [ApplicationSummary] = [ + ApplicationSummary(id: "app_hub", slug: "hub", name: "Substratal Hub", summary: "This app: dashboard, inbox and administration.", visibility: "internal", accessTier: "free", reviewStatus: .active), + ApplicationSummary(id: "app_timetrack", slug: "timetrack", name: "TimeTrack", summary: "Time tracking and timesheet export for teams.", visibility: "public", accessTier: "standard", reviewStatus: .active), + ApplicationSummary(id: "app_invoicer", slug: "invoicer", name: "Invoicer", summary: "Send and track invoices.", visibility: "public", accessTier: "standard", reviewStatus: .active), + ApplicationSummary(id: "app_notes", slug: "notes", name: "Field Notes", summary: "Quick notes that sync everywhere.", visibility: "public", accessTier: "free", reviewStatus: .active), + ApplicationSummary(id: "app_ledger", slug: "ledger", name: "Ledger", summary: "Third-party bookkeeping, awaiting review.", visibility: "public", accessTier: "standard", reviewStatus: .pending), + ] + + static let seedKeys: [APIKeySummary] = [ + APIKeySummary(id: "key_01JAGE5F6G7H8J9K011M2N304P", name: "timetrack-backend", scope: "app_timetrack", mode: "live", intendedUse: "service", restrictDestructive: false, permissions: ["entitlements.manage", "users.list"], secretHint: "…6c8e", lastUsedAt: .now.addingTimeInterval(-90), expiresAt: .now.addingTimeInterval(86_400 * 340), createdAt: .now.addingTimeInterval(-86_400 * 25)), + APIKeySummary(id: "key_01JAGF6G7H8J9K011M2N304P5Q", name: "support-assistant-mcp", scope: "platform", mode: "live", intendedUse: "agent", restrictDestructive: true, permissions: ["audit.view", "users.list"], secretHint: "…a41f", lastUsedAt: .now.addingTimeInterval(-86_400 * 3), createdAt: .now.addingTimeInterval(-86_400 * 18)), + APIKeySummary(id: "key_01JAGG7H8J9K011M2N304P5Q6R", name: "invoicer-tests", scope: "app_invoicer", mode: "test", intendedUse: "service", restrictDestructive: false, permissions: ["entitlements.manage"], secretHint: "…0b2d", expiresAt: .now.addingTimeInterval(86_400 * 9), createdAt: .now.addingTimeInterval(-86_400 * 50)), + APIKeySummary(id: "key_01JAGH8J9K011M2N304P5Q6R7S", name: "billing-webhook-handler (old)", scope: "platform", mode: "live", intendedUse: "service", restrictDestructive: false, permissions: ["entitlements.manage"], secretHint: "…77aa", revokedAt: .now.addingTimeInterval(-86_400 * 7), createdAt: .now.addingTimeInterval(-86_400 * 120)), + ] +} diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Services/HubService.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Services/HubService.swift new file mode 100644 index 0000000..67afb6f --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Services/HubService.swift @@ -0,0 +1,30 @@ +import Foundation + +/// The outcome of the first sign-in step (AUTH-01). +public enum SignInResult: Sendable, Equatable { + case signedIn(SessionTokens) + /// AUTH-02: the account has two-factor on; finish with `verifyMFA`. + case mfaRequired(mfaToken: String) +} + +/// Everything screens ask of the API. Screens never call HTTP directly (CLIENT-ARCHITECTURE §1). +/// `LiveHubService` talks to the Platform API; `DemoHubService` serves fixtures for previews and design review. +public protocol HubService: Sendable { + func signIn(email: String, password: String) async throws -> SignInResult + func verifyMFA(mfaToken: String, code: String) async throws -> SessionTokens + func signOut(allSessions: Bool) async throws + + func currentUser() async throws -> UserSummary + func capabilities() async throws -> Capabilities + + func users(_ query: UserQuery, cursor: String?) async throws -> Page + func user(id: String) async throws -> UserSummary + func access(userID: String) async throws -> [AccessSummary] + func applications(cursor: String?) async throws -> Page + func apiKeys(includeInactive: Bool, cursor: String?) async throws -> Page + func createAPIKey(_ draft: NewAPIKey, idempotencyKey: String) async throws -> CreatedAPIKey + func revokeAPIKey(id: String) async throws + func auditEvents(cursor: String?) async throws -> Page + func roles(scope: String?) async throws -> [RoleSummary] + func permissionCatalog() async throws -> [String] +} diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Services/LiveHubService.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Services/LiveHubService.swift new file mode 100644 index 0000000..3a285bf --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Services/LiveHubService.swift @@ -0,0 +1,224 @@ +import Foundation +import OpenAPIRuntime +import OpenAPIURLSession +import SubstratalAPI + +/// `HubService` backed by the Platform API through the generated client. +/// +/// Middleware order matters: errors are mapped outermost, so `AuthMiddleware` (innermost) still sees a raw +/// `401` and can refresh and replay before anything is turned into an `APIError`. +public struct LiveHubService: HubService { + private let client: Client + private let tokens: TokenStore + + public init(configuration: APIConfiguration, tokens: TokenStore, versionMonitor: APIVersionMonitor) { + self.tokens = tokens + let transport = URLSessionTransport() + let shared: [any ClientMiddleware] = [ + ErrorMappingMiddleware(), + RequestContextMiddleware(userAgent: configuration.userAgent), + APIVersionMiddleware(monitor: versionMonitor), + ] + client = Client(serverURL: configuration.baseURL, transport: transport, middlewares: shared + [AuthMiddleware(tokens: tokens)]) + + // Refresh goes through a client without AuthMiddleware, so a failed refresh can't recurse. + let refreshClient = Client(serverURL: configuration.baseURL, transport: transport, middlewares: shared) + Task { + await tokens.setRefresher { refreshToken in + try await unwrapping { + let session = try await refreshClient.auth_refreshToken(body: .json(.init(refreshToken: refreshToken))).ok.body.json + return SessionTokens(session) + } + } + } + } + + // MARK: Session + + public func signIn(email: String, password: String) async throws -> SignInResult { + try await unwrapping { + switch try await client.auth_login(body: .json(.init(email: email, password: password))).ok.body.json { + case .AuthSession(let session): + let fresh = SessionTokens(session) + await tokens.store(fresh) + return .signedIn(fresh) + case .MfaChallenge(let challenge): + return .mfaRequired(mfaToken: challenge.mfaToken) + } + } + } + + public func verifyMFA(mfaToken: String, code: String) async throws -> SessionTokens { + try await unwrapping { + let session = try await client.auth_verifyMfa(body: .json(.init(mfaToken: mfaToken, code: code))).ok.body.json + let fresh = SessionTokens(session) + await tokens.store(fresh) + return fresh + } + } + + public func signOut(allSessions: Bool) async throws { + defer { Task { await tokens.clear() } } + _ = try? await unwrapping { try await client.auth_logout(body: .json(.init(allSessions: allSessions))) } + } + + public func currentUser() async throws -> UserSummary { + try await unwrapping { UserSummary(try await client.users_get(path: .init(id: "me")).ok.body.json) } + } + + /// CLIENT-ARCHITECTURE §5. A `403` on the role catalog means "no admin permissions", not an error. + public func capabilities() async throws -> Capabilities { + try await unwrapping { + async let assignments = client.roles_listForUser(path: .init(id: "me"), query: .init(limit: 100)) + async let owned = client.applications_list(query: .init(limit: 100, owned: true)) + async let tenants = client.tenants_list(query: .init(limit: 100)) + + let heldRoleIDs = Set(try await assignments.ok.body.json.value2.data?.compactMap(\.roleId) ?? []) + var permissions = Set() + if let catalog = try? await client.roles_list(query: .init(limit: 100, scope: "platform")).ok.body.json.value2.data { + for role in catalog where heldRoleIDs.contains(role.value2.id ?? "") { + permissions.formUnion(role.value1.permissions) + } + } + let me = try await currentUser() + return Capabilities( + platformPermissions: permissions, + ownedApplicationIDs: try await owned.ok.body.json.value2.data?.compactMap(\.id) ?? [], + ownedTenantIDs: (try? await tenants.ok.body.json.value2.data?.compactMap(\.id)) ?? [], + plan: me.plan ?? "free" + ) + } + } + + // MARK: Administration + + public func users(_ query: UserQuery, cursor: String?) async throws -> Page { + try await unwrapping { + let page = try await client.users_list(query: .init( + limit: 25, cursor: cursor, status: query.status, + q: query.text.isEmpty ? nil : query.text, + roleId: query.roleIDs.isEmpty ? nil : query.roleIDs + )).ok.body.json + return Page(items: page.value2.data?.map(UserSummary.init) ?? [], nextCursor: page.value1.page.nextCursor) + } + } + + public func user(id: String) async throws -> UserSummary { + try await unwrapping { UserSummary(try await client.users_get(path: .init(id: id)).ok.body.json) } + } + + public func access(userID: String) async throws -> [AccessSummary] { + try await unwrapping { + let rows = try await client.entitlements_listForUser(path: .init(id: userID), query: .init(limit: 100)).ok.body.json.value2.data ?? [] + let byApp = Dictionary(grouping: rows, by: { $0.applicationId ?? "unknown" }) + return byApp.keys.sorted().map { appID in + let paths = byApp[appID] ?? [] + let statuses = paths.compactMap(\.status) + let resolved: StatusKind = statuses.contains("active") ? .active : statuses.first.map(StatusKind.init(rawValue:)) ?? .none + let sources = paths.compactMap(\.source) + return AccessSummary(applicationID: appID, applicationName: appID, status: resolved, sources: sources, + platformManaged: sources.allSatisfy { $0 == "free" || $0 == "subscription" }) + } + } + } + + // MARK: Developer + + public func applications(cursor: String?) async throws -> Page { + try await unwrapping { + let page = try await client.applications_list(query: .init(limit: 50, cursor: cursor)).ok.body.json + return Page(items: page.value2.data?.map(ApplicationSummary.init) ?? [], nextCursor: page.value1.page.nextCursor) + } + } + + public func apiKeys(includeInactive: Bool, cursor: String?) async throws -> Page { + try await unwrapping { + let page = try await client.apiKeys_list(query: .init(limit: 50, cursor: cursor, includeInactive: includeInactive)).ok.body.json + return Page(items: page.value2.data?.map(APIKeySummary.init) ?? [], nextCursor: page.value1.page.nextCursor) + } + } + + public func createAPIKey(_ draft: NewAPIKey, idempotencyKey: String) async throws -> CreatedAPIKey { + try await RequestContext.$idempotencyKey.withValue(idempotencyKey) { + try await unwrapping { + let created = try await client.apiKeys_create(body: .json(.init( + name: draft.name, scope: draft.scope, permissions: draft.permissions.sorted(), mode: draft.mode, + intendedUse: draft.intendedUse, expiresAt: draft.expiresAt + ))).created.body.json + return CreatedAPIKey(key: APIKeySummary(created.value1), secret: created.value2.secret ?? "") + } + } + } + + public func revokeAPIKey(id: String) async throws { + try await unwrapping { _ = try await client.apiKeys_delete(path: .init(id: id)).noContent } + } + + public func auditEvents(cursor: String?) async throws -> Page { + try await unwrapping { + let page = try await client.audit_list(query: .init(limit: 50, cursor: cursor)).ok.body.json + return Page(items: page.value2.data?.map(AuditEntry.init) ?? [], nextCursor: page.value1.page.nextCursor) + } + } + + public func roles(scope: String?) async throws -> [RoleSummary] { + try await unwrapping { + try await client.roles_list(query: .init(limit: 100, scope: scope)).ok.body.json.value2.data?.map(RoleSummary.init) ?? [] + } + } + + public func permissionCatalog() async throws -> [String] { + try await unwrapping { + try await client.permissions_list(query: .init(limit: 200)).ok.body.json.value2.data?.compactMap(\.key) ?? [] + } + } +} + +// MARK: - Mapping generated types to domain models + +extension SessionTokens { + init(_ session: Components.Schemas.AuthSession) { + self.init(accessToken: session.accessToken, expiresIn: session.expiresIn, refreshToken: session.refreshToken, + sessionID: session.sessionId, userID: session.userId) + } +} + +extension UserSummary { + init(_ user: Components.Schemas.User) { + self.init(id: user.id ?? "", email: user.email ?? "", status: StatusKind(rawValue: user.status ?? "active"), + mfaEnabled: user.mfaEnabled ?? false, emailVerified: user.emailVerified ?? false, testMode: user.testMode ?? false, + plan: user.plan, createdAt: user.createdAt, lastLoginAt: user.lastLoginAt, expiresAt: user.expiresAt) + } +} + +extension ApplicationSummary { + init(_ app: Components.Schemas.ApplicationSummary) { + self.init(id: app.id ?? "", slug: app.slug ?? "", name: app.name ?? app.slug ?? "", summary: app.description, + visibility: app.visibility ?? "public", accessTier: app.accessTier ?? "standard", + reviewStatus: StatusKind.applicationReview(app.reviewStatus ?? "approved")) + } +} + +extension APIKeySummary { + init(_ key: Components.Schemas.ApiKey) { + self.init(id: key.value2.id ?? "", name: key.value1.name, scope: key.value1.scope, mode: key.value1.mode, + intendedUse: key.value1.intendedUse ?? "service", restrictDestructive: key.value1.restrictDestructive ?? false, + permissions: key.value1.permissions, secretHint: key.value2.secretHint, lastUsedAt: key.value2.lastUsedAt, + expiresAt: key.value1.expiresAt, revokedAt: key.value2.revokedAt, createdAt: key.value2.createdAt) + } +} + +extension AuditEntry { + init(_ event: Components.Schemas.AuditEvent) { + self.init(id: event.id ?? "", action: event.action ?? "", actorType: event.actor?._type ?? "system", + actorID: event.actor?.id ?? "system", targetType: event.target?._type, targetID: event.target?.id, + applicationID: event.applicationId, requestID: event.requestId, timestamp: event.timestamp ?? .now) + } +} + +extension RoleSummary { + init(_ role: Components.Schemas.Role) { + self.init(id: role.value2.id ?? "", name: role.value1.name, scope: role.value1.scope, description: role.value1.description, + permissions: role.value1.permissions, seed: role.value2.seed ?? false, assignmentCount: role.value2.assignmentCount ?? 0) + } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Session/KeychainStore.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Session/KeychainStore.swift new file mode 100644 index 0000000..3c95695 --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Session/KeychainStore.swift @@ -0,0 +1,45 @@ +import Foundation +import Security + +/// Stores the rotating refresh token. Admin sessions use `WhenUnlockedThisDeviceOnly`: +/// never synced to iCloud, never readable while the device is locked. +public struct KeychainStore: Sendable { + let service: String + + public init(service: String = "com.substratal.hub.session") { self.service = service } + + public func read(_ account: String) -> String? { + var query = baseQuery(account) + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + + /// Replaces the value atomically, so a crash between rotate and use can't leave the old token behind. + public func write(_ value: String, for account: String) { + let data = Data(value.utf8) + let update: [String: Any] = [kSecValueData as String: data] + let status = SecItemUpdate(baseQuery(account) as CFDictionary, update as CFDictionary) + if status == errSecItemNotFound { + var add = baseQuery(account) + add[kSecValueData as String] = data + add[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly + SecItemAdd(add as CFDictionary, nil) + } + } + + public func delete(_ account: String) { + SecItemDelete(baseQuery(account) as CFDictionary) + } + + private func baseQuery(_ account: String) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrSynchronizable as String: false, + ] + } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Session/SessionModel.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Session/SessionModel.swift new file mode 100644 index 0000000..523db19 --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Session/SessionModel.swift @@ -0,0 +1,94 @@ +import Foundation +import Observation + +/// The signed-in state the whole app observes (CLIENT-ARCHITECTURE §3, §5). +@MainActor @Observable +public final class SessionModel { + public enum Phase: Equatable { + /// Checking the Keychain for a surviving session on launch. + case restoring + case signedOut + /// AUTH-02: password accepted, two-factor code needed. + case awaitingMFA(mfaToken: String) + case signedIn + } + + public private(set) var phase: Phase = .restoring + public private(set) var user: UserSummary? + public private(set) var capabilities: Capabilities = .none + /// A sign-in or MFA error to show inline on AUTH-01 / AUTH-02. + public var lastError: APIError? + public private(set) var isWorking = false + + public let service: any HubService + private let tokens: TokenStore? + + public init(service: any HubService, tokens: TokenStore?) { + self.service = service + self.tokens = tokens + } + + /// Restores a session from the Keychain refresh token, or shows AUTH-01. + public func restore() async { + guard let tokens else { phase = .signedOut; return } + guard await tokens.hasStoredSession else { phase = .signedOut; return } + do { + try await tokens.refresh() + try await loadIdentity() + phase = .signedIn + } catch { + phase = .signedOut + } + } + + public func signIn(email: String, password: String) async { + await run { + switch try await service.signIn(email: email, password: password) { + case .signedIn: + try await loadIdentity() + phase = .signedIn + case .mfaRequired(let token): + phase = .awaitingMFA(mfaToken: token) + } + } + } + + public func verifyMFA(code: String) async { + guard case .awaitingMFA(let token) = phase else { return } + await run { + _ = try await service.verifyMFA(mfaToken: token, code: code) + try await loadIdentity() + phase = .signedIn + } + } + + public func cancelMFA() { phase = .signedOut } + + /// Sign out = `POST /auth/logout`, then wipe credentials and caches (CLIENT-ARCHITECTURE §3 rule 4). + public func signOut(everywhere: Bool = false) async { + try? await service.signOut(allSessions: everywhere) + await tokens?.clear() + user = nil + capabilities = .none + phase = .signedOut + } + + /// Recomputed on sign-in, on foreground, and after any `403` (CLIENT-ARCHITECTURE §5). + public func refreshCapabilities() async { + if let fresh = try? await service.capabilities() { capabilities = fresh } + } + + private func loadIdentity() async throws { + async let me = service.currentUser() + async let caps = service.capabilities() + user = try await me + capabilities = (try? await caps) ?? .none + } + + private func run(_ work: () async throws -> Void) async { + isWorking = true + lastError = nil + defer { isWorking = false } + do { try await work() } catch let error as APIError { lastError = error } catch { lastError = .transport(error.localizedDescription) } + } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalCore/Session/TokenStore.swift b/Packages/SubstratalKit/Sources/SubstratalCore/Session/TokenStore.swift new file mode 100644 index 0000000..89f58fb --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalCore/Session/TokenStore.swift @@ -0,0 +1,79 @@ +import Foundation + +/// A fresh platform session from login, MFA verify, or refresh. +public struct SessionTokens: Sendable, Equatable { + public let accessToken: String + public let expiresAt: Date + public let refreshToken: String + public let sessionID: String + public let userID: String + + public init(accessToken: String, expiresIn: Int, refreshToken: String, sessionID: String, userID: String, now: Date = .now) { + self.accessToken = accessToken + self.expiresAt = now.addingTimeInterval(TimeInterval(expiresIn)) + self.refreshToken = refreshToken + self.sessionID = sessionID + self.userID = userID + } +} + +/// Holds tokens and performs single-flight refresh (CLIENT-ARCHITECTURE §3): +/// a reused refresh token revokes the whole session, so two refreshes must never run at once. +public actor TokenStore { + public typealias Refresher = @Sendable (_ refreshToken: String) async throws -> SessionTokens + + private let keychain: KeychainStore + private var tokens: SessionTokens? + private var inFlight: Task? + private var refresher: Refresher? + + private static let refreshAccount = "refresh_token" + + public init(keychain: KeychainStore = KeychainStore()) { + self.keychain = keychain + } + + public func setRefresher(_ refresher: @escaping Refresher) { self.refresher = refresher } + + /// True when a refresh token survives from a previous launch. + public var hasStoredSession: Bool { keychain.read(Self.refreshAccount) != nil } + + public func store(_ tokens: SessionTokens) { + keychain.write(tokens.refreshToken, for: Self.refreshAccount) + self.tokens = tokens + } + + public func clear() { + inFlight?.cancel() + inFlight = nil + tokens = nil + keychain.delete(Self.refreshAccount) + } + + /// The current access token, refreshed first if it expires within 60 s. + public func accessToken() async throws -> String? { + if let tokens, tokens.expiresAt.timeIntervalSinceNow > 60 { return tokens.accessToken } + guard tokens != nil || hasStoredSession else { return nil } + return try await refresh().accessToken + } + + /// Refreshes once, however many callers ask at the same moment. + @discardableResult + public func refresh() async throws -> SessionTokens { + if let inFlight { return try await inFlight.value } + guard let refresher, let refreshToken = tokens?.refreshToken ?? keychain.read(Self.refreshAccount) else { + throw APIError.unauthenticated + } + let task = Task { try await refresher(refreshToken) } + inFlight = task + defer { inFlight = nil } + do { + let fresh = try await task.value + store(fresh) + return fresh + } catch { + if case APIError.unauthenticated = error { clear() } + throw error + } + } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalUI/Components/AccessSwitch.swift b/Packages/SubstratalKit/Sources/SubstratalUI/Components/AccessSwitch.swift new file mode 100644 index 0000000..fd9230a --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalUI/Components/AccessSwitch.swift @@ -0,0 +1,65 @@ +import SwiftUI +import SubstratalCore + +/// The on/off switch for one person's access to one app (ACX-01). Shows the resolved status first and the +/// paths one tap away. Platform-managed access can only be turned off and back on, never edited or revoked. +public struct AccessRow: View { + let access: AccessSummary + let onToggle: @MainActor (Bool) -> Void + + public init(_ access: AccessSummary, onToggle: @escaping @MainActor (Bool) -> Void) { + self.access = access + self.onToggle = onToggle + } + + public var body: some View { + HStack(spacing: 12) { + AppIconPlaceholder(name: access.applicationName) + VStack(alignment: .leading, spacing: 4) { + Text(access.applicationName).font(.body.weight(.medium)) + HStack(spacing: 6) { + StatusBadge(access.status) + Text(access.sources.map(Self.sourceLabel).joined(separator: " · ")) + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + } + Spacer() + Toggle("Access to \(access.applicationName)", isOn: Binding( + get: { access.status == .active || access.status == .scheduled }, + set: { value in MainActor.assumeIsolated { onToggle(value) } } + )) + .labelsHidden() + } + .padding(.vertical, 2) + } + + /// entitlement.source_labels in status-semantics.json. + static func sourceLabel(_ source: String) -> String { + switch source { + case "purchase": "Purchased" + case "trial": "Trial" + case "admin_grant": "Granted by admin" + case "org_seat": "Via organization" + case "free": "Free app" + case "subscription": "Standard plan" + default: source.replacingOccurrences(of: "_", with: " ").capitalized + } + } +} + +/// Initials tile used until icon upload exists (OPEN-QUESTIONS #1). +public struct AppIconPlaceholder: View { + let name: String + @Environment(\.palette) private var palette + + public init(name: String) { self.name = name } + + public var body: some View { + Text(String(name.prefix(1)).uppercased()) + .font(.headline) + .foregroundStyle(palette.accent) + .frame(width: 36, height: 36) + .background(palette.accentSubtle, in: RoundedRectangle(cornerRadius: 9, style: .continuous)) + .accessibilityHidden(true) + } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalUI/Components/IDChip.swift b/Packages/SubstratalKit/Sources/SubstratalUI/Components/IDChip.swift new file mode 100644 index 0000000..bb1344a --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalUI/Components/IDChip.swift @@ -0,0 +1,32 @@ +import SwiftUI + +/// A monospaced, copyable object ID (`usr_…`, `key_…`). Long-press copies it. +public struct IDChip: View { + let id: String + @State private var copied = false + + public init(_ id: String) { self.id = id } + + public var body: some View { + Text(id) + .font(.caption.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.quaternary, in: RoundedRectangle(cornerRadius: 5)) + .contextMenu { + Button("Copy ID", systemImage: "doc.on.doc") { copy() } + } + .accessibilityLabel("ID \(id)") + .accessibilityAction(named: "Copy ID") { copy() } + } + + private func copy() { + #if canImport(UIKit) + UIPasteboard.general.string = id + #endif + copied = true + } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalUI/Components/ScreenScaffold.swift b/Packages/SubstratalKit/Sources/SubstratalUI/Components/ScreenScaffold.swift new file mode 100644 index 0000000..feb95a0 --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalUI/Components/ScreenScaffold.swift @@ -0,0 +1,53 @@ +import SwiftUI +import SubstratalCore + +/// Stand-in for a screen whose UI isn't built yet. It shows the spec's ID, route, archetype and phase, +/// and links to the screen's spec and help page, so every route resolves from day one. +public struct ScreenPlaceholder: View { + let screen: ScreenID + let params: [String: String] + @Environment(\.openURL) private var openURL + + public init(_ screen: ScreenID, params: [String: String] = [:]) { + self.screen = screen + self.params = params + } + + public var body: some View { + List { + Section { + LabeledContent("Screen", value: screen.rawValue) + LabeledContent("Route", value: Route(screen, params).path).font(.body.monospaced()) + LabeledContent("Archetype", value: screen.info.archetype) + LabeledContent("Phase", value: screen.info.phase) + } footer: { + Text("Not built yet. Its elements, data and API calls are specified in ios/SCREENS.md.") + } + if !params.isEmpty { + Section("Parameters") { + ForEach(params.sorted(by: { $0.key < $1.key }), id: \.key) { LabeledContent($0.key, value: $0.value) } + } + } + Section { + Button("Open the screen spec", systemImage: "doc.text") { + openURL(URL(string: "https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.html#\(screen.info.anchor)")!) + } + Button("Help for this screen", systemImage: "questionmark.circle") { openURL(HelpCenter.url(for: screen)) } + } + } + .navigationTitle(screen.title) + } +} + +/// The "?" toolbar button every screen carries, opening its page in the shared help center. +public struct HelpButton: View { + let screen: ScreenID + @Environment(\.openURL) private var openURL + + public init(_ screen: ScreenID) { self.screen = screen } + + public var body: some View { + Button("Help", systemImage: "questionmark.circle") { openURL(HelpCenter.url(for: screen)) } + .help("Help for \(screen.title)") + } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalUI/Components/SecretRevealSheet.swift b/Packages/SubstratalKit/Sources/SubstratalUI/Components/SecretRevealSheet.swift new file mode 100644 index 0000000..6d2dfee --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalUI/Components/SecretRevealSheet.swift @@ -0,0 +1,82 @@ +#if os(iOS) +import SwiftUI + +/// KEY-03 and every other one-time secret (webhook signing secrets, SCIM tokens). +/// It can't be dismissed until the person confirms they saved it. The secret is hidden while the +/// screen is captured, and the copy expires from the pasteboard after 60 s and never syncs via Universal Clipboard. +public struct SecretRevealSheet: View { + let title: String + let secret: String + let note: String + let onDone: () -> Void + + @State private var acknowledged = false + @State private var copied = false + @State private var isCaptured = false + + public init(title: String, secret: String, note: String, onDone: @escaping () -> Void) { + self.title = title + self.secret = secret + self.note = note + self.onDone = onDone + } + + public var body: some View { + NavigationStack { + Form { + Section { + Label("You won't be able to see this again", systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + .font(.subheadline.weight(.semibold)) + Text(note).font(.footnote).foregroundStyle(.secondary) + } + Section("Secret") { + Group { + if isCaptured { + Label("Hidden while the screen is being recorded or mirrored", systemImage: "eye.slash") + .foregroundStyle(.secondary) + } else { + Text(secret).font(.body.monospaced()).textSelection(.enabled) + } + } + .privacySensitive() + Button(copied ? "Copied — clears in 60 seconds" : "Copy secret", systemImage: copied ? "checkmark" : "doc.on.doc") { + copySecret() + } + .disabled(isCaptured) + } + Section { + Toggle("I've stored this secret somewhere safe", isOn: $acknowledged) + } + } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("Done", action: onDone).disabled(!acknowledged) + } + } + } + .interactiveDismissDisabled() + .onAppear(perform: updateCaptureState) + .onReceive(NotificationCenter.default.publisher(for: UIScreen.capturedDidChangeNotification)) { _ in updateCaptureState() } + } + + private func copySecret() { + UIPasteboard.general.setItems([[UIPasteboard.typeAutomatic: secret]], + options: [.localOnly: true, .expirationDate: Date().addingTimeInterval(60)]) + copied = true + } + + private func updateCaptureState() { + isCaptured = (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.screen.isCaptured ?? false + } +} + +#Preview { + Text("Behind").sheet(isPresented: .constant(true)) { + SecretRevealSheet(title: "timetrack-backend", secret: "satk_live_9f2a1c7e4b3d8f0a2c5e7b1d9f3a6c8e", + note: "Use it as a Bearer token from your server. API keys never belong in an app or a browser.") {} + } +} +#endif diff --git a/Packages/SubstratalKit/Sources/SubstratalUI/Components/StateViews.swift b/Packages/SubstratalKit/Sources/SubstratalUI/Components/StateViews.swift new file mode 100644 index 0000000..2430c03 --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalUI/Components/StateViews.swift @@ -0,0 +1,74 @@ +import SwiftUI +import SubstratalCore + +/// Loading / empty / error states every screen needs (README Definition of Done). +public enum LoadState { + case idle + case loading + case loaded(Value) + case failed(APIError) + + public var value: Value? { if case .loaded(let v) = self { v } else { nil } } + public var isLoading: Bool { if case .loading = self { true } else { false } } +} + +public struct ErrorStateView: View { + let error: APIError + let retry: (() -> Void)? + + public init(_ error: APIError, retry: (() -> Void)? = nil) { + self.error = error + self.retry = retry + } + + public var body: some View { + ContentUnavailableView { + Label(title, systemImage: symbol) + } description: { + Text(error.userMessage) + } actions: { + if let retry { Button("Try again", action: retry).buttonStyle(.borderedProminent) } + } + } + + private var title: String { + switch error { + case .forbidden: "No access" + case .notFound: "Not found" + case .transport: "You're offline" + case .rateLimited: "Slow down" + default: "Something went wrong" + } + } + + private var symbol: String { + switch error { + case .forbidden: "lock" + case .notFound: "questionmark.folder" + case .transport: "wifi.slash" + case .rateLimited: "hourglass" + default: "exclamationmark.triangle" + } + } +} + +/// Wraps a screen's content in its load state. +public struct LoadStateView: View { + let state: LoadState + let retry: () -> Void + @ViewBuilder let content: (Value) -> Content + + public init(_ state: LoadState, retry: @escaping () -> Void, @ViewBuilder content: @escaping (Value) -> Content) { + self.state = state + self.retry = retry + self.content = content + } + + public var body: some View { + switch state { + case .idle, .loading: ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed(let error): ErrorStateView(error, retry: retry) + case .loaded(let value): content(value) + } + } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalUI/Components/StatusBadge.swift b/Packages/SubstratalKit/Sources/SubstratalUI/Components/StatusBadge.swift new file mode 100644 index 0000000..ff75dc0 --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalUI/Components/StatusBadge.swift @@ -0,0 +1,33 @@ +import SwiftUI +import SubstratalCore + +/// Icon + label, never colour alone (ACCESSIBILITY, status-semantics.json). +public struct StatusBadge: View { + let status: StatusKind + var label: String? + + public init(_ status: StatusKind, label: String? = nil) { + self.status = status + self.label = label + } + + public var body: some View { + Label(label ?? status.label, systemImage: status.sfSymbol) + .font(.caption.weight(.semibold)) + .labelStyle(.titleAndIcon) + .foregroundStyle(status.role.color) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(status.role.color.opacity(0.14), in: Capsule()) + .accessibilityElement(children: .combine) + .accessibilityLabel("Status: \(label ?? status.label)") + } +} + +#Preview { + VStack(alignment: .leading) { + ForEach([StatusKind.active, .scheduled, .disabled, .expired, .revoked, .none, .invited, .suspended, .pending], id: \.self) { + StatusBadge($0) + } + }.padding() +} diff --git a/Packages/SubstratalKit/Sources/SubstratalUI/Components/SystemStateBanner.swift b/Packages/SubstratalKit/Sources/SubstratalUI/Components/SystemStateBanner.swift new file mode 100644 index 0000000..29be721 --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalUI/Components/SystemStateBanner.swift @@ -0,0 +1,54 @@ +import SwiftUI +import SubstratalCore + +/// GLB-03 system states driven by the API version monitor (CLIENT-ARCHITECTURE §4.1): +/// "Update available" (banner, once per day) when the server sends `Deprecation`, and +/// "Update required" (blocking) on `400 unsupported_api_version`. +public struct APIVersionBanner: View { + let state: APIVersionState + + public init(_ state: APIVersionState) { self.state = state } + + public var body: some View { + if case .deprecated(let sunset) = state { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Image(systemName: "arrow.down.app") + VStack(alignment: .leading, spacing: 2) { + Text("Update available").font(.subheadline.weight(.semibold)) + Text(sunsetText(sunset)).font(.footnote).foregroundStyle(.secondary) + } + Spacer() + } + .padding(12) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12)) + .padding(.horizontal) + .accessibilityElement(children: .combine) + } + } + + private func sunsetText(_ date: Date?) -> String { + guard let date else { return "This version of the app is being retired. Update when you can." } + return "This version stops working on \(date.formatted(date: .long, time: .omitted)). Update before then." + } +} + +/// Full-screen blocker for `unsupported_api_version`. All API traffic stops until the app is updated. +public struct UpdateRequiredView: View { + let supported: [String] + let recheck: () -> Void + + public init(supported: [String], recheck: @escaping () -> Void) { + self.supported = supported + self.recheck = recheck + } + + public var body: some View { + ContentUnavailableView { + Label("Update required", systemImage: "exclamationmark.arrow.triangle.2.circlepath") + } description: { + Text("This version of Substratal can't talk to the service any more. Update the app from the App Store to continue.") + } actions: { + Button("Check again", action: recheck) + } + } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalUI/Theme/StatusColors.swift b/Packages/SubstratalKit/Sources/SubstratalUI/Theme/StatusColors.swift new file mode 100644 index 0000000..c65088b --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalUI/Theme/StatusColors.swift @@ -0,0 +1,45 @@ +import SwiftUI +import SubstratalCore + +/// Status colours are deliberately not themed (THEMES.md §2): the same in every theme, light and dark sets. +/// System colours keep them correct under Increase Contrast and in both appearances. +public extension StatusKind.Role { + var color: Color { + switch self { + case .success: Color(uiColorName: .systemGreen) + case .info: Color(uiColorName: .systemBlue) + case .warning: Color(uiColorName: .systemOrange) + case .critical: Color(uiColorName: .systemRed) + case .neutral: Color(uiColorName: .systemGray) + case .accent: Color.accentColor + } + } +} + +/// Danger red, also unthemed. +public extension Color { + static let danger = Color(uiColorName: .systemRed) +} + +extension Color { + enum SystemName { case systemGreen, systemBlue, systemOrange, systemRed, systemGray } + init(uiColorName: SystemName) { + #if canImport(UIKit) + switch uiColorName { + case .systemGreen: self.init(uiColor: .systemGreen) + case .systemBlue: self.init(uiColor: .systemBlue) + case .systemOrange: self.init(uiColor: .systemOrange) + case .systemRed: self.init(uiColor: .systemRed) + case .systemGray: self.init(uiColor: .systemGray) + } + #else + switch uiColorName { + case .systemGreen: self = .green + case .systemBlue: self = .blue + case .systemOrange: self = .orange + case .systemRed: self = .red + case .systemGray: self = .gray + } + #endif + } +} diff --git a/Packages/SubstratalKit/Sources/SubstratalUI/Theme/SubstratalTheme.swift b/Packages/SubstratalKit/Sources/SubstratalUI/Theme/SubstratalTheme.swift new file mode 100644 index 0000000..adf3e80 --- /dev/null +++ b/Packages/SubstratalKit/Sources/SubstratalUI/Theme/SubstratalTheme.swift @@ -0,0 +1,124 @@ +// SubstratalTheme.swift — generated from shared/themes/themes.json. Lives in SubstratalKit (shared by iOS and macOS). +// Five light and five dark themes. Defaults: Moss Paper (light) and Forest Floor (dark). +import SwiftUI +import Observation + +public struct ThemePalette: Equatable, Sendable { + public let background: Color + public let sidebar: Color + public let surface: Color + public let raised: Color + public let text: Color + public let textSecondary: Color + public let textTertiary: Color + public let separator: Color + public let accent: Color + public let accentStrong: Color + public let onAccent: Color + public let accentSubtle: Color + public let link: Color + public let selection: Color + public let controlFill: Color + public let controlFillStrong: Color + public let focusRing: Color +} + +/// Settings.theme from the API (`PATCH /users/me/settings`). +public enum ThemeMode: String, Codable, CaseIterable, Sendable { case light, dark, system } + +public enum LightTheme: String, Codable, CaseIterable, Identifiable, Sendable { + case tealLedger = "teal-ledger" + case indigoRoot = "indigo-root" + case mossPaper = "moss-paper" + case coralClay = "coral-clay" + case sporePink = "spore-pink" + public static let `default`: LightTheme = .mossPaper + public var id: String { rawValue } + public var displayName: String { + switch self { + case .tealLedger: "Teal Ledger" + case .indigoRoot: "Indigo Root" + case .mossPaper: "Moss Paper" + case .coralClay: "Coral Clay" + case .sporePink: "Spore Pink" + } + } + public var palette: ThemePalette { + switch self { + case .tealLedger: ThemePalette(background: Color(hex: 0xF3F6F6), sidebar: Color(hex: 0xEAF0F0), surface: Color(hex: 0xF6F9F9), raised: Color(hex: 0xFFFFFF), text: Color(hex: 0x10272A), textSecondary: Color(hex: 0x56686A), textTertiary: Color(hex: 0x9BA4A6), separator: Color(hex: 0xD9E2E2), accent: Color(hex: 0x137C7A), accentStrong: Color(hex: 0x0E5F5D), onAccent: Color(hex: 0xFFFFFF), accentSubtle: Color(hex: 0xDDF0EE), link: Color(hex: 0x137C7A), selection: Color(hex: 0xDDF0EE), controlFill: Color(hex: 0xEEF0F0), controlFillStrong: Color(hex: 0xE2E5E5), focusRing: Color(hex: 0x137C7A)) + case .indigoRoot: ThemePalette(background: Color(hex: 0xF4F4FA), sidebar: Color(hex: 0xEBEBF5), surface: Color(hex: 0xF7F7FC), raised: Color(hex: 0xFFFFFF), text: Color(hex: 0x1A1838), textSecondary: Color(hex: 0x5D5B78), textTertiary: Color(hex: 0x9F9EAB), separator: Color(hex: 0xDEDEEB), accent: Color(hex: 0x3D3BB8), accentStrong: Color(hex: 0x2C2A8F), onAccent: Color(hex: 0xFFFFFF), accentSubtle: Color(hex: 0xE6E6FA), link: Color(hex: 0x3D3BB8), selection: Color(hex: 0xE6E6FA), controlFill: Color(hex: 0xEFEFF1), controlFillStrong: Color(hex: 0xE4E3E7), focusRing: Color(hex: 0x3D3BB8)) + case .mossPaper: ThemePalette(background: Color(hex: 0xF5F4EE), sidebar: Color(hex: 0xEDECE3), surface: Color(hex: 0xF9F8F3), raised: Color(hex: 0xFFFFFE), text: Color(hex: 0x22261A), textSecondary: Color(hex: 0x5F6455), textTertiary: Color(hex: 0xA2A49E), separator: Color(hex: 0xE1DFD2), accent: Color(hex: 0x4A7322), accentStrong: Color(hex: 0x375719), onAccent: Color(hex: 0xFFFFFF), accentSubtle: Color(hex: 0xE6EFD8), link: Color(hex: 0x4A7322), selection: Color(hex: 0xE6EFD8), controlFill: Color(hex: 0xF0F0EE), controlFillStrong: Color(hex: 0xE4E5E3), focusRing: Color(hex: 0x4A7322)) + case .coralClay: ThemePalette(background: Color(hex: 0xF9F5F2), sidebar: Color(hex: 0xF1EBE6), surface: Color(hex: 0xFBF8F6), raised: Color(hex: 0xFFFFFF), text: Color(hex: 0x2A1D17), textSecondary: Color(hex: 0x6E5D55), textTertiary: Color(hex: 0xA6A09E), separator: Color(hex: 0xEAE0D8), accent: Color(hex: 0xB44529), accentStrong: Color(hex: 0x90361F), onAccent: Color(hex: 0xFFFFFF), accentSubtle: Color(hex: 0xF7E3DB), link: Color(hex: 0xB44529), selection: Color(hex: 0xF7E3DB), controlFill: Color(hex: 0xF0EFEF), controlFillStrong: Color(hex: 0xE5E4E3), focusRing: Color(hex: 0xB44529)) + case .sporePink: ThemePalette(background: Color(hex: 0xF9F6F8), sidebar: Color(hex: 0xF2ECF0), surface: Color(hex: 0xFBF8FA), raised: Color(hex: 0xFFFFFF), text: Color(hex: 0x2A1622), textSecondary: Color(hex: 0x6E5A66), textTertiary: Color(hex: 0xA69DA2), separator: Color(hex: 0xEADFE5), accent: Color(hex: 0xB3285B), accentStrong: Color(hex: 0x8F1F48), onAccent: Color(hex: 0xFFFFFF), accentSubtle: Color(hex: 0xF7E0E9), link: Color(hex: 0xB3285B), selection: Color(hex: 0xF7E0E9), controlFill: Color(hex: 0xF0EFF0), controlFillStrong: Color(hex: 0xE5E3E4), focusRing: Color(hex: 0xB3285B)) + } + } +} + +public enum DarkTheme: String, Codable, CaseIterable, Identifiable, Sendable { + case nightSoil = "night-soil" + case deepTeal = "deep-teal" + case graphiteSpore = "graphite-spore" + case forestFloor = "forest-floor" + case emberClay = "ember-clay" + public static let `default`: DarkTheme = .forestFloor + public var id: String { rawValue } + public var displayName: String { + switch self { + case .nightSoil: "Night Soil" + case .deepTeal: "Deep Teal" + case .graphiteSpore: "Graphite Spore" + case .forestFloor: "Forest Floor" + case .emberClay: "Ember Clay" + } + } + public var palette: ThemePalette { + switch self { + case .nightSoil: ThemePalette(background: Color(hex: 0x15132E), sidebar: Color(hex: 0x1B1938), surface: Color(hex: 0x211F42), raised: Color(hex: 0x25234B), text: Color(hex: 0xECEBFA), textSecondary: Color(hex: 0xA6A4C8), textTertiary: Color(hex: 0x797794), separator: Color(hex: 0x34325C), accent: Color(hex: 0x8E8CFF), accentStrong: Color(hex: 0xB4B3FF), onAccent: Color(hex: 0x15132E), accentSubtle: Color(hex: 0x2C2A63), link: Color(hex: 0x8E8CFF), selection: Color(hex: 0x2C2A63), controlFill: Color(hex: 0x333157), controlFillStrong: Color(hex: 0x3D3B60), focusRing: Color(hex: 0x8E8CFF)) + case .deepTeal: ThemePalette(background: Color(hex: 0x0D1819), sidebar: Color(hex: 0x122021), surface: Color(hex: 0x162729), raised: Color(hex: 0x1A2E30), text: Color(hex: 0xE3F1F0), textSecondary: Color(hex: 0x93ADAC), textTertiary: Color(hex: 0x6E8081), separator: Color(hex: 0x26403F), accent: Color(hex: 0x55CFC9), accentStrong: Color(hex: 0x8BE3DE), onAccent: Color(hex: 0x062423), accentSubtle: Color(hex: 0x133C3B), link: Color(hex: 0x55CFC9), selection: Color(hex: 0x133C3B), controlFill: Color(hex: 0x283C3D), controlFillStrong: Color(hex: 0x324547), focusRing: Color(hex: 0x55CFC9)) + case .graphiteSpore: ThemePalette(background: Color(hex: 0x161618), sidebar: Color(hex: 0x1D1D20), surface: Color(hex: 0x222226), raised: Color(hex: 0x28282C), text: Color(hex: 0xF1EFF1), textSecondary: Color(hex: 0xA3A0A7), textTertiary: Color(hex: 0x7C7C7F), separator: Color(hex: 0x34343A), accent: Color(hex: 0xFF86AE), accentStrong: Color(hex: 0xFFB0CA), onAccent: Color(hex: 0x3A0A1E), accentSubtle: Color(hex: 0x3E2030), link: Color(hex: 0xFF86AE), selection: Color(hex: 0x3E2030), controlFill: Color(hex: 0x36363A), controlFillStrong: Color(hex: 0x404044), focusRing: Color(hex: 0xFF86AE)) + case .forestFloor: ThemePalette(background: Color(hex: 0x10150F), sidebar: Color(hex: 0x151C14), surface: Color(hex: 0x1A2219), raised: Color(hex: 0x1F291E), text: Color(hex: 0xE8EFE2), textSecondary: Color(hex: 0x9DAA95), textTertiary: Color(hex: 0x737C70), separator: Color(hex: 0x2C3929), accent: Color(hex: 0xB8E26A), accentStrong: Color(hex: 0xD3F09A), onAccent: Color(hex: 0x1A2608), accentSubtle: Color(hex: 0x2C3B19), link: Color(hex: 0xB8E26A), selection: Color(hex: 0x2C3B19), controlFill: Color(hex: 0x2D372C), controlFillStrong: Color(hex: 0x374136), focusRing: Color(hex: 0xB8E26A)) + case .emberClay: ThemePalette(background: Color(hex: 0x191412), sidebar: Color(hex: 0x201916), surface: Color(hex: 0x261E1A), raised: Color(hex: 0x2C231F), text: Color(hex: 0xF4ECE8), textSecondary: Color(hex: 0xB3A39B), textTertiary: Color(hex: 0x807773), separator: Color(hex: 0x3B302B), accent: Color(hex: 0xFF9A80), accentStrong: Color(hex: 0xFFBCA9), onAccent: Color(hex: 0x3D1206), accentSubtle: Color(hex: 0x44261D), link: Color(hex: 0xFF9A80), selection: Color(hex: 0x44261D), controlFill: Color(hex: 0x3A312D), controlFillStrong: Color(hex: 0x443B37), focusRing: Color(hex: 0xFF9A80)) + } + } +} + +/// Holds the user's choice. Persist `light`/`dark` to the app_hub settings keys +/// `appearance.light_theme` / `appearance.dark_theme` and cache them locally for launch. +@MainActor @Observable public final class ThemeStore { + public var mode: ThemeMode = .system + public var light: LightTheme = .default + public var dark: DarkTheme = .default + public init() {} + /// Unknown ids from the server fall back to the defaults. + public func apply(mode: String?, light: String?, dark: String?) { + self.mode = mode.flatMap(ThemeMode.init(rawValue:)) ?? .system + self.light = light.flatMap(LightTheme.init(rawValue:)) ?? .default + self.dark = dark.flatMap(DarkTheme.init(rawValue:)) ?? .default + } + public var preferredColorScheme: ColorScheme? { mode == .light ? .light : mode == .dark ? .dark : nil } + public func palette(for scheme: ColorScheme) -> ThemePalette { scheme == .dark ? dark.palette : light.palette } +} + +private struct PaletteKey: EnvironmentKey { static let defaultValue = LightTheme.default.palette } +public extension EnvironmentValues { var palette: ThemePalette { get { self[PaletteKey.self] } set { self[PaletteKey.self] = newValue } } } + +/// Apply once at the root: `ContentView().substratalTheme(store)`. +public struct SubstratalThemeModifier: ViewModifier { + @Environment(\.colorScheme) private var scheme + @Environment(\.colorSchemeContrast) private var contrast + let store: ThemeStore + public func body(content: Content) -> some View { + let p = store.palette(for: scheme) + content + .environment(\.palette, p) + .tint(contrast == .increased ? p.accentStrong : p.accent) + .preferredColorScheme(store.preferredColorScheme) + } +} +public extension View { func substratalTheme(_ store: ThemeStore) -> some View { modifier(SubstratalThemeModifier(store: store)) } } + +extension Color { + init(hex: UInt32) { + self.init(.sRGB, red: Double((hex >> 16) & 0xFF) / 255, green: Double((hex >> 8) & 0xFF) / 255, blue: Double(hex & 0xFF) / 255, opacity: 1) + } +} diff --git a/Packages/SubstratalKit/Tests/SubstratalCoreTests/ModelTests.swift b/Packages/SubstratalKit/Tests/SubstratalCoreTests/ModelTests.swift new file mode 100644 index 0000000..360bc6a --- /dev/null +++ b/Packages/SubstratalKit/Tests/SubstratalCoreTests/ModelTests.swift @@ -0,0 +1,23 @@ +import Testing +@testable import SubstratalCore + +struct ModelTests { + @Test func unknownEnumValuesDegradeGracefully() { + let status = StatusKind(rawValue: "quarantined") + #expect(status == .unknown("quarantined")) + #expect(status.role == .neutral) + #expect(status.label == "Quarantined") + } + + @Test func reviewStatusMapping() { + #expect(StatusKind.applicationReview("pending_review") == .pending) + #expect(StatusKind.applicationReview("rejected") == .revoked) + } + + @Test func manageTabHiddenForEndUsers() { + #expect(!Capabilities.none.showsManage) + #expect(Capabilities(ownedApplicationIDs: ["app_x"]).showsManage) + #expect(Capabilities.superadmin.showsAdministration && Capabilities.superadmin.showsDeveloper) + #expect(!Capabilities(platformPermissions: ["users.list"]).shows(.aud01)) + } +} diff --git a/Packages/SubstratalKit/Tests/SubstratalCoreTests/RouteTests.swift b/Packages/SubstratalKit/Tests/SubstratalCoreTests/RouteTests.swift new file mode 100644 index 0000000..55e90cf --- /dev/null +++ b/Packages/SubstratalKit/Tests/SubstratalCoreTests/RouteTests.swift @@ -0,0 +1,41 @@ +import Foundation +import Testing +@testable import SubstratalCore + +struct RouteTests { + @Test func everyHubScreenHasAUniqueRoute() { + let routes = ScreenID.allCases.map(\.info.route).filter { $0.hasPrefix("/") } + #expect(Set(routes).count == routes.count) + #expect(ScreenID.allCases.count == 75) + } + + @Test func literalSegmentsBeatParameters() { + #expect(Route.parse(path: "/admin/users/new")?.screen == .usr02) + #expect(Route.parse(path: "/admin/users/usr_123")?.screen == .usr03) + #expect(Route.parse(path: "/admin/users/usr_123")?["userId"] == "usr_123") + } + + @Test func parsesUniversalLinksAndCustomScheme() { + let universal = Route.parse(URL(string: "https://dashboard.substratalapps.com/dev/keys/key_ABC")!) + #expect(universal == Route(.key04, ["keyId": "key_ABC"])) + let custom = Route.parse(URL(string: "substratal://admin/audit")!) + #expect(custom?.screen == .aud01) + let emailed = Route.parse(URL(string: "https://dashboard.substratalapps.com/reset-password?token=pwr_1")!) + #expect(emailed?.screen == .auth06) + #expect(emailed?["token"] == "pwr_1") + } + + @Test func ignoresForeignURLs() { + #expect(Route.parse(URL(string: "https://example.com/admin/users")!) == nil) + } + + @Test func rebuildsCanonicalPath() { + #expect(Route(.apl07, ["appId": "app_hub"]).path == "/dev/apps/app_hub/push") + #expect(Route(.app01).path == "/") + } + + @Test func helpLinksAreStablePerScreen() { + #expect(HelpCenter.url(for: .key03).absoluteString.hasSuffix("/help/screens/key-03.html")) + #expect(HelpCenter.apiReference.absoluteString.hasSuffix("/help/api/v\(APIConfiguration.apiVersion)/")) + } +} diff --git a/Packages/SubstratalKit/Tests/SubstratalCoreTests/VersioningTests.swift b/Packages/SubstratalKit/Tests/SubstratalCoreTests/VersioningTests.swift new file mode 100644 index 0000000..102fc08 --- /dev/null +++ b/Packages/SubstratalKit/Tests/SubstratalCoreTests/VersioningTests.swift @@ -0,0 +1,53 @@ +import Foundation +import HTTPTypes +import OpenAPIRuntime +import Testing +@testable import SubstratalCore + +struct VersioningTests { + @Test func baseURLHasNoVersionPrefix() { + let config = APIConfiguration.make(.production, appVersion: "1", osVersion: "26") + #expect(config.baseURL.absoluteString == "https://api.substratalapps.com") + #expect(!config.baseURL.path.contains("v1")) + } + + /// The pinned header value must be one the vendored contract declares (api-contract/CONTRACT.json). + @Test func pinnedVersionIsDeclaredByTheContract() throws { + let contract = URL(filePath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .deletingLastPathComponent().deletingLastPathComponent() + .appending(path: "api-contract/CONTRACT.json") + struct Record: Decodable { let substratal_versions: [String]; let info_version: String } + let record = try JSONDecoder().decode(Record.self, from: Data(contentsOf: contract)) + #expect(record.substratal_versions.contains(APIConfiguration.apiVersion)) + #expect(record.info_version == contractVersion) + } + + @Test @MainActor func middlewareSendsHeaderAndRecordsDeprecation() async throws { + let monitor = APIVersionMonitor() + let middleware = APIVersionMiddleware(monitor: monitor) + let request = HTTPRequest(method: .get, scheme: "https", authority: "api.substratalapps.com", path: "/users") + let (response, _) = try await middleware.intercept(request, body: nil, baseURL: URL(string: "https://api.substratalapps.com")!, operationID: "users.list") { sent, _, _ in + #expect(sent.headerFields[HTTPField.Name("Substratal-Version")!] == "1") + var fields = HTTPFields() + fields[HTTPField.Name("Substratal-Version")!] = "1" + fields[HTTPField.Name("Deprecation")!] = "@1798761600" + fields[HTTPField.Name("Sunset")!] = "Sat, 01 May 2027 00:00:00 GMT" + return (HTTPResponse(status: .ok, headerFields: fields), nil) + } + #expect(response.status == .ok) + guard case .deprecated(let sunset) = monitor.state else { Issue.record("expected deprecated"); return } + #expect(sunset != nil) + } + + @Test @MainActor func unsupportedVersionBlocksTheApp() async throws { + let monitor = APIVersionMonitor() + let middleware = APIVersionMiddleware(monitor: monitor) + let request = HTTPRequest(method: .get, scheme: "https", authority: "api.substratalapps.com", path: "/users") + let body = #"{"error":{"code":"unsupported_api_version","message":"x","details":{"supported":["2"]}}}"# + _ = try await middleware.intercept(request, body: nil, baseURL: URL(string: "https://api.substratalapps.com")!, operationID: "users.list") { _, _, _ in + (HTTPResponse(status: .badRequest), HTTPBody(body)) + } + #expect(monitor.state == .unsupported(supported: ["2"])) + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..cddd60f --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# Substratal Hub for iOS and iPadOS + +The native iOS/iPadOS client of the [Substratal Apps Platform API](https://compositecode.github.io/substratalapps.com/): +the management app for the platform (users, access, roles, applications, API keys, webhooks, audit), and in time the +full Substratal Hub for every user. It's built from the shared +[Hub specifications](https://compositecode.github.io/substratalapps-applications-specs/) and links to the shared +[help center](https://compositecode.github.io/substratalapps-applications-specs/help/). + +- **Plan:** [docs/management-app-plan.md](docs/management-app-plan.md) +- **API gaps:** [docs/api-gaps.md](docs/api-gaps.md), which maps to the API repo's `[api-gaps]` issues + +## Requirements + +- Xcode 27 (Swift 6.4, iOS 26 SDK). Deployment target **iOS 17**. +- [XcodeGen](https://github.com/yonaskolb/XcodeGen) only if you change `project.yml` (`brew install xcodegen`). + The generated `SubstratalHub.xcodeproj` is committed. +- Python 3 with PyYAML (installed on demand), only to re-sync the API contract. + +## Run it + +```bash +open SubstratalHub.xcodeproj # trust the OpenAPIGenerator plugin when Xcode asks, then Run +``` + +The app talks to production (`https://api.substratalapps.com`) by default. To explore without an account, run with +**demo data**: Edit Scheme → Run → Arguments → enable `-demo YES`. Or from the command line: + +```bash +make build +xcrun simctl launch booted com.substratal.hub -demo YES -autoSignIn YES -route /admin/users +``` + +`-autoSignIn YES` skips sign-in, and `-route ` opens any Hub route as if it were a deep link. Both work in demo +mode only. In demo mode, any password and any 6-digit code sign you in as a superadmin. + +## API versioning + +The Platform API has **no version in its URLs**. Every path is at the host root (`/users`, not `/v1/users`), and +`/v1/…` returns `404`. The version goes in a request header: + +```http +Substratal-Version: 1 +``` + +- `APIConfiguration.apiVersion` pins the version this build speaks. `APIVersionMiddleware` sends it on every request, + and the generated client never passes it per call. +- A `Deprecation`/`Sunset` response shows the GLB-03 **Update available** banner. `400 unsupported_api_version` + stops API traffic and shows **Update required** (`APIVersionMonitor`). +- `contractVersion` (the `openapi.yaml` `info.version`, now `1.0.0-draft.9`) is a separate thing, shown on + Account → About. +- A unit test fails if the pinned header value isn't one the vendored contract declares. + +See the help center's [API versioning](https://compositecode.github.io/substratalapps-applications-specs/help/developers/api-versioning.html) page. + +## Layout + +``` +App/ the iOS app target + SubstratalHubApp.swift entry point; AppEnvironment wires live or demo services + Shell/ sections (INFORMATION-ARCHITECTURE), tabs / iPad sidebar, Navigator (deep links), ScreenView (route → screen) + Features/ screens by domain: Auth, Home, Inbox, Account, Users, Roles, Audit, Applications, Keys + Resources/Assets.xcassets brand assets and app icon from the specs package +Packages/SubstratalKit/ shared with the future macOS app + SubstratalAPI client generated from openapi.yaml at build time (swift-openapi-generator) + SubstratalCore middleware (version, auth + single-flight refresh, errors, idempotency), session, Keychain, + Capabilities, domain models, ScreenID (all 75 screens) and Route parsing, HelpCenter links + SubstratalUI theme (5 light + 5 dark), StatusBadge, IDChip, AccessRow, SecretRevealSheet, state views +api-contract/ the untouched upstream openapi.yaml, and CONTRACT.json (where it came from, its hash, its version) +scripts/ sync-openapi.sh, derive-openapi.py, generate-screen-ids.py +UITests/ smoke test against demo data +``` + +## What's built + +| Area | Status | +|---|---| +| Shell | iPhone: Home · Inbox · Orgs · Manage · Account (Manage hidden without rights). iPad (iOS 18+): sidebar with the full tree. Every one of the 75 routes resolves from deep links, `substratal://` and Universal Links. Unbuilt screens show a placeholder that links to their spec and help. | +| Sign-in | AUTH-01, AUTH-02 (live: login, MFA, Keychain session restore, single-flight refresh, sign out, sign out everywhere) | +| Administration | USR-01 (search, status filter, paging), USR-03 (overview, access with AccessRow), ROL-01/02, AUD-01/02 | +| Developer | APL-01/03, KEY-01 (list, revoke), KEY-02 → KEY-03 (create, then a one-time secret reveal hidden from capture, with a 60 s local-only clipboard) | +| Personal | APP-01 Home grid, ACC-01 Account (with help links and About), SET-01 appearance and themes | +| Not yet | Write paths behind the access switch (ACX-03), the remaining forms, push registration (waits on `app_hub` and APNs credentials, API issues #24/#25), biometric lock, widgets, App Intents | + +## Keeping in sync + +| What | How | +|---|---| +| API contract | `make contract`, or the daily **API contract sync** workflow, which opens a PR when `openapi.yaml` changes | +| Screen registry | `make screens` regenerates `ScreenID.swift` from the specs (the same workflow runs it) | +| Xcode project | Edit `project.yml`, then `make project`. CI fails if the committed project doesn't match. | +| Help center | Merging to `main` sends `app-docs-updated` to the specs repo (**Help center rebuild** workflow; needs the `SPECS_DOCS_TOKEN` secret). The help center also rebuilds daily. | + +## Tests + +```bash +make test-kit # routing, versioning middleware, contract pin, status mapping (swift test) +make test-ui # sign-in → two-factor → shell, against demo data +``` diff --git a/SubstratalHub.xcodeproj/project.pbxproj b/SubstratalHub.xcodeproj/project.pbxproj new file mode 100644 index 0000000..21c2a27 --- /dev/null +++ b/SubstratalHub.xcodeproj/project.pbxproj @@ -0,0 +1,645 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 109F1B316B5DD035AB6D90D0 /* AppEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3502B5D9769C4FF868701CF4 /* AppEnvironment.swift */; }; + 200EA652A8075959AAD15914 /* APIKeysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D4168CF5C871612D8EDD95F /* APIKeysView.swift */; }; + 503AE2D53521F2D6006F4D44 /* SubstratalHubApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C70AA0B7FC7F9B7BC4D2968 /* SubstratalHubApp.swift */; }; + 5A207B3888279455E91575E4 /* HubSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21F922340DA3B64EFB347BC6 /* HubSection.swift */; }; + 5DFABFDAACE507CB11F1A524 /* SignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4B94B5C88575DDF063D085CB /* SignInView.swift */; }; + 5EC391CB7836931649877184 /* AuditLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D551A582DE38E105E8C22693 /* AuditLogView.swift */; }; + 5F46C4F269DF9EC5839C5F65 /* UsersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2BCDBAFC359366C7E1B5A85 /* UsersView.swift */; }; + 60D824064BA2B077E1B35785 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D969C9B30A2821D2685A18DA /* HomeView.swift */; }; + 678CDC9870EE54C7D87D6C36 /* SmokeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94CDCF393463C90732E9A8F2 /* SmokeTests.swift */; }; + 6C70E92D670B29054F41FE37 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F705AC52E366D154941118 /* RootView.swift */; }; + 78ABE1E1F4C30CA7B3CE608C /* SubstratalCore in Frameworks */ = {isa = PBXBuildFile; productRef = 7B3F472506115D22A2489FDE /* SubstratalCore */; }; + 83D65CA495254F088B3C0614 /* MainShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 269BF7A31135F9E08C1111CA /* MainShell.swift */; }; + A5BDA889699A73335C1CD7C5 /* RolesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA985CEB7E0395D20499E034 /* RolesView.swift */; }; + A82E15BA69D0E47322BF2003 /* ApplicationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA9B6FD36443B3A9A64D4E66 /* ApplicationsView.swift */; }; + B3A107B8F90B7E4A71736839 /* ManageHubView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F94155F13BA21FD62061D99 /* ManageHubView.swift */; }; + C8B91453FF0D5842748602EF /* SubstratalUI in Frameworks */ = {isa = PBXBuildFile; productRef = BF9C5C4BEC78465574DC90D6 /* SubstratalUI */; }; + CB721D9181090F11CC272B50 /* InboxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89D1CB17CABCAE854FC3AB7E /* InboxView.swift */; }; + CE631AFA0293D95DF62AFC3F /* ScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA80CE4F9D1AE9E96D451BDB /* ScreenView.swift */; }; + D7B662CE2F44A8396918ADFB /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D02F06E1BBE55702E18F182 /* AccountView.swift */; }; + E531FFBB5831EC2FAC0F9239 /* Navigator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 261AC59792431F640B74AB50 /* Navigator.swift */; }; + F59EABA762C2A88F8202A277 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = E10A2B1788BDDDE391330BBD /* Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + A0C0951A0B440EE142329C98 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 64825BC73D4AE9A1C76EF2DA /* Project object */; + proxyType = 1; + remoteGlobalIDString = F83DCBC2D2322BDD4DBA8563; + remoteInfo = SubstratalHub; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 0661FCCCD8C09C12FAB2768C /* SubstratalHub.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SubstratalHub.entitlements; sourceTree = ""; }; + 1D4168CF5C871612D8EDD95F /* APIKeysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIKeysView.swift; sourceTree = ""; }; + 21F922340DA3B64EFB347BC6 /* HubSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HubSection.swift; sourceTree = ""; }; + 25F705AC52E366D154941118 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + 261AC59792431F640B74AB50 /* Navigator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Navigator.swift; sourceTree = ""; }; + 269BF7A31135F9E08C1111CA /* MainShell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainShell.swift; sourceTree = ""; }; + 2E518929BE203EF72D562BAF /* SubstratalHubUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = SubstratalHubUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3502B5D9769C4FF868701CF4 /* AppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppEnvironment.swift; sourceTree = ""; }; + 4B94B5C88575DDF063D085CB /* SignInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInView.swift; sourceTree = ""; }; + 4C70AA0B7FC7F9B7BC4D2968 /* SubstratalHubApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubstratalHubApp.swift; sourceTree = ""; }; + 4F94155F13BA21FD62061D99 /* ManageHubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageHubView.swift; sourceTree = ""; }; + 5D02F06E1BBE55702E18F182 /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = ""; }; + 89D1CB17CABCAE854FC3AB7E /* InboxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboxView.swift; sourceTree = ""; }; + 94CDCF393463C90732E9A8F2 /* SmokeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SmokeTests.swift; sourceTree = ""; }; + A5B3E2E5958D5A780561E3FE /* SubstratalKit */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SubstratalKit; path = Packages/SubstratalKit; sourceTree = SOURCE_ROOT; }; + AA985CEB7E0395D20499E034 /* RolesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RolesView.swift; sourceTree = ""; }; + AC99EBE1A8FD7AD673FDC725 /* SubstratalHub.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = SubstratalHub.app; sourceTree = BUILT_PRODUCTS_DIR; }; + B2BCDBAFC359366C7E1B5A85 /* UsersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UsersView.swift; sourceTree = ""; }; + BA80CE4F9D1AE9E96D451BDB /* ScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenView.swift; sourceTree = ""; }; + D551A582DE38E105E8C22693 /* AuditLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuditLogView.swift; sourceTree = ""; }; + D969C9B30A2821D2685A18DA /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; + E10A2B1788BDDDE391330BBD /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + E2142B66ED90DF084E2A7601 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + EA9B6FD36443B3A9A64D4E66 /* ApplicationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationsView.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 5EEBC937AE4DC81C7B691AA9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 78ABE1E1F4C30CA7B3CE608C /* SubstratalCore in Frameworks */, + C8B91453FF0D5842748602EF /* SubstratalUI in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 22465C433CB2EFE8F0033520 /* Account */ = { + isa = PBXGroup; + children = ( + 5D02F06E1BBE55702E18F182 /* AccountView.swift */, + ); + path = Account; + sourceTree = ""; + }; + 2D4BD3A6A1C76CABB6B5E7AD /* Audit */ = { + isa = PBXGroup; + children = ( + D551A582DE38E105E8C22693 /* AuditLogView.swift */, + ); + path = Audit; + sourceTree = ""; + }; + 3CB44E5CA4C5EC7C5010436A /* Home */ = { + isa = PBXGroup; + children = ( + D969C9B30A2821D2685A18DA /* HomeView.swift */, + ); + path = Home; + sourceTree = ""; + }; + 42E6308ACE09516EA2268F1D /* Features */ = { + isa = PBXGroup; + children = ( + 22465C433CB2EFE8F0033520 /* Account */, + C0251CD2D08D7490CC205F5D /* Applications */, + 2D4BD3A6A1C76CABB6B5E7AD /* Audit */, + DE0303D15A5C5A456C7C8472 /* Auth */, + 3CB44E5CA4C5EC7C5010436A /* Home */, + 786B0DD2E7CA532D5C8C9570 /* Inbox */, + DEC97666807B5746AC5C60F0 /* Keys */, + FCD54E3ABDB458CB0FD843DC /* Roles */, + 7B9BDF6AD1A87A816754E963 /* Users */, + ); + path = Features; + sourceTree = ""; + }; + 4B89118DB19DDA72632140DB /* Products */ = { + isa = PBXGroup; + children = ( + AC99EBE1A8FD7AD673FDC725 /* SubstratalHub.app */, + 2E518929BE203EF72D562BAF /* SubstratalHubUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 4D66643FA51B79F523EF1ECF /* UITests */ = { + isa = PBXGroup; + children = ( + 94CDCF393463C90732E9A8F2 /* SmokeTests.swift */, + ); + path = UITests; + sourceTree = ""; + }; + 4DAB903D92F8755549324A47 /* Packages */ = { + isa = PBXGroup; + children = ( + A5B3E2E5958D5A780561E3FE /* SubstratalKit */, + ); + path = Packages; + sourceTree = ""; + }; + 4E4EF65A5701673955DA8B09 /* Support */ = { + isa = PBXGroup; + children = ( + E2142B66ED90DF084E2A7601 /* Info.plist */, + 0661FCCCD8C09C12FAB2768C /* SubstratalHub.entitlements */, + ); + path = Support; + sourceTree = ""; + }; + 786B0DD2E7CA532D5C8C9570 /* Inbox */ = { + isa = PBXGroup; + children = ( + 89D1CB17CABCAE854FC3AB7E /* InboxView.swift */, + ); + path = Inbox; + sourceTree = ""; + }; + 7B9BDF6AD1A87A816754E963 /* Users */ = { + isa = PBXGroup; + children = ( + B2BCDBAFC359366C7E1B5A85 /* UsersView.swift */, + ); + path = Users; + sourceTree = ""; + }; + C015E0170B1BB6C7825EA6B7 = { + isa = PBXGroup; + children = ( + E739A1E3BBCA716D98EA7734 /* App */, + 4DAB903D92F8755549324A47 /* Packages */, + 4D66643FA51B79F523EF1ECF /* UITests */, + 4B89118DB19DDA72632140DB /* Products */, + ); + sourceTree = ""; + }; + C0251CD2D08D7490CC205F5D /* Applications */ = { + isa = PBXGroup; + children = ( + EA9B6FD36443B3A9A64D4E66 /* ApplicationsView.swift */, + ); + path = Applications; + sourceTree = ""; + }; + D5C225EF28C3C9EAA2137B34 /* Shell */ = { + isa = PBXGroup; + children = ( + 21F922340DA3B64EFB347BC6 /* HubSection.swift */, + 269BF7A31135F9E08C1111CA /* MainShell.swift */, + 4F94155F13BA21FD62061D99 /* ManageHubView.swift */, + 261AC59792431F640B74AB50 /* Navigator.swift */, + BA80CE4F9D1AE9E96D451BDB /* ScreenView.swift */, + ); + path = Shell; + sourceTree = ""; + }; + DE0303D15A5C5A456C7C8472 /* Auth */ = { + isa = PBXGroup; + children = ( + 4B94B5C88575DDF063D085CB /* SignInView.swift */, + ); + path = Auth; + sourceTree = ""; + }; + DEC97666807B5746AC5C60F0 /* Keys */ = { + isa = PBXGroup; + children = ( + 1D4168CF5C871612D8EDD95F /* APIKeysView.swift */, + ); + path = Keys; + sourceTree = ""; + }; + E739A1E3BBCA716D98EA7734 /* App */ = { + isa = PBXGroup; + children = ( + 42E6308ACE09516EA2268F1D /* Features */, + FDD8A9CC04AA0DA9C064EF70 /* Resources */, + D5C225EF28C3C9EAA2137B34 /* Shell */, + 4E4EF65A5701673955DA8B09 /* Support */, + 3502B5D9769C4FF868701CF4 /* AppEnvironment.swift */, + 25F705AC52E366D154941118 /* RootView.swift */, + 4C70AA0B7FC7F9B7BC4D2968 /* SubstratalHubApp.swift */, + ); + path = App; + sourceTree = ""; + }; + FCD54E3ABDB458CB0FD843DC /* Roles */ = { + isa = PBXGroup; + children = ( + AA985CEB7E0395D20499E034 /* RolesView.swift */, + ); + path = Roles; + sourceTree = ""; + }; + FDD8A9CC04AA0DA9C064EF70 /* Resources */ = { + isa = PBXGroup; + children = ( + E10A2B1788BDDDE391330BBD /* Assets.xcassets */, + ); + path = Resources; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 0AE99E1480B79A1EE9B3F8A0 /* SubstratalHubUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5F6B6985E6B157A2C33C8DD8 /* Build configuration list for PBXNativeTarget "SubstratalHubUITests" */; + buildPhases = ( + F90CB74E491EA8CEE29A5EC2 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 2B8F77E3D7A835FE5484D0B0 /* PBXTargetDependency */, + ); + name = SubstratalHubUITests; + packageProductDependencies = ( + ); + productName = SubstratalHubUITests; + productReference = 2E518929BE203EF72D562BAF /* SubstratalHubUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; + F83DCBC2D2322BDD4DBA8563 /* SubstratalHub */ = { + isa = PBXNativeTarget; + buildConfigurationList = B2831D7B240BFBEEC37EA304 /* Build configuration list for PBXNativeTarget "SubstratalHub" */; + buildPhases = ( + EC20FC60D845491A9976D104 /* Sources */, + 462CCDDC4B2AACFE2A80FB84 /* Resources */, + 5EEBC937AE4DC81C7B691AA9 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = SubstratalHub; + packageProductDependencies = ( + 7B3F472506115D22A2489FDE /* SubstratalCore */, + BF9C5C4BEC78465574DC90D6 /* SubstratalUI */, + ); + productName = SubstratalHub; + productReference = AC99EBE1A8FD7AD673FDC725 /* SubstratalHub.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 64825BC73D4AE9A1C76EF2DA /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 2700; + TargetAttributes = { + 0AE99E1480B79A1EE9B3F8A0 = { + DevelopmentTeam = BJA9558E4B; + ProvisioningStyle = Automatic; + TestTargetID = F83DCBC2D2322BDD4DBA8563; + }; + F83DCBC2D2322BDD4DBA8563 = { + DevelopmentTeam = BJA9558E4B; + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 1851440F4B3DFBB19075B1FD /* Build configuration list for PBXProject "SubstratalHub" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = C015E0170B1BB6C7825EA6B7; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 48BCBE3645EF4B308E772325 /* XCLocalSwiftPackageReference "Packages/SubstratalKit" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 4B89118DB19DDA72632140DB /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + F83DCBC2D2322BDD4DBA8563 /* SubstratalHub */, + 0AE99E1480B79A1EE9B3F8A0 /* SubstratalHubUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 462CCDDC4B2AACFE2A80FB84 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + F59EABA762C2A88F8202A277 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + EC20FC60D845491A9976D104 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 200EA652A8075959AAD15914 /* APIKeysView.swift in Sources */, + D7B662CE2F44A8396918ADFB /* AccountView.swift in Sources */, + 109F1B316B5DD035AB6D90D0 /* AppEnvironment.swift in Sources */, + A82E15BA69D0E47322BF2003 /* ApplicationsView.swift in Sources */, + 5EC391CB7836931649877184 /* AuditLogView.swift in Sources */, + 60D824064BA2B077E1B35785 /* HomeView.swift in Sources */, + 5A207B3888279455E91575E4 /* HubSection.swift in Sources */, + CB721D9181090F11CC272B50 /* InboxView.swift in Sources */, + 83D65CA495254F088B3C0614 /* MainShell.swift in Sources */, + B3A107B8F90B7E4A71736839 /* ManageHubView.swift in Sources */, + E531FFBB5831EC2FAC0F9239 /* Navigator.swift in Sources */, + A5BDA889699A73335C1CD7C5 /* RolesView.swift in Sources */, + 6C70E92D670B29054F41FE37 /* RootView.swift in Sources */, + CE631AFA0293D95DF62AFC3F /* ScreenView.swift in Sources */, + 5DFABFDAACE507CB11F1A524 /* SignInView.swift in Sources */, + 503AE2D53521F2D6006F4D44 /* SubstratalHubApp.swift in Sources */, + 5F46C4F269DF9EC5839C5F65 /* UsersView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + F90CB74E491EA8CEE29A5EC2 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 678CDC9870EE54C7D87D6C36 /* SmokeTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 2B8F77E3D7A835FE5484D0B0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F83DCBC2D2322BDD4DBA8563 /* SubstratalHub */; + targetProxy = A0C0951A0B440EE142329C98 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 1CEF4939949C9EA1282F1DDA /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.substratal.hub.uitests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = SubstratalHub; + }; + name = Debug; + }; + 3358A4EF80A064BC87AAA97F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = App/Support/SubstratalHub.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + INFOPLIST_FILE = App/Support/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.substratal.hub; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 63E5927F15AA02A05714EA1D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = BJA9558E4B; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 0.1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + 67269708EF626EBE37AC051E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = App/Support/SubstratalHub.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + INFOPLIST_FILE = App/Support/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.substratal.hub; + SDKROOT = iphoneos; + SUPPORTS_MACCATALYST = NO; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 81F9CDC485A51A9087982067 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.substratal.hub.uitests; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = SubstratalHub; + }; + name = Release; + }; + D4E1BB3082AE5DD6C3C1A4B8 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + DEVELOPMENT_TEAM = BJA9558E4B; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MARKETING_VERSION = 0.1.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1851440F4B3DFBB19075B1FD /* Build configuration list for PBXProject "SubstratalHub" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 63E5927F15AA02A05714EA1D /* Debug */, + D4E1BB3082AE5DD6C3C1A4B8 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 5F6B6985E6B157A2C33C8DD8 /* Build configuration list for PBXNativeTarget "SubstratalHubUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1CEF4939949C9EA1282F1DDA /* Debug */, + 81F9CDC485A51A9087982067 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + B2831D7B240BFBEEC37EA304 /* Build configuration list for PBXNativeTarget "SubstratalHub" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 67269708EF626EBE37AC051E /* Debug */, + 3358A4EF80A064BC87AAA97F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 48BCBE3645EF4B308E772325 /* XCLocalSwiftPackageReference "Packages/SubstratalKit" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Packages/SubstratalKit; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 7B3F472506115D22A2489FDE /* SubstratalCore */ = { + isa = XCSwiftPackageProductDependency; + productName = SubstratalCore; + }; + BF9C5C4BEC78465574DC90D6 /* SubstratalUI */ = { + isa = XCSwiftPackageProductDependency; + productName = SubstratalUI; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 64825BC73D4AE9A1C76EF2DA /* Project object */; +} diff --git a/SubstratalHub.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/SubstratalHub.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/SubstratalHub.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/SubstratalHub.xcodeproj/xcshareddata/xcschemes/SubstratalHub.xcscheme b/SubstratalHub.xcodeproj/xcshareddata/xcschemes/SubstratalHub.xcscheme new file mode 100644 index 0000000..9331582 --- /dev/null +++ b/SubstratalHub.xcodeproj/xcshareddata/xcschemes/SubstratalHub.xcscheme @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/UITests/SmokeTests.swift b/UITests/SmokeTests.swift new file mode 100644 index 0000000..f71bb81 --- /dev/null +++ b/UITests/SmokeTests.swift @@ -0,0 +1,30 @@ +import XCTest + +/// Runs against demo data (`-uiTesting`), so it needs no network or account. +@MainActor +final class SmokeTests: XCTestCase { + func testSignInWithTwoFactorReachesHome() { + let app = XCUIApplication() + app.launchArguments = ["-uiTesting"] + app.launch() + + let email = app.textFields["Email"] + XCTAssertTrue(email.waitForExistence(timeout: 5)) + email.tap() + email.typeText("adron@substratalapps.com") + app.secureTextFields["Password"].tap() + app.secureTextFields["Password"].typeText("demo-password") + app.buttons["Sign in"].tap() + + let code = app.textFields["6-digit code"] + // The Passwords "Save Password?" sheet (from the username/password content types) covers the form. + let notNow = app.buttons["Not Now"] + if notNow.waitForExistence(timeout: 3) { notNow.tap() } + code.tap() + code.typeText("123456") + app.buttons["Verify"].tap() + + // iOS 26's floating tab bar exposes tabs as buttons rather than under `tabBars`. + XCTAssertTrue(app.buttons["Manage"].firstMatch.waitForExistence(timeout: 10)) + } +} diff --git a/api-contract/CONTRACT.json b/api-contract/CONTRACT.json new file mode 100644 index 0000000..b55cbd7 --- /dev/null +++ b/api-contract/CONTRACT.json @@ -0,0 +1,10 @@ +{ + "source": "https://compositecode.github.io/substratalapps.com/openapi.yaml", + "sha256": "ae796abe3696c69b907e393ea0e8c4ec54b4b0acf05f4fa7fb24ddd96a52a1da", + "info_version": "1.0.0-draft.9", + "substratal_versions": [ + "1" + ], + "operations": 124, + "synced_at": "2026-10-23T15:49:50Z" +} diff --git a/api-contract/openapi.yaml b/api-contract/openapi.yaml new file mode 100644 index 0000000..f36006d --- /dev/null +++ b/api-contract/openapi.yaml @@ -0,0 +1,4446 @@ +openapi: 3.1.0 +info: + title: Substratal Apps — Platform API + version: "1.0.0-draft.9" + summary: >- + User accounts, auth, roles, per-app entitlements (the on/off access + switch), profiles, settings, organizations, tenancy, subscription billing, + and push notifications for applications built on Substratal Apps. Target contract for an API + that is not yet implemented; the prose specification is authoritative. + description: >- + Full documentation: https://compositecode.github.io/substratalapps.com/ — read + /api-reference/conventions/ first (IDs, pagination, PATCH semantics, + ETag/If-Match, idempotency, error shape). + + Every request must send the API version in a `Substratal-Version` + header (currently `1`, the only version); every response echoes the + version that served it. A missing header is `400 api_version_required`, + and an unknown version is `400 unsupported_api_version`, both with + `details.supported`. Exempt: `GET /health`, `POST /auth/oauth/token` + (called by standard OAuth libraries), the `/.well-known` endpoints, and + SCIM under `/scim/v2` (called by identity providers). + Every versioned operation declares it as the shared `ApiVersion` header + parameter, so generated clients know to send it. See + /api-reference/conventions/#versioning. + + Vendor extensions used here: + `x-substratal-destructive` (`always` | `conditional`) marks operations + annotated destructiveHint for MCP and rejected for API Keys with + restrict_destructive=true. For `conditional`, only requests matching + `x-substratal-destructive-when` are rejected; the rest of the + operation stays callable. + Application and Role ids are derived (app_, role__), + not random; every other id is a prefix plus a ULID. `x-substratal-phase` gives the roadmap phase an operation + ships in. + license: + name: Unlicensed draft — internal specification, not yet released +externalDocs: + description: Substratal Apps Platform API documentation + url: https://compositecode.github.io/substratalapps.com/ +servers: + - url: https://api.substratalapps.com + description: Production + +security: + - bearerAuth: [] + +tags: + - name: Auth + - name: Users + - name: Profiles + - name: Settings + - name: Applications + - name: Entitlements + - name: Roles & Permissions + - name: Organizations + - name: Tenancy + - name: Billing + - name: Audit + - name: Webhooks + - name: Notifications + description: >- + Push notifications (APNs, FCM, Web Push) and the notification inbox. + - name: API Keys + - name: Meta + +paths: + /health: + get: + operationId: health.check + tags: [Meta] + summary: Health check + security: [] + responses: + "200": + description: Service is responding + content: + application/json: + schema: + type: object + properties: + status: { type: string, enum: [ok] } + + # ───────────────────────────── Auth ───────────────────────────── + + /auth/signup: + post: + operationId: auth.signup + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Create an account with email and password + description: "An email held by an invited User is rejected with 409 email_taken (details.reason invitation_pending) and the invitation is re-sent; invitations complete only through invitations/accept." + security: [] + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: { type: string, format: email } + password: { type: string, format: password, minLength: 12, maxLength: 128 } + display_name: { type: string, minLength: 1, maxLength: 100 } + application_id: { type: string, description: "Informational: records which app the signup came from. Grants nothing." } + test_mode: { type: boolean, default: false, description: "Create a test-mode User." } + expires_at: { type: string, format: date-time, description: "Test mode only: when the account stops working. Default 24 hours from now; at least 15 minutes away, at most 30 days. 422 expires_at_out_of_range, or live_user when test_mode is false." } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Account created and signed in + content: + application/json: + schema: { $ref: "#/components/schemas/AuthSession" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /auth/login: + post: + operationId: auth.login + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Exchange email and password for a session (or an MFA challenge) + security: [] + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email, password] + properties: + email: { type: string, format: email } + password: { type: string, format: password } + test_mode: { type: boolean, default: false, description: "Sign in the test-mode account with this email." } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A session, or an MFA challenge when mfa_required is true + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/AuthSession" + - $ref: "#/components/schemas/MfaChallenge" + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /auth/mfa/verify: + post: + operationId: auth.verifyMfa + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Complete a login that returned an MFA challenge + security: [] + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [mfa_token] + properties: + mfa_token: { type: string } + code: { type: string, pattern: "^[0-9]{6}$" } + recovery_code: { type: string } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Session issued + content: + application/json: + schema: { $ref: "#/components/schemas/AuthSession" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /auth/token/refresh: + post: + operationId: auth.refreshToken + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Rotate a platform refresh token for a new access token + security: [] + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [refresh_token] + properties: + refresh_token: { type: string } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: New session tokens (the old refresh token is now invalid) + content: + application/json: + schema: { $ref: "#/components/schemas/AuthSession" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + + /auth/logout: + post: + operationId: auth.logout + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Revoke the current session, or every session + x-substratal-phase: mvp + x-substratal-destructive: conditional + x-substratal-destructive-when: "all_sessions is true" + requestBody: + content: + application/json: + schema: + type: object + properties: + all_sessions: { type: boolean, default: false } + responses: + "204": { description: Logged out } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + + /auth/app-tokens: + post: + operationId: auth.createAppToken + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Mint a per-app JWT from a platform session (embedded login) + description: "Requires a User token. Fails with 403 embedded_login_not_allowed for a self-registered (third-party) Application, which must use the hosted flow; 403 entitlement_required unless the user's resolved access to the Application is active; 409 application_not_available unless it's approved; and 403 tenant_suspended if its Tenant is suspended." + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [application_id] + properties: + application_id: { type: string } + organization_id: { type: string, description: "Attribute org_id to this Organization's grant." } + responses: + "200": + description: App token + content: + application/json: + schema: { $ref: "#/components/schemas/AppToken" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + + /auth/oauth/authorization-codes: + post: + operationId: auth.createAuthorizationCode + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Mint a one-time authorization code for a hosted launch (PKCE) + description: "Called by Substratal's hosted sign-in page on the signed-in user's behalf." + x-substratal-phase: phase3 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [application_id, redirect_uri, code_challenge, code_challenge_method] + properties: + application_id: { type: string } + redirect_uri: { type: string } + code_challenge: { type: string, minLength: 43, maxLength: 128 } + code_challenge_method: { type: string, enum: [S256] } + state: { type: string, maxLength: 512 } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Authorization code + content: + application/json: + schema: + type: object + required: [code, expires_in, redirect_to] + properties: + code: { type: string } + expires_in: { type: integer, example: 60 } + redirect_to: { type: string } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /auth/oauth/token: + post: + operationId: auth.exchangeOAuthToken + tags: [Auth] + summary: Exchange an authorization code or app refresh token for an app token + description: "Public-client OAuth 2.0 token endpoint. Errors use the OAuth error shape ({error, error_description}), not the usual envelope." + security: [] + x-substratal-phase: phase3 + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/OAuthTokenRequest" } + application/x-www-form-urlencoded: + schema: { $ref: "#/components/schemas/OAuthTokenRequest" } + responses: + "200": + description: App token and rotated app refresh token + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/AppToken" + - type: object + properties: + refresh_token: { type: string } + refresh_token_expires_in: { type: integer } + "400": + description: OAuth error + content: + application/json: + schema: { $ref: "#/components/schemas/OAuthError" } + + /auth/email/verify: + post: + operationId: auth.verifyEmail + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Confirm an email address from an emailed token + security: [] + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token] + properties: + token: { type: string } + responses: + "204": { description: Verified } + "400": { $ref: "#/components/responses/BadRequest" } + + /auth/email/verify/resend: + post: + operationId: auth.resendEmailVerification + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Re-send a verification email (always 202) + security: [] + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string, format: email } + test_mode: { type: boolean, default: false } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "202": { description: Accepted } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /auth/password/forgot: + post: + operationId: auth.forgotPassword + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Send a password-reset email (always 202) + security: [] + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string, format: email } + test_mode: { type: boolean, default: false } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "202": { description: Accepted } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /auth/password/reset: + post: + operationId: auth.resetPassword + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Set a new password from a reset token (revokes every session) + security: [] + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token, new_password] + properties: + token: { type: string } + new_password: { type: string, format: password, minLength: 12, maxLength: 128 } + responses: + "204": { description: Password reset } + "400": { $ref: "#/components/responses/BadRequest" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /auth/invitations/accept: + post: + operationId: auth.acceptInvitation + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Set a password on an invited account, activate it, and sign in + security: [] + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [token, password] + properties: + token: { type: string } + password: { type: string, format: password, minLength: 12, maxLength: 128 } + display_name: { type: string, maxLength: 100 } + responses: + "200": + description: Session issued + content: + application/json: + schema: { $ref: "#/components/schemas/AuthSession" } + "400": { $ref: "#/components/responses/BadRequest" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /auth/sso/{provider}/callback: + post: + operationId: auth.ssoCallback + tags: [Auth] + summary: Complete an SSO login (reserved — 501 until the broker integration ships) + security: [] + x-substratal-phase: phase3 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - name: provider + in: path + required: true + schema: { type: string } + requestBody: + content: + application/json: + schema: { type: object } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Session issued + content: + application/json: + schema: { $ref: "#/components/schemas/AuthSession" } + "501": + description: not_implemented + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /users/{id}/password: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + post: + operationId: auth.changePassword + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Change password given the current one (self only) + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [current_password, new_password] + properties: + current_password: { type: string, format: password } + new_password: { type: string, format: password, minLength: 12, maxLength: 128 } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Changed; other sessions revoked } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /users/{id}/mfa/totp: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + post: + operationId: auth.enrollMfaTotp + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Begin TOTP enrollment (self only) + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Pending TOTP secret + content: + application/json: + schema: + type: object + properties: + secret: { type: string } + otpauth_uri: { type: string } + expires_in: { type: integer } + "409": { $ref: "#/components/responses/Conflict" } + delete: + operationId: auth.resetMfaTotp + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Support reset of a user's TOTP (requires users.manage) + x-substratal-phase: mvp + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: MFA removed; sessions revoked } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /users/{id}/mfa/totp/confirm: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + post: + operationId: auth.confirmMfaTotp + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Confirm TOTP enrollment with a first code; returns recovery codes once + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [code] + properties: + code: { type: string, pattern: "^[0-9]{6}$" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Enabled + content: + application/json: + schema: + type: object + properties: + mfa_enabled: { type: boolean } + recovery_codes: { type: array, items: { type: string }, minItems: 10, maxItems: 10 } + "401": { $ref: "#/components/responses/Unauthorized" } + "409": { $ref: "#/components/responses/Conflict" } + + /users/{id}/mfa/totp/disable: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + post: + operationId: auth.disableMfaTotp + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Turn TOTP off given a current code or recovery code (self only) + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: + type: object + description: "Exactly one of code or recovery_code." + properties: + code: { type: string, pattern: "^[0-9]{6}$", description: "A current TOTP code" } + recovery_code: { type: string } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Disabled } + "401": { $ref: "#/components/responses/Unauthorized" } + "409": { $ref: "#/components/responses/Conflict" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /users/{id}/sessions: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: auth.listSessions + tags: [Auth] + summary: List active sessions (self or users.manage) + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of sessions + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/Session" } } + + /users/{id}/sessions/revoke-all: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + post: + operationId: auth.revokeAllSessions + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Revoke every session (self or users.manage) + description: >- + Signs the User out everywhere: each live session is revoked with revoked_reason + revoked_all, which ends its refresh tokens and app refresh tokens and deactivates + the push devices it registered. keep_current keeps the calling session, and only + applies to your own sessions. Writes user.sessions_revoked. + x-substratal-phase: mvp + x-substratal-destructive: always + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + keep_current: { type: boolean, default: false, description: "Self only; 422 self_only otherwise." } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Revoked + content: + application/json: + schema: + type: object + required: [revoked] + properties: + revoked: { type: integer, description: "How many sessions were revoked." } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /users/{id}/sessions/{sessionId}: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + - name: sessionId + in: path + required: true + schema: { type: string } + delete: + operationId: auth.revokeSession + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Auth] + summary: Revoke one session + x-substratal-phase: mvp + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Revoked } + "404": { $ref: "#/components/responses/NotFound" } + + # ───────────────────────────── Users ───────────────────────────── + + /users: + get: + operationId: users.list + tags: [Users] + summary: List or search users + description: >- + Requires users.list. An app-scoped key with users.list is forced to application_id=, + and any role_id it sends must be one of its own Application's Roles (403 role_scope_mismatch). + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: status, in: query, description: "deleted is allowed for users.manage only", schema: { type: string, enum: [active, invited, suspended, deleted] } } + - { name: email, in: query, schema: { type: string, format: email } } + - { name: q, in: query, schema: { type: string, minLength: 3 } } + - { name: application_id, in: query, schema: { type: string } } + - { name: organization_id, in: query, schema: { type: string } } + - name: role_id + in: query + description: >- + Users holding any of these Roles by explicit assignment. Repeatable. Implicit holders + of an Application's default_app_role aren't listed. + style: form + explode: true + schema: { type: array, items: { type: string }, maxItems: 20 } + - { name: created_after, in: query, schema: { type: string, format: date-time } } + - { name: created_before, in: query, schema: { type: string, format: date-time } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of users + content: + application/json: + schema: { $ref: "#/components/schemas/UserPage" } + "403": { $ref: "#/components/responses/Forbidden" } + post: + operationId: users.create + tags: [Users] + summary: Create (invite) a user + description: "Requires users.manage. Also creates a default Profile and Settings and assigns the member platform role, in one transaction." + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKeyOptional" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [email] + properties: + email: { type: string, format: email } + display_name: { type: string, maxLength: 100 } + status: { type: string, enum: [invited, active], default: invited } + send_invitation: { type: boolean, default: true } + expires_at: { type: string, format: date-time, description: "Test-mode keys only: when the User stops working. Default 24 hours from now; at least 15 minutes away, at most 30 days. 422 expires_at_out_of_range, or live_user with a live key." } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: User created + content: + application/json: + schema: { $ref: "#/components/schemas/User" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /users/{id}: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: users.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Users] + summary: Fetch one user + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The user + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/User" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: users.update + tags: [Users] + summary: Update email (self, pending verification) or status (users.manage) + x-substratal-phase: mvp + x-substratal-destructive: conditional + x-substratal-destructive-when: "status becomes suspended" + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + content: + application/json: + schema: + type: object + properties: + email: { type: string, format: email } + status: { type: string, enum: [active, suspended], description: "users.manage only. active on a deleted User restores it." } + expires_at: { type: string, format: date-time, description: "users.manage only, test-mode Users only. At least 15 minutes from now and at most 30 days after created_at. Moving it later on an expired User brings them back and fires access.granted (test_user_extended)." } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated user + content: + application/json: + schema: { $ref: "#/components/schemas/User" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + delete: + operationId: users.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Users] + summary: Soft-delete a user (self or users.manage) + x-substratal-phase: mvp + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /users/{id}/suspend: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + post: + operationId: users.suspend + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Users] + summary: Suspend a user (users.manage) + x-substratal-phase: mvp + x-substratal-destructive: always + requestBody: + content: + application/json: + schema: + type: object + properties: + reason: { type: string, maxLength: 2000 } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: User suspended + content: + application/json: + schema: { $ref: "#/components/schemas/User" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + + /users/{id}/invitation: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + post: + operationId: users.resendInvitation + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Users] + summary: Re-send the invitation email to an invited user (users.manage) + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "202": { description: Sent } + "409": { $ref: "#/components/responses/Conflict" } + + /users/{id}/export: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: users.export + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Users] + summary: Export everything this API holds about this user + description: "Self, or users.manage. GDPR Article 20 / CCPA right-to-know. Covers every table the hard-delete cascade clears, plus global Settings, which the cascade leaves in place." + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Full data export bundle + content: + application/json: + schema: { $ref: "#/components/schemas/UserExport" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /users/{id}/erasure-requests: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + post: + operationId: users.requestErasure + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Users] + summary: Request right-to-erasure (soft-delete now, hard-delete cascade in 7 days) + x-substratal-phase: mvp + x-substratal-destructive: always + requestBody: + content: + application/json: + schema: + type: object + properties: + reason: { type: string, maxLength: 2000 } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "202": + description: Scheduled + content: + application/json: + schema: { $ref: "#/components/schemas/ErasureRequest" } + + /users/{id}/erasure-requests/current: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + delete: + operationId: users.cancelErasure + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Users] + summary: Cancel a scheduled erasure inside its 7-day window (users.manage) + x-substratal-phase: mvp + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Cancelled } + "404": { $ref: "#/components/responses/NotFound" } + + # ─────────────────────────── Profiles & Settings ─────────────────────────── + + /users/{id}/profile: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: profiles.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Profiles] + summary: Fetch global Profile + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The global Profile + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/Profile" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: profiles.update + tags: [Profiles] + summary: Update global Profile (self or users.manage) + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + content: + application/json: + schema: { $ref: "#/components/schemas/ProfileWrite" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated Profile + content: + application/json: + schema: { $ref: "#/components/schemas/Profile" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /users/{id}/settings: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: settings.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Settings] + summary: Fetch global Settings (self, or platform users.list) + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The global Settings + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/Settings" } + patch: + operationId: settings.update + tags: [Settings] + summary: Update global Settings (self or users.manage) + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + content: + application/json: + schema: { $ref: "#/components/schemas/SettingsWrite" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated Settings + content: + application/json: + schema: { $ref: "#/components/schemas/Settings" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /users/{id}/apps/{appId}/profile: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + - $ref: "#/components/parameters/AppId" + get: + operationId: appProfiles.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Profiles] + summary: Fetch per-app AppProfile (self, the app's own key, or platform users.list; defaults if none written yet) + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The AppProfile + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/AppProfile" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: appProfiles.update + tags: [Profiles] + summary: Update per-app AppProfile (custom is merged one level; null removes a key) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + content: + application/json: + schema: + type: object + properties: + display_handle: { type: [string, "null"], minLength: 1, maxLength: 64 } + custom: { type: object } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated AppProfile + content: + application/json: + schema: { $ref: "#/components/schemas/AppProfile" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /users/{id}/apps/{appId}/settings: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + - $ref: "#/components/parameters/AppId" + get: + operationId: appSettings.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Settings] + summary: Fetch fully resolved per-app settings (self, the app's own key, or platform users.list) + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Resolved AppSettings + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/AppSettingsResolved" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: appSettings.update + tags: [Settings] + summary: Write per-app overrides (validated against settings_schema; null clears) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + content: + application/json: + schema: + type: object + required: [overrides] + properties: + overrides: { type: object } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Resolved AppSettings after the write + content: + application/json: + schema: { $ref: "#/components/schemas/AppSettingsResolved" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + # ───────────────────────────── Applications ───────────────────────────── + + /applications: + get: + operationId: applications.list + tags: [Applications] + summary: List the catalog (filtered by visibility rules) + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: visibility, in: query, schema: { $ref: "#/components/schemas/ApplicationVisibility" } } + - { name: review_status, in: query, schema: { $ref: "#/components/schemas/ApplicationReviewStatus" } } + - { name: access_tier, in: query, schema: { $ref: "#/components/schemas/ApplicationAccessTier" } } + - { name: owned, in: query, schema: { type: boolean } } + - { name: tenant_id, in: query, schema: { type: string } } + - { name: owner_user_id, in: query, schema: { type: string } } + - { name: owner_organization_id, in: query, schema: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Applications (trimmed view) + content: + application/json: + schema: { $ref: "#/components/schemas/ApplicationSummaryPage" } + post: + operationId: applications.create + tags: [Applications] + summary: Register an Application (applications.manage, or self-service into review) + description: >- + applications.manage creates any Application, approved by default (concierge onboarding). + Any other User with a verified email may submit their own: it's owned by the caller (or an + Organization they're an org_admin of), starts in pending_review, and can't set visibility, + access_tier, review_status, or review_notes (403 moderation_field_forbidden). Unverified + email is 403 email_not_verified; API Keys without applications.manage are 403 forbidden. + Self-service submissions are limited to 10 per User per day. + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKeyOptional" + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ApplicationWrite" + - type: object + required: [slug, name, launch_url] + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Application created + content: + application/json: + schema: { $ref: "#/components/schemas/Application" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /applications/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: applications.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Applications] + summary: Fetch one Application + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The Application + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/Application" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: applications.update + tags: [Applications] + summary: Update a catalog entry (owner — configuration; applications.manage — anything) + x-substratal-phase: mvp + x-substratal-destructive: conditional + x-substratal-destructive-when: "review_status becomes rejected or suspended, or available_app_roles/permissions shrink" + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + content: + application/json: + schema: { $ref: "#/components/schemas/ApplicationWrite" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated Application + content: + application/json: + schema: { $ref: "#/components/schemas/Application" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + # ───────────────────────────── Entitlements ───────────────────────────── + + /users/{id}/entitlements: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: entitlements.listForUser + tags: [Entitlements] + summary: A user's entitlements, personal and org-sourced + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: application_id, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: "#/components/schemas/EntitlementStatus" } } + - { name: source, in: query, schema: { $ref: "#/components/schemas/EntitlementSource" } } + - { name: include_inactive, in: query, schema: { type: boolean, default: false } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Entitlements + content: + application/json: + schema: { $ref: "#/components/schemas/EntitlementPage" } + post: + operationId: entitlements.grant + tags: [Entitlements] + summary: Grant a personal Entitlement + description: "Requires entitlements.manage (platform, or app-confined to the key's own Application), or the Application's owner." + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [application_id, source] + properties: + application_id: { type: string } + source: { type: string, enum: [purchase, trial, admin_grant] } + order_id: { type: [string, "null"], maxLength: 255 } + starts_at: { type: string, format: date-time } + ends_at: { type: [string, "null"], format: date-time } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Entitlement created + content: + application/json: + schema: { $ref: "#/components/schemas/Entitlement" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /entitlements: + get: + operationId: entitlements.list + tags: [Entitlements] + summary: List entitlements across users (entitlements.manage or the Application's owner; app keys and owners confined) + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: application_id, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: "#/components/schemas/EntitlementStatus" } } + - { name: user_id, in: query, schema: { type: string } } + - { name: organization_id, in: query, schema: { type: string } } + - { name: source, in: query, schema: { $ref: "#/components/schemas/EntitlementSource" } } + - { name: order_id, in: query, schema: { type: string } } + - { name: ends_before, in: query, schema: { type: string, format: date-time } } + - { name: include_inactive, in: query, schema: { type: boolean, default: false } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Entitlements + content: + application/json: + schema: { $ref: "#/components/schemas/EntitlementPage" } + "403": { $ref: "#/components/responses/Forbidden" } + + /entitlements/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: entitlements.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Entitlements] + summary: Fetch one entitlement + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The Entitlement + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/Entitlement" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: entitlements.update + tags: [Entitlements] + summary: "The toggle: change status, term, source (trial→purchase), order_id, or an org grant's member scope" + x-substratal-phase: mvp + x-substratal-destructive: conditional + x-substratal-destructive-when: "status becomes disabled or revoked" + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + status: { $ref: "#/components/schemas/EntitlementStatus" } + disabled_reason: { type: [string, "null"], maxLength: 255 } + ends_at: { type: [string, "null"], format: date-time } + source: { type: string, enum: [purchase] } + order_id: { type: string, maxLength: 255 } + member_scope: { $ref: "#/components/schemas/EntitlementMemberScope" } + member_overrides: { type: array, maxItems: 1000, items: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated Entitlement + content: + application/json: + schema: { $ref: "#/components/schemas/Entitlement" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + delete: + operationId: entitlements.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Entitlements] + summary: Hard-remove a grant made in error (no order_id, under 24 hours old) + x-substratal-phase: mvp + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Removed } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + + # ─────────────────────────── Roles & Permissions ─────────────────────────── + + /permissions: + get: + operationId: permissions.list + tags: [Roles & Permissions] + summary: List every Permission key the caller can see + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: scope, in: query, description: "'platform' or an application_id", schema: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Permissions + content: + application/json: + schema: { $ref: "#/components/schemas/PermissionPage" } + + /roles: + get: + operationId: roles.list + tags: [Roles & Permissions] + summary: List Roles + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: scope, in: query, description: "'platform' or an application_id", schema: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Roles + content: + application/json: + schema: { $ref: "#/components/schemas/RolePage" } + post: + operationId: roles.create + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Roles & Permissions] + summary: Define a Role + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/RoleCreate" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Role created + content: + application/json: + schema: { $ref: "#/components/schemas/Role" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /roles/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: roles.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Roles & Permissions] + summary: Fetch one Role + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The Role + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/Role" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: roles.update + tags: [Roles & Permissions] + summary: Change a Role's permissions or description + x-substratal-phase: mvp + x-substratal-destructive: conditional + x-substratal-destructive-when: "permissions shrink" + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + description: { type: [string, "null"], maxLength: 2000 } + permissions: { type: array, items: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated Role + content: + application/json: + schema: { $ref: "#/components/schemas/Role" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + delete: + operationId: roles.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Roles & Permissions] + summary: Delete an unassigned, non-seed Role + x-substratal-phase: mvp + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Deleted } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + + /users/{id}/roles: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: roles.listForUser + tags: [Roles & Permissions] + summary: List a user's Role assignments (including implicit default_app_role) + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: application_id, in: query, schema: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of UserRoleAssignments + content: + application/json: + schema: { $ref: "#/components/schemas/UserRoleAssignmentPage" } + + /users/{id}/roles/{roleId}: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + - { name: roleId, in: path, required: true, schema: { type: string } } + post: + operationId: roles.assign + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Roles & Permissions] + summary: Assign a Role (idempotent — 200 if already held) + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Already assigned + content: + application/json: + schema: { $ref: "#/components/schemas/UserRoleAssignment" } + "201": + description: Role assigned + content: + application/json: + schema: { $ref: "#/components/schemas/UserRoleAssignment" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + operationId: roles.remove + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Roles & Permissions] + summary: Remove a Role assignment (idempotent) + x-substratal-phase: mvp + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Removed } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + + /users/{id}/apps/{appId}/effective-permissions: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + - $ref: "#/components/parameters/AppId" + get: + operationId: permissions.getEffective + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Roles & Permissions] + summary: The live access check (always 200) + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Effective permissions + content: + application/json: + schema: { $ref: "#/components/schemas/EffectivePermissions" } + "404": { $ref: "#/components/responses/NotFound" } + + # ───────────────────────────── Organizations ───────────────────────────── + + /organizations: + get: + operationId: organizations.list + tags: [Organizations] + summary: List your Organizations (all, with organizations.manage) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: status, in: query, schema: { type: string, enum: [active, suspended] } } + - { name: q, in: query, schema: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Organizations + content: + application/json: + schema: { $ref: "#/components/schemas/OrganizationPage" } + post: + operationId: organizations.create + tags: [Organizations] + summary: Create an Organization (any active User; caller becomes org_admin) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKeyOptional" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: { type: string, minLength: 1, maxLength: 100 } + responses: + "201": + description: Organization created + content: + application/json: + schema: { $ref: "#/components/schemas/Organization" } + "400": { $ref: "#/components/responses/BadRequest" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /organizations/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: organizations.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Organizations] + summary: Fetch one Organization + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The Organization + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/Organization" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: organizations.update + tags: [Organizations] + summary: Rename (org admin), or suspend/reactivate (organizations.manage) + x-substratal-phase: phase2 + x-substratal-destructive: conditional + x-substratal-destructive-when: "status becomes suspended" + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + content: + application/json: + schema: + type: object + properties: + name: { type: string, minLength: 1, maxLength: 100 } + status: { type: string, enum: [active, suspended] } + reason: { type: string, maxLength: 2000 } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated Organization + content: + application/json: + schema: { $ref: "#/components/schemas/Organization" } + "403": { $ref: "#/components/responses/Forbidden" } + + /organizations/{id}/members: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: organizations.listMembers + tags: [Organizations] + summary: List members + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: role, in: query, schema: { $ref: "#/components/schemas/OrganizationMemberRole" } } + - { name: membership_status, in: query, schema: { $ref: "#/components/schemas/OrganizationMembershipStatus" } } + - { name: q, in: query, schema: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of members + content: + application/json: + schema: { $ref: "#/components/schemas/OrganizationMemberPage" } + post: + operationId: organizations.addMember + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Organizations] + summary: Invite a member by email (org admin), or add one directly by user_id (organizations.manage) + description: "By email, creates a pending membership (and an invited User if none exists) and emails an invitation; the response is identical whether or not the email already had an account. By user_id (organizations.manage only), the membership is active immediately. Never fails on a plan limit." + x-substratal-phase: phase2 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + user_id: { type: string } + email: { type: string, format: email } + role: { $ref: "#/components/schemas/OrganizationMemberRole" } + oneOf: + - required: [user_id] + - required: [email] + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Member invited (pending) or added (active) + content: + application/json: + schema: { $ref: "#/components/schemas/OrganizationMember" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + + /organizations/{id}/members/me/accept: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + post: + operationId: organizations.acceptMembership + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Organizations] + summary: Accept a pending membership (the invited User's own token) + x-substratal-phase: phase2 + responses: + "200": + description: The member, now active + content: + application/json: + schema: { $ref: "#/components/schemas/OrganizationMember" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /organizations/{id}/members/{userId}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + - { name: userId, in: path, required: true, description: "A usr_ id, or 'me' to leave", schema: { type: string } } + patch: + operationId: organizations.updateMember + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Organizations] + summary: Change a member's role + x-substratal-phase: phase2 + x-substratal-destructive: conditional + x-substratal-destructive-when: "demotes an org_admin" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [role] + properties: + role: { $ref: "#/components/schemas/OrganizationMemberRole" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated member + content: + application/json: + schema: { $ref: "#/components/schemas/OrganizationMember" } + "409": { $ref: "#/components/responses/Conflict" } + delete: + operationId: organizations.removeMember + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Organizations] + summary: Remove a member or withdraw an invitation; or leave or decline (userId = me) + x-substratal-phase: phase2 + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Removed } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + + /organizations/{id}/scim-token: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: organizations.getScimToken + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Organizations] + summary: Show the Organization's SCIM token (never its secret) + description: "org_admin or organizations.manage. 404 scim_token_not_found when SCIM isn't set up." + x-substratal-phase: phase3 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The token + content: + application/json: + schema: { $ref: "#/components/schemas/ScimToken" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + post: + operationId: organizations.createScimToken + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Organizations] + summary: Create (or rotate) the Organization's SCIM token + description: >- + org_admin or organizations.manage, with a User token. Returns the secret once, for the identity + provider's SCIM settings, with base_url. Replaces any existing token at once. Writes + organization.scim_token_created. + x-substratal-phase: phase3 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: The new token, with its secret + content: + application/json: + schema: { $ref: "#/components/schemas/ScimToken" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + delete: + operationId: organizations.deleteScimToken + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Organizations] + summary: Turn SCIM off for the Organization + description: "org_admin or organizations.manage. The token stops working at once. Writes organization.scim_token_revoked." + x-substratal-phase: phase3 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Revoked } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /organizations/{id}/entitlements: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: organizations.listEntitlements + tags: [Organizations] + summary: List org-wide (seat) grants + description: Same filters as GET /entitlements, scoped to this Organization. + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: application_id, in: query, schema: { type: string } } + - { name: status, in: query, schema: { $ref: "#/components/schemas/EntitlementStatus" } } + - { name: order_id, in: query, schema: { type: string } } + - { name: ends_before, in: query, schema: { type: string, format: date-time } } + - { name: include_inactive, in: query, schema: { type: boolean } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Entitlements + content: + application/json: + schema: { $ref: "#/components/schemas/EntitlementPage" } + post: + operationId: organizations.grantEntitlement + tags: [Organizations] + summary: Grant an app to the whole Organization + description: "Requires entitlements.manage (platform, or the Application's own confined key) or the Application's owner. Never the org's own admin." + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [application_id] + properties: + application_id: { type: string } + order_id: { type: [string, "null"], maxLength: 255 } + starts_at: { type: string, format: date-time } + ends_at: { type: [string, "null"], format: date-time } + member_scope: { $ref: "#/components/schemas/EntitlementMemberScope" } + member_overrides: { type: array, maxItems: 1000, items: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Org-wide Entitlement created + content: + application/json: + schema: { $ref: "#/components/schemas/Entitlement" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + # ───────────────────────────── Tenancy ───────────────────────────── + + /tenants: + get: + operationId: tenants.list + tags: [Tenancy] + summary: List Tenants (your own; all with tenants.manage) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: tier, in: query, schema: { $ref: "#/components/schemas/TenantTier" } } + - { name: status, in: query, schema: { $ref: "#/components/schemas/TenantStatus" } } + - { name: owner_user_id, in: query, schema: { type: string } } + - { name: owner_organization_id, in: query, schema: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Tenants + content: + application/json: + schema: { $ref: "#/components/schemas/TenantPage" } + + /tenants/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: tenants.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Tenancy] + summary: Fetch one Tenant + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A Tenant + content: + application/json: + schema: { $ref: "#/components/schemas/Tenant" } + "404": { $ref: "#/components/responses/NotFound" } + + /tenants/{id}/tier-change-requests: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: tenants.listTierChangeRequests + tags: [Tenancy] + summary: List tier-change requests for a Tenant + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of tier-change requests + content: + application/json: + schema: { $ref: "#/components/schemas/TenantTierChangeRequestPage" } + post: + operationId: tenants.requestTierChange + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Tenancy] + summary: Open a tier-change request on a customer's behalf (tenants.manage) + x-substratal-phase: phase2 + x-substratal-destructive: always + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [requested_tier, reason] + properties: + requested_tier: { type: string, enum: [isolated, dedicated_region] } + requested_region: { $ref: "#/components/schemas/Region" } + reason: { type: string, maxLength: 2000 } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Tier-change request created + content: + application/json: + schema: { $ref: "#/components/schemas/TenantTierChangeRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /tenants/{id}/tier-change-requests/{requestId}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + - { name: requestId, in: path, required: true, schema: { type: string } } + get: + operationId: tenants.getTierChangeRequest + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Tenancy] + summary: Fetch one tier-change request + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The request + content: + application/json: + schema: { $ref: "#/components/schemas/TenantTierChangeRequest" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: tenants.updateTierChangeRequest + tags: [Tenancy] + summary: Schedule, start, complete, or cancel a tier-change request (tenants.manage) + x-substratal-phase: phase2 + x-substratal-destructive: always + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [status] + properties: + status: { type: string, enum: [scheduled, in_progress, completed, cancelled] } + scheduled_for: { type: string, format: date-time } + notes: { type: string, maxLength: 2000 } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated request + content: + application/json: + schema: { $ref: "#/components/schemas/TenantTierChangeRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "409": { $ref: "#/components/responses/Conflict" } + + # ───────────────────────────── Billing ───────────────────────────── + + /users/{id}/subscription: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: billing.getUserSubscription + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Billing] + summary: A User's plan, what covers it, and their own subscription (self or billing.manage) + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The User's plan and individual subscription + content: + application/json: + schema: { $ref: "#/components/schemas/UserSubscription" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /users/me/billing/checkout-sessions: + post: + operationId: billing.createUserCheckoutSession + tags: [Billing] + summary: Start a Stripe Checkout to subscribe yourself to Standard (User token) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/CheckoutSessionCreate" } + responses: + "201": + description: Checkout URL + content: + application/json: + schema: { $ref: "#/components/schemas/RedirectSession" } + "400": { $ref: "#/components/responses/BadRequest" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + "503": { $ref: "#/components/responses/ServiceUnavailable" } + + /users/me/billing/portal-sessions: + post: + operationId: billing.createUserPortalSession + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Billing] + summary: Open the Stripe Customer Portal for your own subscription (User token) + x-substratal-phase: phase2 + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PortalSessionCreate" } + responses: + "201": + description: Portal URL + content: + application/json: + schema: { $ref: "#/components/schemas/RedirectSession" } + "400": { $ref: "#/components/responses/BadRequest" } + "409": { $ref: "#/components/responses/Conflict" } + "503": { $ref: "#/components/responses/ServiceUnavailable" } + + /organizations/{id}/subscription: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: billing.getOrganizationSubscription + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Billing] + summary: An Organization's plan and Standard subscription (org admin or billing.manage) + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The Organization's plan and subscription + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/OrganizationSubscription" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: billing.updateOrganizationSubscription + tags: [Billing] + summary: Choose which members the Organization's subscription covers (org admin) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + member_scope: { $ref: "#/components/schemas/EntitlementMemberScope" } + member_overrides: { type: [array, "null"], maxItems: 1000, items: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/OrganizationSubscription" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /organizations/{id}/billing/checkout-sessions: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + post: + operationId: billing.createOrganizationCheckoutSession + tags: [Billing] + summary: Start a Stripe Checkout to subscribe the Organization to Standard (org admin, User token) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/CheckoutSessionCreate" + - type: object + properties: + member_scope: { $ref: "#/components/schemas/EntitlementMemberScope" } + member_overrides: { type: array, maxItems: 1000, items: { type: string } } + responses: + "201": + description: Checkout URL + content: + application/json: + schema: { $ref: "#/components/schemas/RedirectSession" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + "503": { $ref: "#/components/responses/ServiceUnavailable" } + + /organizations/{id}/billing/portal-sessions: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + post: + operationId: billing.createOrganizationPortalSession + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Billing] + summary: Open the Stripe Customer Portal for the Organization's subscription (org admin or billing.manage, User token) + x-substratal-phase: phase2 + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PortalSessionCreate" } + responses: + "201": + description: Portal URL + content: + application/json: + schema: { $ref: "#/components/schemas/RedirectSession" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "503": { $ref: "#/components/responses/ServiceUnavailable" } + + # ───────────────────────────── Audit ───────────────────────────── + + /audit-events: + get: + operationId: audit.list + tags: [Audit] + summary: Query the audit log (hot window only) + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: target_user_id, in: query, schema: { type: string } } + - { name: target_type, in: query, schema: { $ref: "#/components/schemas/AuditTargetType" } } + - { name: target_id, in: query, schema: { type: string } } + - { name: actor_id, in: query, schema: { type: string } } + - { name: actor_type, in: query, schema: { type: string, enum: [user, api_key, system] } } + - name: action + in: query + style: form + explode: true + schema: { type: array, items: { $ref: "#/components/schemas/AuditAction" } } + - { name: application_id, in: query, schema: { type: string } } + - { name: organization_id, in: query, schema: { type: string } } + - { name: tenant_id, in: query, schema: { type: string } } + - { name: since, in: query, schema: { type: string, format: date-time } } + - { name: until, in: query, schema: { type: string, format: date-time } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Audit Events + headers: + Audit-Hot-Window-Start: + description: Earliest timestamp available to this caller's scope + schema: { type: string, format: date-time } + content: + application/json: + schema: { $ref: "#/components/schemas/AuditEventPage" } + "403": { $ref: "#/components/responses/Forbidden" } + + /audit-events/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: audit.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Audit] + summary: Fetch one Audit Event + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The event + content: + application/json: + schema: { $ref: "#/components/schemas/AuditEvent" } + "404": { $ref: "#/components/responses/NotFound" } + + /audit-exports: + post: + operationId: audit.createExport + tags: [Audit] + summary: Export Audit Events to a file (asynchronous) + description: >- + Exports every Audit Event matching the same filters as GET /audit-events, in the caller's + scope there, as NDJSON or CSV. Hot window only. Returns 202 at once; a job writes the file, + usually within a minute. Poll GET /audit-exports/{id} until status is ready, then download + from download_url. Exports are kept 7 days. Writes audit.exported when the file is ready. + x-substratal-phase: phase3 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/AuditExportRequest" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "202": + description: Export queued + content: + application/json: + schema: { $ref: "#/components/schemas/AuditExport" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /audit-exports/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: audit.getExport + tags: [Audit] + summary: Fetch an export's status, and its download link once ready + description: "Visible to whoever requested it, and to audit.view. download_url is freshly signed on every call and valid for 15 minutes." + x-substratal-phase: phase3 + parameters: + - $ref: "#/components/parameters/ApiVersion" + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The export + content: + application/json: + schema: { $ref: "#/components/schemas/AuditExport" } + "404": { $ref: "#/components/responses/NotFound" } + + # ───────────────────────────── Webhooks ───────────────────────────── + + /webhooks: + get: + operationId: webhooks.list + tags: [Webhooks] + summary: List subscriptions visible to the caller + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: application_id, in: query, schema: { type: string } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Webhook subscriptions + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookPage" } + post: + operationId: webhooks.create + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Webhooks] + summary: Subscribe a URL to event types + x-substratal-phase: phase2 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [url, events] + properties: + url: { type: string, format: uri } + events: + type: array + minItems: 1 + items: + oneOf: + - $ref: "#/components/schemas/WebhookEvent" + - { type: string, const: "*" } + description: { type: string, maxLength: 255 } + scope: { type: string, description: "An application_id, or 'platform' (webhooks.manage only). Required for an owner's User token or a webhooks.manage caller; optional for an app key, where it defaults to (and must equal) the key's own Application." } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Webhook created — includes the secret once + content: + application/json: + schema: { $ref: "#/components/schemas/WebhookWithSecret" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /webhooks/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: webhooks.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Webhooks] + summary: Fetch one subscription + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The subscription + content: + application/json: + schema: { $ref: "#/components/schemas/Webhook" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: webhooks.update + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Webhooks] + summary: Change url, events, description, api_version, or re-enable + x-substratal-phase: phase2 + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + url: { type: string, format: uri } + events: { type: array, items: { type: string } } + description: { type: string, maxLength: 255 } + api_version: { type: string } + status: { type: string, enum: [healthy] } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated subscription + content: + application/json: + schema: { $ref: "#/components/schemas/Webhook" } + "422": { $ref: "#/components/responses/Unprocessable" } + delete: + operationId: webhooks.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Webhooks] + summary: Unsubscribe + x-substratal-phase: phase2 + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Unsubscribed } + "404": { $ref: "#/components/responses/NotFound" } + + /webhooks/{id}/rotate-secret: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + post: + operationId: webhooks.rotateSecret + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Webhooks] + summary: Issue a new signing secret (24-hour overlap) + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: New secret + content: + application/json: + schema: + type: object + properties: + id: { type: string } + signing_secret: { type: string } + previous_secret_expires_at: { type: string, format: date-time } + + /webhooks/{id}/test: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + post: + operationId: webhooks.sendTest + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Webhooks] + summary: Send a webhook.test event now + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "202": + description: Enqueued + content: + application/json: + schema: + type: object + properties: + delivery_id: { type: string } + + /webhooks/{id}/deliveries: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: webhooks.listDeliveries + tags: [Webhooks] + summary: Recent delivery attempts (30-day log) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: event_id, in: query, schema: { type: string } } + - { name: status, in: query, schema: { type: string, enum: [pending, succeeded, failed] } } + - { name: since, in: query, schema: { type: string, format: date-time } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of deliveries + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/WebhookDelivery" } } + + /webhooks/{id}/deliveries/{deliveryId}/redeliver: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + - { name: deliveryId, in: path, required: true, schema: { type: string } } + post: + operationId: webhooks.redeliver + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Webhooks] + summary: Re-send one past event (same event id) + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "202": { description: Enqueued } + "404": { $ref: "#/components/responses/NotFound" } + + # ───────────────────────────── Notifications ───────────────────────────── + + /applications/{id}/push-credentials: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: pushCredentials.list + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Notifications] + summary: List the push providers an Application has configured (the owner, the app's own key, or applications.manage) + description: "Never returns key material. At most one credential per provider, so the list isn't paginated." + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Every configured provider + content: + application/json: + schema: { $ref: "#/components/schemas/PushCredentialList" } + "404": { $ref: "#/components/responses/NotFound" } + post: + operationId: pushCredentials.create + tags: [Notifications] + summary: Configure one push provider (the owner or applications.manage, User token only) + description: "private_key (apns) and service_account (fcm) are write-only and stored KMS-encrypted. For web_push the platform generates the VAPID key pair and returns its public half. An API Key gets 400 user_token_required. A second credential for the same provider is 409 push_credential_exists; material that doesn't parse is 422 invalid_push_credential." + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKeyOptional" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PushCredentialCreate" } + responses: + "201": + description: Credential configured (never echoes the secret) + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/PushCredential" } + "400": { $ref: "#/components/responses/BadRequest" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /applications/{id}/push-credentials/{provider}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + - name: provider + in: path + required: true + schema: { $ref: "#/components/schemas/PushProvider" } + get: + operationId: pushCredentials.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Notifications] + summary: Fetch one provider's configuration + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The credential + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/PushCredential" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: pushCredentials.update + tags: [Notifications] + summary: Replace key material, change non-secret fields, or re-enable a failed credential (User token only) + description: "Writable fields are the provider's own create fields, plus status (only failed → active). Any change to key material also returns a failed credential to active. The VAPID key pair can't be rotated in place: delete and recreate the web_push credential." + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/PushCredentialUpdate" } + responses: + "200": + description: The updated credential + content: + application/json: + schema: { $ref: "#/components/schemas/PushCredential" } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + delete: + operationId: pushCredentials.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Notifications] + summary: Remove a push provider (User token only) + description: "Pending deliveries to that provider are skipped. Removing web_push deactivates every web_push device of the Application (inactive_reason credential_removed), because browser subscriptions are bound to the VAPID key." + x-substratal-phase: phase2 + x-substratal-destructive: always + responses: + "204": { description: Removed } + "400": { $ref: "#/components/responses/BadRequest" } + "404": { $ref: "#/components/responses/NotFound" } + + /users/{id}/devices: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: devices.list + tags: [Notifications] + summary: List a User's push devices (self, the app's own key, or users.list) + description: "Only active devices unless status or include_inactive=true is passed. An app key sees only its own Application's devices." + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: application_id, in: query, schema: { type: string } } + - { name: provider, in: query, schema: { $ref: "#/components/schemas/PushProvider" } } + - { name: status, in: query, schema: { $ref: "#/components/schemas/DeviceStatus" } } + - { name: include_inactive, in: query, schema: { type: boolean, default: false } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Devices + content: + application/json: + schema: { $ref: "#/components/schemas/DevicePage" } + "404": { $ref: "#/components/responses/NotFound" } + post: + operationId: devices.register + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Notifications] + summary: Register a push device, or refresh an existing registration (self or the app's own key) + description: "An idempotent upsert keyed on (application_id, provider, token). 201 for a new Device, 200 for a refresh of the caller's own. A token registered to a different User is moved: the old Device is deleted and a new one created (201). The User must have active access to application_id (403 entitlement_required). A User token ties the device to its session; an app key's registration has session_id null. At most 20 active devices per User per Application: a 21st deactivates the oldest (inactive_reason limit)." + x-substratal-phase: phase2 + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DeviceRegistration" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Existing registration refreshed (and reactivated if it was inactive) + content: + application/json: + schema: { $ref: "#/components/schemas/Device" } + "201": + description: Device registered + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/Device" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /users/{id}/devices/{deviceId}: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + - { name: deviceId, in: path, required: true, schema: { type: string } } + get: + operationId: devices.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Notifications] + summary: Fetch one device + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The Device + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/Device" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: devices.update + tags: [Notifications] + summary: Update a device's name, app version, environment, or rotated token (self or the app's own key) + description: "Writing a token reactivates an inactive device. If the new token is already another Device of the same User and Application, that other registration is deleted." + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DeviceUpdate" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The updated Device + content: + application/json: + schema: { $ref: "#/components/schemas/Device" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + delete: + operationId: devices.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Notifications] + summary: Unregister a device (self, the app's own key, or users.manage) + description: "Hard delete. Pending deliveries to it are dropped. A users.manage delete of someone else's device writes the device.deleted Audit Event." + x-substratal-phase: phase2 + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Unregistered } + "404": { $ref: "#/components/responses/NotFound" } + + /users/{id}/devices/{deviceId}/test: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + - { name: deviceId, in: path, required: true, schema: { type: string } } + post: + operationId: devices.sendTest + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Notifications] + summary: Send a test push to one device and wait for the provider's answer (self or the app's own key) + description: "Synchronous, up to 10 seconds. Ignores the push preference, works on inactive devices, isn't stored in the inbox, and is limited to 10 per device per hour. 409 push_not_configured if the Application has no credential for the device's provider." + x-substratal-phase: phase2 + requestBody: + content: + application/json: + schema: + type: object + properties: + title: { type: string, minLength: 1, maxLength: 120 } + body: { type: [string, "null"], maxLength: 1000 } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The provider's answer + content: + application/json: + schema: { $ref: "#/components/schemas/DeviceTestResult" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /users/{id}/notifications: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: notifications.list + tags: [Notifications] + summary: List a User's notification inbox (self, the app's own key, or users.list) + description: "Newest first. Archived notifications are excluded unless state=archived or include_inactive=true. An app key sees only its own Application's notifications, never platform alerts." + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: state, in: query, schema: { $ref: "#/components/schemas/NotificationState" } } + - { name: application_id, in: query, schema: { type: string } } + - { name: source, in: query, schema: { $ref: "#/components/schemas/NotificationSource" } } + - { name: category, in: query, schema: { type: string } } + - { name: priority, in: query, schema: { $ref: "#/components/schemas/NotificationPriority" } } + - { name: created_after, in: query, schema: { type: string, format: date-time } } + - { name: created_before, in: query, schema: { type: string, format: date-time } } + - { name: include_inactive, in: query, schema: { type: boolean, default: false } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of Notifications (without deliveries) + content: + application/json: + schema: { $ref: "#/components/schemas/NotificationPage" } + "404": { $ref: "#/components/responses/NotFound" } + post: + operationId: notifications.send + tags: [Notifications] + summary: Send one notification to one User (the app's own key with notifications.send, or platform notifications.send) + description: "Always creates the notification in the User's inbox; push.status says what happened to the push. The recipient must have active access to the Application (403 entitlement_required). The whole push payload must fit in 4 KB (422 notification_too_large). Categories under security., account., and billing. are reserved for platform alerts (422 reserved_category). Over 60 notifications per User per Application per hour is 429 rate_limited with details.scope recipient." + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IdempotencyKeyOptional" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/NotificationCreate" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Notification created; push queued or skipped + content: + application/json: + schema: { $ref: "#/components/schemas/Notification" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/TooManyRequests" } + + /users/{id}/notifications/unread-count: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + get: + operationId: notifications.unreadCount + tags: [Notifications] + summary: Count unread notifications, in total and per Application (self or the app's own key) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - { name: application_id, in: query, schema: { type: string } } + - { name: source, in: query, schema: { $ref: "#/components/schemas/NotificationSource" } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Unread, unarchived counts + content: + application/json: + schema: { $ref: "#/components/schemas/NotificationUnreadCount" } + "404": { $ref: "#/components/responses/NotFound" } + + /users/{id}/notifications/mark-read: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + post: + operationId: notifications.markRead + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Notifications] + summary: Mark every unread notification matching the filters as read (self or the app's own key) + description: "Every filter is optional; an empty body marks everything the caller can see. before limits it to notifications created at or before that time. An app key's application_id is forced to its own." + x-substratal-phase: phase2 + requestBody: + content: + application/json: + schema: + type: object + properties: + application_id: { type: string } + source: { $ref: "#/components/schemas/NotificationSource" } + category: { type: string } + before: { type: string, format: date-time } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: How many were marked read + content: + application/json: + schema: + type: object + required: [updated_count] + properties: + updated_count: { type: integer, minimum: 0 } + "404": { $ref: "#/components/responses/NotFound" } + + /users/{id}/notifications/{notificationId}: + parameters: + - $ref: "#/components/parameters/UserIdOrMe" + - { name: notificationId, in: path, required: true, schema: { type: string } } + get: + operationId: notifications.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [Notifications] + summary: Fetch one notification, with its per-device deliveries + x-substratal-phase: phase2 + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The Notification with deliveries (empty after 30 days) + headers: + ETag: { $ref: "#/components/headers/ETag" } + content: + application/json: + schema: { $ref: "#/components/schemas/NotificationWithDeliveries" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: notifications.update + tags: [Notifications] + summary: Mark one notification read, unread, or archived (self or the app's own key) + x-substratal-phase: phase2 + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/IfMatch" + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [state] + properties: + state: { $ref: "#/components/schemas/NotificationState" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The updated Notification + content: + application/json: + schema: { $ref: "#/components/schemas/Notification" } + "404": { $ref: "#/components/responses/NotFound" } + "409": { $ref: "#/components/responses/Conflict" } + "422": { $ref: "#/components/responses/Unprocessable" } + + # ───────────────────────────── API Keys ───────────────────────────── + + /api-keys: + get: + operationId: apiKeys.list + tags: [API Keys] + summary: List keys the caller may manage + x-substratal-phase: mvp + parameters: + - $ref: "#/components/parameters/ApiVersion" + - $ref: "#/components/parameters/Limit" + - $ref: "#/components/parameters/Cursor" + - { name: scope, in: query, schema: { type: string } } + - { name: mode, in: query, schema: { type: string, enum: [live, test] } } + - { name: intended_use, in: query, schema: { type: string, enum: [service, agent] } } + - { name: include_inactive, in: query, description: "Include revoked and expired keys", schema: { type: boolean, default: false } } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: A page of API Keys + content: + application/json: + schema: { $ref: "#/components/schemas/ApiKeyPage" } + post: + operationId: apiKeys.create + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [API Keys] + summary: Create a key (api_keys.manage, or the Application's owner for an app-scoped key) + x-substratal-phase: mvp + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/ApiKeyWrite" } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "201": + description: Key created — includes the secret once + content: + application/json: + schema: { $ref: "#/components/schemas/ApiKeyWithSecret" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/Unprocessable" } + + /api-keys/{id}: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + get: + operationId: apiKeys.get + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [API Keys] + summary: Fetch one key (never the secret) + x-substratal-phase: mvp + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: The key + content: + application/json: + schema: { $ref: "#/components/schemas/ApiKey" } + "404": { $ref: "#/components/responses/NotFound" } + patch: + operationId: apiKeys.update + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [API Keys] + summary: Change name, permissions, or restrict_destructive + x-substratal-phase: mvp + x-substratal-destructive: conditional + x-substratal-destructive-when: "restrict_destructive set to false" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string, maxLength: 100 } + permissions: { type: array, items: { type: string } } + restrict_destructive: { type: boolean } + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: Updated key + content: + application/json: + schema: { $ref: "#/components/schemas/ApiKey" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/Unprocessable" } + delete: + operationId: apiKeys.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [API Keys] + summary: Revoke permanently + x-substratal-phase: mvp + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "204": { description: Revoked } + "404": { $ref: "#/components/responses/NotFound" } + + /api-keys/{id}/rotate: + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + post: + operationId: apiKeys.rotate + parameters: + - $ref: "#/components/parameters/ApiVersion" + tags: [API Keys] + summary: Issue a new secret, invalidating the old one immediately (no overlap) + x-substratal-phase: mvp + x-substratal-destructive: always + responses: + "400": { $ref: "#/components/responses/BadRequest" } + "200": + description: New secret issued + content: + application/json: + schema: { $ref: "#/components/schemas/ApiKeyWithSecret" } + "404": { $ref: "#/components/responses/NotFound" } + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + description: >- + A platform access token (JWT, from /auth/login, /auth/signup, + /auth/mfa/verify, /auth/token/refresh) or an API Key secret + (satk_live_... / satk_test_...). See Conventions → Authentication. + + headers: + ETag: + description: Weak validator over the row's version, e.g. W/"7". Send it back as If-Match. + schema: { type: string } + + parameters: + ApiVersion: + name: Substratal-Version + in: header + required: true + description: >- + The API version this request is written against (Conventions → Versioning). + Required on every operation except GET /health and POST /auth/oauth/token; + a missing value returns 400 api_version_required, an unknown one 400 + unsupported_api_version. Every versioned response echoes it. + schema: { type: string, enum: ["1"] } + Limit: + name: limit + in: query + schema: { type: integer, minimum: 1, default: 25, maximum: 100 } + Cursor: + name: cursor + in: query + schema: { type: string } + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + description: Client-generated, 1–255 characters; a UUID v4 is recommended. Remembered 24 hours per caller. + schema: { type: string, minLength: 1, maxLength: 255 } + IdempotencyKeyOptional: + name: Idempotency-Key + in: header + required: false + schema: { type: string, minLength: 1, maxLength: 255 } + IfMatch: + name: If-Match + in: header + required: false + description: The ETag from a prior read. A mismatch returns 409 version_conflict. + schema: { type: string } + UserIdOrMe: + name: id + in: path + required: true + description: "A usr_... id, or the literal string 'me' for the calling User (not valid for API Keys)." + schema: { type: string } + AppId: + name: appId + in: path + required: true + schema: { type: string } + + responses: + BadRequest: + description: "Malformed request (invalid_request, invalid_cursor, user_token_required, idempotency_key_required, api_version_required, unsupported_api_version, token_invalid)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Unauthorized: + description: "Missing or invalid credentials (unauthenticated, session_revoked, invalid_credentials, invalid_mfa_code, …)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Forbidden: + description: "Authenticated but not permitted (forbidden, destructive_operation_restricted, entitlement_required, role_escalation_forbidden, …)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + NotFound: + description: Resource does not exist or is not visible to the caller + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Conflict: + description: "State conflict (version_conflict, idempotency_key_reused, entitlement_already_exists, entitlement_platform_managed, subscription_exists, user_protected, invalid_status_transition, …)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + Unprocessable: + description: "Semantically invalid (validation_failed with details.fields, settings_schema_violation, weak_password, …)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + TooManyRequests: + description: "rate_limited / too_many_attempts — see Retry-After" + headers: + Retry-After: { schema: { type: integer } } + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + ServiceUnavailable: + description: "service_unavailable / billing_unavailable — see Retry-After" + headers: + Retry-After: { schema: { type: integer } } + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + schemas: + Error: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: { type: string, example: entitlement_required } + message: { type: string } + details: + type: object + properties: + fields: + type: array + items: + type: object + required: [field, code] + properties: + field: { type: string } + code: { type: string } + additionalProperties: true + + OAuthError: + type: object + required: [error] + properties: + error: { type: string, enum: [invalid_request, invalid_grant, invalid_client, unsupported_grant_type, access_denied] } + error_description: { type: string } + + PageInfo: + type: object + required: [next_cursor, has_more] + properties: + next_cursor: { type: [string, "null"] } + has_more: { type: boolean } + + PageEnvelope: + type: object + required: [data, page] + properties: + page: { $ref: "#/components/schemas/PageInfo" } + + Actor: + type: object + required: [type, id] + properties: + type: { type: string, enum: [user, api_key, system] } + id: { type: string } + + Region: + type: string + enum: [us-east-1, us-west-2, ca-central-1, eu-west-1, eu-central-1, ap-southeast-2] + + # ── Auth ── + AuthSession: + type: object + required: [token_type, access_token, expires_in, refresh_token, session_id, user_id] + properties: + token_type: { type: string, enum: [Bearer] } + access_token: { type: string } + expires_in: { type: integer, example: 900 } + refresh_token: { type: string } + refresh_token_expires_in: { type: integer, example: 2592000 } + session_id: { type: string } + user_id: { type: string } + email_verified: { type: boolean } + + MfaChallenge: + type: object + required: [mfa_required, mfa_token, methods, expires_in] + properties: + mfa_required: { type: boolean, const: true } + mfa_token: { type: string } + methods: { type: array, items: { type: string, enum: [totp, recovery_code] } } + expires_in: { type: integer, example: 300 } + + AppToken: + type: object + required: [token_type, app_token, expires_in, application_id] + properties: + token_type: { type: string, enum: [Bearer] } + app_token: { type: string, description: "RS256 JWT; aud = application_id; 5-minute TTL. Claims: see AppTokenClaims." } + expires_in: { type: integer, example: 300 } + application_id: { type: string } + + AppTokenClaims: + type: object + description: Decoded payload of an app token, for Application implementers. + properties: + iss: { type: string, const: "https://api.substratalapps.com" } + aud: { type: string } + sub: { type: string } + sid: { type: string } + org_id: { type: [string, "null"] } + email: { type: string } + email_verified: { type: boolean } + entitlement_status: { $ref: "#/components/schemas/ResolvedEntitlementStatus" } + effective_permissions: { type: array, items: { type: string } } + test_mode: { type: boolean } + iat: { type: integer } + exp: { type: integer } + jti: { type: string } + + OAuthTokenRequest: + type: object + required: [grant_type, client_id] + properties: + grant_type: { type: string, enum: [authorization_code, refresh_token] } + client_id: { type: string, description: The application_id } + code: { type: string } + code_verifier: { type: string } + redirect_uri: { type: string } + refresh_token: { type: string } + + Session: + type: object + properties: + id: { type: string } + created_at: { type: string, format: date-time } + last_seen_at: { type: string, format: date-time } + expires_at: { type: string, format: date-time, description: "The earlier of the idle expiry (30 days after the last refresh) and the absolute expiry (90 days after creation)." } + ip_address: { type: string } + user_agent: { type: string } + amr: { type: array, items: { type: string } } + current: { type: boolean } + + # ── Users ── + UserStatus: + type: string + enum: [active, invited, suspended, deleted] + + User: + type: object + properties: + id: { type: string } + email: { type: string, format: email } + email_verified: { type: boolean } + pending_email: { type: [string, "null"], format: email } + status: { $ref: "#/components/schemas/UserStatus" } + mfa_enabled: { type: boolean } + signup_application_id: { type: [string, "null"] } + test_mode: { type: boolean } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + last_login_at: { type: [string, "null"], format: date-time } + expires_at: { type: [string, "null"], format: date-time, description: "When a test-mode User stops working (Auth → Test-mode Users → Lifetime). Always null for live Users." } + plan: { $ref: "#/components/schemas/Plan" } + plan_sources: { type: array, items: { $ref: "#/components/schemas/PlanSource" }, description: "What gives this User Standard: their own subscription, an Organization's, or both. Empty on Free." } + + UserPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/User" } } + + ErasureRequest: + type: object + properties: + user_id: { type: string } + status: { type: string, enum: [scheduled, cancelled, completed] } + requested_at: { type: string, format: date-time } + scheduled_for: { type: string, format: date-time } + + UserExport: + type: object + description: "Reads from the same tables the hard-delete cascade writes to — deliberately symmetric with it." + properties: + exported_at: { type: string, format: date-time } + user: { $ref: "#/components/schemas/User" } + profile: { $ref: "#/components/schemas/Profile" } + settings: { $ref: "#/components/schemas/Settings" } + identities: + type: array + description: "Never includes password_hash or mfa_secret." + items: + type: object + properties: + method: { type: string, enum: [password, sso] } + mfa_enabled: { type: boolean } + last_used_at: { type: [string, "null"], format: date-time } + sessions: { type: array, items: { $ref: "#/components/schemas/Session" } } + organizations: + type: array + items: + type: object + properties: + organization_id: { type: string } + role: { $ref: "#/components/schemas/OrganizationMemberRole" } + joined_at: { type: string, format: date-time } + roles: { type: array, items: { $ref: "#/components/schemas/UserRoleAssignment" } } + applications: + type: array + items: + type: object + properties: + application_id: { type: string } + entitlement: { $ref: "#/components/schemas/Entitlement" } + app_profile: { $ref: "#/components/schemas/AppProfile" } + app_settings: + type: object + properties: + overrides: { type: object } + audit_events: + type: array + description: "This user's own trail as target_user_id (hot window)." + items: { $ref: "#/components/schemas/AuditEvent" } + devices: + type: array + description: "Push devices, active and inactive. Never includes the token." + items: { $ref: "#/components/schemas/Device" } + notifications: + type: array + description: "Every notification still retained (90 days), without deliveries." + items: { $ref: "#/components/schemas/Notification" } + + # ── Profiles & Settings ── + ProfileWrite: + type: object + properties: + display_name: { type: string, minLength: 1, maxLength: 100 } + avatar_url: { type: [string, "null"], format: uri, maxLength: 2048 } + contact_email: { type: [string, "null"], format: email } + contact_phone: { type: [string, "null"], pattern: "^\\+[1-9][0-9]{7,14}$" } + + Profile: + allOf: + - $ref: "#/components/schemas/ProfileWrite" + - type: object + properties: + user_id: { type: string } + locale: { type: string, readOnly: true, example: en-US, description: "Mirror of the global Settings locale; write it there." } + updated_at: { type: string, format: date-time } + + AppProfile: + type: object + properties: + user_id: { type: string } + application_id: { type: string } + display_handle: { type: [string, "null"] } + effective_display_name: { type: string, readOnly: true } + custom: { type: object } + updated_at: { type: [string, "null"], format: date-time } + + SettingsWrite: + type: object + properties: + locale: { type: string } + timezone: { type: string, example: America/Denver } + theme: { type: string, enum: [light, dark, system] } + notifications: + type: object + additionalProperties: { type: [boolean, "null"] } + + Settings: + allOf: + - $ref: "#/components/schemas/SettingsWrite" + - type: object + properties: + user_id: { type: string } + updated_at: { type: string, format: date-time } + + AppSettingsResolved: + type: object + properties: + user_id: { type: string } + application_id: { type: string } + resolved: { type: object } + sources: + type: object + additionalProperties: { type: string, enum: [app_override, global, app_default] } + overrides: { type: object } + stale_overrides: { type: array, items: { type: string } } + updated_at: { type: [string, "null"], format: date-time } + + # ── Applications ── + ApplicationVisibility: + type: string + enum: [public, invite_only, internal] + + ApplicationReviewStatus: + type: string + enum: [approved, pending_review, rejected, suspended] + + ApplicationPermission: + type: object + required: [key] + properties: + key: { type: string, pattern: "^app\\.[a-z0-9_-]+\\.[a-z0-9_.]{1,64}$" } + description: { type: string, maxLength: 255 } + + ApplicationAccessTier: + type: string + enum: [free, standard] + description: "Which plan unlocks the Application: free for every User, standard for Users covered by a Standard subscription. Set by applications.manage; defaults to standard." + + ApplicationSummary: + type: object + properties: + id: { type: string } + slug: { type: string } + name: { type: string } + description: { type: [string, "null"] } + icon_url: { type: [string, "null"] } + visibility: { $ref: "#/components/schemas/ApplicationVisibility" } + access_tier: { $ref: "#/components/schemas/ApplicationAccessTier" } + review_status: { $ref: "#/components/schemas/ApplicationReviewStatus" } + + ApplicationSummaryPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/ApplicationSummary" } } + + ApplicationWrite: + type: object + properties: + slug: { type: string, pattern: "^[a-z][a-z0-9-]{1,63}$", description: "Create only; immutable. 'platform' is reserved (rejected with validation_failed, code unknown_value). The Application's id is app_." } + name: { type: string, minLength: 1, maxLength: 100 } + description: { type: [string, "null"], maxLength: 2000 } + icon_url: { type: [string, "null"], format: uri } + launch_url: { type: string, format: uri } + redirect_uris: { type: array, maxItems: 10, items: { type: string } } + support_url: { type: [string, "null"], format: uri } + email_from_name: { type: [string, "null"], maxLength: 50 } + settings_schema: { type: object, description: "JSON Schema 2020-12 subset — see Domain Model → Settings → settings_schema rules" } + permissions: { type: array, maxItems: 100, items: { $ref: "#/components/schemas/ApplicationPermission" } } + available_app_roles: { type: array, maxItems: 20, items: { type: string, pattern: "^[a-z][a-z0-9_]{1,40}$" } } + default_app_role: { type: [string, "null"] } + visibility: { $ref: "#/components/schemas/ApplicationVisibility" } + access_tier: { $ref: "#/components/schemas/ApplicationAccessTier" } + owner_user_id: { type: [string, "null"] } + owner_organization_id: { type: [string, "null"] } + review_status: { $ref: "#/components/schemas/ApplicationReviewStatus" } + review_notes: { type: [string, "null"], maxLength: 2000 } + receives_platform_notifications: { type: boolean, default: false, description: "When true, this Application's devices also receive pushes for platform alerts (security and account notices). A configuration field the owner may set." } + + Application: + allOf: + - $ref: "#/components/schemas/ApplicationWrite" + - type: object + properties: + id: { type: string } + tenant_id: { type: string, readOnly: true } + settings_schema_stats: + type: object + readOnly: true + properties: + stale_override_counts: { type: object, additionalProperties: { type: integer } } + self_registered: { type: boolean, readOnly: true, description: "true when a developer submitted it themselves, not staff. A self-registered Application must use the hosted sign-in flow (POST /auth/app-tokens returns 403 embedded_login_not_allowed), and only applications.manage can turn on receives_platform_notifications for it." } + test_mode: { type: boolean } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + # ── Entitlements ── + EntitlementStatus: + type: string + enum: [active, disabled, expired, revoked] + + ResolvedEntitlementStatus: + type: string + enum: [active, scheduled, disabled, expired, revoked, none] + description: "scheduled: the only live path is a grant whose starts_at is in the future." + + EntitlementSource: + type: string + enum: [purchase, trial, admin_grant, org_seat, free, subscription] + description: "free and subscription rows are platform-managed: granted and ended by the platform from the Application's access_tier and the holder's subscription. An admin may disable and re-enable one; nothing else about it can be changed." + + EntitlementMemberScope: + type: string + enum: [all_members, allowlist, denylist] + + EntitlementGrantedVia: + type: object + description: "Computed per member at read time; present only when reading a specific member's view of an org-wide grant." + properties: + organization_id: { type: string } + member_decision: { type: string, enum: [included, excluded] } + + Entitlement: + type: object + properties: + id: { type: string } + user_id: { type: [string, "null"] } + organization_id: { type: [string, "null"] } + application_id: { type: string } + status: { $ref: "#/components/schemas/EntitlementStatus" } + source: { $ref: "#/components/schemas/EntitlementSource" } + order_id: { type: [string, "null"], maxLength: 255, description: "Opaque developer-supplied billing reference" } + subscription_id: { type: [string, "null"], readOnly: true, description: "The subscription a source: subscription row comes from." } + starts_at: { type: string, format: date-time } + ends_at: { type: [string, "null"], format: date-time } + disabled_reason: { type: [string, "null"] } + member_scope: { oneOf: [ { $ref: "#/components/schemas/EntitlementMemberScope" }, { type: "null" } ] } + member_overrides: { type: [array, "null"], items: { type: string } } + included_member_count: { type: integer, readOnly: true } + granted_via: { $ref: "#/components/schemas/EntitlementGrantedVia" } + test_mode: { type: boolean } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + EntitlementPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/Entitlement" } } + + # ── Roles & Permissions ── + Permission: + type: object + properties: + key: { type: string } + scope: { type: string } + description: { type: string } + + PermissionPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/Permission" } } + + RoleCreate: + type: object + required: [name, scope, permissions] + properties: + name: { type: string, pattern: "^[a-z][a-z0-9_]{1,40}$" } + scope: { type: string, description: "'platform' or an application_id" } + description: { type: [string, "null"], maxLength: 2000 } + permissions: { type: array, items: { type: string } } + + Role: + allOf: + - $ref: "#/components/schemas/RoleCreate" + - type: object + properties: + id: { type: string, description: "role_platform_ or role__" } + seed: { type: boolean } + assignment_count: { type: integer } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + RolePage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/Role" } } + + UserRoleAssignment: + type: object + properties: + user_id: { type: string } + role_id: { type: string } + application_id: { type: [string, "null"] } + assigned_at: { type: [string, "null"], format: date-time } + assigned_by: { oneOf: [ { $ref: "#/components/schemas/Actor" }, { type: "null" } ] } + implicit: { type: boolean } + + UserRoleAssignmentPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/UserRoleAssignment" } } + + EffectivePermissions: + type: object + required: [user_id, application_id, allowed, user_status, entitlement_status, effective_permissions] + properties: + user_id: { type: string } + application_id: { type: string } + allowed: { type: boolean } + user_status: { $ref: "#/components/schemas/UserStatus" } + entitlement_status: { $ref: "#/components/schemas/ResolvedEntitlementStatus" } + access_paths: + type: array + items: + type: object + properties: + source: { $ref: "#/components/schemas/EntitlementSource" } + entitlement_id: { type: string } + status: { $ref: "#/components/schemas/EntitlementStatus" } + organization_id: { type: [string, "null"] } + member_decision: { type: [string, "null"], enum: [included, excluded, null] } + roles: { type: array, items: { type: string } } + effective_permissions: { type: array, items: { type: string } } + computed_at: { type: string, format: date-time } + + # ── Organizations ── + Organization: + type: object + properties: + id: { type: string } + name: { type: string } + status: { type: string, enum: [active, suspended] } + member_count: { type: integer, description: "Active members only" } + my_role: { $ref: "#/components/schemas/OrganizationMemberRole" } + my_membership_status: { $ref: "#/components/schemas/OrganizationMembershipStatus" } + created_by: { type: string } + test_mode: { type: boolean } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + OrganizationPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/Organization" } } + + OrganizationMemberRole: + type: string + enum: [org_admin, member] + + OrganizationMember: + type: object + properties: + user_id: { type: string } + email: { type: string, format: email } + display_name: { type: [string, "null"], description: "null while the membership is pending" } + membership_status: { $ref: "#/components/schemas/OrganizationMembershipStatus" } + role: { $ref: "#/components/schemas/OrganizationMemberRole" } + invited_at: { type: string, format: date-time } + joined_at: { type: [string, "null"], format: date-time } + + OrganizationMembershipStatus: + type: string + enum: [pending, active] + + OrganizationMemberPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/OrganizationMember" } } + + # ── Tenancy & Billing ── + TenantOwnerType: + type: string + enum: [user, organization] + + TenantTier: + type: string + enum: [shared, isolated, dedicated_region] + + TenantStatus: + type: string + enum: [active, migrating, suspended] + + SubscriptionStatus: + type: string + enum: [active, trialing, past_due, canceled, unpaid, incomplete, incomplete_expired, paused] + description: "Mirrors Stripe. active, trialing, and past_due cover their users with Standard; the rest don't." + + Tenant: + type: object + properties: + id: { type: string } + owner_type: { $ref: "#/components/schemas/TenantOwnerType" } + owner_user_id: { type: [string, "null"] } + owner_organization_id: { type: [string, "null"] } + tier: { $ref: "#/components/schemas/TenantTier" } + region: { $ref: "#/components/schemas/Region" } + status: { $ref: "#/components/schemas/TenantStatus" } + application_count: { type: integer } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + TenantPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/Tenant" } } + + TenantTierChangeRequestStatus: + type: string + enum: [pending, scheduled, in_progress, completed, cancelled] + + TenantTierChangeRequest: + type: object + properties: + id: { type: string } + tenant_id: { type: string } + from_tier: { $ref: "#/components/schemas/TenantTier" } + from_region: { $ref: "#/components/schemas/Region" } + requested_tier: { $ref: "#/components/schemas/TenantTier" } + requested_region: { oneOf: [ { $ref: "#/components/schemas/Region" }, { type: "null" } ] } + status: { $ref: "#/components/schemas/TenantTierChangeRequestStatus" } + reason: { type: string } + notes: { type: [string, "null"] } + scheduled_for: { type: [string, "null"], format: date-time } + requested_by: { type: string } + created_at: { type: string, format: date-time } + started_at: { type: [string, "null"], format: date-time } + completed_at: { type: [string, "null"], format: date-time } + + TenantTierChangeRequestPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/TenantTierChangeRequest" } } + + Plan: + type: string + enum: [free, standard] + description: "Business and enterprise plans come later." + + PlanSource: + type: object + required: [type, id] + properties: + type: { type: string, enum: [user, organization] } + id: { type: string, description: "The User (an individual subscription) or the Organization whose subscription covers this User." } + + Subscription: + type: object + properties: + id: { type: string, description: "sbs_ prefix" } + subscriber_type: { type: string, enum: [user, organization] } + user_id: { type: [string, "null"] } + organization_id: { type: [string, "null"] } + plan: { type: string, enum: [standard] } + status: { $ref: "#/components/schemas/SubscriptionStatus" } + current_period_start: { type: [string, "null"], format: date-time } + current_period_end: { type: [string, "null"], format: date-time } + cancel_at_period_end: { type: boolean } + member_scope: { oneOf: [ { $ref: "#/components/schemas/EntitlementMemberScope" }, { type: "null" } ], description: "Organization subscriptions only." } + member_overrides: { type: [array, "null"], items: { type: string } } + quantity: + type: object + description: "Users billed. Always 1 for an individual subscription; for an Organization, covered members." + properties: + current: { type: integer } + last_synced: { type: [integer, "null"] } + last_synced_at: { type: [string, "null"], format: date-time } + unit_amount: { type: integer, description: "Cents per user per month (600)." } + currency: { type: string, const: usd } + test_mode: { type: boolean } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + UserSubscription: + type: object + properties: + user_id: { type: string } + plan: { $ref: "#/components/schemas/Plan" } + plan_sources: { type: array, items: { $ref: "#/components/schemas/PlanSource" } } + subscription: { oneOf: [ { $ref: "#/components/schemas/Subscription" }, { type: "null" } ], description: "The User's own individual subscription, if they've ever started one." } + + OrganizationSubscription: + type: object + properties: + organization_id: { type: string } + plan: { $ref: "#/components/schemas/Plan" } + subscription: { oneOf: [ { $ref: "#/components/schemas/Subscription" }, { type: "null" } ] } + + CheckoutSessionCreate: + type: object + required: [plan, success_url, cancel_url] + properties: + plan: { type: string, enum: [standard, business, enterprise], description: "Only standard is self-service; business and enterprise return 422 plan_requires_sales." } + success_url: { type: string, format: uri } + cancel_url: { type: string, format: uri } + + PortalSessionCreate: + type: object + required: [return_url] + properties: + return_url: { type: string, format: uri } + + RedirectSession: + type: object + required: [url, expires_at] + properties: + url: { type: string, format: uri } + expires_at: { type: string, format: date-time } + + # ── Audit ── + AuditAction: + type: string + enum: + - user.created + - user.updated + - user.email_changed + - user.suspended + - user.reactivated + - user.deleted + - user.expired + - user.erasure_requested + - user.erasure_cancelled + - user.erased + - user.password_changed + - user.password_reset + - user.mfa_enabled + - user.mfa_disabled + - user.mfa_reset + - user.sessions_revoked + - audit.exported + - entitlement.granted + - entitlement.disabled + - entitlement.revoked + - entitlement.expired + - entitlement.updated + - entitlement.member_scope_changed + - entitlement.deleted + - role.created + - role.updated + - role.deleted + - role.assigned + - role.removed + - profile.updated + - settings.updated + - application.created + - application.updated + - application.review_status_changed + - organization.created + - organization.updated + - organization.member_invited + - organization.member_added + - organization.member_removed + - organization.member_role_changed + - organization.scim_token_created + - organization.scim_token_revoked + - subscription.created + - subscription.status_changed + - subscription.members_changed + - tenant.tier_change_requested + - tenant.tier_change_request_updated + - tenant.tier_changed + - api_key.created + - api_key.updated + - api_key.rotated + - api_key.revoked + - api_key.restrict_destructive_disabled + - webhook.created + - webhook.updated + - webhook.deleted + - webhook.secret_rotated + - push_credential.created + - push_credential.updated + - push_credential.deleted + - device.deleted + + AuditTargetType: + type: string + enum: [user, entitlement, role, role_assignment, application, organization, subscription, tenant, tier_change_request, api_key, webhook, push_credential, device, audit_export] + + AuditExportRequest: + type: object + description: "The same filters as GET /audit-events, as body fields, plus format." + properties: + format: { type: string, enum: [ndjson, csv], default: ndjson } + target_user_id: { type: string, description: "A User id, or me." } + target_type: { $ref: "#/components/schemas/AuditTargetType" } + target_id: { type: string } + actor_id: { type: string } + actor_type: { type: string, enum: [user, api_key, system] } + action: { type: array, items: { $ref: "#/components/schemas/AuditAction" }, maxItems: 100 } + application_id: { type: string } + organization_id: { type: string } + tenant_id: { type: string } + since: { type: string, format: date-time } + until: { type: string, format: date-time } + + AuditExport: + type: object + properties: + id: { type: string } + status: { type: string, enum: [pending, running, ready, failed] } + format: { type: string, enum: [ndjson, csv] } + filters: { type: object, description: "The filters as requested, with target_user_id me resolved to the caller's id." } + row_count: { type: [integer, "null"], description: "Set once ready." } + download_url: { type: [string, "null"], format: uri, description: "Set once ready. Signed, valid for 15 minutes; fetch the export again for a fresh one." } + download_url_expires_at: { type: [string, "null"], format: date-time } + error: { type: [string, "null"], description: "Set when failed: too_many_rows (over 200,000), too_large (over 400 MB), or internal_error." } + created_at: { type: string, format: date-time } + completed_at: { type: [string, "null"], format: date-time } + expires_at: { type: string, format: date-time, description: "When the file and this record are deleted: 7 days after the request." } + test_mode: { type: boolean } + + ScimToken: + type: object + properties: + id: { type: string } + organization_id: { type: string } + token: { type: string, description: "The secret (scim_…). Only in the response that creates it." } + base_url: { type: string, format: uri, description: "The SCIM 2.0 base URL to give the identity provider." } + created_by: { type: string } + created_at: { type: string, format: date-time } + last_used_at: { type: [string, "null"], format: date-time } + test_mode: { type: boolean } + + AuditEvent: + type: object + properties: + id: { type: string } + action: { $ref: "#/components/schemas/AuditAction" } + actor: { $ref: "#/components/schemas/Actor" } + target: + type: object + properties: + type: { $ref: "#/components/schemas/AuditTargetType" } + id: { type: string } + target_user_id: { type: [string, "null"] } + application_id: { type: [string, "null"] } + organization_id: { type: [string, "null"] } + tenant_id: { type: [string, "null"] } + before: { type: [object, "null"] } + after: { type: [object, "null"] } + request_id: { type: [string, "null"] } + timestamp: { type: string, format: date-time } + + AuditEventPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/AuditEvent" } } + + # ── Webhooks ── + WebhookEvent: + type: string + enum: + - access.granted + - access.revoked + - entitlement.granted + - entitlement.disabled + - entitlement.revoked + - entitlement.expired + - entitlement.updated + - entitlement.member_scope_changed + - entitlement.deleted + - role.assigned + - role.removed + - user.suspended + - user.reactivated + - user.deleted + - user.expired + - organization.member_added + - organization.member_removed + - application.review_status_changed + - device.registered + - device.deactivated + - device.deleted + - notification.push_failed + - push_credential.failed + - webhook.test + + AccessEventReason: + type: string + enum: + - entitlement_granted + - entitlement_started + - entitlement_enabled + - entitlement_disabled + - entitlement_revoked + - entitlement_expired + - entitlement_deleted + - org_grant_changed + - org_member_added + - org_member_removed + - user_suspended + - user_reactivated + - user_deleted + - test_user_expired + - test_user_extended + + WebhookEventEnvelope: + type: object + description: "Body POSTed to a subscriber. Signed with Substratal-Signature: t=,v1=." + required: [id, type, api_version, created_at, test_mode, data] + properties: + id: { type: string, description: "wev_… — stable across retries; dedupe on it" } + type: { $ref: "#/components/schemas/WebhookEvent" } + api_version: { type: string, example: "2026-10-05" } + created_at: { type: string, format: date-time } + application_id: { type: [string, "null"] } + test_mode: { type: boolean } + data: { type: object } + + Webhook: + type: object + properties: + id: { type: string } + scope: { type: string, description: "'platform' or an application_id" } + url: { type: string, format: uri } + events: { type: array, items: { type: string } } + description: { type: [string, "null"] } + status: { type: string, enum: [healthy, unhealthy, disabled] } + api_version: { type: string } + test_mode: { type: boolean } + consecutive_failures: { type: integer } + last_delivery_at: { type: [string, "null"], format: date-time } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + WebhookWithSecret: + allOf: + - $ref: "#/components/schemas/Webhook" + - type: object + properties: + signing_secret: { type: string } + + WebhookPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/Webhook" } } + + WebhookDelivery: + type: object + properties: + id: { type: string } + event_id: { type: string } + event_type: { $ref: "#/components/schemas/WebhookEvent" } + attempt: { type: integer, minimum: 1, maximum: 6 } + status: { type: string, enum: [pending, succeeded, failed] } + response_status: { type: [integer, "null"] } + duration_ms: { type: [integer, "null"] } + error: { type: [string, "null"] } + next_retry_at: { type: [string, "null"], format: date-time } + created_at: { type: string, format: date-time } + + # ── Notifications ── + PushProvider: + type: string + enum: [apns, fcm, web_push] + + PushCredentialCreate: + type: object + description: "Fields depend on provider. apns: team_id, key_id, topic, private_key. fcm: service_account. web_push: subject (the VAPID key pair is generated)." + required: [provider] + properties: + provider: { $ref: "#/components/schemas/PushProvider" } + team_id: { type: string, minLength: 10, maxLength: 10, description: "apns" } + key_id: { type: string, minLength: 10, maxLength: 10, description: "apns" } + topic: { type: string, maxLength: 255, description: "apns: the app's bundle id" } + private_key: { type: string, writeOnly: true, description: "apns: the .p8 file's contents (PEM PKCS#8, P-256). Write-only." } + service_account: { type: object, writeOnly: true, description: "fcm: the service-account JSON key (type, project_id, private_key, client_email). Write-only." } + subject: { type: string, maxLength: 255, description: "web_push: a mailto: or https: contact URI (RFC 8292)" } + + PushCredentialUpdate: + type: object + properties: + team_id: { type: string, minLength: 10, maxLength: 10 } + key_id: { type: string, minLength: 10, maxLength: 10 } + topic: { type: string, maxLength: 255 } + private_key: { type: string, writeOnly: true } + service_account: { type: object, writeOnly: true } + subject: { type: string, maxLength: 255 } + status: { type: string, enum: [active], description: "Only failed → active." } + + PushCredential: + type: object + required: [id, application_id, provider, status] + properties: + id: { type: string, description: "pcr_…" } + application_id: { type: string } + provider: { $ref: "#/components/schemas/PushProvider" } + status: { type: string, enum: [active, failed] } + team_id: { type: string, description: "apns" } + key_id: { type: string, description: "apns" } + topic: { type: string, description: "apns" } + project_id: { type: string, description: "fcm" } + client_email: { type: string, description: "fcm" } + subject: { type: string, description: "web_push" } + vapid_public_key: { type: string, description: "web_push: base64url uncompressed P-256 point, for PushManager.subscribe({applicationServerKey})" } + failure_reason: { type: [string, "null"] } + failed_at: { type: [string, "null"], format: date-time } + last_used_at: { type: [string, "null"], format: date-time } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + PushCredentialList: + type: object + required: [data] + properties: + data: { type: array, maxItems: 3, items: { $ref: "#/components/schemas/PushCredential" } } + + DeviceStatus: + type: string + enum: [active, inactive] + + DeviceInactiveReason: + type: string + enum: [unregistered, session_revoked, user_suspended, user_deleted, test_user_expired, stale, limit, credential_removed] + + WebPushSubscription: + type: object + description: "The PushSubscription from PushManager.subscribe(), as JSON." + required: [endpoint, keys] + properties: + endpoint: { type: string, format: uri, maxLength: 2048 } + keys: + type: object + required: [p256dh, auth] + properties: + p256dh: { type: string } + auth: { type: string } + + DeviceRegistration: + type: object + required: [application_id, provider] + properties: + application_id: { type: string } + provider: { $ref: "#/components/schemas/PushProvider" } + token: { type: string, minLength: 1, maxLength: 4096, writeOnly: true, description: "apns (hex, 64–200 characters) and fcm only" } + subscription: { $ref: "#/components/schemas/WebPushSubscription" } + environment: { type: string, enum: [production, sandbox], description: "apns only, required for it" } + name: { type: [string, "null"], maxLength: 100 } + app_version: { type: [string, "null"], maxLength: 50 } + + DeviceUpdate: + type: object + properties: + token: { type: string, minLength: 1, maxLength: 4096, writeOnly: true } + subscription: { $ref: "#/components/schemas/WebPushSubscription" } + environment: { type: string, enum: [production, sandbox] } + name: { type: [string, "null"], maxLength: 100 } + app_version: { type: [string, "null"], maxLength: 50 } + + Device: + type: object + required: [id, user_id, application_id, provider, token_hint, status] + properties: + id: { type: string, description: "dvc_…" } + user_id: { type: string } + application_id: { type: string } + provider: { $ref: "#/components/schemas/PushProvider" } + environment: { type: [string, "null"], enum: [production, sandbox, null] } + token_hint: { type: string, example: "…9f3a", description: "The token itself is never returned." } + name: { type: [string, "null"] } + app_version: { type: [string, "null"] } + session_id: { type: [string, "null"], description: "The registering session; null when an app key registered it." } + status: { $ref: "#/components/schemas/DeviceStatus" } + inactive_reason: + oneOf: + - $ref: "#/components/schemas/DeviceInactiveReason" + - type: "null" + last_registered_at: { type: string, format: date-time } + last_delivery_at: { type: [string, "null"], format: date-time } + test_mode: { type: boolean } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + DevicePage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/Device" } } + + DeviceTestResult: + type: object + required: [device_id, status] + properties: + device_id: { type: string } + status: { type: string, enum: [sent, failed] } + provider_status: { type: [integer, "null"], description: "The provider's HTTP status; null on a timeout or connection error." } + error: { type: [string, "null"], description: "The provider's reason code, e.g. BadDeviceToken, UNREGISTERED, InvalidProviderToken, or timeout." } + duration_ms: { type: integer } + + NotificationPriority: + type: string + enum: [normal, high] + + NotificationSource: + type: string + enum: [application, platform] + + NotificationState: + type: string + enum: [unread, read, archived] + + NotificationPushStatus: + type: string + enum: [queued, sent, failed, skipped] + + NotificationSkipReason: + type: string + enum: [push_not_requested, push_disabled, no_devices, no_credentials, credential_failed, expired] + + NotificationCreate: + type: object + required: [title, category] + properties: + title: { type: string, minLength: 1, maxLength: 120 } + body: { type: [string, "null"], maxLength: 1000 } + category: { type: string, maxLength: 64, pattern: "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)*$", description: "Sender-defined. security., account., and billing. are reserved for platform alerts." } + priority: { $ref: "#/components/schemas/NotificationPriority" } + push: { type: boolean, default: true, description: "false writes to the inbox without a push." } + data: + type: object + maxProperties: 20 + additionalProperties: { type: string } + description: "String values only (FCM's data payload carries only strings); 2 KB serialized; the key substratal is reserved." + action_url: { type: [string, "null"], maxLength: 2048 } + badge: { type: [integer, "null"], minimum: 0, maximum: 99999 } + sound: { type: [string, "null"], enum: [default, null], description: "Defaults to default for high priority, null (silent) for normal." } + collapse_key: { type: [string, "null"], maxLength: 64, pattern: "^[A-Za-z0-9._-]+$" } + ttl_seconds: { type: integer, minimum: 0, maximum: 2419200, default: 86400 } + application_id: { type: string, description: "Only for a platform notifications.send caller, and required for one." } + + NotificationPush: + type: object + required: [requested, status] + properties: + requested: { type: boolean } + status: { $ref: "#/components/schemas/NotificationPushStatus" } + devices_targeted: { type: integer, minimum: 0 } + devices_sent: { type: integer, minimum: 0 } + devices_failed: { type: integer, minimum: 0 } + skip_reason: + oneOf: + - $ref: "#/components/schemas/NotificationSkipReason" + - type: "null" + + Notification: + type: object + required: [id, user_id, source, category, priority, title, push, state, created_at] + properties: + id: { type: string, description: "ntf_…" } + user_id: { type: string } + application_id: { type: [string, "null"], description: "null for a platform alert" } + source: { $ref: "#/components/schemas/NotificationSource" } + category: { type: string } + priority: { $ref: "#/components/schemas/NotificationPriority" } + title: { type: string } + body: { type: [string, "null"] } + data: { type: object, additionalProperties: { type: string } } + action_url: { type: [string, "null"] } + badge: { type: [integer, "null"] } + sound: { type: [string, "null"] } + collapse_key: { type: [string, "null"] } + ttl_seconds: { type: integer } + push: { $ref: "#/components/schemas/NotificationPush" } + state: { $ref: "#/components/schemas/NotificationState" } + read_at: { type: [string, "null"], format: date-time } + archived_at: { type: [string, "null"], format: date-time } + sender: + type: object + required: [type, id] + properties: + type: { type: string, enum: [api_key, user, system] } + id: { type: string } + test_mode: { type: boolean } + created_at: { type: string, format: date-time } + updated_at: { type: string, format: date-time } + + NotificationDelivery: + type: object + properties: + id: { type: string, description: "ndl_…" } + device_id: { type: [string, "null"], description: "null once the Device is deleted" } + provider: { $ref: "#/components/schemas/PushProvider" } + status: { type: string, enum: [pending, sent, failed, skipped] } + attempts: { type: integer, minimum: 0, maximum: 4 } + error: { type: [string, "null"] } + sent_at: { type: [string, "null"], format: date-time } + + NotificationWithDeliveries: + allOf: + - $ref: "#/components/schemas/Notification" + - type: object + properties: + deliveries: { type: array, items: { $ref: "#/components/schemas/NotificationDelivery" } } + + NotificationPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/Notification" } } + + NotificationUnreadCount: + type: object + required: [unread_count, applications] + properties: + unread_count: { type: integer, minimum: 0 } + applications: + type: array + items: + type: object + required: [application_id, unread_count] + properties: + application_id: { type: [string, "null"], description: "null for platform alerts" } + unread_count: { type: integer, minimum: 0 } + + # ── API Keys ── + ApiKeyWrite: + type: object + required: [name, scope, permissions, mode] + properties: + name: { type: string, maxLength: 100 } + scope: { type: string, description: "'platform' or an application_id" } + permissions: { type: array, items: { type: string } } + mode: + type: string + enum: [live, test] + description: Fixed at creation. + intended_use: + type: string + enum: [service, agent] + default: service + description: Sets the default for restrict_destructive (true for agent, false for service). + restrict_destructive: + type: boolean + description: "When true, every operation marked x-substratal-destructive is rejected with 403 destructive_operation_restricted." + expires_at: { type: [string, "null"], format: date-time } + + ApiKey: + allOf: + - $ref: "#/components/schemas/ApiKeyWrite" + - type: object + properties: + id: { type: string } + secret_hint: { type: string, example: "…6c8e" } + created_by: + type: object + description: "Always a User: API Keys can't create API Keys." + properties: + type: { type: string, enum: [user] } + id: { type: string } + last_used_at: { type: [string, "null"], format: date-time } + created_at: { type: string, format: date-time } + revoked_at: { type: [string, "null"], format: date-time } + + ApiKeyWithSecret: + allOf: + - $ref: "#/components/schemas/ApiKey" + - type: object + properties: + secret: { type: string } + + ApiKeyPage: + allOf: + - $ref: "#/components/schemas/PageEnvelope" + - type: object + properties: + data: { type: array, items: { $ref: "#/components/schemas/ApiKey" } } diff --git a/design/theme-options.html b/design/theme-options.html new file mode 100644 index 0000000..28e6435 --- /dev/null +++ b/design/theme-options.html @@ -0,0 +1,325 @@ +Substratal iOS Themes + + + + +
+
+
Substratal Apps · iOS client · Theme selection
+

Pick one light theme and one dark theme

+

Six candidates for the iOS app, each shown on the same screen: the Apps tab, where a person sees which apps are switched on for them and why. Choose one from each row. The app follows the system setting by default, and people can override it in Account → Appearance, which writes the reserved theme key in their global Settings.

+
+
Same screen everywhereOnly color changes between specimens, so you're comparing palettes and nothing else.
+
Accessible by defaultBody text and accent pass WCAG AA (4.5:1) on every surface. Ratios are listed under each option.
+
Native typeThe app uses SF Pro and Dynamic Type. IBM Plex stays on the docs site.
+
+
+ +
+

Light

Grouped-list background, white cards, one accent.

+
+
+ +
+

Dark

Accent lifted for dark surfaces. Filled buttons use dark text.

+
+
+ +
+

Your pair

How the two choices sit together when the system flips.

+
+

App names, the organization and the trial dates on these screens are sample data. When you've chosen, copy the result and paste it back into the conversation. I'll consolidate on those two and write the full iOS spec around them.

+
+
+ +
+
+
+
+ +
+
+
+ + diff --git a/docs/api-gaps.md b/docs/api-gaps.md new file mode 100644 index 0000000..bc3b54c --- /dev/null +++ b/docs/api-gaps.md @@ -0,0 +1,38 @@ +# API gaps for the iOS app + +What the iOS Hub needs from the Platform API, as of 2026-10-23 (`openapi.yaml` `1.0.0-draft.9`, 124 operations, +`Substratal-Version: 1`). Gaps are tracked as [`[api-gaps]` issues in the API repository](https://github.com/CompositeCode/substratalapps.com/issues?q=is%3Aissue+%22%5Bapi-gaps%5D%22), +shared with the web and other Hub apps. This file maps the gaps first listed here (2026-10-21) onto those issues and +adds what building the iOS client turned up. + +## Mapping to the API's issues + +| First listed here | Issue | Status | Effect on iOS | +|---|---|---|---| +| G-01 `dashboard.substratalapps.com` doesn't resolve | [#39](https://github.com/CompositeCode/substratalapps.com/issues/39) go-live steps; the dashboard pages are built by the web Hub | Open. The domain still doesn't resolve on 2026-10-23. | Universal Links and `webcredentials` need the domain's `apple-app-site-association`. Until then, emailed links can't open the app, and `substratal://` links work only when typed or tapped in-app. | +| G-02 Application removal | [#52](https://github.com/CompositeCode/substratalapps.com/issues/52) G15 | Open | APL-03 offers Suspend only | +| G-03 Security events API | [#42](https://github.com/CompositeCode/substratalapps.com/issues/42) G5, [#43](https://github.com/CompositeCode/substratalapps.com/issues/43) G6 | Open | Console screen SEC-01 is web-only (OPEN-QUESTIONS #27) | +| G-04 Metrics and usage | [#44](https://github.com/CompositeCode/substratalapps.com/issues/44) G7, [#53](https://github.com/CompositeCode/substratalapps.com/issues/53) G16 | Open | Web-only console | +| G-05 Alarms to admins' devices | Web Ops gateway (no API issue) | — | Not on iOS. Alarm email, and AWS Chatbot to Slack, remain the phone path. | +| G-06 System configuration and flags | [#48](https://github.com/CompositeCode/substratalapps.com/issues/48) G11 | Open | iOS flags default per spec. A `501` turns a feature off. | +| G-07 / G-08 Staff MFA and step-up | [#50](https://github.com/CompositeCode/substratalapps.com/issues/50) G13 | Open | Help pages say two-factor is recommended, not enforced | +| G-09 One call for "my permissions" | Not tracked | — | Capabilities uses the spec's six-call recipe. Propose `GET /users/me/permissions`. | +| G-10 `GET /users?role_id=` | [#54](https://github.com/CompositeCode/substratalapps.com/issues/54) G17 | **Done** (draft.9) | Wired into `UserQuery.roleIDs`. ROL-02 links to "People with this role". | +| G-11 Bulk operations | [#55](https://github.com/CompositeCode/substratalapps.com/issues/55) G18 | Deferred | Client fans out, 4 requests at a time | +| G-12 Revoke all of a user's sessions | [#51](https://github.com/CompositeCode/substratalapps.com/issues/51) G14 | **Done** (draft.9) | `auth_revokeAllSessions` is generated. USR-04 will use it. | +| G-13 Audit export | [#56](https://github.com/CompositeCode/substratalapps.com/issues/56), [#33](https://github.com/CompositeCode/substratalapps.com/issues/33) | **Done** (draft.8) | AUD-01 export is next | +| G-14 Per-key rate-limit overrides | [#49](https://github.com/CompositeCode/substratalapps.com/issues/49) G12 | Open | — | +| G-15 Asset upload | Not tracked (specs OPEN-QUESTIONS #1) | — | Avatars and icons show initials | +| G-16 `app_hub` and push | [#25](https://github.com/CompositeCode/substratalapps.com/issues/25), [#24](https://github.com/CompositeCode/substratalapps.com/issues/24) | Open | Push registration (NTF-03/04) waits on these | + +## Found while generating the Swift client + +`swift-openapi-generator` 1.14 builds all 124 operations. To make the generated client honour the API's contract, +`scripts/derive-openapi.py` adjusts the copy it reads (the vendored original in `api-contract/` is untouched): + +| Finding | Why it matters | Workaround here | Suggested fix in `openapi.yaml` | +|---|---|---|---| +| String enums become frozen Swift enums | One new enum value would fail decoding of a whole response, which breaks the API's own rule that enums grow without a version bump | Enums are removed from the derived copy. The domain layer maps strings, with an `unknown` case. | Document `x-extensible-enum`, or keep enums but note the rule in `info.description` for generator users | +| Nullable `oneOf: [X, {type: "null"}]` is skipped | Ten properties were dropped from the generated types (for example `Subscription.member_scope`, `Device.inactive_reason`, `UserRoleAssignment.assigned_by`) | Unwrapped to `X`, and dropped from `required` | Express nullability as `type: [object, "null"]` with `$ref` in `allOf`, or leave nullable properties out of `required` | +| `required` names properties defined only in a sibling `allOf` branch | Six warnings (for example `applications.create` `slug`, `name`, `launch_url`; `PageEnvelope.data`) | None needed: the properties are still generated from the other branch | Move `required` next to the `properties` it names | +| The `Substratal-Version` parameter on every operation | Every generated call would need the header passed by hand | Stripped from the derived copy. `APIVersionMiddleware` sends it on every request. | None needed. Declaring it is correct, and clients should set it in middleware. | diff --git a/docs/management-app-plan.md b/docs/management-app-plan.md new file mode 100644 index 0000000..64ec288 --- /dev/null +++ b/docs/management-app-plan.md @@ -0,0 +1,350 @@ +# Substratal Apps — iOS Management App Plan + +Plan for the first client of the Substratal Apps Platform API: a native iOS/iPadOS app administrators use to manage and monitor `https://api.substratalapps.com`. + +Sources reviewed on 2026-10-21: + +- API spec site and repo: [compositecode.github.io/substratalapps.com](https://compositecode.github.io/substratalapps.com/), `CompositeCode/substratalapps.com` at `967e114` (2026-10-20). Covers `openapi.yaml` 3.1, 118 operations, the Go implementation, Terraform, `plan-remaining.md` and `remaining_manual_steps.md`. +- Client specs: [compositecode.github.io/substratalapps-applications-specs](https://compositecode.github.io/substratalapps-applications-specs/), `CompositeCode/substratalapps-applications-specs` at `e296d7e` (2026-10-18). Covers the shared spec, the iOS handoff, the 75-screen inventory and the themes. +- Live probes (2026-10-21, when paths still carried `/v1`): `GET /health` returned `200`, `GET /applications` without a token returned `401` with the documented error envelope, and `/.well-known/jwks.json` serves an RS256 key. `dashboard.substratalapps.com` and `auth.substratalapps.com` don't resolve in DNS. + +The companion document [api-gaps.md](api-gaps.md) lists what the API is missing. + +> **Update, 2026-10-23.** Superseded where this conflicts: +> +> - **API versioning moved from the URL to a header.** Every path is at the host root (`https://api.substratalapps.com/users`, not `/v1/users`), and every request sends `Substratal-Version: 1`. `/v1/…` returns `404`. Missing the header returns `400 api_version_required`. `Deprecation`/`Sunset` response headers announce retirement, and `400 unsupported_api_version` follows it. The app implements this in `APIVersionMiddleware` and the GLB-03 Update available / Update required states. See the [help center's versioning page](https://compositecode.github.io/substratalapps-applications-specs/help/developers/api-versioning.html). +> - **The API is now `1.0.0-draft.9`, with 124 operations.** Since this plan was written it gained test-mode user expiry, self-service app registration, audit exports, SCIM, `GET /users?role_id=` and `POST /users/{id}/sessions/revoke-all`. +> - **API gaps are tracked as `[api-gaps]` issues in the API repo** (#38–#65). See [api-gaps.md](api-gaps.md) for the mapping. +> - **The Operations section (§4–§5.1–5.3, §5.9) is web-only for now.** The specs adopted the console screens (OPS-*, SEC-01, CFG-*, RPT-01, INT-01) for the web Hub only, behind its same-origin Ops gateway. iOS skips them (PRODUCT-SPEC §2a, OPEN-QUESTIONS #27), and this app follows that. +> - **Help and documentation live in the shared help center** at . Every screen's **?** button opens its page there. + +--- + +## 1. Verdict: is the API ready to build against? + +**Yes for most of the management surface. No for three things the request explicitly asks for.** + +| Area in the request | API today | Ready? | +|---|---|---| +| User authentication: users, roles, permissions | Users CRUD, suspend, sessions, MFA reset, platform and app Roles, `effective-permissions`, Organizations | ✅ Build now | +| API key management | Full CRUD plus rotate, test/live mode, agent keys with `restrict_destructive`, app-confined scopes | ✅ Build now | +| Application management (list, add, configure, review, suspend) | `GET/POST/PATCH /applications`, review lifecycle, push credentials, app keys, webhooks | ⚠️ Partly. Adding, configuring and suspending work. **Removing an application has no endpoint** (no `DELETE`, no `archived` status). | +| Audit logs and user activity | `GET /audit-events` with rich filters, one-year hot window | ✅ for state changes. ⚠️ Sign-ins, failed logins, lockouts and `401`/`403`s go to CloudWatch only. No API reads them. | +| Monitoring dashboards: usage and performance | Nothing. Metrics exist only in CloudWatch (API Gateway, Lambda, Aurora). | ❌ Needs new endpoints | +| Alerts and notifications for critical events | CloudWatch alarms go to SNS and then **email only**. Platform push alerts exist, but only for `security.*`, `account.*` and `billing.*` user alerts. | ❌ Needs an ops-alert path | +| Configuration settings for the API system | Rate limits are hard-coded in `internal/core/ratelimit.go`. No feature flags and no system config resource. | ❌ Needs a config resource | +| Integrations with external tools | Webhooks (domain events), the MCP server, Stripe | ✅ for domain events. Ops integrations go through SNS (see §9). | + +Operational blockers that are not code: + +1. **Email links point at a domain that doesn't exist.** Inviting a user (USR-02) or sending a password reset sends a link to `dashboard.substratalapps.com`, which doesn't resolve. The same domain must host the `apple-app-site-association` file for Universal Links. It is the cheapest item on the list and it blocks the invite flow (see api-gaps G-01). +2. **`app_hub` must be registered** as an Application, with an APNs credential, before the app can receive push (client spec, CLIENT-ARCHITECTURE §2). +3. **The protected superadmin's password** has to be set with `cmd/admin bootstrap` or a reset (`remaining_manual_steps.md`). That account then needs TOTP enrolled before it is used on a phone. + +--- + +## 2. Scope decision: one app, admin first + +The client spec already defines this app. **Substratal Hub for iOS** is one universal SwiftUI app that serves every persona and gates by Capabilities. Administrators get a **Manage** tab that end users never see. The request describes a management app, and the Manage tab plus Developer section is that app. + +**Recommendation:** build the Hub's iOS codebase, but in admin-first order: the Manage tab, its screens, and a new **Operations** section for monitoring, alerts and config, which the spec doesn't cover. End-user screens (Home, Inbox, Orgs, Plan & Billing) come after, in the same codebase. This avoids a second admin-only app that would duplicate the session, API client, theming and push work. It also matches the spec's Definition of Done and the macOS sharing plan (`SubstratalKit`). + +The alternative is a separate admin-only app, "Substratal Console". It fits only if you want a different bundle ID, a private distribution channel (Apple Business Manager custom app or TestFlight only), or an MDM-managed admin device. It doubles the APNs credentials, since an Application has one APNs topic (spec OPEN-QUESTIONS #3). **This is the first decision for you to make (§14).** + +--- + +## 3. Users and what they can do + +These are the client spec's personas, limited to the ones the management app is for. The rights come from the API's platform permission catalog. + +| Persona | Platform role | What they manage | +|---|---|---| +| **Superadmin** (you, at first) | `superadmin` (protected) | Everything, including platform API keys, the application catalog, roles, and the new ops/config screens | +| **Support** | `support` | Users (read), access toggles, audit, tenants, billing (read) | +| **Billing admin** | `billing_admin` | Billing, refund authority, audit, users (read) | +| **App owner (developer)** | none: owns an Application | Their app's config, push credentials, app-scoped keys, webhooks, and grants confined to their app | +| **Custom narrow roles** | e.g. `support_readonly` | Defined in ROL-03 from the platform permission catalog | + +The ops screens add new platform permissions (`ops.view`, `ops.manage`, `config.manage`). See api-gaps G-04 to G-06. + +--- + +## 4. Information architecture + +### iPhone + +There are five tabs per the spec: **Home · Inbox · Orgs · Manage · Account**. During the admin-first milestones, the iPhone build opens to **Manage**, and the end-user tabs stay flag-gated until their milestone. + +``` +Manage (GLB-02 hub, grouped list, each row shows a count/health badge) +├── OPERATIONS ← new section, not in the client spec +│ ├── Overview OPS-01 health, traffic, errors, latency, alarms +│ ├── Alarms OPS-02 active + recent alarm history → OPS-03 detail +│ ├── Usage OPS-04 by application, by API key, by operation +│ ├── Security events OPS-05 sign-ins, failures, lockouts, 401/403, 429 +│ └── System config OPS-06 rate limits, feature flags, maintenance mode +├── ADMINISTRATION +│ ├── Users USR-01 → USR-03 (tabs: Overview · Access ACX-01 · Roles ROL-04 · Sessions USR-04 · Notifications NTF-05 · Activity) +│ ├── Access ACX-05 +│ ├── Roles ROL-01 → ROL-02 +│ ├── Audit log AUD-01 → AUD-02 +│ └── Send notification NTF-06 +└── DEVELOPER / PLATFORM + ├── Applications APL-01 → APL-03 (tabs: Overview · Settings schema · Permissions & roles · Keys · Webhooks · Push · Usage · Danger zone) + ├── API keys KEY-01 → KEY-04 + ├── Webhooks WHK-01 → WHK-03 → WHK-04 + └── Tenants BIL-04 +``` + +### iPad + +`TabView(.sidebarAdaptable)` shows the full tree in the sidebar, with `NavigationSplitView` list and detail panes. The Operations overview works as a full-width dashboard and stays readable in Split View at ⅓ width. It supports multiple windows: open a user's audit trail beside their access screen. + +### Routes and deep links + +The spec's routes stay as written. The new screens use `/ops`, `/ops/alarms/:alarmId`, `/ops/usage?app=…&key=…`, `/ops/security`, and `/ops/config`. An alarm push notification deep-links to `/ops/alarms/:alarmId`. + +--- + +## 5. Feature set + +Each feature is listed with its screens and the API calls behind it. ✅ marks existing endpoints and 🆕 marks endpoints proposed in api-gaps.md. + +### 5.1 Operations overview (OPS-01): the landing screen for admins + +- **Health strip.** `GET /health` ✅ plus a `GET /ops/status` 🆕 rollup: API, database ACU vs. max, webhook queue depth and DLQ count, push worker backlog, and the last deploy SHA and time. +- **Traffic, last 1h/24h/7d.** Request count, 4xx and 5xx rate, and p50/p95/p99 latency against the SLO lines (p95 < 150 ms on hot paths, p95 < 400 ms on everything else, 99.9% availability). Data comes from `GET /ops/metrics` 🆕 and is drawn with Swift Charts. +- **Active alarms** come from `GET /ops/alarms?state=ALARM` 🆕. Tapping one opens OPS-03. +- **Top movers:** top 5 applications and API keys by request volume and by error rate (`GET /ops/usage` 🆕). +- **Attention list:** unhealthy webhooks (`GET /webhooks` ✅ shows status and `consecutive_failures`), failed push credentials ✅, the app review queue (`GET /applications?review_status=pending_review` ✅), and keys expiring within 14 days (`GET /api-keys` ✅, filtered client-side on `expires_at`). +- **Cost guardrail:** month-to-date AWS spend against the $75 warn and $150 hard budgets, from `GET /ops/cost` 🆕. + +### 5.2 Alarms and alerts (OPS-02 and OPS-03) + +- List the alarms (`api_5xx`, `lambda_errors`, `lambda_throttles`, `aurora_near_max`, `webhook_dlq`, the `security-*` alarms, and the budget alarm) with state, reason and history. +- **Push to admins:** when an alarm changes state, every user holding `ops.view` gets a time-sensitive APNs push in category `ops.alarm`, with actions **Acknowledge** and **Open**. See api-gaps G-05 for the SNS → Lambda → platform-alert path. +- **Acknowledge or silence:** acknowledgement is recorded in audit as `ops.alarm_acknowledged`, and silencing for 1h, 4h or 24h is supported (🆕). Thresholds stay in Terraform, so the app reads them and doesn't edit them. Infrastructure changes go through PRs (§9). + +### 5.3 Usage and analytics (OPS-04) + +- Grouped by **application**, **API key** or **operation**: requests, error rate, p95 latency, and `429` count. +- A per-key drill-down shows `last_used_at` ✅, its rate-limit bucket, and its top operations and error codes. +- Business metrics come from existing list endpoints and need no new API: users by status, active entitlements per app (`GET /entitlements?application_id=…` ✅), Standard subscribers, and org seats. +- Exports share CSV through the share sheet (`ShareLink`, `Transferable`). + +### 5.4 User and access administration (spec D6, D7, D8; all ✅) + +- **Directory (USR-01):** search (`q`), filters for status, app, org and created date, cursor pagination, and multi-select bulk suspend (one request per row with 4 in parallel until a bulk endpoint exists; gap G-11). +- **User overview (USR-03):** profile, status, MFA state, platform roles, organizations, and plan. +- **Access (ACX-01 to ACX-05):** the AccessSwitch per app. It shows the resolved status first and every path (personal, org seat, platform-managed) one tap away, plus "Why?" explanations from `effective-permissions`. Writes send `If-Match` and `Idempotency-Key`. +- **Roles (ROL-01 to ROL-04):** list and create platform roles, edit permissions (seed roles are read-only), and assign or remove roles. The API blocks escalation (`role_escalation_forbidden`) and the UI explains it. +- **Sessions (USR-04):** list and revoke a user's sessions. **Force sign-out everywhere** for another user needs gap G-12. Today it means suspending them, or revoking sessions one at a time. +- **Security actions:** suspend or reactivate, reset MFA (`DELETE /users/{id}/mfa/totp` ✅), resend the invitation, data export, and erasure request. Each has a confirm dialog that names the object. +- **Activity tab:** the audit trail filtered by `target_user_id` ✅, merged with the user's security events (G-03 🆕). + +### 5.5 Application management (spec D9, plus additions) + +- **List (APL-01):** all applications with filters for review status, visibility, access tier and owner. A health dot shows when webhooks or push credentials are failing. +- **Register (APL-02):** `POST /applications` with slug, name, launch URL and owner. Slug validation runs client-side with the same regex. The confirm step warns that **the slug is permanent**. +- **Configure (APL-03, APL-04, APL-05):** redirect URIs, `settings_schema` (a structured editor plus a raw JSON view), the permission catalog, role names, and the default role. +- **Review (APL-06):** the queue, approve, reject (notes required), and suspend or reinstate. +- **Access and permissions:** the users with access, grants confined to this app, and the app's API keys and webhooks, all in tabs. +- **Usage tab:** OPS-04 filtered to this app. +- **Danger zone → Remove application:** needs gap G-02. Until then, the app offers **Suspend** (which blocks new grants and new app tokens) and explains that permanent removal isn't available yet. + +### 5.6 API key management (spec D10, ✅) + +How keys are handled on the client: + +- **The app never holds an API key.** Admins act through their own platform session, using a user token. The API forbids API keys from managing keys (`user_token_required`), and putting a platform key on a phone would create a long-lived credential that can be lost with the device. API keys are for servers and agents only. +- **Create (KEY-02):** set the scope (platform or one application), permissions (only those valid for the scope, from `GET /permissions`), mode, intended use, and expiry. Defaults: `test` mode when the scope is a test application, `restrict_destructive` on for agent keys, and an expiry of 1 year. +- **Secret reveal (KEY-03):** the secret is shown once, in a sheet that can't be dismissed by swiping. It's hidden in the app-switcher snapshot and blanked while the screen is recorded. Copy writes to the pasteboard with a 60-second local-only expiry, using `UIPasteboard` `.expirationDate` and `.localOnly` so it doesn't travel by Universal Clipboard. The share sheet is offered only for AirDrop or a password manager. The secret is never cached or logged. +- **Rotate:** the guided flow is **zero-downtime rotation**, the API's recommended path: create a replacement key, cut the service over, confirm the old key's `last_used_at` stops advancing, then revoke the old key. In-place `rotate` is also available, with a warning that the old secret dies immediately. +- **Hygiene view:** keys never used, keys idle for 90+ days, agent keys with `restrict_destructive` off (audited), and keys close to expiry. + +### 5.7 Webhooks, push and integrations (spec WHK-*, APL-07, ✅) + +- Webhook list with health, delivery log, redeliver, send test, and rotate signing secret (shown once, like key secrets). +- Push credentials per application (APNs, FCM, Web Push), with status and failures. + +### 5.8 Audit log (spec D12, ✅) + +- Filters: actor, action (multi), target, application, organization, tenant, and time range. Shows the `Audit-Hot-Window-Start` boundary. +- Event detail shows a before/after diff and the request ID, and links to the actor and target. +- **Saved investigations:** filter presets stored on the device. Export is behind `audit_export`, which is Phase 3 in the API (gap G-13). + +### 5.9 System configuration (OPS-06, needs G-06) + +- **Rate limits:** read the defaults, and set per-operation and per-key overrides. NFR → Rate limiting already calls for raising per-key limits for billing integrations. +- **Feature flags:** server-driven flags for the client (`notifications`, `billing`, `organizations`, `audit_export`, …), replacing hard-coded defaults. The spec's "501 turns a flag off" fallback stays. +- **Maintenance mode:** writes return `503`, and reads still work. +- **Email sender and support URL defaults.** +- Every change requires step-up auth (§6), sends `If-Match`, and writes an audit event `config.updated` with before and after values. + +### 5.10 Notifications (spec D13, ✅) + +- NTF-06 sends a platform notification to a user. NTF-05 shows a user's notifications and devices for support. +- The admin's own inbox shows ops alarms (`ops.alarm`), security alerts, and account alerts. + +--- + +## 6. Authentication and security model for the app + +| Concern | Design | +|---|---| +| Sign-in | Embedded flow: `POST /auth/login` → `mfa_required` → `POST /auth/mfa/verify`. This is acceptable because the app is first-party (Trust Model). Hosted PKCE comes later, after `auth.substratalapps.com` exists. | +| Tokens | Access token (15 min) in memory only. Refresh token (rotating, 30d idle / 90d absolute) in the Keychain with `kSecAttrAccessibleWhenUnlockedThisDeviceOnly`. That's stricter than the spec's `AfterFirstUnlock`, because admin sessions shouldn't refresh while the phone is locked. Refresh is single-flight: a reused refresh token revokes the session. | +| MFA | **Required for anyone with platform permissions.** The client enforces it now: an admin without TOTP who signs in is sent to ACC-05 before the Manage tab unlocks. The server should enforce it too (G-07). `amr` in the access token shows whether `otp` was used. | +| Device lock | Face ID / Touch ID is required to open the app when the user holds any platform permission (`LAContext`, `.deviceOwnerAuthentication`). The app re-locks after 5 minutes in the background or 15 minutes idle (spec NFR §3). | +| Step-up | Destructive or credential actions (create or rotate a platform key, assign `superadmin`, remove an application, change system config, reset MFA) need Face ID on the device now. Server-side step-up comes with G-08. | +| Privacy | Admin data is held in memory only (spec §7). The app blurs on `scenePhase != .active`, and secrets are hidden while `UIScreen.isCaptured`. No PII, tokens or notification content goes into logs. | +| Lost device | Another admin revokes the session (USR-04). A sign-out on a device deactivates its push registrations server-side. | +| Network | ATS defaults with TLS 1.2+. Certificate pinning is optional. **Not recommended** at first, because the API Gateway/ACM certificate rotates. Pin the Amazon root CA if you want it. | + +--- + +## 7. iOS technology stack + +| Concern | Choice | Why | +|---|---|---| +| Language and UI | **Swift 6** (strict concurrency), **SwiftUI**, **iOS 17 minimum** (per spec) with the iOS 26 SDK | Native, matches the spec, Liquid Glass through standard components. iOS 17 gives `@Observable`, SwiftData and the newer `TabView`/charts APIs. | +| State | `@Observable` stores per feature, plus an app-wide `Capabilities` store | Simple, no third-party architecture framework. Add TCA only if the team already uses it. | +| API client | **`swift-openapi-generator`** with `OpenAPIURLSession`, generated from `openapi.yaml` (3.1) at build time by the SwiftPM plugin | Follows the spec's "screens never call HTTP directly" rule. The generated client drifts only when the spec changes. | +| PATCH semantics | A hand-written `Patch` tri-state (`absent / null / value`) for PATCH bodies | The API needs explicit `null` and field presence on PATCH. Generated optionals can't express that, which is the same reason the Go server writes its bodies by hand. | +| Middleware | Bearer auth with single-flight refresh, `Idempotency-Key` injection, `If-Match`/`ETag` store, `Retry-After`/`429` backoff, `X-Request-Id` capture, `501` → feature-flag-off | Every rule in CLIENT-ARCHITECTURE §4 lives in one layer. | +| Charts | **Swift Charts** | Built in, accessible (Audio Graphs), themeable, no dependency. | +| Storage | Keychain for the refresh token. SwiftData or files with `NSFileProtectionComplete` for non-admin caches only. | Admin data stays in memory (spec §7). | +| Push | APNs through the API's own push pipeline as `app_hub`. **No Firebase or third-party push SDK.** | Free, and it's already built server-side. | +| Crash and performance | **MetricKit** plus Xcode Organizer. No third-party SDK at first. | Free and private. Add a hosted crash service only if Organizer's data turns out too thin. | +| Shared code | A `SubstratalKit` Swift package (API client, Session, Capabilities, Theme, models) used by iOS now and macOS later | Per the spec. | +| Dependencies | `swift-openapi-generator`, `swift-openapi-runtime`, `swift-openapi-urlsession`, and `swift-collections` if needed. Nothing else. | Fewer dependencies mean less churn and less supply-chain risk. | +| Testing | Swift Testing for units, XCUITest for the persona matrix, and snapshot tests of key screens in the 2 default themes | | +| CI/CD | **Xcode Cloud** (25 compute hours a month are included with the $99/yr Developer Program) builds every PR and ships `main` to TestFlight | GitHub-hosted macOS runners cost 10× the Linux rate, and Xcode Cloud fits the free allowance at this team size. | +| Environments | Build configs: `Debug-Local` (devserver at `localhost:8080`), `Debug-Prod-Test` (prod with test-mode sign-in), and `Release` | There's no staging API by design (one AWS account at Tier 0). The API's test mode is the sandbox. | +| Distribution | TestFlight for admins first, then the App Store (universal) once end-user screens ship. Use Apple Business Manager custom-app distribution if you choose the separate admin-only app. | | + +### Project layout + +``` +substratalapps-ios/ +├── App/ SubstratalHub (iOS target): App, scenes, routing, tabs +├── Features/ one folder per domain: Ops, Users, Access, Roles, Audit, +│ Applications, Keys, Webhooks, Notifications, Account, Auth +├── Packages/SubstratalKit/ +│ ├── Sources/API/ openapi.yaml (vendored + checksum) → generated client, middleware +│ ├── Sources/Session/ login, MFA, refresh, Keychain, biometric lock +│ ├── Sources/Capabilities/ permission gating +│ ├── Sources/Theme/ SubstratalTheme.swift from the spec package +│ └── Sources/Models/ Patch, ID prefixes, enums with unknown-case fallback +├── Extensions/NotificationService/ notification service extension (category actions) +├── Tests/ UITests/ +├── design/ theme exploration (existing) +└── docs/ this plan, api-gaps.md +``` + +Enums must decode unknown values to an `.unknown(String)` case. The API's versioning rules make adding enum values a non-breaking change. + +--- + +## 8. AWS architecture and cost + +**The iOS app adds no always-on infrastructure.** It calls `api.substratalapps.com` directly. CORS doesn't apply to a native client, so the browser-only BFF the web spec needs (OPEN-QUESTIONS #14) isn't needed here. The new server pieces fit into the existing Lambda functions and Aurora: + +``` +iPhone/iPad ──HTTPS──► API Gateway (HTTP API) ──► API Lambda (Go) + ├─ existing 118 operations → Aurora (Data API) + ├─ /ops/metrics, /status → CloudWatch GetMetricData (60 s in-memory cache) + ├─ /ops/alarms → CloudWatch DescribeAlarms + ├─ /ops/usage → Aurora usage_hourly rollup table + ├─ /ops/cost → Cost Explorer (cached 6 h, $0.01/call) + └─ /system/config → Aurora system_config (cached 60 s) +Jobs Lambda (existing, hourly) ── Logs Insights query over the API log group ──► usage_hourly, security_events +CloudWatch alarms ─► SNS alarms topic (existing) ─► ops-alert Lambda (new, tiny) ─► platform alert → APNs via app_hub +S3 + CloudFront (new) ─► dashboard.substratalapps.com: apple-app-site-association + verify/reset/invite pages +``` + +| New item | Approx. monthly cost at Tier 0 | +|---|---| +| CloudWatch `GetMetricData`: about 20 metrics per dashboard load, cached 60 s | Under $1 at admin-scale use ($0.01 per 1,000 metrics requested) | +| Logs Insights hourly rollup | Cents. $0.005 per GB scanned, and Tier 0 logs are MB-scale. | +| Cost Explorer API, cached 6 h | About $1.20 (4 calls a day at $0.01) | +| ops-alert Lambda plus SNS fan-out | ~$0, within the free tier | +| `dashboard.substratalapps.com` static site (S3 + CloudFront + ACM) | ~$0 to $1 | +| Aurora rows for rollups and config | ~$0, within the existing floor | +| **Total added** | **About $1 to $3 a month** on top of the $45 to $55 floor | +| Apple Developer Program | $99/year (Xcode Cloud's 25 h/month is included) | + +Things to avoid: a per-request write to Aurora for usage, which adds latency and cost on the hot path; high-cardinality custom CloudWatch metrics per key, at $0.30 per metric per month, which grow with the key count; Amazon Managed Grafana at $9+ per user per month; a NAT gateway; and a second API environment. + +--- + +## 9. Integrations + +| Integration | How | Cost | +|---|---|---| +| Slack or Teams alarm channel | Point the existing SNS alarms topic at AWS Chatbot | Free | +| PagerDuty or Opsgenie | SNS → their HTTPS endpoint, or an EventBridge rule | Vendor plan | +| Downstream systems reacting to domain events | Webhooks (✅): `access.revoked`, `role.removed`, `user.*`, `application.*`, … | — | +| AI assistants | The MCP server (✅) with an `agent` key that has `restrict_destructive` on. The app can create such keys with the safe preset. | — | +| Billing | Stripe portal and refunds stay in Stripe. The app links out (`billing.manage`). | — | +| Infrastructure changes | Terraform through GitHub PRs. The app shows the deploy SHA and links to the release PR, but it doesn't run deploys. | — | +| Shortcuts and Siri | App Intents for "Turn off access for {person} in {app}" (with confirmation) and "Show API health" | — | +| Widgets | A Home or Lock Screen widget for API health (5xx rate and active alarms), refreshed by a silent push on alarm state changes plus a timeline every 30 min | — | + +--- + +## 10. Design system + +- Use the spec's themes from `ios/theme/SubstratalTheme.swift`: 5 light and 5 dark, with Moss Paper and Forest Floor as defaults. SET-01 offers the choice. +- `design/theme-options.html` in this repo proposes a **different** set of six themes (Bedrock, Quartz, Lichen, Strata, Obsidian, Basalt). One set has to win, or the spec's `THEMES.md` gets updated. This is decision §14-3. +- Operations screens use the existing status semantics (`status-semantics.json`) for ok/warn/fail, never color alone. Each chart has an accessible summary and Audio Graph support. +- Use SF Symbols per INFORMATION-ARCHITECTURE §5. Suggested symbols for the new sections: Operations `waveform.path.ecg`, Alarms `bell.badge`, Usage `chart.bar.xaxis`, Security events `lock.shield`, System config `slider.horizontal.below.square.and.square.filled`. + +--- + +## 11. Milestones + +Each milestone is shippable through TestFlight. + +| # | Milestone | Content | API dependencies | +|---|---|---|---| +| M0 | Foundations (1–2 wk) | Xcode project, `SubstratalKit`, generated client plus middleware, Session (login, MFA, refresh, Keychain, biometric lock), Capabilities, theming, Manage hub shell, Xcode Cloud → TestFlight | None. Ops: set the superadmin password and enroll TOTP. | +| M1 | Core admin (3–4 wk) | Users, Access, Roles, Audit, Sessions, MFA reset. Bulk actions client-side. | None (all ✅) | +| M2 | Platform admin (2–3 wk) | Applications (register, configure, review, suspend), API keys (create, reveal, zero-downtime rotation, hygiene), Webhooks, Push credentials, Tenants | G-01 (email-link domain) to send invitations for real | +| M3 | Operations (3 wk) | OPS-01 overview, OPS-02/03 alarms with push, OPS-04 usage, OPS-05 security events | G-03, G-04, G-05; `app_hub` registered with APNs | +| M4 | Config and hardening (2 wk) | OPS-06 system config, server-side MFA requirement, step-up, application removal, widgets, App Intents | G-02, G-06, G-07, G-08 | +| M5 | End-user Hub (later) | Home, Inbox, Orgs, Account, Plan & Billing, so the iOS Hub matches the full spec | Billing upgrade on iOS stays off pending App Store policy (spec OPEN-QUESTIONS #10) | + +These assume one experienced iOS developer working with AI assistance. API work for M3 and M4 runs in parallel in the Go repo. + +--- + +## 12. Testing and acceptance + +- **Persona matrix:** a test account per persona, in both test mode and live mode. Check that the tab set and every action match Capabilities. +- **Contract:** decode real responses captured from the devserver (`make dev` in the API repo), and fail on unknown fields only in debug builds. +- **Security:** no secrets in logs (CI greps for `satk_`, `rtk_` and `eyJ` in captured logs), a screenshot/recording test on KEY-03, and Keychain wiped on sign-out. +- **Resilience:** 401 refresh replay, 409 `version_conflict` reload/re-apply, 429 countdown, 503 banner, and offline. +- **Accessibility:** VoiceOver, Dynamic Type at AX5, and charts with Audio Graphs. +- **Performance:** cold start to Manage in ≤ 1.5 s, and 60/120 fps scroll on a 10k-row audit list. + +--- + +## 13. Risks + +| Risk | Mitigation | +|---|---| +| A lost phone holding superadmin power | Required MFA, biometric lock, Keychain `WhenUnlockedThisDeviceOnly`, server-side MFA enforcement (G-07), and a second superadmin able to revoke sessions | +| App Store review of an admin-heavy app | Provide a demo account with a non-superadmin custom role. A separate admin app could use custom-app distribution instead. | +| The client spec drifts from the API: the spec still says notifications return `501` and calls the API "v1.4.0", while the API docs never use that number and notifications shipped 2026-10-18 | Vendor `openapi.yaml` with a checksum and regenerate on a schedule. Update the spec (api-gaps "Spec drift"). | +| Usage metrics lag by an hour | The overview combines near-real-time CloudWatch metrics (1-min resolution) with the hourly rollups for per-app and per-key breakdowns. Label the freshness on screen. | + +--- + +## 14. Decisions needed from you + +1. **One Hub app built admin-first (recommended), or a separate admin-only app?** +2. **Distribution:** TestFlight-only for admins, or the App Store from the start? +3. **Themes:** the spec's 5+5 (Moss Paper / Forest Floor), or the six in `design/theme-options.html`? +4. **Application removal semantics** (api-gaps G-02): soft archive (recommended) or hard delete? +5. **Ops permissions:** new `ops.view` / `config.manage` permissions, or reuse `audit.view` / superadmin-only? diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..dbb7e7a --- /dev/null +++ b/project.yml @@ -0,0 +1,91 @@ +# Source of truth for SubstratalHub.xcodeproj. Regenerate with `xcodegen generate` (or `make project`) after +# editing this file; the generated project is committed so the repo opens in Xcode without extra tools. +name: SubstratalHub +options: + bundleIdPrefix: com.substratal + deploymentTarget: + iOS: "17.0" + xcodeVersion: "27.0" + createIntermediateGroups: true + groupSortPosition: top +settings: + base: + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + DEVELOPMENT_TEAM: BJA9558E4B + CODE_SIGN_STYLE: Automatic + MARKETING_VERSION: "0.1.0" + CURRENT_PROJECT_VERSION: "1" + ENABLE_USER_SCRIPT_SANDBOXING: YES + DEAD_CODE_STRIPPING: YES +packages: + SubstratalKit: + path: Packages/SubstratalKit +targets: + SubstratalHub: + type: application + platform: iOS + sources: + - path: App + dependencies: + - package: SubstratalKit + product: SubstratalCore + - package: SubstratalKit + product: SubstratalUI + info: + path: App/Support/Info.plist + properties: + CFBundleDisplayName: Substratal + LSApplicationCategoryType: public.app-category.productivity + UILaunchScreen: + UIImageName: Brand/BrandMark + UIColorName: Brand/IconBackground + UIApplicationSceneManifest: + UIApplicationSupportsMultipleScenes: true + UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight] + UISupportedInterfaceOrientations~ipad: [UIInterfaceOrientationPortrait, UIInterfaceOrientationPortraitUpsideDown, UIInterfaceOrientationLandscapeLeft, UIInterfaceOrientationLandscapeRight] + CFBundleURLTypes: + - CFBundleURLName: com.substratal.hub + CFBundleURLSchemes: [substratal] + NSFaceIDUsageDescription: Substratal uses Face ID to unlock administration and to confirm sensitive changes. + ITSAppUsesNonExemptEncryption: false + entitlements: + path: App/Support/SubstratalHub.entitlements + properties: + com.apple.developer.associated-domains: + - applinks:dashboard.substratalapps.com + - webcredentials:dashboard.substratalapps.com + aps-environment: development + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.substratal.hub + TARGETED_DEVICE_FAMILY: "1,2" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + SUPPORTS_MACCATALYST: NO + SubstratalHubUITests: + type: bundle.ui-testing + platform: iOS + sources: + - path: UITests + dependencies: + - target: SubstratalHub + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.substratal.hub.uitests + GENERATE_INFOPLIST_FILE: YES +schemes: + SubstratalHub: + build: + targets: + SubstratalHub: all + SubstratalHubUITests: [test] + run: + config: Debug + commandLineArguments: + "-demo": false + test: + config: Debug + targets: + - SubstratalHubUITests + - package: SubstratalKit/SubstratalCoreTests diff --git a/scripts/derive-openapi.py b/scripts/derive-openapi.py new file mode 100755 index 0000000..3685ada --- /dev/null +++ b/scripts/derive-openapi.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Derives the copy of openapi.yaml that swift-openapi-generator reads from the untouched upstream file. + + scripts/derive-openapi.py api-contract/openapi.yaml Packages/SubstratalKit/Sources/SubstratalAPI/openapi.yaml + +Three changes, each so the generated client honours the API's contract rather than breaking on it: + +1. The per-operation `ApiVersion` header parameter is removed. APIVersionMiddleware sends + `Substratal-Version` on every request (CLIENT-ARCHITECTURE §4.1), so no call site passes it. +2. `enum` constraints are removed from string schemas. The generator emits frozen Swift enums, and one + unknown value would fail the whole response, but the API may add enum values without a version bump + (NFR → Versioning: "treat an unrecognized enum value as handle generically"). The domain layer maps the + strings, with an explicit unknown case. +3. Nullable shapes (`oneOf: [X, {type: "null"}]`, `type: [T, "null"]`) become plain X / T, and such properties + are dropped from `required`, so an explicit `null` decodes as nil. The generator can't express them otherwise. +""" +import sys +import yaml + +source, target = sys.argv[1], sys.argv[2] +doc = yaml.safe_load(open(source, encoding="utf-8")) + +def is_null(schema): + return isinstance(schema, dict) and schema.get("type") == "null" and len(schema) == 1 + +def unwrap_nullable(schema): + """Returns (schema without null, was_nullable).""" + if not isinstance(schema, dict): + return schema, False + for key in ("oneOf", "anyOf"): + options = schema.get(key) + if isinstance(options, list) and any(is_null(o) for o in options): + rest = [o for o in options if not is_null(o)] + merged = {k: v for k, v in schema.items() if k != key} + if len(rest) == 1: + merged.update(rest[0] if "$ref" not in rest[0] else {"allOf": [rest[0]]} if merged else rest[0]) + else: + merged[key] = rest + return merged, True + t = schema.get("type") + if isinstance(t, list) and "null" in t: + rest = [x for x in t if x != "null"] + schema = dict(schema) + schema["type"] = rest[0] if len(rest) == 1 else rest + return schema, True + return schema, False + +def walk(node): + if isinstance(node, dict): + # Rule 2: open enums. + if "enum" in node and node.get("type", "string") == "string": + node.pop("enum") + # Rule 3: nullable properties. + props = node.get("properties") + if isinstance(props, dict): + nullable = set() + for name, prop in list(props.items()): + unwrapped, was = unwrap_nullable(prop) + if was: + props[name] = unwrapped + nullable.add(name) + if nullable and isinstance(node.get("required"), list): + node["required"] = [r for r in node["required"] if r not in nullable] or None + if node["required"] is None: + node.pop("required") + for key, value in list(node.items()): + node[key] = walk(value) + return node + if isinstance(node, list): + # Rule 1: drop the version header parameter reference wherever it's listed. + return [walk(x) for x in node if not (isinstance(x, dict) and x.get("$ref") == "#/components/parameters/ApiVersion")] + return node + +walk(doc) +header = ("# DERIVED by scripts/derive-openapi.py from api-contract/openapi.yaml. Do not edit; run scripts/sync-openapi.sh.\n") +with open(target, "w", encoding="utf-8") as out: + out.write(header) + yaml.safe_dump(doc, out, sort_keys=False, allow_unicode=True, width=120) diff --git a/scripts/generate-screen-ids.py b/scripts/generate-screen-ids.py new file mode 100755 index 0000000..fb0dd7f --- /dev/null +++ b/scripts/generate-screen-ids.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Regenerates Packages/SubstratalKit/Sources/SubstratalCore/Routing/ScreenID.swift from the shared screen index. + + scripts/generate-screen-ids.py [path/to/substratal-application-specs] + +Defaults to ../substratalapps-applications-specs/substratal-application-specs (a sibling checkout). +Console (domain C) screens are skipped: they are web-only (PRODUCT-SPEC §2a, OPEN-QUESTIONS #27). +""" +import pathlib, re, sys + +root = pathlib.Path(__file__).resolve().parent.parent +specs = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else root.parent / "substratalapps-applications-specs" / "substratal-application-specs" +index = (specs / "00-shared" / "SCREEN-SPECS.md").read_text(encoding="utf-8") +row = re.compile(r'^\| \[([A-Z]{3,4}-\d{2})\]\(#([^)]+)\) \| (.+?) \| (\S+) \| `([^`]+)` \| (\S+) \| (.+?) \|$') +rows = [m.groups() for m in map(row.match, (l.strip() for l in index.splitlines())) if m] +hub = [r for r in rows if r[3] != "C"] + +def case(sid): return sid.lower().replace("-", "") + +out = [ + "// Generated from 00-shared/SCREEN-SPECS.md (spec targeting API v1.8.0) by scripts/generate-screen-ids.py. Do not edit by hand.", + "// The 12 C · Console screens (OPS-*, SEC-01, CFG-*, RPT-01, INT-01) are web-only; iOS skips them (PRODUCT-SPEC §2a, OPEN-QUESTIONS #27).", + "import Foundation", "", + "/// Every Hub screen, with the canonical ID, route and archetype from the shared spec.", + "public enum ScreenID: String, CaseIterable, Identifiable, Sendable, Codable {", +] +out += [f' case {case(r[0])} = "{r[0]}"' for r in hub] +out += ["", " public var id: String { rawValue }", "", + " public struct Info: Sendable, Hashable {", + " public let title: String", " public let domain: String", " public let route: String", + " public let archetype: String", " public let phase: String", + " /// The heading anchor in SCREEN-SPECS.md and in the help center.", + " public let anchor: String", " }", "", + " public var info: Info {", " switch self {"] +for sid, anchor, title, dom, route, arch, phase in hub: + t = title.replace('"', '\\"') + out.append(f' case .{case(sid)}: Info(title: "{t}", domain: "{dom}", route: "{route}", archetype: "{arch}", phase: "{phase}", anchor: "{anchor}")') +out += [" }", " }", "", " public var title: String { info.title }", "}", ""] +target = root / "Packages/SubstratalKit/Sources/SubstratalCore/Routing/ScreenID.swift" +target.write_text("\n".join(out), encoding="utf-8") +print(f"{len(hub)} screens → {target.relative_to(root)}") diff --git a/scripts/sync-openapi.sh b/scripts/sync-openapi.sh new file mode 100755 index 0000000..067f62b --- /dev/null +++ b/scripts/sync-openapi.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Vendors the Platform API contract and derives the copy the Swift generator reads. +# +# scripts/sync-openapi.sh # latest published spec (the API's docs site, built from its main branch) +# scripts/sync-openapi.sh # a local openapi.yaml, e.g. ../substratalapps.com/docs/openapi.yaml +# +# api-contract/openapi.yaml is the untouched upstream file, and api-contract/CONTRACT.json records where it came from. +# Packages/SubstratalKit/Sources/SubstratalAPI/openapi.yaml is derived from it by scripts/derive-openapi.py +# (version header handled by middleware, open enums, nullable fields); see that script for why. +set -euo pipefail +cd "$(dirname "$0")/.." + +PUBLISHED_URL="https://compositecode.github.io/substratalapps.com/openapi.yaml" +SOURCE="${1:-$PUBLISHED_URL}" +UPSTREAM="api-contract/openapi.yaml" +DERIVED="Packages/SubstratalKit/Sources/SubstratalAPI/openapi.yaml" + +if [[ -f "$SOURCE" ]]; then [[ "$SOURCE" -ef "$UPSTREAM" ]] || cp "$SOURCE" "$UPSTREAM"; else curl -fsSL "$SOURCE" -o "$UPSTREAM"; fi + +python3 -c "import yaml" 2>/dev/null || python3 -m pip install --quiet --user pyyaml +python3 scripts/derive-openapi.py "$UPSTREAM" "$DERIVED" + +python3 - "$UPSTREAM" "$SOURCE" <<'PY' +import hashlib, json, re, sys, datetime +path, source = sys.argv[1], sys.argv[2] +text = open(path, encoding="utf-8").read() +info_version = re.search(r'^ version: "?([^"\n]+)"?', text, re.M).group(1) +api_versions = re.search(r'ApiVersion:\n(?:.*\n)*?\s+schema: \{ type: string, enum: \[([^\]]*)\] \}', text) +operations = len(re.findall(r'^\s+operationId: ', text, re.M)) +record = { + "source": source, + "sha256": hashlib.sha256(text.encode()).hexdigest(), + "info_version": info_version, + "substratal_versions": [v.strip().strip('"') for v in api_versions.group(1).split(",")] if api_versions else [], + "operations": operations, + "synced_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), +} +# Keep the previous timestamp when the contract is unchanged, so a no-op sync leaves no diff. +try: + previous = json.load(open("api-contract/CONTRACT.json")) + if previous.get("sha256") == record["sha256"]: + record["synced_at"] = previous.get("synced_at", record["synced_at"]) +except (OSError, ValueError): + pass +json.dump(record, open("api-contract/CONTRACT.json", "w"), indent=2) +open("api-contract/CONTRACT.json", "a").write("\n") +print(f"openapi.yaml {info_version}: {operations} operations, Substratal-Version {record['substratal_versions']}") +PY