# Substratal Hub Application Specifications — full shared spec > Every shared document of the Substratal Hub specification package, concatenated. Platform-specific specs: https://compositecode.github.io/substratalapps-applications-specs/platforms/web.md · https://compositecode.github.io/substratalapps-applications-specs/platforms/ios.md · https://compositecode.github.io/substratalapps-applications-specs/platforms/android.md · https://compositecode.github.io/substratalapps-applications-specs/platforms/windows.md · https://compositecode.github.io/substratalapps-applications-specs/platforms/linux.md · https://compositecode.github.io/substratalapps-applications-specs/platforms/macos.md --- # README.md Source: https://compositecode.github.io/substratalapps-applications-specs/spec/README.md ## Substratal Applications Specifications Build specifications for the six **Substratal Hub** user-interface apps — **macOS, Windows, iOS/iPadOS, Android, Linux (GNOME) and Web (responsive PWA, based on the macOS design)** — against the **Substratal Apps Platform API spec v1.4.0** (2026-10-18; `openapi.yaml` 1.0.0-draft.3, 118 operations): https://compositecode.github.io/substratalapps.com/ This edition adds the API's **push notifications and notification inbox**, the **Free / Standard** subscription model, and **platform-managed entitlements**. See `00-shared/CHANGES.md` for everything that moved. This edition also adds the **Substratal logo standard** (`00-shared/brand/`) with a complete logo and app-icon kit for every platform (`/brand-assets/`). The logo is placed in every design. It also adds the **theme system**: 5 light and 5 dark themes, defaults **Moss Paper** (light) and **Forest Floor** (dark), user-selectable in Settings › Appearance (`00-shared/THEMES.md`). Every screen is rendered in both defaults, and each platform has its own theme code in `/theme/`. ### Layout ``` substratal-application-specs/ ├── README.md ├── 00-shared/ source of truth for every team │ ├── THEMES.md theme system: roles, 10 palettes, contrast, persistence, platform mapping │ ├── themes/ themes.json · themes.csv · themes-overview.png │ ├── brand/ logo standard: BRAND.md, tokens, SVG masters, PNG exports, logo-standard.png │ ├── CHANGES.md what changed since the previous package (API v1.2.2 → v1.4.0) │ ├── PRODUCT-SPEC.md scope, personas, 13 domains → 75 screens, phases, acceptance criteria │ ├── SCREEN-SPECS.md every screen: route, who, loads, elements (kind · data · rules), actions (API call · result handling), states │ ├── INFORMATION-ARCHITECTURE.md nav tree + routes, visibility rules, per-platform containers, deep links, icons │ ├── CLIENT-ARCHITECTURE.md sessions, request conventions, Capabilities, the Hub as app_hub, push + inbox, caching, billing hand-off, flags, security │ ├── UX-FLOWS.md · flows/*.svg · ux-flows.html 16 end-to-end flows │ ├── COMPONENTS.md behavior contracts + native mapping on all 6 platforms │ ├── COPY-AND-STATES.md labels, confirmations, empty states, upsell copy, error messages, notification actions │ ├── ACCESSIBILITY.md · NON-FUNCTIONAL.md · OPEN-QUESTIONS.md │ ├── status-semantics.json status → label, semantic role, icons per platform │ └── API-SCREEN-MAP.csv all 118 operations → screens (100 % covered) └── macos/ windows/ ios/ android/ linux/ web/ one self-contained folder per team ├── README.md reading order + definition of done ├── PLATFORM-SPEC.md stack, navigation, interaction, push/notifications, billing, integrations, distribution, testing ├── SCREENS.md all 75 screens with this platform's container and native control per element ├── theme/ native theme code (Swift / XAML+C# / Kotlin / GTK CSS / CSS+TS) ├── brand-assets/ app icons + logo files in this platform's native formats ├── screens/{light,dark}/*.png · mockups/ reference renders (web: desktop, tablet, phone + interaction examples) └── shared/ copy of 00-shared ``` ### Push notifications at a glance | App | How notifications arrive | |---|---| | iOS, iPadOS, macOS | APNs, as first-party app `app_hub` (one bundle id across iOS and macOS) | | Android | FCM, as `app_hub` | | Web | Web Push (VAPID), as `app_hub`; installed PWA on iOS/iPadOS | | Windows, Linux | The API has no push provider for them — the app polls and shows local notifications | Every app shows the same unified **Inbox** of all apps' notifications plus Substratal's security and account alerts. --- # 00-shared/CHANGES.md Source: https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/CHANGES.md ## Changes in this package This package targets **API spec v1.4.0** (2026-10-18; `openapi.yaml` 1.0.0-draft.3, **118 operations**). The previous package targeted v1.2.2 (98 operations). The API changes, and what they did to the apps: ### 0a. Themes (this revision) - Adds the **theme system**: 5 light themes (Teal Ledger, Indigo Root, **Moss Paper**, Coral Clay, Spore Pink) and 5 dark themes (Night Soil, Deep Teal, Graphite Spore, **Forest Floor**, Ember Clay). Defaults are Moss Paper and Forest Floor. Rules are in `THEMES.md`; tokens are in `themes/themes.json` and `themes/themes.csv`. - **SET-01** now has Light theme and Dark theme pickers next to Appearance, plus a new reference screen `SET-01-appearance-themes` on every platform. The choices are stored as `app_hub` settings (OPEN-QUESTIONS #22). - Every reference screen and mockup is rendered in **both default themes** (`screens/light/`, `screens/dark/`). Mockups preview any theme with `?mode=dark&light=&dark=`. - New per-platform theme code in `/theme/`: Swift `ThemeStore` and palettes (iOS/macOS); WinUI theme dictionaries and `ThemeService` (Windows); Material 3 colour schemes (Android); libadwaita CSS (Linux); CSS variables with a TypeScript resolver (web). - COMPONENTS adds **ThemePicker**. CLIENT-ARCHITECTURE adds §13 Themes. Theming is now in scope (PRODUCT-SPEC). ### 0. Logo standard and app icons (this revision) - Adopted logo: **interlined root twist** mark with the `[substratal]` wordmark (JetBrains Mono, coral brackets, ink `sub`, teal `stratal`, `◆ apps` descriptor). Rules: `brand/BRAND.md`. - Placed in every design: AUTH-01/02 sign-in (all platforms), web sidebar header, Windows title bar, notification rows for `source: platform`. Reference screens and mockups have been re-rendered. - New per-platform kits under `/brand-assets/`: Xcode asset catalogs (AppIcon with dark/tinted, Icon Composer layers, logo imagesets, colour sets), `.icns`, Windows `.ico` and full MSIX asset set, Android adaptive/themed/notification icons as vectors and PNGs, Linux hicolor and symbolic icons with `.desktop` and AppStream branding, and the web favicon set, PWA icons (any/maskable/monochrome), Web Push badge and OG image. - Each `PLATFORM-SPEC.md` has a new **Brand & app icon** section. COMPONENTS adds **BrandLockup**. ### 1. Push notifications and the notification inbox (API: Push Notifications, 17 new operations) | API change | Effect on the Hub | |---|---| | Devices per Application (`/users/{id}/devices`), APNs / FCM / Web Push | The Hub registers itself as first-party app **`app_hub`** and registers each install (CLIENT-ARCHITECTURE §2, §6). New flow F12, screens **NTF-03 Devices & notifications** and **NTF-04 Turn on notifications**. | | Inbox (`/users/{id}/notifications`, unread-count, mark-read) | The client-composed inbox is gone. **NTF-01 Inbox** and **NTF-02 Notification** read the real inbox; badges use `unread-count` everywhere. Inbox is a top-level destination on every platform (mobile tab bar: Home · Inbox · Orgs · Manage · Account). | | Platform alerts (`security.*`, `account.*`) pushed to apps with `receives_platform_notifications` | `app_hub` opts in; categories get native actions (Review sessions, Accept invitation…). Flow F13. | | `notifications.push` channel in Settings (global and per app) | Push toggles in SET-01 and SET-02. | | Push credentials per Application (`/applications/{id}/push-credentials`) | New **APL-07 Push credentials** tab for app owners; `receives_platform_notifications` toggle on APL-03. Flow F15. | | `notifications.send` permission and `POST /users/{id}/notifications` | New staff tool **NTF-06 Send notification**; app keys can carry confined `notifications.send` (KEY-02). | | Support can read any inbox and devices (`users.list`), delete devices (`users.manage`) | New user-detail tab **NTF-05**. Flow F16. | | Webhook events `device.*`, `notification.push_failed`, `push_credential.failed` | WHK-02 event list and "Push health" preset. | | Logout / session revoke / suspend / delete deactivate devices | Copy for sign-out, suspend and "sign out everywhere" updated. | | All Notifications operations return 501 until implemented | Everything above ships behind flags that a 501 turns off. | | No WNS provider; no Linux push | Windows and Linux keep polling and show local notifications (OPEN-QUESTIONS #2). | The old push-relay assumption (previous OPEN-QUESTIONS #2) is withdrawn for iOS, macOS, Android and Web. ### 2. Free / Standard pricing (API: Billing rewritten) | API change | Effect on the Hub | |---|---| | Developers/Tenants are no longer billed; Tenant `plan`, `subscription_status`, `restricted` and `/tenants/{id}/usage` removed | Old "Plan & usage" developer screen removed. **BIL-04 Tenant & infrastructure** keeps tier/region and tier-change requests only, under Developer › Tenant. | | Users have `plan` and `plan_sources`; `GET /users/{id}/subscription`; individual Checkout/Portal | New **BIL-01 My plan** under Personal › Plan & Billing (Account on mobile). Flow F10 rewritten. | | Organization subscription with `member_scope` and Checkout/Portal | New **BIL-02 Organization subscription** tab on organizations. Flow F14. | | Checkout success ≠ coverage; poll until `plan = standard` | **BIL-03** returns to `https://dashboard.substratalapps.com/billing/return…` and polls. | | Applications gain `access_tier` (`free` / `standard`) | Home separates "Included with Standard" tiles with an upsell; APL-02/03 show access tier (staff-only field). | | `402 subscription_required`, plan caps, `seat_limit` removed; `plan_limit_reached` reserved | Removed from every screen and from COPY. | | Store policy | Standard is now a consumer subscription unlocking apps → mobile upgrade buttons default off pending policy review (OPEN-QUESTIONS #10). | ### 3. Platform-managed entitlements | API change | Effect on the Hub | |---|---| | `Entitlement.source` adds `free` and `subscription`; `subscription_id` | New source labels; APP-02 and ACX-01 show "Managed by Substratal" paths. | | Only disable / re-enable allowed; anything else `409 entitlement_platform_managed` | ACX-01/03 hide term, scope and delete for those rows; confirm copy explains. | | Platform rows and developer grants coexist | ACX-02 no longer blocks on platform rows; ACX-05 adds a "Show platform-managed" filter. | ### 4. Smaller changes - Audit hot window is one year for every Tenant (AUD-01 reads `Audit-Hot-Window-Start`); new audit actions `subscription.*`, `push_credential.*`, `device.deleted`. - Seed role `superadmin` gains `notifications.send`; `billing.manage` now means people's and organizations' subscriptions. - Signup with an invited user's email returns `409 email_taken` and re-sends the invitation (AUTH-03 copy). - ID prefixes added: `sbs_`, `dvc_`, `pcr_`, `ntf_`, `ndl_`. - Web host is `https://dashboard.substratalapps.com` (matches the API's billing examples). ### 5. Package changes - 75 screens (was 68): +NTF-01…06, +APL-07, BIL screens replaced; GLB-03 Inbox folded into NTF-01; GLB-02 is now the combined **Manage** hub; GLB-03 is System states. - 16 flows (was 11): F10 rewritten; F12–F16 added. - New: `CHANGES.md`. Updated: every shared document and every platform spec and screen list; mockups add Inbox, Devices, My plan, and Push credentials screens. --- # 00-shared/PRODUCT-SPEC.md Source: https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/PRODUCT-SPEC.md ## Substratal Hub — Product Spec **What we're building:** six client apps — macOS, Windows, iOS/iPadOS, Android, Linux (GNOME) and a responsive Web app/PWA — together the **Substratal Hub**: the dashboard client the API spec plans for. Each is a full client of the **Substratal Apps Platform API, spec v1.4.0 (2026-10-18), `openapi.yaml` 1.0.0-draft.3, 118 operations.** People use the Hub to launch and manage their apps, read their notifications, choose Free or Standard, run their organizations, and — for staff and app developers — administer users, access, roles, applications, credentials, push and audit. **Source of truth:** https://compositecode.github.io/substratalapps.com/. If this package and the API spec disagree, the spec wins; record the difference in `OPEN-QUESTIONS.md`. What changed since the previous package is in `CHANGES.md`. **In scope:** visual design. That means the logo and app icons (`brand/BRAND.md`) and the theme system: 5 light and 5 dark themes with defaults Moss Paper and Forest Floor, chosen by the user in SET-01 (`THEMES.md`). This package defines structure, behavior, content, theming and native construction; every reference screen is rendered in both default themes. ### 1. Personas | Persona | Who | Rights | Main screens | |---|---|---|---| | **End user (Free)** | Every account | Own account; free apps | AUTH-*, ACC-*, APP-*, SET-*, NTF-01..04, BIL-01, ORG-01/09 | | **Subscriber (Standard)** | Pays $6/month for themselves, or covered by an organization | Every standard app | + BIL-01 manage billing | | **Org admin** | `OrganizationMembership.role = org_admin` | Members, seat grants, the org's Standard subscription and who it covers | ORG-*, BIL-02/03 | | **App owner (developer)** | Owns an Application (and therefore a Tenant) | App configuration, push credentials, app keys, webhooks, grants for own app. **Not billed** — Tenants have no plan. | APL-*, KEY-*, WHK-*, BIL-04, ACX-* (confined) | | **Support** | Seed role `support` | `users.list`, `entitlements.manage`, `audit.view`, `tenants.manage`, `billing.manage` | USR-*, ACX-*, NTF-05, AUD-*, BIL-04, BIL-01/02 (read) | | **Billing admin** | Seed role `billing_admin` | `billing.manage`, `billing.refund`, `audit.view`, `users.list` | BIL-01/02 (read + portal), USR-01/03, AUD-* | | **Superadmin** | Seed role `superadmin` | Everything, incl. `notifications.send` | All, incl. NTF-06 | ### 2. Domains and screens (75) | Domain | Purpose | Screens | |---|---|---| | D1 Sign-in & Identity | Get a session | AUTH-01 Sign in · AUTH-02 Two-factor · AUTH-03 Create account · AUTH-04 Verify email · AUTH-05 Forgot password · AUTH-06 New password · AUTH-07 Accept invitation | | D2 Account & Security | Own identity and safety | ACC-01 Account & profile · ACC-02 Change email · ACC-03 Change password · ACC-04 Security · ACC-05 Turn on 2FA · ACC-06 Turn off 2FA · ACC-07 Privacy · ACC-08 Delete account | | D3 My Apps | See and launch apps | APP-01 Home · APP-02 App info · APP-03 Launch · APP-04 App profile · APP-05 Past apps | | D4 Settings | Preferences, incl. push on/off | SET-01 Global · SET-02 Per app | | D5 Organizations | Teams and seat grants | ORG-01 List · ORG-02 New · ORG-03 Members · ORG-04 Invite · ORG-05 Member actions · ORG-06 Apps & seats · ORG-07 Grant / scope · ORG-08 Settings · ORG-09 Invitation | | D6 Users | Account administration | USR-01 Directory · USR-02 Invite · USR-03 Overview · USR-04 Sessions · USR-05 Suspend | | D7 Access | The on/off switch | ACX-01 User access · ACX-02 Grant · ACX-03 Turn off/edit · ACX-04 Why? · ACX-05 All access | | D8 Roles & Permissions | What people can do | ROL-01 Roles · ROL-02 Detail · ROL-03 New · ROL-04 User roles | | D9 Applications | Developer app config | APL-01 List · APL-02 Register · APL-03 Overview · APL-04 Settings schema · APL-05 Permissions & roles · APL-06 Review · **APL-07 Push credentials** | | D10 Developer credentials | Keys and webhooks | KEY-01 Keys · KEY-02 Create · KEY-03 Secret reveal · KEY-04 Key detail · WHK-01 Webhooks · WHK-02 Add/edit · WHK-03 Deliveries · WHK-04 Delivery | | D11 Plan & Billing | Free/Standard for people and orgs; tenancy | **BIL-01 My plan · BIL-02 Organization subscription · BIL-03 Checkout/portal · BIL-04 Tenant & infrastructure** | | D12 Audit Log | Accountability | AUD-01 Log · AUD-02 Event | | **D13 Notifications** | Inbox, push devices, staff tools | **NTF-01 Inbox · NTF-02 Notification · NTF-03 Devices & notifications · NTF-04 Turn on notifications · NTF-05 User notifications & devices (support) · NTF-06 Send notification (staff)** | | G Shell | Cross-cutting | GLB-01 Command palette · GLB-02 Manage hub · GLB-03 System states | ### 3. Core ideas every screen must respect 1. **Is the app on for this person?** → Entitlement status (`active`, `scheduled`, `disabled`, `expired`, `revoked`, `none`) — StatusBadge + AccessSwitch. 2. **If on, what can they do?** → Roles → Permissions — role list / permission checklist. 3. **Where does access come from?** A person can have several paths to one app: **platform-managed** (`free` for free apps, `subscription` while Standard covers them) and **developer grants** (`purchase`, `trial`, `admin_grant`, `org_seat`). They coexist. The UI shows the resolved status first and the paths one tap/click away. Platform-managed rows can only be turned off and back on — never edited, revoked or deleted. 4. **Who pays?** People, not developers. Free costs nothing; **Standard is $6 per user per month**, bought by a person for themselves or by an organization for the members it chooses. Payment always happens in Stripe in the browser. 5. **Notifications have two halves.** The **inbox** (API, always complete, 90 days) and **push** (best effort, per device). The Hub shows a unified inbox of every app's notifications plus Substratal's security and account alerts; it pushes only its own and platform alerts (it is the first-party app `app_hub` that opts into platform alerts). Push is never used to grant or revoke anything. ### 4. Phase gating Build every screen; ship behind flags (CLIENT-ARCHITECTURE §8): | Phase | Screens / features | |---|---| | **MVP** | D1 (no SSO/passkeys), D2, APP-01/02/03/05, SET-01, D6, ACX-01/02/03/05, ROL-* (platform scope), APL-01/02/03, KEY-*, D12, GLB-* | | **Phase 2** | APP-04, SET-02, ACX-04, app-scoped roles, APL-04/05, WHK-*, **D13 Notifications + APL-07**, **D11 Billing (Free/Standard)**, ORG-* (organizations and org subscriptions), BIL-04 tier changes | | **Phase 3** | Hosted launch (PKCE) and the hosted sign-in page (served by the Web Hub), SSO, APL-06 review + self-service registration, audit export | Notifications operations currently return `501 not_implemented` until the reference implementation ships them — the client treats 501 as "flag off". ### 5. Cross-platform consistency contract Every app matches on: screen inventory, IDs and routes; navigation sections and order; copy; status semantics (`status-semantics.json`); behavior contracts (AccessSwitch, SecretReveal, confirms, Undo windows, idempotency, conflicts, push handling); and every gesture/shortcut having a visible equivalent. Apps **differ** where their platform's conventions differ, as defined in each `PLATFORM-SPEC.md` — including which push service is available (§6 of CLIENT-ARCHITECTURE). ### 6. Acceptance criteria (each app) - [ ] All 75 screens implemented or flag-gated per phase; each reachable by route/deep link. - [ ] Every API operation in `API-SCREEN-MAP.csv` reachable (118/118; `/auth/oauth/token` is called by launched apps, not the Hub). - [ ] Capabilities gating matches INFORMATION-ARCHITECTURE for all personas (test account per persona, plus a Free and a Standard end user, and an org-covered user). - [ ] Loading, empty, error (401/403/404/409/422/429/501/503) and offline states on every screen. - [ ] Push: register, refresh, rotate, sign-out deactivation, tap-to-route, actionable categories and badge counts verified per CLIENT-ARCHITECTURE §6 (Windows/Linux: local notifications from polling). - [ ] Checkout and portal round-trips verified in Stripe test mode, including the "Finishing up…" poll. - [ ] Accessibility checklist passes with the platform screen reader and the largest text size. - [ ] Performance budgets met (NON-FUNCTIONAL.md). No secrets, tokens or notification content in logs or analytics. --- # 00-shared/SCREEN-SPECS.md Source: https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/SCREEN-SPECS.md ## Screen Specs Every screen in the Substratal Hub, identical across all six apps. Platform folders contain the same specs with native controls filled in (`SCREENS.md`). **Element kinds** are defined in `COMPONENTS.md`. **Archetypes:** `auth` Unauthenticated full-screen form, outside the app shell; `grid` Top-level collection rendered as tiles; `list` Top-level searchable/filterable collection (rows); `table` Top-level dense collection with columns (desktop), rows (compact); `detail` Object detail with header + tabs/segments; `tab` A tab/segment inside a detail screen; `form` Create/edit form presented modally (sheet/dialog); `settings` Grouped settings form, edits save per-row; `confirm` Destructive or consequential confirmation (alert/dialog), optional reason field; `secret` One-time secret reveal, cannot be dismissed until acknowledged; `wizard` Multi-step modal flow; `popover` Lightweight anchored or half-height panel for explanation; `handoff` Leaves the app (browser / other app) and handles the return; `overlay` App-wide overlay (palette, inbox panel); `hub` Mobile hub list linking to sections. Universal rules for every screen (not repeated below): Capabilities gating (INFORMATION-ARCHITECTURE §2), request conventions — Idempotency-Key on creates, If-Match on PATCH/DELETE, cursor pagination, error decoding (CLIENT-ARCHITECTURE §3), copy (COPY-AND-STATES), accessibility (ACCESSIBILITY). ### Index | ID | Screen | Domain | Route | Archetype | Phase | |---|---|---|---|---|---| | [AUTH-01](#auth-01--sign-in) | Sign in | D1 | `/sign-in` | auth | MVP | | [AUTH-02](#auth-02--two-factor-challenge) | Two-factor challenge | D1 | `/sign-in/mfa` | auth | MVP | | [AUTH-03](#auth-03--create-account) | Create account | D1 | `/sign-up` | auth | MVP | | [AUTH-04](#auth-04--verify-your-email) | Verify your email | D1 | `/verify-email` | auth | MVP | | [AUTH-05](#auth-05--forgot-password) | Forgot password | D1 | `/forgot-password` | auth | MVP | | [AUTH-06](#auth-06--set-a-new-password) | Set a new password | D1 | `/reset-password` | auth | MVP | | [AUTH-07](#auth-07--accept-invitation) | Accept invitation | D1 | `/invite` | auth | MVP | | [ACC-01](#acc-01--account--profile) | Account & profile | D2 | `/account` | settings | MVP | | [ACC-02](#acc-02--change-email) | Change email | D2 | `/account/email` | form | MVP | | [ACC-03](#acc-03--change-password) | Change password | D2 | `/account/security/password` | form | MVP | | [ACC-04](#acc-04--security) | Security | D2 | `/account/security` | settings | MVP | | [ACC-05](#acc-05--turn-on-two-factor) | Turn on two-factor | D2 | `/account/security/2fa/enable` | wizard | MVP | | [ACC-06](#acc-06--turn-off-two-factor) | Turn off two-factor | D2 | `/account/security/2fa/disable` | confirm | MVP | | [ACC-07](#acc-07--privacy) | Privacy | D2 | `/account/privacy` | settings | MVP | | [ACC-08](#acc-08--delete-account) | Delete account | D2 | `/account/privacy/delete` | confirm | MVP | | [APP-01](#app-01--home--my-apps) | Home — My Apps | D3 | `/` | grid | MVP | | [APP-02](#app-02--app-info) | App info | D3 | `/apps/:appId` | popover | MVP | | [APP-03](#app-03--launch-app) | Launch app | D3 | `/apps/:appId/launch` | handoff | MVP | | [APP-04](#app-04--app-profile) | App profile | D3 | `/apps/:appId/profile` | form | Phase 2 | | [APP-05](#app-05--past-apps) | Past apps | D3 | `/apps/past` | list | MVP | | [SET-01](#set-01--settings--global) | Settings — Global | D4 | `/settings` | settings | MVP | | [SET-02](#set-02--settings--per-app) | Settings — Per app | D4 | `/settings/apps/:appId` | settings | Phase 2 | | [ORG-01](#org-01--organizations) | Organizations | D5 | `/orgs` | list | MVP | | [ORG-02](#org-02--new-organization) | New organization | D5 | `/orgs/new` | form | MVP | | [ORG-03](#org-03--organization--members) | Organization — Members | D5 | `/orgs/:orgId/members` | detail | MVP | | [ORG-04](#org-04--invite-member) | Invite member | D5 | `/orgs/:orgId/members/invite` | form | MVP | | [ORG-05](#org-05--member-actions) | Member actions | D5 | `/orgs/:orgId/members/:userId` | confirm | MVP | | [ORG-06](#org-06--organization--apps--seats) | Organization — Apps & seats | D5 | `/orgs/:orgId/apps` | tab | Phase 2 | | [ORG-07](#org-07--grant-app-to-organization--member-scope) | Grant app to organization / member scope | D5 | `/orgs/:orgId/apps/grant` | form | Phase 2 | | [ORG-08](#org-08--organization--settings) | Organization — Settings | D5 | `/orgs/:orgId/settings` | settings | MVP | | [ORG-09](#org-09--invitation) | Invitation | D5 | `/orgs/:orgId/invitation` | confirm | MVP | | [USR-01](#usr-01--users) | Users | D6 | `/admin/users` | table | MVP | | [USR-02](#usr-02--invite-user) | Invite user | D6 | `/admin/users/new` | form | MVP | | [USR-03](#usr-03--user--overview) | User — Overview | D6 | `/admin/users/:userId` | detail | MVP | | [USR-04](#usr-04--user--sessions) | User — Sessions | D6 | `/admin/users/:userId/sessions` | tab | MVP | | [USR-05](#usr-05--suspend-user) | Suspend user | D6 | `/admin/users/:userId/suspend` | confirm | MVP | | [ACX-01](#acx-01--user--access) | User — Access | D7 | `/admin/users/:userId/access` | tab | MVP | | [ACX-02](#acx-02--grant-access) | Grant access | D7 | `/admin/users/:userId/access/grant` | form | MVP | | [ACX-03](#acx-03--turn-off--edit-access) | Turn off / edit access | D7 | `/admin/entitlements/:entId` | confirm | MVP | | [ACX-04](#acx-04--access-check-why) | Access check (Why?) | D7 | `/admin/users/:userId/apps/:appId/why` | popover | MVP | | [ACX-05](#acx-05--access-all) | Access (all) | D7 | `/admin/access` | table | MVP | | [ROL-01](#rol-01--roles) | Roles | D8 | `/admin/roles` | list | MVP | | [ROL-02](#rol-02--role-detail) | Role detail | D8 | `/admin/roles/:roleId` | detail | MVP | | [ROL-03](#rol-03--new-role) | New role | D8 | `/admin/roles/new` | form | MVP | | [ROL-04](#rol-04--user--roles) | User — Roles | D8 | `/admin/users/:userId/roles` | tab | MVP | | [APL-01](#apl-01--applications) | Applications | D9 | `/dev/apps` | list | MVP | | [APL-02](#apl-02--register-application) | Register application | D9 | `/dev/apps/new` | wizard | MVP | | [APL-03](#apl-03--application--overview) | Application — Overview | D9 | `/dev/apps/:appId` | detail | MVP | | [APL-04](#apl-04--settings-schema) | Settings schema | D9 | `/dev/apps/:appId/schema` | tab | Phase 2 | | [APL-05](#apl-05--permissions--roles) | Permissions & roles | D9 | `/dev/apps/:appId/roles` | tab | Phase 2 | | [APL-06](#apl-06--review) | Review | D9 | `/dev/apps/:appId/review` | form | Phase 3 | | [KEY-01](#key-01--api-keys) | API keys | D10 | `/dev/keys` | table | MVP | | [KEY-02](#key-02--create-api-key) | Create API key | D10 | `/dev/keys/new` | form | MVP | | [KEY-03](#key-03--secret-reveal) | Secret reveal | D10 | `(modal)` | secret | MVP | | [KEY-04](#key-04--api-key-detail) | API key detail | D10 | `/dev/keys/:keyId` | detail | MVP | | [WHK-01](#whk-01--webhooks) | Webhooks | D10 | `/dev/webhooks` | list | Phase 2 | | [WHK-02](#whk-02--add--edit-webhook) | Add / edit webhook | D10 | `/dev/webhooks/new` | form | Phase 2 | | [WHK-03](#whk-03--webhook--deliveries) | Webhook — Deliveries | D10 | `/dev/webhooks/:hookId` | detail | Phase 2 | | [WHK-04](#whk-04--delivery-detail) | Delivery detail | D10 | `/dev/webhooks/:hookId/deliveries/:deliveryId` | popover | Phase 2 | | [BIL-01](#bil-01--my-plan) | My plan | D11 | `/billing` | settings | Phase 2 | | [BIL-02](#bil-02--organization-subscription) | Organization subscription | D11 | `/orgs/:orgId/subscription` | tab | Phase 2 | | [BIL-03](#bil-03--checkout--billing-portal) | Checkout / billing portal | D11 | `/billing/return` | handoff | Phase 2 | | [BIL-04](#bil-04--tenant--infrastructure) | Tenant & infrastructure | D11 | `/dev/tenant` | detail | Phase 2 | | [AUD-01](#aud-01--audit-log) | Audit log | D12 | `/admin/audit` | table | MVP | | [AUD-02](#aud-02--audit-event) | Audit event | D12 | `/admin/audit/:eventId` | popover | MVP | | [NTF-01](#ntf-01--inbox) | Inbox | D13 | `/inbox` | list | Phase 2 | | [NTF-02](#ntf-02--notification) | Notification | D13 | `/inbox/:notificationId` | popover | Phase 2 | | [NTF-03](#ntf-03--devices--notifications) | Devices & notifications | D13 | `/account/devices` | settings | Phase 2 | | [NTF-04](#ntf-04--turn-on-notifications) | Turn on notifications | D13 | `/account/devices/enable` | wizard | Phase 2 | | [NTF-05](#ntf-05--user--notifications--devices) | User — Notifications & devices | D13 | `/admin/users/:userId/notifications` | tab | Phase 2 | | [NTF-06](#ntf-06--send-notification) | Send notification | D13 | `/admin/notifications/new` | form | Phase 2 | | [APL-07](#apl-07--push-credentials) | Push credentials | D9 | `/dev/apps/:appId/push` | tab | Phase 2 | | [GLB-01](#glb-01--command-palette) | Command palette | G | `(overlay)` | overlay | MVP | | [GLB-02](#glb-02--manage-hub) | Manage hub | G | `/manage` | hub | MVP | | [GLB-03](#glb-03--system-states) | System states | G | `(global)` | overlay | MVP | ### D1 · Sign-in & Identity #### AUTH-01 — Sign in **Domain:** D1 Sign-in & Identity · **Route:** `/sign-in` · **Archetype:** auth · **Phase:** MVP **Who:** Unauthenticated **Purpose:** Exchange email + password for a session, or start an MFA challenge. **Entry points:** App launch with no stored session; Session expired / revoked (preserves `?next=` destination); Sign out | Element | Kind | Data / API | Rules | |---|---|---|---| | Email | email-field | body.email | required; RFC 5322; trim; autofill username | | Password | password-field | body.password | required; reveal toggle; autofill current-password | | Sign in | primary-button | POST /auth/login | disabled until both fields non-empty | | Forgot password? | link | → AUTH-05 | | | Create an account | link | → AUTH-03 | | | Continue with SSO | button | POST /auth/sso/{provider}/callback | hidden behind flag `sso` (API returns 501 today) | | Sign in with a passkey | button | — | hidden behind flag `passkeys`; reserved for when the API supports WebAuthn | | Action | Call | Result handling | |---|---|---| | Sign in | `POST /auth/login` | 200 AuthSession → store tokens → go to `next` or APP-01. 200 MfaChallenge → AUTH-02 with mfa_token. | | — | | 401 → inline form error "Email or password is incorrect." (never reveal which). 403 → account suspended/locked message. 429 → disable button, countdown from Retry-After. | **States:** Submitting: button spinner, fields read-only; Locked out (5 failures / 15 min): explain wait time **Notes:** `test_mode` is never exposed in end-user UI; developer builds may show a hidden toggle (long-press logo / ⌥-click) for test-mode sign-in. The horizontal logo lockup (BrandLockup, `brand/BRAND.md`) sits above the form. #### AUTH-02 — Two-factor challenge **Domain:** D1 Sign-in & Identity · **Route:** `/sign-in/mfa` · **Archetype:** auth · **Phase:** MVP **Who:** Holder of mfa_token **Purpose:** Complete sign-in with a TOTP code or a recovery code. **Entry points:** AUTH-01 returned MfaChallenge | Element | Kind | Data / API | Rules | |---|---|---|---| | Code | otp-field | body.code | 6 digits; paste fills all; auto-submit on 6th digit; OS one-time-code autofill | | Use a recovery code | link | swaps to Recovery code field | only if `methods` includes recovery_code | | Recovery code | text-field | body.recovery_code | format xxxx-xxxx; case-insensitive | | Expiry countdown | caption | MfaChallenge.expires_in | announce at 60 s and 10 s | | Verify | primary-button | POST /auth/mfa/verify | | | Action | Call | Result handling | |---|---|---| | Verify | `POST /auth/mfa/verify` | 200 → same as AUTH-01 success. 401 → "That code didn't work." clear field. Expired → back to AUTH-01 with message. 429 → countdown. | **States:** Challenge expired #### AUTH-03 — Create account **Domain:** D1 Sign-in & Identity · **Route:** `/sign-up` · **Archetype:** auth · **Phase:** MVP **Who:** Unauthenticated **Purpose:** Self-service signup with email and password. **Entry points:** AUTH-01 link; Marketing/app deep link with optional `application_id` | Element | Kind | Data / API | Rules | |---|---|---|---| | Display name | text-field | body.display_name | optional; ≤ 100 | | Email | email-field | body.email | required | | Password | password-field | body.password | required; strength meter; autofill new-password; show policy hint | | Create account | primary-button | POST /auth/signup | | | Terms / Privacy | links | external | | | Action | Call | Result handling | |---|---|---| | Create account | `POST /auth/signup` | 201 AuthSession → AUTH-04 (verify pending) then APP-01. 409 email_taken → "An account with this email may already exist. Sign in or reset your password." (with details.reason invitation_pending the API re-sends the invitation: say "Check your email for your invitation.") 422 → field errors. | **Notes:** Pass `application_id` through when signup started from an app's link; it is recorded as signup_application_id. Every new User starts on the Free plan and receives platform-managed `free` Entitlements to every free Application, including the Hub itself (`app_hub`). No plan choice at signup; upgrading is BIL-01. #### AUTH-04 — Verify your email **Domain:** D1 Sign-in & Identity · **Route:** `/verify-email` · **Archetype:** auth · **Phase:** MVP **Who:** Signed in (unverified) or token holder **Purpose:** Tell the user to check email; complete verification from the emailed link. **Entry points:** After signup; Persistent banner on APP-01 while `email_verified` is false; Deep link `/verify-email?token=…` | Element | Kind | Data / API | Rules | |---|---|---|---| | Instruction | text | User.email | "We sent a link to {email}." | | Resend email | button | POST /auth/email/verify/resend | 30 s cooldown after each send | | Continue to Substratal | button | → APP-01 | unverified users may continue | | Change email | link | → ACC-02 | | | Action | Call | Result handling | |---|---|---| | Deep link opened | `POST /auth/email/verify {token}` | 204 → success state, refresh User. 400 → "This link has expired." + Resend. | | Resend email | `POST /auth/email/verify/resend` | Always 202 → toast "If that address needs verifying, we sent a new link." | #### AUTH-05 — Forgot password **Domain:** D1 Sign-in & Identity · **Route:** `/forgot-password` · **Archetype:** auth · **Phase:** MVP **Who:** Unauthenticated **Purpose:** Request a password-reset email. | Element | Kind | Data / API | Rules | |---|---|---|---| | Email | email-field | body.email | prefilled from AUTH-01 | | Send reset link | primary-button | POST /auth/password/forgot | | | Action | Call | Result handling | |---|---|---| | Send reset link | `POST /auth/password/forgot` | Always 202 → confirmation "If an account exists for {email}, we sent a reset link." 429 → countdown. | #### AUTH-06 — Set a new password **Domain:** D1 Sign-in & Identity · **Route:** `/reset-password` · **Archetype:** auth · **Phase:** MVP **Who:** Reset-token holder **Purpose:** Choose a new password from the emailed reset link. **Entry points:** Deep link `/reset-password?token=…` | Element | Kind | Data / API | Rules | |---|---|---|---| | New password | password-field | body.new_password | strength meter; autofill new-password | | Confirm password | password-field | client only | must match | | Notice | caption | | "You'll be signed out on all devices." | | Save password | primary-button | POST /auth/password/reset | | | Action | Call | Result handling | |---|---|---| | Save password | `POST /auth/password/reset` | 204 → clear local session → AUTH-01 with success banner. 400 → expired link + link to AUTH-05. 422 → field errors. | #### AUTH-07 — Accept invitation **Domain:** D1 Sign-in & Identity · **Route:** `/invite` · **Archetype:** auth · **Phase:** MVP **Who:** Invitation-token holder **Purpose:** Activate an account an admin created: set password and sign in. **Entry points:** Deep link `/invite?token=…` from the invitation email | Element | Kind | Data / API | Rules | |---|---|---|---| | Display name | text-field | body.display_name | optional | | Password | password-field | body.password | strength meter | | Accept and continue | primary-button | POST /auth/invitations/accept | | | Action | Call | Result handling | |---|---|---| | Accept and continue | `POST /auth/invitations/accept` | 200 AuthSession → APP-01. 400 → "This invitation is no longer valid. Ask your admin to resend it." | ### D2 · Account & Security #### ACC-01 — Account & profile **Domain:** D2 Account & Security · **Route:** `/account` · **Archetype:** settings · **Phase:** MVP **Who:** Self **Purpose:** View and edit the signed-in user's global profile. **Loads:** `GET /users/me` · `GET /users/me/profile` | Element | Kind | Data / API | Rules | |---|---|---|---| | Avatar | image-picker | Profile.avatar_url | camera / photo library / file / paste; client uploads to app asset storage then PATCHes URL* | | Display name | text-field | Profile.display_name | required; ≤ 100; save on blur/Return | | Email | row → ACC-02 | User.email + email_verified + pending_email | shows "Pending: {pending_email}" when set | | User ID | id-chip | User.id | copy | | Contact email | email-field | Profile.contact_email | optional | | Contact phone | phone-field | Profile.contact_phone | optional; E.164 formatting by locale | | Plan | row → BIL-01 | User.plan free\|standard + plan_sources | "Free" or "Standard · via {org}" / "Standard · your subscription" | | Member since / last sign-in | caption | User.created_at, last_login_at | locale formatted | | Sign out | button | POST /auth/logout | always visible at the bottom of Account (mobile) / account menu (desktop) | | Action | Call | Result handling | |---|---|---| | Edit field | `PATCH /users/me/profile (If-Match)` | Save per field; 409 version_conflict → reload prompt; 422 → field error. | | Sign out | `POST /auth/logout` | best effort; always wipe local credentials and cache → AUTH-01 | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES **Notes:** *The API stores `avatar_url` only. Upload target is a client-side decision (spec gap — see OPEN-QUESTIONS). #### ACC-02 — Change email **Domain:** D2 Account & Security · **Route:** `/account/email` · **Archetype:** form · **Phase:** MVP **Who:** Self **Purpose:** Start an email change; the new address must be verified. | Element | Kind | Data / API | Rules | |---|---|---|---| | New email | email-field | body.email | required; differs from current | | Save | primary-button | PATCH /users/me | | | Action | Call | Result handling | |---|---|---| | Save | `PATCH /users/me {email}` | 200 → User.pending_email set; show AUTH-04-style "check your new inbox". 409 not_unique → "That email is already in use." | #### ACC-03 — Change password **Domain:** D2 Account & Security · **Route:** `/account/security/password` · **Archetype:** form · **Phase:** MVP **Who:** Self **Purpose:** Change password knowing the current one. | Element | Kind | Data / API | Rules | |---|---|---|---| | Current password | password-field | body.current_password | autofill current-password | | New password | password-field | body.new_password | strength meter; autofill new-password | | Save | primary-button | POST /users/me/password | | | Action | Call | Result handling | |---|---|---| | Save | `POST /users/me/password` | 204 → toast "Password changed". 401 → "Current password is incorrect." 422 → policy errors. | #### ACC-04 — Security **Domain:** D2 Account & Security · **Route:** `/account/security` · **Archetype:** settings · **Phase:** MVP **Who:** Self **Purpose:** Password, two-factor and active sessions in one place. **Loads:** `GET /users/me` · `GET /users/me/sessions` | Element | Kind | Data / API | Rules | |---|---|---|---| | Password | row → ACC-03 | | "Change…" | | Two-factor authentication | toggle | User.mfa_enabled | on → ACC-05; off → ACC-06 | | Sessions | list | Session[]: user_agent → device name/icon, ip_address → approximate location*, last_seen_at, current | current session marked "This device" and not revocable here | | Sign out | row-button | DELETE /users/me/sessions/{sessionId} | per non-current row | | Sign out of all other sessions | destructive-button | DELETE /users/me/sessions/{sessionId} for each non-current session | confirm first; also stops push to those sessions' devices | | Devices & notifications | row → NTF-03 | count of active devices | | | Action | Call | Result handling | |---|---|---| | Revoke one | `DELETE /users/me/sessions/{sessionId}` | Optimistic remove; 404 → already gone, refresh. | | Sign out everywhere else | `DELETE each non-current session` | Confirm first (COPY). Uses per-session deletes so the current session survives; `logout all_sessions` signs this device out too. Revoking a session deactivates the push devices it registered. | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES **Notes:** *IP → city lookup is client-side/optional; never show the raw IP to non-staff unless the user expands details. #### ACC-05 — Turn on two-factor **Domain:** D2 Account & Security · **Route:** `/account/security/2fa/enable` · **Archetype:** wizard · **Phase:** MVP **Who:** Self **Purpose:** Enroll an authenticator app (TOTP) and save recovery codes. | Element | Kind | Data / API | Rules | |---|---|---|---| | Step 1 — Scan | qr + secret | POST /users/me/mfa/totp → otpauth_uri, secret | QR, "Copy setup key", "Open in authenticator" (otpauth:// link on mobile) | | Step 2 — Confirm | otp-field | body.code | 6 digits | | Step 3 — Recovery codes | secret-list | confirm response: 10 recovery codes | Copy all · Save as file · Print · Share (mobile); "I've saved these" checkbox gates Done | | Action | Call | Result handling | |---|---|---| | Start | `POST /users/me/mfa/totp` | 409 → already enabled; close. | | Confirm | `POST /users/me/mfa/totp/confirm` | 200 → step 3. 401 → wrong code. | **States:** Cancel before confirm leaves MFA off (no server cleanup needed) #### ACC-06 — Turn off two-factor **Domain:** D2 Account & Security · **Route:** `/account/security/2fa/disable` · **Archetype:** confirm · **Phase:** MVP **Who:** Self **Purpose:** Disable TOTP with a current code or a recovery code. | Element | Kind | Data / API | Rules | |---|---|---|---| | Code or recovery code | otp-field / text-field | body.code \| body.recovery_code | | | Turn off | destructive-button | POST /users/me/mfa/totp/disable | | | Action | Call | Result handling | |---|---|---| | Turn off | `POST /users/me/mfa/totp/disable` | 204 → toggle off, toast. 401 → wrong code. 429 → countdown. | #### ACC-07 — Privacy **Domain:** D2 Account & Security · **Route:** `/account/privacy` · **Archetype:** settings · **Phase:** MVP **Who:** Self **Purpose:** Export personal data and delete the account. | Element | Kind | Data / API | Rules | |---|---|---|---| | Download my data | button | GET /users/me/export | saves `substratal-export-{date}.json` via save dialog / share sheet / download | | Delete account | destructive-row → ACC-08 | | | | Action | Call | Result handling | |---|---|---| | Download | `GET /users/me/export` | 200 → save file. 429 → "You can export again in {n} minutes." | #### ACC-08 — Delete account **Domain:** D2 Account & Security · **Route:** `/account/privacy/delete` · **Archetype:** confirm · **Phase:** MVP **Who:** Self **Purpose:** Request erasure: deactivate now, erase after 7 days. | Element | Kind | Data / API | Rules | |---|---|---|---| | What happens | text | | list: signed out everywhere; apps lose access; erased on {date}; support can cancel within 7 days | | Reason | text-area | body.reason | optional | | Type DELETE | text-field | client only | must equal localized confirm word | | Delete account | destructive-button | POST /users/me/erasure-requests | | | Action | Call | Result handling | |---|---|---| | Delete account | `POST /users/me/erasure-requests` | 202 ErasureRequest → clear local data → AUTH-01 with "Your account is scheduled for deletion on {scheduled_for}." | **Notes:** If the User has an individual Standard subscription, warn first and link to "Manage billing" (BIL-03) — erasure doesn't cancel Stripe billing by itself (see OPEN-QUESTIONS). ### D3 · My Apps #### APP-01 — Home — My Apps **Domain:** D3 My Apps · **Route:** `/` · **Archetype:** grid · **Phase:** MVP **Who:** Signed in **Purpose:** See every app you can use, its access status, and launch it. **Loads:** `GET /applications (catalog the caller can see; each has access_tier free|standard)` · `GET /users/me/entitlements?include_inactive=true` · `GET /users/me (plan, plan_sources)` · `GET /organizations?status=active (for pending invites)` · `GET /users/me/notifications/unread-count (bell badge)` | Element | Kind | Data / API | Rules | |---|---|---|---| | Search | search | client filter on name/description | | | Grid / List | segmented | client preference | persisted locally | | Invitation banner | banner | Organization.my_membership_status = pending | "{org} invited you…" → ORG-09 | | Verify-email banner | banner | User.email_verified = false | → AUTH-04 | | Your apps | tile-grid | join Application × resolved entitlement per app | tile = icon, name, StatusBadge, helper text (COPY); order: recently launched, then A–Z; pin to top (local) | | Included with Standard | tile-grid | standard apps with status none while User.plan = free | badge "Standard"; tap → upsell sheet → BIL-01 ("Get every app for $6/month") | | Available | tile-grid | other public apps with status none | "Get access" → app support/launch page | | Inbox bell | toolbar button + count | NotificationUnreadCount.unread_count | → NTF-01 | | Past apps | row → APP-05 | count of revoked/expired | | | Action | Call | Result handling | |---|---|---| | Open tile (active) | `→ APP-03` | Launch | | Open tile (other status) | `→ APP-02` | Explain status | | Context menu / long-press | | Open · App settings (SET-02) · App profile (APP-04) · Pin · Copy app ID | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES; Empty: "No apps yet" (COPY); Offline: show cached grid read-only, launch disabled **Notes:** Resolved status per app: if any path active → Active; else precedence scheduled > disabled > expired > revoked > none (matches the API's resolved_entitlement_status). Use `GET /users/me/apps/{appId}/effective-permissions` for the authoritative answer when the user opens APP-02. Tile helper text by source of the active path: `free` → "Free"; `subscription` → "Standard" (or "Standard · via {org}"); developer grants as before. The Hub itself (`app_hub`) is hidden from the grid. #### APP-02 — App info **Domain:** D3 My Apps · **Route:** `/apps/:appId` · **Archetype:** popover · **Phase:** MVP **Who:** Signed in **Purpose:** Explain an app's access status and how the user has access. **Loads:** `GET /applications/{appId}` · `GET /users/me/apps/{appId}/effective-permissions` | Element | Kind | Data / API | Rules | |---|---|---|---| | Header | icon + name + StatusBadge | Application, EffectivePermissions.entitlement_status | | | Description | text | Application.description | | | Access paths | list | EffectivePermissions.access_paths[]: source, status, organization_id, member_decision | "Free", "Standard · your subscription", "Standard · via Northwind", "Personal · Granted by admin", "Via Northwind (seat)"; member_decision excluded explained | | Tier | badge | Application.access_tier | "Free" or "Standard" | | Your roles | chips | EffectivePermissions.roles | Phase 2 | | Primary action | button | | Active → Open; none + standard tier + plan free → Upgrade to Standard (BIL-01); none+public → Get access; disabled/expired/revoked → Contact support (support_url) | | Notification settings | row → SET-02 | resolved notifications.push for this app | "Push notifications: On/Off" | | App settings / App profile | rows | → SET-02, APP-04 | only when active | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### APP-03 — Launch app **Domain:** D3 My Apps · **Route:** `/apps/:appId/launch` · **Archetype:** handoff · **Phase:** MVP **Who:** Active entitlement **Purpose:** Mint an app token (or authorization code) and open the app. | Element | Kind | Data / API | Rules | |---|---|---|---| | Progress | inline spinner on tile | | ≤ 1 s target | | Organization picker | menu | body.organization_id | only when the user has access via >1 org and the app needs org context | | Action | Call | Result handling | |---|---|---| | Embedded launch | `POST /auth/app-tokens {application_id, organization_id?}` | 200 AppToken (5-min JWT) → open launch_url with token per app contract; never cache the token. 403 → refresh status, show APP-02. 409 → choose org. | | Hosted launch (Phase 3) | `POST /auth/oauth/authorization-codes (PKCE S256)` | 201 → open redirect_uri?code=…&state=… in system browser / auth session | **Notes:** Record launch time locally for "recently launched" ordering. #### APP-04 — App profile **Domain:** D3 My Apps · **Route:** `/apps/:appId/profile` · **Archetype:** form · **Phase:** Phase 2 **Who:** Self, active entitlement **Purpose:** Per-app display handle and custom profile fields. **Loads:** `GET /users/me/apps/{appId}/profile` | Element | Kind | Data / API | Rules | |---|---|---|---| | Display handle | text-field | AppProfile.display_handle | optional; shows effective_display_name as placeholder | | Custom fields | key/value editor | AppProfile.custom | only keys the app documents; null removes a key | | Action | Call | Result handling | |---|---|---| | Save | `PATCH /users/me/apps/{appId}/profile (If-Match)` | | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### APP-05 — Past apps **Domain:** D3 My Apps · **Route:** `/apps/past` · **Archetype:** list · **Phase:** MVP **Who:** Signed in **Purpose:** Apps whose access was revoked or expired. **Loads:** `GET /users/me/entitlements?include_inactive=true&status=revoked,expired` | Element | Kind | Data / API | Rules | |---|---|---|---| | Rows | list | Application name/icon, status, ended/revoked date, source | tap → APP-02 | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES ### D4 · Settings #### SET-01 — Settings — Global **Domain:** D4 Settings · **Route:** `/settings` · **Archetype:** settings · **Phase:** MVP **Who:** Self **Purpose:** Preferences that apply to every app. **Loads:** `GET /users/me/settings` · `GET /users/me/apps/app_hub/settings` (theme choices) | Element | Kind | Data / API | Rules | |---|---|---|---| | Language | select | Settings.locale | BCP 47; default = OS language; changing re-localizes the UI live | | Time zone | searchable select | Settings.timezone | IANA; default = OS zone; "Use device time zone" option | | Appearance | segmented | Settings.theme light/dark/system | Light · Dark · Match system (default). Applies instantly; Match system follows OS changes live. See THEMES.md | | Light theme | swatch picker | `app_hub` setting `appearance.light_theme` | 5 themes: Teal Ledger, Indigo Root, **Moss Paper (default)**, Coral Clay, Spore Pink. Each swatch previews background, sidebar, accent and text; selected swatch shows a check and accent ring; accessible name "{name} light theme, selected" | | Dark theme | swatch picker | `app_hub` setting `appearance.dark_theme` | 5 themes: Night Soil, Deep Teal, Graphite Spore, **Forest Floor (default)**, Ember Clay. Same behaviour as Light theme | | Push notifications | toggle | Settings.notifications.push (missing = on) | global master switch for push from every app; inbox still fills (NTF-01). Shows OS permission state beneath: "Allowed in system settings" / "Blocked — Open system settings" | | Email notifications | toggle | Settings.notifications.email | | | SMS notifications | toggle | Settings.notifications.sms | disabled until Profile.contact_phone set | | Share usage analytics | toggle | client preference | default per platform policy; see CLIENT-ARCHITECTURE §10 | | Scope list | list | Global + each app with active access | → SET-02 (desktop: middle column) | | Action | Call | Result handling | |---|---|---| | Change any row | `PATCH /users/me/settings (If-Match)` | Optimistic; revert + toast on error. | | Choose a light or dark theme | `PATCH /users/me/apps/app_hub/settings (If-Match)` with `appearance.light_theme` / `appearance.dark_theme` | Apply instantly, cache locally, then sync; 409 → refetch and re-apply; 422 → revert + toast "Couldn't save your theme. Try again." | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### SET-02 — Settings — Per app **Domain:** D4 Settings · **Route:** `/settings/apps/:appId` · **Archetype:** settings · **Phase:** Phase 2 **Who:** Self, active entitlement **Purpose:** Override global settings and set app-specific preferences from the app's settings_schema. **Loads:** `GET /applications/{appId} (settings_schema)` · `GET /users/me/apps/{appId}/settings` | Element | Kind | Data / API | Rules | |---|---|---|---| | Schema form | generated rows | settings_schema properties × AppSettingsResolved.resolved | string+enum → select; string → text; string date-time → date-time picker; boolean → toggle; integer/number with min/max → stepper/number; object/array → read-only JSON preview | | Source caption | caption per row | AppSettingsResolved.sources[key] | app_override → "Changed for this app · Reset"; global → "Using your global setting"; app_default → "App default" | | Reset | link per overridden row | PATCH overrides {key:null} | | | Push notifications for this app | toggle | resolved notifications.push; override via overrides.notifications.push | source caption as other rows; Reset clears the override | | Stale overrides | banner | AppSettingsResolved.stale_overrides | "Some saved settings no longer apply" → Clear | | Action | Call | Result handling | |---|---|---| | Change row | `PATCH /users/me/apps/{appId}/settings {overrides:{key:value}} (If-Match)` | 422 → field error from details (enum_mismatch, out_of_range, undeclared_key). | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES ### D5 · Organizations #### ORG-01 — Organizations **Domain:** D5 Organizations · **Route:** `/orgs` · **Archetype:** list · **Phase:** MVP **Who:** Signed in (all orgs with organizations.manage) **Purpose:** Organizations you belong to, plus pending invitations. **Loads:** `GET /organizations` | Element | Kind | Data / API | Rules | |---|---|---|---| | Pending invitations | section | my_membership_status = pending | Accept / Decline inline → ORG-09 | | Rows | list | Organization: name, member_count, my_role, status | suspended orgs show badge | | Search | search | q | server-side when > 50 | | New organization | primary-button | → ORG-02 | | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### ORG-02 — New organization **Domain:** D5 Organizations · **Route:** `/orgs/new` · **Archetype:** form · **Phase:** MVP **Who:** Any active user **Purpose:** Create an organization; creator becomes org admin. | Element | Kind | Data / API | Rules | |---|---|---|---| | Name | text-field | body.name | required; ≤ 100 | | Action | Call | Result handling | |---|---|---| | Create | `POST /organizations (Idempotency-Key)` | 201 → ORG-03. 429 → rate limited. | #### ORG-03 — Organization — Members **Domain:** D5 Organizations · **Route:** `/orgs/:orgId/members` · **Archetype:** detail · **Phase:** MVP **Who:** Member (read), org_admin / organizations.manage (write) **Purpose:** See and manage members and pending invitations. **Loads:** `GET /organizations/{id}` · `GET /organizations/{id}/members` | Element | Kind | Data / API | Rules | |---|---|---|---| | Header | avatar + name + id-chip + status | Organization | my_role shown as caption | | Tabs | tabs | Members · Apps & seats · Subscription · Activity · Settings | Subscription tab = BIL-02 (org_admin / billing.manage only) | | Filter | chips | role, membership_status | | | Rows | list | OrganizationMember: display_name/email, role, membership_status, joined_at/invited_at | pending rows show Invited badge + invited date | | Invite | primary-button | → ORG-04 | org_admin only | | Action | Call | Result handling | |---|---|---| | Row menu | `→ ORG-05` | Change role · Remove · Withdraw invitation | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### ORG-04 — Invite member **Domain:** D5 Organizations · **Route:** `/orgs/:orgId/members/invite` · **Archetype:** form · **Phase:** MVP **Who:** org_admin; organizations.manage may add by user_id **Purpose:** Invite people by email (or add an existing user, staff only). | Element | Kind | Data / API | Rules | |---|---|---|---| | Emails | token-field | body.email (one request per address) | paste a list separated by commas/newlines | | Role | radio-group | body.role org_admin \| member | default member | | Add existing user | user-picker | body.user_id | organizations.manage only | | Action | Call | Result handling | |---|---|---| | Send invitations | `POST /organizations/{id}/members × n` | Per-address results summary; 409 → "already a member" per address. Responses look identical whether or not the address has an account (by design). | **Notes:** Existing Users also receive an `account.organization_invitation` platform alert (NTF-01, and a push where the Hub has a device). #### ORG-05 — Member actions **Domain:** D5 Organizations · **Route:** `/orgs/:orgId/members/:userId` · **Archetype:** confirm · **Phase:** MVP **Who:** org_admin / organizations.manage **Purpose:** Change a member's role or remove them. | Element | Kind | Data / API | Rules | |---|---|---|---| | Role | radio-group | body.role | | | Remove from organization | destructive-button | DELETE …/members/{userId} | COPY confirm | | Action | Call | Result handling | |---|---|---| | Change role | `PATCH /organizations/{id}/members/{userId}` | 409 → last admin can't be demoted (explain). | | Remove | `DELETE /organizations/{id}/members/{userId}` | 409 → last admin can't leave/remove. | #### ORG-06 — Organization — Apps & seats **Domain:** D5 Organizations · **Route:** `/orgs/:orgId/apps` · **Archetype:** tab · **Phase:** Phase 2 **Who:** Member (read), org_admin (write) **Purpose:** Org-wide app grants and who they include. **Loads:** `GET /organizations/{id}/entitlements` | Element | Kind | Data / API | Rules | |---|---|---|---| | Rows | list | Entitlement (org): app, status, source (org_seat \| subscription), member_scope, included_member_count, starts_at/ends_at | AccessSwitch for status (org_admin with rights); `subscription` rows read "Included with the organization's Standard subscription" and their scope is edited in BIL-02 | | Grant app | button | → ORG-07 | | | Action | Call | Result handling | |---|---|---| | Toggle | `PATCH /entitlements/{id} {status}` | Off requires confirm (COPY). On platform-managed rows only active ⇄ disabled is allowed; anything else 409 entitlement_platform_managed. | | Edit scope | `→ ORG-07 (org_seat) / BIL-02 (subscription)` | | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### ORG-07 — Grant app to organization / member scope **Domain:** D5 Organizations · **Route:** `/orgs/:orgId/apps/grant` · **Archetype:** form · **Phase:** Phase 2 **Who:** entitlements.manage or app owner; scope edits by org_admin **Purpose:** Create an org-wide grant or change who it includes. | Element | Kind | Data / API | Rules | |---|---|---|---| | App | select | body.application_id | create only | | Who gets access | radio-group | member_scope all_members \| allowlist \| denylist | COPY labels | | Members | member-picker | member_overrides | visible for allowlist/denylist | | Starts / Ends | date-fields | starts_at, ends_at | optional | | Order ID | text-field | order_id | optional; staff/app owner | | Action | Call | Result handling | |---|---|---| | Grant | `POST /organizations/{id}/entitlements (Idempotency-Key)` | 409 entitlement_already_exists → already granted (view it). 422 → field errors. | | Save scope | `PATCH /entitlements/{id} {member_scope, member_overrides}` | | #### ORG-08 — Organization — Settings **Domain:** D5 Organizations · **Route:** `/orgs/:orgId/settings` · **Archetype:** settings · **Phase:** MVP **Who:** org_admin (rename), organizations.manage (suspend), any member (leave) **Purpose:** Rename, leave, or (staff) suspend the organization. | Element | Kind | Data / API | Rules | |---|---|---|---| | Name | text-field | Organization.name | | | Leave organization | destructive-row | DELETE …/members/me | | | Suspend / Reactivate | destructive-row | PATCH {status, reason} | staff only; reason required | | Action | Call | Result handling | |---|---|---| | Rename | `PATCH /organizations/{id} {name}` | | | Leave | `DELETE /organizations/{id}/members/me` | 409 last admin | #### ORG-09 — Invitation **Domain:** D5 Organizations · **Route:** `/orgs/:orgId/invitation` · **Archetype:** confirm · **Phase:** MVP **Who:** Invited user **Purpose:** Accept or decline an organization invitation. **Entry points:** Invitation banner on APP-01; ORG-01 pending section; `account.organization_invitation` notification (NTF-01 / push action "Accept") | Element | Kind | Data / API | Rules | |---|---|---|---| | Org name + inviter | text | Organization | | | Accept | primary-button | | | | Decline | button | | | | Action | Call | Result handling | |---|---|---| | Accept | `POST /organizations/{id}/members/me/accept` | 200 → ORG-03; apps granted via org (and the org's Standard subscription, if it covers the member) appear on APP-01. Mark the related notification read. | | Decline | `DELETE /organizations/{id}/members/me` | | ### D6 · Users #### USR-01 — Users **Domain:** D6 Users · **Route:** `/admin/users` · **Archetype:** table · **Phase:** MVP **Who:** users.list (write actions need users.manage) **Purpose:** Find and manage user accounts. **Loads:** `GET /users?limit=50&cursor&status&q&application_id&organization_id&created_after&created_before` | Element | Kind | Data / API | Rules | |---|---|---|---| | Search | search | q (name, email, ID prefix usr_) | debounced 300 ms; exact ID jumps to USR-03 | | Status filter | chips | status active \| invited \| suspended \| deleted | deleted visible to users.manage only | | More filters | menu | application_id, organization_id, created range | | | Columns | table | display name + email, status, id, last_login_at, created_at | sortable client-side within loaded page; server order = created_at desc | | Multi-select | selection | | bulk: Suspend, Resend invitation, Export CSV (client-side of loaded rows) | | Invite user | primary-button | → USR-02 | users.manage | | Action | Call | Result handling | |---|---|---| | Open row | `→ USR-03` | | | Row menu | | Suspend · Reactivate · Resend invitation · Copy ID · Copy email | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### USR-02 — Invite user **Domain:** D6 Users · **Route:** `/admin/users/new` · **Archetype:** form · **Phase:** MVP **Who:** users.manage **Purpose:** Create a user and send an invitation (or create active). | Element | Kind | Data / API | Rules | |---|---|---|---| | Email | email-field | body.email | required | | Display name | text-field | body.display_name | optional | | Send invitation email | toggle | body.send_invitation | default on | | Create as active | toggle | body.status active\|invited | default off (invited) | | Action | Call | Result handling | |---|---|---| | Invite | `POST /users (Idempotency-Key)` | 201 → USR-03. 409 → "A user with that email exists" + link. | #### USR-03 — User — Overview **Domain:** D6 Users · **Route:** `/admin/users/:userId` · **Archetype:** detail · **Phase:** MVP **Who:** users.list (read), users.manage (write) **Purpose:** One user's account state and administrative actions. **Loads:** `GET /users/{id}` · `GET /users/{id}/profile` · `GET /users/{id}/subscription (billing.manage only)` | Element | Kind | Data / API | Rules | |---|---|---|---| | Header | avatar, display name, email, id-chip, StatusBadge | User + Profile | | | Tabs | tabs | Overview · Access · Roles · Sessions · Notifications · Activity | Activity = AUD-01 filtered target_user_id; Notifications = NTF-05 | | Facts | key-value list | email_verified, pending_email, mfa_enabled, plan, plan_sources, created_at, last_login_at, signup_application_id, test_mode | plan_sources link to the org or to BIL-01 subscription view (billing.manage) | | Actions | toolbar/menu | | Suspend (USR-05) · Reactivate · Resend invitation · Reset two-factor · Export data · Delete · Restore · Cancel scheduled erasure | | Action | Call | Result handling | |---|---|---| | Reactivate / Restore | `PATCH /users/{id} {status:active} (If-Match)` | | | Resend invitation | `POST /users/{id}/invitation` | 202 toast; 409 → not in invited state | | Reset two-factor | `DELETE /users/{id}/mfa/totp` | confirm | | Export | `GET /users/{id}/export` | save file | | Delete | `DELETE /users/{id}` | confirm | | Cancel erasure | `DELETE /users/{id}/erasure-requests/current` | visible during 7-day window | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### USR-04 — User — Sessions **Domain:** D6 Users · **Route:** `/admin/users/:userId/sessions` · **Archetype:** tab · **Phase:** MVP **Who:** users.manage **Purpose:** See and revoke a user's sessions. **Loads:** `GET /users/{id}/sessions` | Element | Kind | Data / API | Rules | |---|---|---|---| | Rows | list | Session: user_agent, ip_address, amr, created_at, last_seen_at, expires_at | staff see full IP | | Action | Call | Result handling | |---|---|---| | Revoke | `DELETE /users/{id}/sessions/{sessionId}` | | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### USR-05 — Suspend user **Domain:** D6 Users · **Route:** `/admin/users/:userId/suspend` · **Archetype:** confirm · **Phase:** MVP **Who:** users.manage **Purpose:** Suspend an account (signs them out everywhere). | Element | Kind | Data / API | Rules | |---|---|---|---| | Reason | text-area | body.reason | optional, written to audit | | Action | Call | Result handling | |---|---|---| | Suspend | `POST /users/{id}/suspend` | 200 → badge Suspended. 409 → already suspended. | **Notes:** Suspension revokes sessions and deactivates every push device the user holds; say so in the confirm body. ### D7 · Access #### ACX-01 — User — Access **Domain:** D7 Access · **Route:** `/admin/users/:userId/access` · **Archetype:** tab · **Phase:** MVP **Who:** users.list (read), entitlements.manage or app owner (write) **Purpose:** Turn apps on or off for one person; see every path that grants access. **Loads:** `GET /users/{id}/entitlements?include_inactive=true` · `GET /applications` | Element | Kind | Data / API | Rules | |---|---|---|---| | Rows (one per app) | list | resolved status per app + paths (personal Entitlement, org grants) | StatusBadge + AccessSwitch on the personal path | | Paths disclosure | expandable | Entitlement.granted_via, source, subscription_id, organization_id, member_decision, starts_at/ends_at, disabled_reason | platform-managed paths (`free`, `subscription`) show a "Managed by Substratal" caption; developer grants and platform rows can coexist | | Org-only access | caption + link | | switch replaced by "Via {org}" → ORG-06 | | Grant access | primary-button | → ACX-02 | | | Why? | button | → ACX-04 | | | Action | Call | Result handling | |---|---|---| | Switch off | `→ ACX-03 confirm → PATCH /entitlements/{id} {status:disabled, disabled_reason}` | Optimistic with Undo (PATCH status:active) for 10 s | | Switch on | `PATCH /entitlements/{id} {status:active}` | No confirm | | Remove grant made in error | `DELETE /entitlements/{id}` | Only developer grants with no order_id and < 24 h old; never on `free`/`subscription` rows (409 entitlement_platform_managed); 409 otherwise | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES **Notes:** The switch on a platform-managed row only disables / re-enables it (the platform never re-enables a row an admin disabled). Term, source and scope edits are hidden for those rows. #### ACX-02 — Grant access **Domain:** D7 Access · **Route:** `/admin/users/:userId/access/grant` · **Archetype:** form · **Phase:** MVP **Who:** entitlements.manage or app owner (own app) **Purpose:** Give a person access to an app. | Element | Kind | Data / API | Rules | |---|---|---|---| | App | select | body.application_id | app owners see only their apps | | Type | radio-group | body.source purchase \| trial \| admin_grant | `free` and `subscription` are never offered — the platform grants them | | Order ID | text-field | body.order_id | required feel for purchase | | Starts | date-time | body.starts_at | default now | | Ends | date-time | body.ends_at | optional; required for trial | | Action | Call | Result handling | |---|---|---| | Grant | `POST /users/{id}/entitlements (Idempotency-Key, required)` | 201 → row Active. 409 entitlement_already_exists → "Already has access — view it". A platform-managed row doesn't block a grant (they coexist). | #### ACX-03 — Turn off / edit access **Domain:** D7 Access · **Route:** `/admin/entitlements/:entId` · **Archetype:** confirm · **Phase:** MVP **Who:** entitlements.manage or app owner **Purpose:** Confirm turning off access, or edit term/source. **Loads:** `GET /entitlements/{id}` | Element | Kind | Data / API | Rules | |---|---|---|---| | Title | text | | "Turn off {app} for {name}?" | | Reason | text-field | body.disabled_reason | optional; audit | | Edit term (advanced) | disclosure | ends_at, source trial→purchase, order_id | hidden for platform-managed rows | | Action | Call | Result handling | |---|---|---| | Turn off | `PATCH /entitlements/{id} {status:disabled, disabled_reason} (If-Match)` | 409 version_conflict → reload and re-apply | | Save changes | `PATCH /entitlements/{id}` | | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### ACX-04 — Access check (Why?) **Domain:** D7 Access · **Route:** `/admin/users/:userId/apps/:appId/why` · **Archetype:** popover · **Phase:** MVP **Who:** users.list or app owner **Purpose:** Explain exactly why a person can or can't use an app. **Loads:** `GET /users/{id}/apps/{appId}/effective-permissions` | Element | Kind | Data / API | Rules | |---|---|---|---| | Verdict | banner | allowed | "Dana can use Timetrack" / "can't" + first failing reason (user_status, entitlement_status) | | Checks | checklist | user_status, entitlement_status, access_paths[], roles, effective_permissions | each with pass/fail icon | | Computed at | caption | computed_at | | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### ACX-05 — Access (all) **Domain:** D7 Access · **Route:** `/admin/access` · **Archetype:** table · **Phase:** MVP **Who:** entitlements.manage or app owner (confined to own apps) **Purpose:** Every grant across users: audit expirations, trials, revocations. **Loads:** `GET /entitlements?application_id&status&source&user_id&organization_id&order_id&ends_before&include_inactive` | Element | Kind | Data / API | Rules | |---|---|---|---| | Filters | chips/menus | app, status, source, ends before (Expiring in 7/30 days presets) | | | Columns | table | user, app, status, source (incl. free / subscription), subscription_id, starts_at, ends_at, order_id, granted_via | source filter defaults to developer grants; "Show platform-managed" chip adds free/subscription rows | | Multi-select | selection | | bulk Turn off (confirm, one PATCH per row, progress + per-row result) | | Action | Call | Result handling | |---|---|---| | Open row | `→ ACX-01 for that user` | | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES ### D8 · Roles & Permissions #### ROL-01 — Roles **Domain:** D8 Roles & Permissions · **Route:** `/admin/roles` · **Archetype:** list · **Phase:** MVP **Who:** users.list (read), roles.manage (write) **Purpose:** Platform and app roles. **Loads:** `GET /roles?scope` · `GET /permissions (includes notifications.send)` | Element | Kind | Data / API | Rules | |---|---|---|---| | Scope | segmented | scope platform \| {application_id} | app picker when App | | Rows | list | Role: name, scope, seed, assignment_count, description | seed → lock icon | | New role | primary-button | → ROL-03 | roles.manage | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### ROL-02 — Role detail **Domain:** D8 Roles & Permissions · **Route:** `/admin/roles/:roleId` · **Archetype:** detail · **Phase:** MVP **Who:** users.list (read), roles.manage (write) **Purpose:** View or edit a role's permissions. **Loads:** `GET /roles/{id}` · `GET /permissions?scope` | Element | Kind | Data / API | Rules | |---|---|---|---| | Header | name, id-chip, scope, assignment_count | | | | Description | text-field | Role.description | read-only when seed | | Permissions | checkbox-list grouped by prefix | Role.permissions ⊂ Permission[].key | read-only when seed; show Permission.description | | Holders | link | → USR-01 filtered | Phase 2 if API adds filter; else hidden | | Delete role | destructive-button | DELETE /roles/{id} | only non-seed with assignment_count 0 | | Action | Call | Result handling | |---|---|---| | Save | `PATCH /roles/{id} {description, permissions} (If-Match)` | | | Delete | `DELETE /roles/{id}` | 409 → still assigned | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### ROL-03 — New role **Domain:** D8 Roles & Permissions · **Route:** `/admin/roles/new` · **Archetype:** form · **Phase:** MVP **Who:** roles.manage **Purpose:** Define a custom role. | Element | Kind | Data / API | Rules | |---|---|---|---| | Name | text-field | body.name | ^[a-z][a-z0-9_]{1,40}$; live validation | | Scope | select | body.scope | platform or an application | | Description | text-area | body.description | | | Permissions | checkbox-list | body.permissions | ≥ 1 | | Action | Call | Result handling | |---|---|---| | Create | `POST /roles` | 409 → name taken in scope | #### ROL-04 — User — Roles **Domain:** D8 Roles & Permissions · **Route:** `/admin/users/:userId/roles` · **Archetype:** tab · **Phase:** MVP **Who:** users.list (read), roles.manage (write) **Purpose:** Assign and remove roles for a user. **Loads:** `GET /users/{id}/roles` | Element | Kind | Data / API | Rules | |---|---|---|---| | Rows | list | UserRoleAssignment: role, application_id, assigned_at, assigned_by, implicit | implicit → "Granted with app access", not removable | | Assign role | button → role picker | | | | Action | Call | Result handling | |---|---|---| | Assign | `POST /users/{id}/roles/{roleId}` | idempotent; 200 already held | | Remove | `DELETE /users/{id}/roles/{roleId}` | 409 → implicit role | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES ### D9 · Applications #### APL-01 — Applications **Domain:** D9 Applications · **Route:** `/dev/apps` · **Archetype:** list · **Phase:** MVP **Who:** Any (catalog), owner/applications.manage (manage) **Purpose:** Applications you own or administer. **Loads:** `GET /applications?owned=true (staff: all, filter review_status)` | Element | Kind | Data / API | Rules | |---|---|---|---| | Rows | list | ApplicationSummary: icon, name, slug, visibility, review_status | | | Register app | primary-button | → APL-02 | applications.manage (Phase 3: self-service) | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### APL-02 — Register application **Domain:** D9 Applications · **Route:** `/dev/apps/new` · **Archetype:** wizard · **Phase:** MVP **Who:** applications.manage **Purpose:** Register a new application. | Element | Kind | Data / API | Rules | |---|---|---|---| | Name | text-field | name | | | Slug | text-field | slug | ^[a-z][a-z0-9-]{1,63}$; 'platform' reserved; immutable — warn | | Launch URL | url-field | launch_url | https | | Redirect URIs | url-list | redirect_uris | | | Visibility | radio-group | visibility public \| invite_only \| internal | | | Access tier | radio-group | access_tier free \| standard (default standard) | applications.manage only; explains who gets access | | Owner | select | owner_user_id \| owner_organization_id | | | Icon | image-picker | icon_url | | | Support URL | url-field | support_url | | | Email from name | text-field | email_from_name | | | Action | Call | Result handling | |---|---|---| | Register | `POST /applications (Idempotency-Key)` | 409 slug taken; 422 field errors. Registration grants the platform-managed Entitlements its access_tier calls for. | #### APL-03 — Application — Overview **Domain:** D9 Applications · **Route:** `/dev/apps/:appId` · **Archetype:** detail · **Phase:** MVP **Who:** Owner (configure), applications.manage (anything) **Purpose:** Configure an application. **Loads:** `GET /applications/{id}` | Element | Kind | Data / API | Rules | |---|---|---|---| | Header | icon, name, id-chip, review StatusBadge | | | | Tabs | tabs | Overview · Settings schema · Permissions & roles · Push · Keys · Webhooks · Access · Activity | Push = APL-07; Keys/Webhooks/Access = KEY-01/WHK-01/ACX-05 filtered to app | | Fields | settings rows | name, description, launch_url, redirect_uris, support_url, email_from_name, icon_url (owner); visibility, access_tier (applications.manage) | slug read-only with lock | | Platform alerts | toggle | receives_platform_notifications | owner-writable; explain that security/account alerts will push under this app's name and icon | | Review notes | banner | review_status rejected/suspended + review_notes | | | Action | Call | Result handling | |---|---|---| | Save field | `PATCH /applications/{id} (If-Match)` | 422 → field errors | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### APL-04 — Settings schema **Domain:** D9 Applications · **Route:** `/dev/apps/:appId/schema` · **Archetype:** tab · **Phase:** Phase 2 **Who:** Owner / applications.manage **Purpose:** Declare per-user settings the app stores in Substratal. | Element | Kind | Data / API | Rules | |---|---|---|---| | Properties | list | settings_schema.properties: key, type, enum, default, min/max, maxLength | add/edit via form | | JSON editor | code-editor | settings_schema | JSON validation; diff before save | | Preview | rendered SET-02 form | | shows exactly what users will see | | Stats | caption | settings_schema_stats | | | Action | Call | Result handling | |---|---|---| | Save | `PATCH /applications/{id} {settings_schema}` | 422 → schema rule errors | #### APL-05 — Permissions & roles **Domain:** D9 Applications · **Route:** `/dev/apps/:appId/roles` · **Archetype:** tab · **Phase:** Phase 2 **Who:** Owner / applications.manage **Purpose:** Declare app permissions, available app roles and the default role. | Element | Kind | Data / API | Rules | |---|---|---|---| | Permissions | editable list | permissions[] {key, description} | key app..* | | Available app roles | token list | available_app_roles (≤ 20) | | | Default app role | select | default_app_role | held implicitly by everyone with access | | Action | Call | Result handling | |---|---|---| | Save | `PATCH /applications/{id}` | | #### APL-06 — Review **Domain:** D9 Applications · **Route:** `/dev/apps/:appId/review` · **Archetype:** form · **Phase:** Phase 3 **Who:** applications.manage **Purpose:** Staff moderation of an application. | Element | Kind | Data / API | Rules | |---|---|---|---| | Review status | radio-group | review_status | | | Notes | text-area | review_notes | visible to owner | | Action | Call | Result handling | |---|---|---| | Save | `PATCH /applications/{id} {review_status, review_notes}` | | #### APL-07 — Push credentials **Domain:** D9 Applications · **Route:** `/dev/apps/:appId/push` · **Archetype:** tab · **Phase:** Phase 2 **Who:** Owner or applications.manage with a User token (write); app key / owner (read) **Purpose:** Configure APNs, FCM and Web Push for an Application so it can send push notifications. **Loads:** `GET /applications/{id}/push-credentials` | Element | Kind | Data / API | Rules | |---|---|---|---| | Providers | list | PushCredential per provider apns \| fcm \| web_push: status active\|failed, failure_reason, failed_at, last_used_at; apns team_id/key_id/topic; fcm project_id/client_email; web_push subject + vapid_public_key | one row per provider with Add / Edit / Remove; failed → warning banner with reason and Fix | | Add APNs | form | POST body: provider apns, team_id (10), key_id (10), topic (bundle id), private_key (.p8) | file picker / drop / paste for the .p8; key never shown again | | Add FCM | form | POST body: provider fcm, service_account (JSON file) | drop/pick the downloaded JSON; validate shape client-side | | Add Web Push | form | POST body: provider web_push, subject (mailto:/https:) | platform generates VAPID keys; show vapid_public_key with Copy for the web client | | Rotate / fix | form | PATCH /applications/{id}/push-credentials/{provider} {key material \| status: active} | web_push can't rotate in place — explain delete + recreate | | Remove | destructive-button | DELETE /applications/{id}/push-credentials/{provider} | confirm; web_push removal deactivates every browser subscription | | Platform alerts | toggle | Application.receives_platform_notifications | mirror of APL-03 toggle | | Action | Call | Result handling | |---|---|---| | Add | `POST /applications/{id}/push-credentials` | 201; 409 push_credential_exists → Edit; 422 invalid_push_credential → field error; 400 user_token_required never happens in the Hub (User token) | | Fetch one | `GET /applications/{id}/push-credentials/{provider}` | on Edit | | Save | `PATCH /applications/{id}/push-credentials/{provider}` | 409 invalid_status_transition | | Remove | `DELETE /applications/{id}/push-credentials/{provider}` | 204 | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES **Notes:** To prove a credential end to end, the owner registers a device of their own app and uses its device test (NTF-03 lists only devices the owner holds). ### D10 · Developer Credentials #### KEY-01 — API keys **Domain:** D10 Developer Credentials · **Route:** `/dev/keys` · **Archetype:** table · **Phase:** MVP **Who:** api_keys.manage or app owner **Purpose:** Service and agent credentials. **Loads:** `GET /api-keys?scope&mode&intended_use&include_inactive` | Element | Kind | Data / API | Rules | |---|---|---|---| | Filters | chips | mode live\|test, intended_use, include revoked | TEST pill on test keys | | Columns | table | name, scope, secret_hint, mode, intended_use, restrict_destructive, last_used_at, expires_at, status(revoked_at) | | | Create key | primary-button | → KEY-02 | | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### KEY-02 — Create API key **Domain:** D10 Developer Credentials · **Route:** `/dev/keys/new` · **Archetype:** form · **Phase:** MVP **Who:** api_keys.manage or app owner (app-scoped) **Purpose:** Create a key with least-privilege permissions. | Element | Kind | Data / API | Rules | |---|---|---|---| | Name | text-field | name | | | Scope | select | scope platform \| application | owners: own apps only | | Mode | segmented | mode live \| test | fixed after creation — say so | | Used by | radio-group | intended_use service \| agent | agent defaults restrict_destructive on | | Block destructive operations | toggle | restrict_destructive | | | Permissions | checkbox-list | permissions | filtered by scope via GET /permissions; app-scoped keys offer the five app-confined platform permissions incl. notifications.send | | Expires | date-field | expires_at | optional; presets 30/90/365 days | | Action | Call | Result handling | |---|---|---| | Create | `POST /api-keys` | 201 ApiKeyWithSecret → KEY-03 | #### KEY-03 — Secret reveal **Domain:** D10 Developer Credentials · **Route:** `(modal)` · **Archetype:** secret · **Phase:** MVP **Who:** Creator **Purpose:** Show a new API key secret, webhook signing secret, or recovery codes exactly once. | Element | Kind | Data / API | Rules | |---|---|---|---| | Secret | monospace selectable text | secret | never logged, never persisted, excluded from screenshots where the OS allows | | Copy | button | clipboard (sensitive) | auto-clear after 60 s where supported | | Acknowledge | checkbox | client | gates Done; modal not dismissible until checked | #### KEY-04 — API key detail **Domain:** D10 Developer Credentials · **Route:** `/dev/keys/:keyId` · **Archetype:** detail · **Phase:** MVP **Who:** api_keys.manage or app owner **Purpose:** Edit, rotate or revoke a key. **Loads:** `GET /api-keys/{id}` · `GET /audit-events?actor_id={id}` | Element | Kind | Data / API | Rules | |---|---|---|---| | Fields | settings rows | name, permissions, restrict_destructive (editable); scope, mode, intended_use, expires_at, created_by, last_used_at (read-only) | | | Rotate | button | POST /api-keys/{id}/rotate | confirm: no overlap | | Revoke | destructive-button | DELETE /api-keys/{id} | confirm | | Recent activity | list | audit events by this key | | | Action | Call | Result handling | |---|---|---| | Save | `PATCH /api-keys/{id}` | | | Rotate | `POST …/rotate` | 200 → KEY-03 | | Revoke | `DELETE /api-keys/{id}` | 204 → row shows Revoked | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### WHK-01 — Webhooks **Domain:** D10 Developer Credentials · **Route:** `/dev/webhooks` · **Archetype:** list · **Phase:** Phase 2 **Who:** webhooks.manage or app owner **Purpose:** Endpoints that receive access-change events. **Loads:** `GET /webhooks?application_id` | Element | Kind | Data / API | Rules | |---|---|---|---| | Rows | list | Webhook: url, events count, status healthy\|unhealthy\|disabled, consecutive_failures, last_delivery_at, scope | | | Add webhook | primary-button | → WHK-02 | | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### WHK-02 — Add / edit webhook **Domain:** D10 Developer Credentials · **Route:** `/dev/webhooks/new` · **Archetype:** form · **Phase:** Phase 2 **Who:** webhooks.manage or app owner **Purpose:** Subscribe a URL to events. | Element | Kind | Data / API | Rules | |---|---|---|---| | URL | url-field | url | https required | | Events | checkbox-list grouped | events (WebhookEvent enum: access.*, entitlement.*, role.*, user.*, organization.*, application.*, device.*, notification.push_failed, push_credential.failed, webhook.test) | presets: Access changes (access.granted, access.revoked) · Push health (notification.push_failed, push_credential.failed, device.deactivated) | | Description | text-field | description | | | Scope | select | scope | | | API version | select | api_version | edit only | | Enabled | toggle | status disabled ↔ healthy | edit only (re-enable) | | Action | Call | Result handling | |---|---|---| | Create | `POST /webhooks` | 201 WebhookWithSecret → KEY-03 | | Save | `PATCH /webhooks/{id}` | | #### WHK-03 — Webhook — Deliveries **Domain:** D10 Developer Credentials · **Route:** `/dev/webhooks/:hookId` · **Archetype:** detail · **Phase:** Phase 2 **Who:** webhooks.manage or app owner **Purpose:** Delivery history, testing and secret rotation. **Loads:** `GET /webhooks/{id}` · `GET /webhooks/{id}/deliveries?status&since&event_id` | Element | Kind | Data / API | Rules | |---|---|---|---| | Header actions | toolbar | | Send test · Rotate secret · Edit · Delete | | Filter | chips | status pending\|succeeded\|failed | | | Rows | table | WebhookDelivery: status, response_status, event_type, event_id, attempt, duration_ms, created_at, next_retry_at, error | failed → Redeliver | | Action | Call | Result handling | |---|---|---| | Send test | `POST /webhooks/{id}/test` | 202 → new pending row appears on refresh (poll 2 s × 5) | | Rotate secret | `POST /webhooks/{id}/rotate-secret` | confirm (24 h overlap) → KEY-03 | | Redeliver | `POST /webhooks/{id}/deliveries/{deliveryId}/redeliver` | 202 toast; same event_id | | Delete | `DELETE /webhooks/{id}` | confirm | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### WHK-04 — Delivery detail **Domain:** D10 Developer Credentials · **Route:** `/dev/webhooks/:hookId/deliveries/:deliveryId` · **Archetype:** popover · **Phase:** Phase 2 **Who:** webhooks.manage or app owner **Purpose:** One delivery attempt in detail. | Element | Kind | Data / API | Rules | |---|---|---|---| | Facts | key-value | all WebhookDelivery fields | | | Redeliver | button | | | ### D11 · Plan & Billing #### BIL-01 — My plan **Domain:** D11 Plan & Billing · **Route:** `/billing` · **Archetype:** settings · **Phase:** Phase 2 **Who:** Self; billing.manage can view any User's (read-only) **Purpose:** Free or Standard: what covers you, your own subscription, upgrade and manage billing. **Loads:** `GET /users/me/subscription` | Element | Kind | Data / API | Rules | |---|---|---|---| | Plan card | card | UserSubscription.plan free\|standard | Free: "Free apps and services"; Standard: "Every app and service" | | Covered by | list | plan_sources[]: {type user\|organization, id} | "Your subscription" / "Northwind Survey Co." (→ ORG-03); both listed when doubly covered | | Double coverage tip | banner | plan_sources has both user and organization | "Your organization already covers you. You can cancel your own subscription in Manage billing." (no automatic refund) | | Your subscription | key-value | Subscription.status, current_period_end, cancel_at_period_end, unit_amount, currency | status badge via status-semantics; past_due → warning banner (Stripe retries ~3 weeks) | | Upgrade to Standard | primary-button | → BIL-03 checkout | when no covering subscription, or own subscription is incomplete/incomplete_expired/canceled/unpaid; price "$6 per month" from unit_amount when known, else from pricing config; store flags on iOS/Android | | Manage billing | button | → BIL-03 portal | when the user has completed a checkout (own subscription exists and isn't incomplete) | | What Standard includes | link | standard apps from GET /applications?access_tier=standard | count + list | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES; After return from checkout: poll GET /users/me/subscription every 2 s (max 30 s) until plan = standard, showing "Finishing up…"; Downgraded: "Your Standard access ended on {date}. Your data is kept." **Notes:** Business/Enterprise aren't offered (422 plan_requires_sales); show "Contact sales" link only. Annual plans and a Standard trial are undecided in the API (DECISIONS #31) — no UI for them. #### BIL-02 — Organization subscription **Domain:** D11 Plan & Billing · **Route:** `/orgs/:orgId/subscription` · **Archetype:** tab · **Phase:** Phase 2 **Who:** org_admin (manage); billing.manage (read + portal) **Purpose:** An organization's Standard subscription and which members it covers. **Loads:** `GET /organizations/{id}/subscription` · `GET /organizations/{id}/members (for the member picker)` | Element | Kind | Data / API | Rules | |---|---|---|---| | Plan card | card | OrganizationSubscription.plan + Subscription.status, current_period_end, cancel_at_period_end | | | Billed users | key-value | quantity.current (live), quantity.last_synced + last_synced_at, unit_amount | "31 covered · 30 billed on next invoice (synced daily)" | | Who's covered | radio-group | member_scope all_members \| allowlist \| denylist | COPY labels | | Members | member-picker | member_overrides (active members only) | visible for allowlist/denylist | | Subscribe the organization | primary-button | → BIL-03 org checkout (member_scope optional) | org_admin; when no subscription or not in force | | Manage billing | button | → BIL-03 org portal | org_admin or billing.manage | | Action | Call | Result handling | |---|---|---| | Save coverage | `PATCH /organizations/{id}/subscription {member_scope, member_overrides} (If-Match)` | effective immediately (access.granted/revoked); billed from next daily sync. 422 member_override_not_a_member → mark rows. 404 subscription_not_found → show Subscribe. | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES **Notes:** Members gained or lost take effect at once; say so before saving. #### BIL-03 — Checkout / billing portal **Domain:** D11 Plan & Billing · **Route:** `/billing/return` · **Archetype:** handoff · **Phase:** Phase 2 **Who:** Self (individual); org_admin (org checkout); org_admin or billing.manage (org portal) **Purpose:** Hand off to Stripe in the browser and confirm the result on return. | Action | Call | Result handling | |---|---|---| | Upgrade (individual) | `POST /users/me/billing/checkout-sessions {plan:standard, success_url, cancel_url} (Idempotency-Key)` | 201 {url, expires_at} → open url externally. 409 subscription_exists → open portal instead. 409 test_mode_billing_unavailable / 503 billing_unavailable → explain. | | Manage billing (individual) | `POST /users/me/billing/portal-sessions {return_url}` | 201 → open url externally. 409 no_billing_account → hide button. | | Subscribe organization | `POST /organizations/{id}/billing/checkout-sessions {plan:standard, success_url, cancel_url, member_scope?, member_overrides?} (Idempotency-Key)` | as individual | | Manage organization billing | `POST /organizations/{id}/billing/portal-sessions {return_url}` | as individual | | Return | `deep link /billing/return?checkout=success\|cancelled (or /orgs/:orgId/subscription?…)` | success: poll the matching GET …/subscription until plan = standard (coverage starts when Stripe's webhook lands, usually seconds); cancelled: toast "Checkout cancelled" | **Notes:** success_url / cancel_url / return_url must be https: use https://dashboard.substratalapps.com/billing/return… (Universal/App Links open the native app). #### BIL-04 — Tenant & infrastructure **Domain:** D11 Plan & Billing · **Route:** `/dev/tenant` · **Archetype:** detail · **Phase:** Phase 2 **Who:** Tenant owner (read), tenants.manage (write) **Purpose:** Where an Application owner's data lives, and support-run tier changes. Tenants aren't billed. **Loads:** `GET /tenants` · `GET /tenants/{id}` · `GET /tenants/{id}/tier-change-requests` | Element | Kind | Data / API | Rules | |---|---|---|---| | Facts | key-value | Tenant: tier, region, status, owner, application_count | status migrating → banner, writes may 503 | | Tier change requests | list | TenantTierChangeRequest: from→requested tier/region, status, scheduled_for, reason, notes | | | Request change | button | → form: requested_tier, requested_region, reason | tenants.manage only; owners see "isolated and dedicated-region tiers come with business pricing — contact sales" | | Action | Call | Result handling | |---|---|---| | Open request | `POST /tenants/{id}/tier-change-requests` | 422/409 errors | | Open request detail | `GET /tenants/{id}/tier-change-requests/{requestId}` | polls every 30 s while visible | | Advance request | `PATCH /tenants/{id}/tier-change-requests/{requestId} {status, scheduled_for, notes} (If-Match)` | staff only; status scheduled → in_progress → completed, or cancelled | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES ### D12 · Audit Log #### AUD-01 — Audit log **Domain:** D12 Audit Log · **Route:** `/admin/audit` · **Archetype:** table · **Phase:** MVP **Who:** audit.view **Purpose:** Who changed what, when. **Loads:** `GET /audit-events?since&until&actor_type&actor_id&action&target_type&target_id&target_user_id&application_id&organization_id&tenant_id` | Element | Kind | Data / API | Rules | |---|---|---|---| | Filters | search + menus | time range (presets 1h/24h/7d/custom), actor type, action (grouped by prefix, wildcard), resource | filters encoded in URL/state for sharing | | Rows | table | timestamp, actor (type icon + name/id), action, target (type + link), app/org | relative time with absolute on hover/long-press | | Inspector | side panel | → AUD-02 | desktop: trailing pane; compact: push | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES **Notes:** Hot window is one year for every Tenant; read the `Audit-Hot-Window-Start` response header and show it in an info caption. Export is Phase 3. New actions to group in the filter: subscription.*, push_credential.*, device.deleted. #### AUD-02 — Audit event **Domain:** D12 Audit Log · **Route:** `/admin/audit/:eventId` · **Archetype:** popover · **Phase:** MVP **Who:** audit.view **Purpose:** One event with before/after diff. **Loads:** `GET /audit-events/{id}` | Element | Kind | Data / API | Rules | |---|---|---|---| | Facts | key-value | action, actor, target, target_user_id, application_id, organization_id, tenant_id, request_id, timestamp | each id links to its screen | | Diff | diff view | before / after | JSON diff, changed keys highlighted | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES ### D13 · Notifications #### NTF-01 — Inbox **Domain:** D13 Notifications · **Route:** `/inbox` · **Archetype:** list · **Phase:** Phase 2 **Who:** Self **Purpose:** Every notification the person has received — from every app and from Substratal — with read and archived state. **Entry points:** Bell in the toolbar / Inbox in the sidebar (desktop) / Inbox tab (compact); Tapping a push; Badges (app icon, Dock/taskbar, PWA) **Loads:** `GET /users/me/notifications?state&application_id&source&category&priority&created_after&created_before&include_inactive` · `GET /users/me/notifications/unread-count` · `GET /applications (names, icons)` | Element | Kind | Data / API | Rules | |---|---|---|---| | Filter | segmented | Unread · All · Archived (state) | default All (unarchived) | | App filter | menu | application_id, source=platform ("Substratal") | per-app unread counts from unread-count.applications[] | | Rows | list | Notification: app icon (or Substratal mark for source=platform), title, body (2 lines), category, priority high → "Important" marker, created_at relative, unread dot | grouped Today / Yesterday / Earlier; high priority security.* rows show a shield icon | | Mark all as read | button | POST /users/me/notifications/mark-read {before: newest loaded created_at, application_id?} | respects current app filter; uses `before` so items arriving now aren't swept | | Multi-select | selection | | bulk Mark read / Mark unread / Archive (one PATCH per row) | | Row actions | row-button | PATCH /users/me/notifications/{notificationId} {state} | swipe/hover: Read ⇄ Unread, Archive | | Action | Call | Result handling | |---|---|---| | Open row | `→ NTF-02 and PATCH {state:read}` | optimistic; badge decrements | | Archive | `PATCH /users/me/notifications/{notificationId} {state:archived}` | toast with Undo (PATCH state:read) | | Mark read / unread | `PATCH /users/me/notifications/{notificationId} {state}` | | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES; Empty: "You're all caught up"; Feature off (501 not_implemented): hide Inbox entry points; badges fall back to none; Retention: items older than 90 days disappear (caption in empty Archived view) **Notes:** The Inbox shows notifications from every Application plus platform alerts, but push banners for another app's notifications arrive through that app's own install — the Hub only pushes its own (`app_hub`) notifications and platform alerts. #### NTF-02 — Notification **Domain:** D13 Notifications · **Route:** `/inbox/:notificationId` · **Archetype:** popover · **Phase:** Phase 2 **Who:** Self (users.list via NTF-05) **Purpose:** One notification in full, with where it leads and how its push went. **Loads:** `GET /users/me/notifications/{notificationId}` | Element | Kind | Data / API | Rules | |---|---|---|---| | Header | icon + app name + time | application_id / source, created_at | | | Title & body | text | title, body | full text | | Open | primary-button | action_url | Hub route → navigate in-app; https → open in browser / installed app (Universal/App Link); private scheme → hand to OS; hidden when null | | Contextual actions | buttons | by category | security.new_sign_in → "Review sessions" (ACC-04) · security.password_changed / email_changed / mfa_disabled / session_reuse_detected → "Secure my account" (ACC-04) · account.organization_invitation → Accept / Decline (ORG-09) · account.access_granted → Open app (APP-03) | | Delivery | disclosure | push.status, devices_targeted/sent/failed, skip_reason, deliveries[] (30 days) | plain-language: "Pushed to 2 devices", "Not pushed — notifications are off for this app" | | Mark unread / Archive | toolbar | PATCH {state} | | | Action | Call | Result handling | |---|---|---| | Opened | `PATCH /users/me/notifications/{notificationId} {state:read}` | on open if unread | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### NTF-03 — Devices & notifications **Domain:** D13 Notifications · **Route:** `/account/devices` · **Archetype:** settings · **Phase:** Phase 2 **Who:** Self **Purpose:** Where push notifications reach this person, and controls for this device. **Loads:** `GET /users/me/devices?include_inactive=true` · `GET /users/me/settings (notifications.push)` | Element | Kind | Data / API | Rules | |---|---|---|---| | This device | card | Hub Device for this install (matched by stored device id) | status, "Notifications are on" / off / blocked by OS; button Turn on (→ NTF-04) or Turn off | | Push notifications (all apps) | toggle | Settings.notifications.push | same control as SET-01 | | Devices | list | Device: name, application_id (app icon), provider, environment, status, inactive_reason, last_registered_at, last_delivery_at, token_hint | grouped by app; inactive rows dimmed with reason ("Signed out", "Not opened in 9 months"…) | | Rename | row-button | PATCH /users/me/devices/{deviceId} {name} | | | Send test notification | row-button | POST /users/me/devices/{deviceId}/test | Hub devices only; shows provider result inline (sent / failed + error) within 10 s | | Remove | row-button | DELETE /users/me/devices/{deviceId} | confirm; that install stops receiving pushes until it registers again | | Action | Call | Result handling | |---|---|---| | Turn off on this device | `DELETE /users/me/devices/{deviceId}` | and stop re-registering until the user turns it back on | | Test | `POST /users/me/devices/{deviceId}/test` | 429 → "Try again later (10 tests per hour)"; 409 push_not_configured → "Push isn't set up for this app yet" | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES **Notes:** Device tokens are never displayed (only token_hint). #### NTF-04 — Turn on notifications **Domain:** D13 Notifications · **Route:** `/account/devices/enable` · **Archetype:** wizard · **Phase:** Phase 2 **Who:** Self **Purpose:** Explain, request the OS permission, and register this install for push. **Entry points:** First time a notification-worthy moment happens (org invite, first launch after sign-in on mobile, enabling in NTF-03/SET-01) — never on cold start before sign-in | Element | Kind | Data / API | Rules | |---|---|---|---| | Primer | text + illustration | | what you'll get: security alerts, invitations, access changes; "You can change this anytime" | | Turn on | primary-button | OS permission prompt | platform API per PLATFORM-SPEC | | Not now | button | | snooze 14 days | | Action | Call | Result handling | |---|---|---| | Permission granted | `POST /users/me/devices {application_id: app_hub, provider, token \| subscription, environment (apns), name, app_version}` | 201 new / 200 refresh → store device id; 403 entitlement_required → Hub access disabled for this user: show inbox-only mode; 422 invalid_device_token → log + retry next launch | | Token rotated by OS | `PATCH /users/me/devices/{deviceId} {token \| subscription}` | | | Permission denied | `—` | Settings shows "Blocked — Open system settings" deep link | **Notes:** Re-register on launch at most once a day (keeps the device from going stale after 270 days). Windows and Linux have no supported push provider in the API: this screen is replaced by an in-app explanation and local notifications from polling (see their PLATFORM-SPEC). #### NTF-05 — User — Notifications & devices **Domain:** D13 Notifications · **Route:** `/admin/users/:userId/notifications` · **Archetype:** tab · **Phase:** Phase 2 **Who:** users.list (read), users.manage (delete devices) **Purpose:** Support view of a person's inbox and push devices for investigations. **Loads:** `GET /users/{id}/notifications?include_inactive=true` · `GET /users/{id}/notifications/{notificationId}` · `GET /users/{id}/devices?include_inactive=true` · `GET /users/{id}/devices/{deviceId}` | Element | Kind | Data / API | Rules | |---|---|---|---| | Inbox | table | Notification: created_at, application/source, category, priority, title, state, push.status, skip_reason | read-only; never mark the user's items read | | Delivery detail | side panel | deliveries[]: provider, status, attempts, error, sent_at | | | Devices | table | Device: app, provider, environment, status, inactive_reason, name, app_version, last_registered_at, last_delivery_at | | | Delete device | destructive-button | DELETE /users/{id}/devices/{deviceId} | users.manage; audited (device.deleted) | **States:** Loading: skeleton after 300 ms; Error: inline with Retry; codes per COPY-AND-STATES #### NTF-06 — Send notification **Domain:** D13 Notifications · **Route:** `/admin/notifications/new` · **Archetype:** form · **Phase:** Phase 2 **Who:** platform notifications.send **Purpose:** Staff tool: send one notification to one person on behalf of an Application. | Element | Kind | Data / API | Rules | |---|---|---|---| | Recipient | picker | user (GET /users?q) | must have active access to the chosen app | | Application | select | body.application_id | required for platform senders | | Category | text-field | body.category | ^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$; security./account./billing. reserved (422 reserved_category) | | Priority | segmented | body.priority normal \| high | high explains provider throttling | | Title | text-field | body.title | 1–120 | | Body | text-area | body.body | ≤ 1,000 | | Data | key/value editor | body.data | ≤ 20 string pairs, 2 KB; key `substratal` reserved | | Open link | url-field | body.action_url | https or private scheme ≤ 2,048 | | Advanced | disclosure | push (bool), badge, sound default\|silent, collapse_key, ttl_seconds | | | Payload size | meters | client estimate of the 4 KB push payload | 422 notification_too_large → details.bytes/max | | Preview | card | lock-screen style preview per platform | warns: lock screens show title/body — keep sensitive content out | | Action | Call | Result handling | |---|---|---| | Send | `POST /users/{id}/notifications (Idempotency-Key)` | 201 → result panel with push.status / skip_reason; 403 entitlement_required → recipient has no access; 429 scope sender\|recipient → countdown | ### G · Global / Shell #### GLB-01 — Command palette **Domain:** G Global / Shell · **Route:** `(overlay)` · **Archetype:** overlay · **Phase:** MVP **Who:** Signed in **Purpose:** Jump anywhere and run any action by typing. | Element | Kind | Data / API | Rules | |---|---|---|---| | Query | search | | Cmd/Ctrl+K (desktop), search button (touch) | | Results | grouped list | Navigation, Apps (launch), Users (GET /users?q), Orgs, Inbox (recent notifications), Actions (Invite user, Create key, Upgrade to Standard, Mark all read…) | permission-filtered; recent items first; ID prefixes resolve directly | #### GLB-02 — Manage hub **Domain:** G Global / Shell · **Route:** `/manage` · **Archetype:** hub · **Phase:** MVP **Who:** Any admin or developer permission **Purpose:** Compact-layout landing list for the Administration and Developer sections. | Element | Kind | Data / API | Rules | |---|---|---|---| | Administration | list | Users, Access, Roles, Audit Log, Send notification — those the caller may open, each with a live count | section hidden when no rights | | Developer | list | Applications, API Keys, Webhooks, Tenant — with counts and health (unhealthy webhooks, failed push credentials) | section hidden when no rights | #### GLB-03 — System states **Domain:** G Global / Shell · **Route:** `(global)` · **Archetype:** overlay · **Phase:** MVP **Who:** — **Purpose:** App-wide banners and blocking states. | Element | Kind | Data / API | Rules | |---|---|---|---| | Offline | banner | network status | reads from cache, writes disabled with explanation | | Maintenance / tenant migrating | banner | 503 service_unavailable + Retry-After | countdown, auto-retry reads | | Session ended | modal → AUTH-01 | 401 after failed refresh | preserve destination | | Forbidden | inline state | 403 | COPY | | Not built yet | inline state | 501 not_implemented (Notifications today) | hide the feature behind its flag; never show an error to end users | | Update required | blocking screen | API version deprecation header (if present) | | | Action | Call | Result handling | |---|---|---| | Silent token refresh | `POST /auth/token/refresh` | single-flight; rotate refresh token atomically (CLIENT-ARCHITECTURE §2) | | Connectivity probe | `GET /health` | used after offline/503 to decide when to clear the banner | | Forced sign-out | `POST /auth/logout` | on session_revoked or user request; the server deactivates this session's push devices | | Badge refresh | `GET /users/me/notifications/unread-count` | on launch, foreground, after a push arrives, and every 60 s while visible (Windows/Linux: also in background) → app icon/Dock/taskbar/PWA badge | **Notes:** `POST /auth/oauth/token` is called by the launched Application (PKCE exchange), never by the Hub. --- # 00-shared/CLIENT-ARCHITECTURE.md Source: https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/CLIENT-ARCHITECTURE.md ## Client Architecture & API Integration How every Substratal Hub client (macOS, Windows, iOS, Android, Linux, Web) talks to the Platform API (spec v1.4.0). These rules are identical everywhere; each `PLATFORM-SPEC.md` names the native libraries that implement them. Spec references: [Conventions](https://compositecode.github.io/substratalapps.com/api-reference/conventions/), [Auth](https://compositecode.github.io/substratalapps.com/api-reference/auth/), [Access Control](https://compositecode.github.io/substratalapps.com/access-control/), [Push Notifications](https://compositecode.github.io/substratalapps.com/api-reference/notifications/), [Billing](https://compositecode.github.io/substratalapps.com/api-reference/billing/), [Non-Functional Requirements](https://compositecode.github.io/substratalapps.com/non-functional-requirements/). ### 1. Layers ``` ┌───────────────────────────────────────────────────────────────┐ │ UI (native views) — screens from SCREEN-SPECS │ ├───────────────────────────────────────────────────────────────┤ │ Presentation state (view models / stores): screen state │ │ machines idle → loading → loaded/empty/error; Capabilities │ ├───────────────────────────────────────────────────────────────┤ │ Domain services: Session · Directory · Access · Roles · Orgs │ │ · Apps · Settings · Developer · Billing · Audit · │ │ Notifications (inbox, badge) · Push (device registration, │ │ incoming push handling) │ ├───────────────────────────────────────────────────────────────┤ │ API client: auth, retries, idempotency, ETags, pagination, │ │ error decoding, rate limits │ ├───────────────────────────────────────────────────────────────┤ │ Local store: secure credentials · response cache · device id │ │ · preferences │ └───────────────────────────────────────────────────────────────┘ ``` **Generate the API client from `openapi.yaml`** for each platform: Swift (`swift-openapi-generator`), C# (Kiota or NSwag), Kotlin (`openapi-generator` + OkHttp/Ktor), Rust/Python for Linux (`openapi-generator`), TypeScript for Web (`openapi-typescript` + `openapi-fetch`). Screens never call HTTP directly. ### 2. The Hub is a first-party Application To receive push notifications the Hub must own devices, and a Device belongs to an Application. So the Hub is registered in the catalog as **`app_hub`** (slug `hub`): | Field | Value | Why | |---|---|---| | `access_tier` | `free` | Every User gets a platform-managed `free` Entitlement at signup, which device registration requires (`allow()` must pass). | | `receives_platform_notifications` | `true` | Security and account alerts push to Hub devices. | | `visibility` | `internal` | Not shown as a launchable tile; APP-01 hides it. | | Push credentials | APNs (topic = the shared iOS/macOS bundle id), FCM (Android), Web Push (VAPID) | Configured once by staff in APL-07. | The Hub still authenticates with **platform sessions** (it never mints app tokens for itself). If an admin disables someone's `app_hub` access, registration returns `403 entitlement_required`; the Hub then runs in inbox-only mode (no push) and says so in NTF-03. ### 3. Session lifecycle | Token | Where it lives | Lifetime | Notes | |---|---|---|---| | Platform access token (JWT RS256) | Memory only | 15 min | `Authorization: Bearer` on every call | | Platform refresh token (opaque, rotating) | OS secure store (Keychain, Credential Locker/DPAPI, Keystore, libsecret); Web: HttpOnly cookie via BFF | per spec | Persist the rotated token atomically before using the new access token | | App token (per-app JWT) | Never stored | 5 min | Minted at launch only (APP-03) | | MFA token | Memory | `expires_in` (~300 s) | Discard on success or expiry | | Push device id (`dvc_…`) | Local preferences (not secret) | until deleted | Used to PATCH token rotations and to DELETE on "turn off" | Rules: 1. **Single-flight refresh.** On `401 unauthenticated`, pause the queue, call `POST /auth/token/refresh` once, replay. Never refresh in parallel — reuse of a rotated token revokes the session and raises a `security.session_reuse_detected` alert. 2. **Proactive refresh** 60 s before expiry while in the foreground. 3. `401` on refresh or `session_revoked` → wipe credentials, show GLB-03 "Session ended", return to AUTH-01 keeping the destination. 4. **Sign out** = `POST /auth/logout`, then wipe secure store, cache, inbox cache and stored device id. The server deactivates this session's push devices (`session_revoked`); signing in again re-registers. 5. One account per device profile in v1. `test_mode` sessions (developer builds only) are stored separately with a persistent TEST indicator. ### 4. Request conventions | Concern | Client behavior | |---|---| | Base URL | `https://api.substratalapps.com/v1` (configurable: prod / staging / local devserver) | | IDs | Opaque prefixed strings (`usr_ app_ org_ ent_ role_ key_ evt_ sbs_ dvc_ pcr_ ntf_ ndl_ tnt_`). Only the prefix is ever interpreted (palette routing). `me` for `{id}` with a user token. | | Idempotency | Generate a UUID v4 **when a create form opens**; send `Idempotency-Key` on submit and every retry. **Required** on personal and org entitlement grants and both Checkout endpoints; send it on every create POST, including `POST …/notifications`. Handle `idempotency_key_reused` (422) and `idempotency_key_in_flight` (409 → retry after 1 s). | | Concurrency | Keep `ETag` from single-resource GETs and writes; send `If-Match` on PATCH/DELETE of Entitlements, Roles, Settings, AppSettings, Profile, Organizations, Users, Organization subscription coverage. On `409 version_conflict`: refetch, then **Reload** or **Re-apply**. | | Pagination | Cursor: `limit` (50 desktop / 25 compact) + `cursor`; stop when `page.has_more` is false. Infinite scroll on touch; virtualized lists + "Load more" on desktop. Keep loaded pages on back-navigation. | | Filtering | List endpoints exclude terminal states (revoked entitlements, inactive devices, archived notifications) unless `status=…` or `include_inactive=true` is passed. | | Errors | Decode `{error:{code,message,details}}`; map `code` → COPY-AND-STATES; `details[].field` → form fields. Raw `message` only inside a "Details" disclosure with the request id. | | Rate limits | 100 req/min per session; plus 600 sends/min per key and 60 notifications per recipient per app per hour; 10 device tests per device per hour. On `429` honor `Retry-After` and show a countdown; back off background polling ×2 up to 5 min. | | 501 | `not_implemented` → turn the feature's flag off for the session, log once, never show an error. | | 503 | Reads auto-retry after `Retry-After`; writes never auto-retry (GLB-03 banner). `billing_unavailable` is a 503 on billing endpoints. | | Timeouts | 10 s connect / 30 s read; reads retry twice with jitter; writes retry only with an Idempotency-Key. | | Headers | `User-Agent: Substratal-{platform}/{version} ({os})` (the API shows it in Sessions); `Accept-Language` from the active locale. | ### 5. Capabilities (permission gating) Computed on sign-in and on every foreground refresh: ``` GET /users/me → status, mfa_enabled, email_verified, plan, plan_sources GET /users/me/roles → platform role ids (+ implicit) GET /roles?scope=platform → role → permission keys (cache 1 h; 403 = no admin permissions) GET /applications?owned=true → owned apps → app-owner rights GET /tenants → owned Tenant(s) GET /organizations → memberships + my_role (org_admin → subscription rights) ``` Exposes `canListUsers`, `canManageUsers`, `canManageEntitlements`, `canManageRoles`, `canViewAudit`, `canManageApplications`, `canManageApiKeys`, `canManageWebhooks`, `canManageTenants`, `canManageBilling`, `canSendNotifications`, `ownsApps[]`, `ownsTenants[]`, `adminOfOrgs[]`, `plan`, `planSources[]`. Navigation and every action read only from `Capabilities`; a `403` triggers a background refresh. The server is always the authority. ### 6. Push notifications and the inbox #### 6.1 What the Hub receives | Kind | In the Hub inbox (NTF-01) | Pushed to Hub devices | |---|---|---| | Platform alerts (`security.*`, `account.*`) | yes | yes — `app_hub` opts in | | Notifications sent by `app_hub` itself | yes | yes | | Notifications from other Applications | yes (the User sees everything addressed to them) | no — they push to that app's own installs | #### 6.2 Provider per platform The API supports **APNs, FCM and Web Push** only. | Hub app | Provider | Notes | |---|---|---| | iOS / iPadOS | `apns` | `environment: sandbox` for development builds, `production` for TestFlight/App Store | | macOS | `apns` | Same bundle id as iOS (Universal Purchase) so the single `app_hub` APNs credential's `topic` covers both — see OPEN-QUESTIONS #3 | | Android | `fcm` | Firebase project for `app_hub` | | Web (PWA) | `web_push` | VAPID public key from `app_hub`'s credential, shipped in the web build config (it's public); iOS/iPadOS Safari only when installed to the Home Screen | | Windows | none | No WNS in the API. Poll `unread-count` + new items and raise **local** app notifications; taskbar badge. See OPEN-QUESTIONS #2 | | Linux | none | No push service on Linux desktops. Poll and raise local `GNotification`s | #### 6.3 Device lifecycle (iOS, macOS, Android, Web) 1. **Ask at the right moment** (NTF-04): after sign-in on mobile at the first meaningful moment, or when the person turns notifications on — never before sign-in. 2. On OS permission granted: obtain the token/subscription → `POST /users/me/devices {application_id: "app_hub", provider, token | subscription, environment, name, app_version}` → store the returned `dvc_` id. 3. **Re-register on launch at most once a day** (keeps `stale` deactivation at bay, re-attaches to the current session after sign-in). 4. **Token rotation** (APNs device token change, FCM `onNewToken`, `pushsubscriptionchange`) → `PATCH /users/me/devices/{deviceId}` with the new token. 5. **Turn off on this device** → `DELETE /users/me/devices/{deviceId}` and stop re-registering until turned on again. The global preference is `Settings.notifications.push` (SET-01); per app via AppSettings override (SET-02). 6. **Sign out**: the server deactivates the session's devices; the client also forgets the device id. 7. OS permission revoked → nothing to call; the provider reports the token invalid on next send and the server deactivates it. Settings shows "Blocked — Open system settings". 8. Max 20 active devices per User per app: older ones become `inactive (limit)` — nothing for the client to do. #### 6.4 Handling an incoming push - Read the `substratal` block: `notification_id`, `application_id`, `source`, `category`, `action_url`. - **Foreground:** show an in-app banner/toast (don't duplicate a system banner), refresh `unread-count`, prepend to the inbox if visible. - **Tap:** `PATCH /users/me/notifications/{id} {state: read}`, then route: Hub route in `action_url` → navigate; other URL → open externally; none → NTF-02. - **Action buttons** by category (COPY-AND-STATES "Push notification text"): e.g. `account.organization_invitation` → **Accept** calls `POST /organizations/{id}/members/me/accept` from the notification action (requires unlock/auth on mobile). - Pushes are prompts, not truth: always refetch from the API. No ordering guarantee; `collapse_key` already collapses on device. - Content is visible on lock screens — the Hub shows only what the API sent; it never adds details to a notification. #### 6.5 Badges and unread counts `GET /users/me/notifications/unread-count` on launch, on foreground, after any push, after marking read, and every 60 s while visible (Windows/Linux also in the background while running). Badge = total `unread_count` (app icon, Dock, taskbar, PWA badge, Inbox nav item). Per-app counts drive the Inbox app filter. #### 6.6 Notification channels / categories - iOS/macOS: register `UNNotificationCategory` per category with actions; `security.*` uses interruption level time-sensitive (requires the Time Sensitive capability). - Android: channels **Security alerts** (high importance), **Account & invitations** (default), **App updates** (default); map by category prefix. - Web/Windows/Linux: actions via the Notifications API / app notification buttons where supported. ### 7. Caching & offline | Data | Cache | Freshness | |---|---|---| | Capabilities, own User/Profile/Settings, plan | persistent | launch + foreground | | Applications catalog | persistent | 1 h, or pull-to-refresh | | Own entitlements | persistent | launch, foreground, 60 s while APP-01 visible | | Inbox (first page) | persistent, encrypted | launch, foreground, on push; 90-day retention mirrors the API | | Subscription | memory | on BIL screens; poll after checkout | | Admin lists (users, access, audit, other people's notifications) | memory only | per visit | | Secrets, app tokens, push credential key material | never | — | Offline: Home and Inbox render read-only from cache; launch disabled (needs a fresh app token). Writes require connectivity — no offline write queue (access changes must be immediate). Clear everything on sign-out. ### 8. Launching apps (APP-03) - **Embedded (MVP):** `POST /auth/app-tokens {application_id, organization_id?}` → hand the 5-min token to the app per its integration contract. - **Hosted / PKCE (Phase 3):** generate verifier/challenge (S256) on device, `POST /auth/oauth/authorization-codes`, open `redirect_uri?code&state` with the platform auth-session browser; validate `state`. The hosted sign-in page itself ships with the Web Hub. ### 9. Billing hand-off - Checkout and Portal are Stripe pages opened in the system browser (or auth-session browser on mobile). `success_url` / `cancel_url` / `return_url` are `https://dashboard.substratalapps.com/billing/return?…` (or `/orgs/{id}/subscription?…`), which Universal/App Links route back to the native app. - Returning to `success_url` doesn't mean coverage started: poll `GET …/subscription` every 2 s for up to 30 s until `plan = standard`, then refresh Capabilities and entitlements (new standard apps appear on Home). - In-app purchase rules for iOS/Android are an open policy question (OPEN-QUESTIONS #10); upgrade buttons are flag-gated per store. ### 10. Feature flags Remote-configurable, also folded into `Capabilities`: `sso`, `passkeys`, `organizations`, `org_grants`, `app_profiles`, `app_settings`, `app_roles`, `webhooks`, **`notifications`** (inbox, devices, NTF-*), **`push`** (device registration per platform), **`push_credentials`** (APL-07), **`send_notifications`** (NTF-06), **`billing`** (BIL-01..03), `billing_upgrade_ios`, `billing_upgrade_android`, `tier_changes`, `hosted_launch`, `app_registration_self_service`, `audit_export`. A `404`/`501` from a gated endpoint turns its flag off for the session. ### 11. Security checklist - TLS only; OS trust store (no pinning). - Secrets (API key secrets, webhook secrets, recovery codes, push key files being uploaded) exist only in memory; KEY-03 window protected from capture where the OS allows. - Push key material (`.p8`, FCM JSON) is read from the picked file and sent once; never cached, logged or written to temp files. - Clipboard marked sensitive and auto-cleared after 60 s. - No tokens, emails, IDs, notification titles/bodies or device tokens in analytics or crash reports. - Admin screens hidden in app-switcher snapshots; auto-lock after 15 min idle. - Deep links and `action_url`s are untrusted input: validate before routing; emailed tokens are exchanged at once and stripped from history. ### 12. Telemetry (privacy-preserving) Shared event names: `app_launch`, `sign_in_success`, `sign_in_mfa_required`, `sign_in_failed{code}`, `screen_view{screen_id}`, `app_tile_launch`, `access_toggle{direction,managed}`, `grant_created{source}`, `role_assigned`, `key_created{mode,intended_use}`, `secret_copied`, `palette_used{result_type}`, `push_permission{result}`, `device_registered{provider}`, `push_opened{category_prefix}`, `inbox_mark_all_read`, `checkout_started{subscriber_type}`, `checkout_completed{subscriber_type}`, `error_shown{code,screen_id}`. No PII; per-install hashed ids; opt-out in SET-01. ### 13. Themes - **Inputs:** `Settings.theme` (light / dark / system) plus `app_hub` settings `appearance.light_theme` and `appearance.dark_theme`. Ids and defaults are in THEMES.md. - **Launch:** apply the cached choice before first paint (web: the inline head script; native: read it from preferences before the first window), then reconcile with `GET /users/me/settings` and `GET /users/me/apps/app_hub/settings`. - **Live changes:** follow OS light/dark changes while in *Match system*. When a choice changes on another device, apply it on the next settings refresh: launch, foreground, or `settings.updated` in the inbox stream if available. - **Fallback:** an unknown id uses the default for its mode, and the error is never surfaced to the user. Platform high-contrast modes bypass the theme. --- # 00-shared/UX-FLOWS.md Source: https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/UX-FLOWS.md ## UX Flows Screen IDs in brackets refer to `SCREEN-SPECS.md`. Flow → screens: F1 AUTH-03/04 · F2 AUTH-01/02/05/06 · F3 APP-01/02/03 · F4 ACC-04/05/06 · F5 ACC-07/08 · F6 USR-01/03, ACX-01..04 · F7 ROL-01..04 · F8 ORG-01..09 · F9 APL-02/03, KEY-02/03, WHK-02/03 · F10 BIL-01/03 · F11 AUD-01/02 · F12 NTF-04/03 · F13 NTF-01/02, ORG-09 · F14 BIL-02/03, ORG-03 · F15 APL-07, NTF-03 · F16 NTF-05/06. Each flow lists the screens, the API calls behind each step, and the branches the UI must handle. Diagrams are Mermaid (render in GitHub, VS Code, or open `ux-flows.html` in this folder). --- ### F1 · First run: sign up → verify → home ```mermaid flowchart TD A[Welcome] -->|Create account| B[Sign up form] A -->|Sign in| L[Sign in] A -.->|Opened from invite link| INV[Accept invitation] B -->|POST /auth/signup| C{201} C -->|session issued| D[Check your email screen] D -->|Resend · POST /auth/email/verify/resend 202| D D -->|deep link token · POST /auth/email/verify| E[Email verified ✓] E --> H[Home · My Apps] C -->|422 validation_failed| B INV -->|POST /auth/invitations/accept| H ``` - Signup returns a session immediately; unverified users reach Home with a persistent "Verify your email" banner. - Every new account is on **Free** with access to every free app (platform-managed `free` entitlements); standard apps show as "Included with Standard". - Password field: show strength meter, show/hide toggle, platform password-manager autofill (`textContentType=.newPassword`, `autocomplete="new-password"`). ### F2 · Sign in with MFA and session upkeep ```mermaid flowchart TD L[Sign in] -->|POST /auth/login| R{response} R -->|session| H[Home] R -->|mfa challenge + methods| M[Two-factor code] M -->|POST /auth/mfa/verify| H M -->|Use a recovery code| MR[Recovery code entry] --> H R -->|401 unauthenticated| L R -->|429 rate_limited| W[Wait message w/ Retry-After] --> L L -->|Forgot password| FP[Email entry] -->|POST /auth/password/forgot 202| FS[If an account exists, we sent a link] FS -.->|deep link| RP[New password] -->|POST /auth/password/reset| SO[Signed out everywhere notice] --> L H -.->|access token 15 min; refresh in background| RT[POST /auth/token/refresh] RT -.->|401 session_revoked| L ``` - MFA challenge expires (`expires_in` ≈ 300 s): show a quiet countdown; on expiry return to Sign in with message. - Store refresh token in the platform secure store only (Keychain, Credential Locker/DPAPI, Android Keystore-backed EncryptedSharedPreferences/DataStore, libsecret). - Offer biometric unlock to re-open the app (Touch ID/Face ID, Windows Hello, BiometricPrompt, — on Linux, OS keyring unlock) — this gates access to the stored refresh token; it is not a server auth factor. ### F3 · Launch an app (the MVP happy path) ```mermaid flowchart TD H[Home · My Apps] -->|GET /applications + GET /users/me/entitlements| G[Grid of tiles w/ resolved status] G -->|tap active tile| X{launch mode} X -->|embedded| T[POST /auth/app-tokens] --> O[Open launch_url with app token
in-app browser / system browser] X -->|hosted PKCE, Phase 3| P[POST /auth/oauth/authorization-codes] --> O G -->|disabled/expired tile| I[Info sheet: why + who to contact] G -->|none + public| GA[Get access → app's own page] G -->|long-press / right-click| CM[Context menu: App settings · App profile · Copy app ID] ``` - Tile ordering: Recently launched, then alphabetical. Pin/favorite is client-side only. - App token TTL is 5 minutes — mint at tap time, never cache. ### F4 · Account security: enable two-factor ```mermaid flowchart LR S[Account › Security] -->|Turn on| E[POST /users/me/mfa/totp] --> Q[QR + secret, Copy / open in authenticator] Q --> C[Enter 6-digit code] -->|POST …/totp/confirm| RC[Recovery codes — shown once] RC -->|Copy · Save · Print| D[Done — I've saved them] D --> S S -->|Turn off| OFF[Enter code or recovery code] -->|POST …/totp/disable| S ``` - Recovery-code screen blocks dismissal until "I've saved these" is checked. - Sessions list: current device marked "This device"; "Sign out of all other sessions" revokes each other session (`DELETE /users/me/sessions/{id}`), which also deactivates their push devices. - Turning 2FA off raises a `security.mfa_disabled` platform alert to every Hub device (F13). ### F5 · Delete my account / export my data ```mermaid flowchart LR P[Account › Privacy] -->|Download my data| X[GET /users/me/export] --> SV[Save file dialog / share sheet] P -->|Delete account| W[Warning: 7-day window, what is erased] --> TY[Type DELETE to confirm] -->|POST erasure-requests| SC[Scheduled for date — signed out] ``` ### F6 · Admin turns a user's access off (and back on) ```mermaid flowchart TD U[Users] -->|search| UD[User detail › Access tab] UD -->|GET /users/:id/entitlements| AL[Per-app rows: resolved status + paths] AL -->|toggle off on a personal path| CF[Confirm: Turn off Timetrack for Dana? reason] CF -->|PATCH /entitlements/:id status=disabled| AL AL -->|org-seat path still active| WN[Warning: still has access via Org Acme — manage seat grant] AL -->|Why?| EP[GET effective-permissions → inspector sheet] AL -->|Grant access| GR[Grant sheet: app, source, starts/ends] -->|POST /users/:id/entitlements| AL ``` - Optimistic toggle with rollback on error; success toast "Access turned off · Undo" (Undo = PATCH back to `active`, within toast lifetime). - If the user only has access through an organization, the switch on that row is replaced by "Via Acme (seat)" with a link to the org's seat grant. - `DELETE` (hard remove) appears only when eligible (developer grant, no `order_id`, < 24h) as "Remove grant made in error". - Platform-managed rows (`free`, `subscription`) only toggle off/on; the confirm explains the access comes with their plan and stays off until turned back on. ### F7 · Roles ```mermaid flowchart LR RL[Roles] --> RD[Role detail: permissions checklist] RL -->|New role| NR[Name, scope platform/app, permissions] -->|POST /roles| RD RD -->|Edit, non-seed only| PATCH[PATCH /roles/:id] UD[User detail › Roles tab] -->|Assign role| PICK[Role picker] -->|POST /users/:id/roles/:roleId| UD ``` ### F8 · Organization admin ```mermaid flowchart TD O[Organizations] -->|New| NO[Name] -->|POST /organizations| OD[Org detail] OD --> MEM[Members tab] -->|Invite by email| INV[POST /organizations/:id/members] MEM -->|change role / remove| MR[PATCH / DELETE member] OD --> SEATS[Apps & Seats tab] -->|Grant app to org| SG[POST /organizations/:id/entitlements] SEATS -->|Member scope: all / allowlist / denylist| MS[PATCH /entitlements/:id member_scope] INVITEE[Invited user · Home banner] -->|Accept| ACC[POST members/me/accept] --> OD INVITEE -->|Decline| DEC[DELETE members/me] ``` ### F9 · Developer: register an app, get credentials, wire webhooks ```mermaid flowchart TD A[Applications] -->|Register| R[Name, slug immutable, launch URL, redirect URIs, visibility] R -->|POST /applications| AD[App detail: Overview · Settings schema · Roles · Keys · Webhooks · Activity] AD -->|Create app key| K[Name, mode live/test, use service/agent, permissions, restrict destructive] K -->|POST /api-keys| SECRET[Secret shown once — Copy] AD -->|Add webhook| W[URL + event types] -->|POST /webhooks| WS[Signing secret shown once] WS -->|Send test| T[POST /webhooks/:id/test] --> DL[Deliveries log] DL -->|failed row| RD[Redeliver] ``` - Key rotation copy: "The current secret stops working immediately." Webhook secret rotation copy: "Both secrets are valid for 24 hours." ### F10 · A person upgrades to Standard ```mermaid flowchart TD H[Home: standard app tile 'Included with Standard'] --> U[Upsell sheet: every app for $6/month] P[Plan & Billing BIL-01] --> U U -->|Upgrade to Standard| C[POST /users/me/billing/checkout-sessions · Idempotency-Key] C -->|201 url| S[Stripe Checkout in browser] C -->|409 subscription_exists| PO[POST /users/me/billing/portal-sessions → Portal] S -->|success_url → app deep link| W[Finishing up… poll GET /users/me/subscription] W -->|plan = standard| D[Refresh Capabilities + entitlements → standard apps unlock on Home] W -->|30 s, still free| L[Taking longer than usual · check again later] S -->|cancel_url| X[Toast: Checkout cancelled] P -->|Manage billing| PO ``` - Mobile store builds show the Upgrade button only when its store flag is on (OPEN-QUESTIONS #10). - Covered by an org and also subscribed individually → BIL-01 suggests cancelling the individual subscription in the Portal. - `past_due` keeps access (Stripe retries ~3 weeks); BIL-01 shows "Payment issue · Update it in Manage billing". ### F11 · Audit investigation ```mermaid flowchart LR AU[Audit Log] -->|filters: actor type, action, resource, date| L[GET /audit-events] L --> D[Event detail: actor, resource link, before/after diff, request id] D -->|open resource| RES[User / App / Org detail] ``` --- ### F12 · Turn on push notifications (iOS, macOS, Android, Web) ```mermaid flowchart TD M[Meaningful moment: first sign-in on mobile, org invite, or toggle in NTF-03/SET-01] --> PR[NTF-04 primer] PR -->|Not now| SN[Snooze 14 days] PR -->|Turn on| OS{OS permission prompt} OS -->|denied| BL[NTF-03 shows Blocked · Open system settings] OS -->|granted| TK[Get APNs token / FCM token / PushSubscription with app_hub VAPID key] TK --> RG[POST /users/me/devices application_id=app_hub] RG -->|201 or 200| ST[Store dvc_ id · This device: On] RG -->|403 entitlement_required| IO[Inbox-only mode explained] ST -.->|daily on launch| RR[POST again to refresh] ST -.->|OS rotates token| PT[PATCH /users/me/devices/:id] ST -.->|Turn off on this device| DL[DELETE /users/me/devices/:id] ST -.->|Send test| TS[POST …/devices/:id/test → sent / failed] ``` - Windows and Linux skip this flow: notifications come from polling and are shown locally (CLIENT-ARCHITECTURE §6.2). - Sign-out deactivates the device server-side; signing in again re-registers it under the new session. ### F13 · Receive and act on a notification ```mermaid flowchart TD EV[Platform alert or app_hub notification is sent] --> IN[Lands in inbox — always] EV --> PU{Device for app_hub and push on?} PU -->|yes| PN[System notification with actions] PU -->|no| IB[Badge updates on next unread-count] PN -->|tap| RD[PATCH state=read] --> RT{action_url} RT -->|Hub route| NAV[Navigate in-app] RT -->|other URL| EXT[Open externally] RT -->|none| ND[NTF-02 detail] PN -->|Accept invitation action| AC[POST /organizations/:id/members/me/accept] PN -->|Review sessions action| SE[ACC-04 sessions] IN --> NT[NTF-01 Inbox: filter, mark read, archive, mark all read] ``` - Foreground pushes become an in-app banner and refresh the inbox; the badge always comes from `unread-count`. - Other apps' notifications appear in the Hub inbox but push only to those apps' own installs. ### F14 · An organization subscribes to Standard for its members ```mermaid flowchart TD O[ORG-03 › Subscription tab BIL-02] -->|no subscription| SB[Subscribe the organization] SB --> SC[Choose who's covered: everyone / selected / everyone except] SC --> CK[POST /organizations/:id/billing/checkout-sessions · member_scope] CK --> ST[Stripe Checkout] -->|success| PL[Poll GET /organizations/:id/subscription until plan = standard] PL --> CV[Covered members get Standard apps at once] O -->|subscription in force| ED[Edit coverage] ED --> CF[Confirm: n gain, m lose access now; billed from next daily sync] CF --> PA[PATCH /organizations/:id/subscription · If-Match] O -->|Manage billing| PO[POST …/billing/portal-sessions → Stripe Portal] ``` - Billed users = covered active members (`quantity.current`); the invoice uses the daily-synced count (`quantity.last_synced`). - Removing a member from the org removes their coverage immediately. ### F15 · Developer sets up push for their app ```mermaid flowchart LR A[APL-03 › Push tab APL-07] --> P{Provider} P -->|APNs| AP[team_id, key_id, topic, .p8 file] P -->|FCM| FC[service account JSON] P -->|Web Push| WP[subject → platform generates VAPID; copy public key] AP & FC & WP --> POST[POST /applications/:id/push-credentials] POST -->|201| OK[Active] POST -->|422 invalid_push_credential| ER[Field error] OK --> T[Prove it: owner's own device test] OK -.->|provider rejects key| F[Status: Needs attention · push_credential.failed webhook] --> FX[PATCH new key material] A --> TG[Toggle platform alerts receives_platform_notifications] ``` ### F16 · Support investigates "I never got the alert" ```mermaid flowchart LR U[USR-03 › Notifications tab NTF-05] --> N[Find notification by category/time] N --> D[push.status + skip_reason + deliveries] D -->|push_disabled| P[Person turned push off — explain] D -->|no_devices| V[Devices table: inactive reasons] D -->|failed| E[Provider error per device] V -->|users.manage| X[Delete stale device] U --> S[Staff with notifications.send: NTF-06 send a test notification on behalf of an app] ``` ### Cross-cutting behavior | Situation | Behavior on every platform | |---|---| | `401 unauthenticated` mid-session | Try refresh once; on failure, return to Sign in preserving the deep link destination. | | `403 forbidden` | Inline empty-state "You don't have permission to view this." — never a crash or blank screen. | | `409 not_unique` / `entitlement_already_exists` | Inline field error or "Already granted — view it" link. | | `409 version_conflict` (`If-Match` mismatch) | "This changed since you opened it." Reload / Re-apply. | | `422 validation_failed` | Map `details[].field` to field-level errors. | | `429 rate_limited` | Disable the action, show retry countdown from `Retry-After`. | | `503 service_unavailable` | Non-blocking banner; queue nothing — writes are retried manually. | | `501 not_implemented` | Feature flag off for the session; entry points hidden. | | Incoming push | Refetch from the API; never trust push content as state. | | Idempotency | Every create/POST sends an `Idempotency-Key` (UUID v4) generated when the form opens, reused on retry (required on grants and checkouts). | | Pagination | Cursor based; preserve scroll position on back-navigation. | | Optimistic updates | Allowed for toggles and role assignment only; everything else waits for server. | --- # 00-shared/INFORMATION-ARCHITECTURE.md Source: https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/INFORMATION-ARCHITECTURE.md ## Information Architecture One navigation model, expressed natively on each platform. Section names, order, grouping and icon meanings are identical everywhere. ### 1. Navigation tree (screen IDs and routes) ``` Substratal Hub ├── PERSONAL │ ├── Home / APP-01 (APP-02 /apps/:appId, APP-05 /apps/past) │ ├── Inbox (unread n) /inbox NTF-01 (NTF-02 /inbox/:notificationId) │ ├── Organizations /orgs ORG-01 (ORG-03 /orgs/:orgId/members, ORG-06 …/apps, BIL-02 …/subscription, ORG-08 …/settings) │ ├── Account /account ACC-01 (ACC-04 /account/security, NTF-03 /account/devices, ACC-07 /account/privacy) │ ├── Plan & Billing /billing BIL-01 (BIL-03 /billing/return) │ └── Settings /settings SET-01 (SET-02 /settings/apps/:appId) ├── ADMINISTRATION /admin (GLB-02 Manage hub on compact layouts) │ ├── Users /admin/users USR-01 (USR-03 /admin/users/:userId + tabs Access ACX-01, Roles ROL-04, Sessions USR-04, Notifications NTF-05, Activity) │ ├── Access /admin/access ACX-05 │ ├── Roles /admin/roles ROL-01 (ROL-02 /admin/roles/:roleId) │ ├── Audit Log /admin/audit AUD-01 (AUD-02 /admin/audit/:eventId) │ └── Send notification /admin/notifications/new NTF-06 └── DEVELOPER /dev (GLB-02 Manage hub on compact layouts) ├── Applications /dev/apps APL-01 (APL-03 /dev/apps/:appId + tabs incl. Push APL-07) ├── API Keys /dev/keys KEY-01 (KEY-04 /dev/keys/:keyId) ├── Webhooks /dev/webhooks WHK-01 (WHK-03 /dev/webhooks/:hookId) └── Tenant /dev/tenant BIL-04 Outside the shell: /sign-in /sign-in/mfa /sign-up /verify-email /forgot-password /reset-password /invite Overlays: Command palette GLB-01 · Turn on notifications NTF-04 · System states GLB-03 ``` ### 2. Visibility rules (from `Capabilities`) | Item | Visible when | |---|---| | Home, Inbox, Account, Plan & Billing, Settings | always (Inbox, devices: flag `notifications`; Plan & Billing: flag `billing`) | | Organizations | flag `organizations` | | → Organization › Subscription tab | `org_admin` of that org, or `billing.manage` | | Administration section | any of `users.list`, `entitlements.manage`, `roles.manage`, `audit.view`, `notifications.send`, or owns ≥ 1 app (Access only, confined) | | → Users | `users.list` | | → Access | `entitlements.manage` or owns ≥ 1 app | | → Roles | `users.list` (read) / `roles.manage` (write) | | → Audit Log | `audit.view` | | → Send notification | platform `notifications.send` | | Developer section | owns ≥ 1 app or Tenant, or any of `applications.manage`, `api_keys.manage`, `webhooks.manage`, `tenants.manage` | | → Application › Push tab | owner or `applications.manage` (write needs a User token — always true in the Hub) | | → Tenant | Tenant owner or `tenants.manage` | Hide what the person can never use. Show but **disable with a reason** what they can see but not do right now ("Managed by Substratal — you can only turn it off", "You're the last admin", "Seed roles can't be edited"). ### 3. Navigation container per platform | Platform | Wide layout | Compact layout | Primary actions | |---|---|---|---| | macOS | `NavigationSplitView`: sidebar (full tree) · list · detail | min window 900 pt — no compact mode | toolbar trailing item | | Windows | `NavigationView` Left; Account, Plan & Billing, Settings in pane footer | LeftCompact → LeftMinimal | page header button + CommandBar | | iOS / iPadOS | iPad: sidebar-adaptable `TabView` (full tree) + split views | iPhone: 5 tabs — **Home · Inbox · Orgs · Manage · Account** (Plan & Billing and Settings inside Account) | nav-bar trailing button | | Android | expanded: permanent drawer (full tree); medium: rail | compact: bottom bar, same 5 destinations | Extended FAB | | Linux (GNOME) | `AdwNavigationSplitView` sidebar (full tree) | single pane < 600 sp | header-bar suggested button | | Web | ≥ 1200 px sidebar + resizable panes; 768–1199 collapsible sidebar + 2 panes | < 768: bottom nav (same 5) + sheets | toolbar button · floating primary button (phone) | Rule: **wide = every section in a sidebar; compact = 5 destinations with Administration and Developer combined in the Manage hub (GLB-02), which is removed when the person has no rights in either.** The Inbox destination carries the unread badge on every layout. ### 4. Routes and deep links The route column in `SCREEN-SPECS.md` is canonical. Web host: **`https://dashboard.substratalapps.com`** (the host the API spec uses in its billing examples). | Platform | Form | |---|---| | Web | `https://dashboard.substratalapps.com{route}` | | iOS, macOS | Universal Links on the same URLs + `substratal://{route}` | | Android | Verified App Links on the same URLs + `substratal://{route}` | | Windows | App URI handler on the same URLs + `substratal:{route}` | | Linux | `x-scheme-handler/substratal` → `substratal://{route}` | Routes encode the view's state (tab, filters as query). Emailed links (`/verify-email?token=`, `/reset-password?token=`, `/invite?token=`, `/orgs/:orgId/invitation`) and Stripe returns (`/billing/return?checkout=…`, `/orgs/:orgId/subscription?checkout=…`) open the installed app when present, else the web app. A notification's `action_url` that is a Hub route opens in-app; any other URL opens externally. Restoring a route without permission shows the Forbidden state, not a redirect. ### 5. Section icons (meaning → platform symbol) | Section | SF Symbols | Fluent | Material Symbols | Adwaita | Web (Lucide) | |---|---|---|---|---|---| | Home | `square.grid.2x2` | `Apps` | `apps` | `view-app-grid-symbolic` | `layout-grid` | | Inbox | `tray` (badge) | `MailInbox` | `inbox` | `mail-inbox-symbolic` | `inbox` | | Organizations | `person.3` | `PeopleTeam` | `groups` | `system-users-symbolic` | `users` | | Account | `person.crop.circle` | `PersonCircle` | `account_circle` | `avatar-default-symbolic` | `circle-user` | | Plan & Billing | `creditcard` | `Payment` | `credit_card` | `wallet-symbolic` | `credit-card` | | Settings | `gearshape` | `Settings` | `settings` | `emblem-system-symbolic` | `settings` | | Manage (compact) | `slider.horizontal.3` | `Options` | `tune` | `preferences-system-symbolic` | `sliders-horizontal` | | Users | `person.2` | `PeopleList` | `group` | `contact-new-symbolic` | `contact` | | Access | `switch.2` | `ToggleRight` | `toggle_on` | `object-select-symbolic` | `toggle-right` | | Roles | `checkmark.shield` | `ShieldKeyhole` | `admin_panel_settings` | `security-high-symbolic` | `shield-check` | | Audit Log | `list.bullet.clipboard` | `History` | `history` | `document-open-recent-symbolic` | `history` | | Send notification | `bell.badge` | `AlertUrgent` | `notification_add` | `bell-symbolic` | `bell-plus` | | Applications | `shippingbox` | `Box` | `widgets` | `application-x-addon-symbolic` | `box` | | API Keys | `key` | `Key` | `key` | `dialog-password-symbolic` | `key-round` | | Webhooks | `arrow.triangle.branch` | `PlugConnected` | `webhook` | `network-transmit-receive-symbolic` | `webhook` | | Tenant | `server.rack` | `Server` | `dns` | `network-server-symbolic` | `server` | ### 6. Standard screen anatomy | Archetype | Wide | Compact | |---|---|---| | list / table | list pane (table on desktop) with search + filter chips | full-width rows, search in nav bar, filters in chip row or sheet | | detail | header (title, ID chip, status) + tabs + content; actions in toolbar | header + segmented control; actions in nav bar / overflow | | form / wizard | sheet / dialog | full-screen sheet / dialog | | confirm | alert/dialog naming the object; destructive button styled destructive | same | | secret | non-dismissible modal | same | | popover | anchored popover or inspector pane | half-height sheet | | handoff | leaves the app; returns via deep link | same | | hub | (not used — sidebar replaces it) | grouped list | --- # 00-shared/THEMES.md Source: https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/THEMES.md ## Substratal themes The Hub ships **five light and five dark themes** on every platform. The defaults are **L3 Moss Paper** (light) and **D4 Forest Floor** (dark). Every reference screen and mockup is rendered in both defaults. Users can switch to any of the other themes in **Settings › Appearance** (SET-01). ![Themes overview](themes/themes-overview.png) ### 1. What the user controls | Setting | Values | Stored in | Default | |---|---|---|---| | Appearance | Light · Dark · Match system | `Settings.theme` (`light` / `dark` / `system`) via `PATCH /users/me/settings` | Match system | | Light theme | `teal-ledger`, `indigo-root`, `moss-paper`, `coral-clay`, `spore-pink` | `app_hub` per-app setting `appearance.light_theme` via `PATCH /users/me/apps/app_hub/settings` | `moss-paper` | | Dark theme | `night-soil`, `deep-teal`, `graphite-spore`, `forest-floor`, `ember-clay` | `app_hub` per-app setting `appearance.dark_theme` | `forest-floor` | **Resolution:** - Dark is used when Appearance is *Dark*, or when it is *Match system* and the OS is currently dark. Otherwise light is used. - Dark uses the chosen dark theme and light uses the chosen light theme. - Theme choices are independent: a user can pair any light theme with any dark theme. **Persistence:** 1. A change applies **instantly** and is written to the local cache first. 2. It is then synced with `If-Match`. On 409, re-fetch and re-apply; on 422, revert and show a toast. 3. On launch, the cached values are applied before first paint, then reconciled with the server. 4. A missing or unknown id falls back to the default for that mode. Keys are lowercase kebab-case. The `app_hub` `settings_schema` must declare the two keys; see OPEN-QUESTIONS #22. Until it does, store them on the device only. Other apps on the platform are unaffected. They read `Settings.theme` only, as before. ### 2. Token roles Every platform implements the same roles. Names come from `themes/themes.json`. | Role | Used for | |---|---| | `background` | Window or grouped background (iOS grouped, Android surface, Windows Mica tint) | | `sidebar` | Sidebar and navigation rail (macOS, Windows, GNOME, web) | | `surface` | Subtle cards, group backgrounds, app tiles | | `raised` | Content panes, list cells, text fields, dialogs, menus | | `text` / `text-secondary` / `text-tertiary` | Primary text and icons / captions / placeholders and disabled only | | `separator` | Hairlines, borders, table rules | | `accent` | Primary buttons, switches, checkboxes, focus ring, selected chips, the macOS selected row, tint | | `accent-strong` | Hover and pressed accent; the tint under Increase Contrast; link text where accent is below 4.5:1 on light | | `on-accent` | Text and icons on accent fills (dark ink on the bright dark-theme accents) | | `accent-subtle` | Selected rows (non-macOS), info banners, the selected inbox item | | `link` | Link text | | `control-fill` / `control-fill-strong` | Secondary buttons, search fields, segmented tracks / progress tracks | | `scrim` + `scrim-opacity` | Modal scrims | **Not themed** (the same in every theme): - Status badge colours (`status-semantics.json`, light and dark sets) - Danger red - The logo and app icon (`brand/`) - Third-party app icons - Avatar hues ### 3. Palettes #### Light | | Theme | id | Background | Sidebar | Surface | Raised | Text | Secondary | Separator | Accent | Accent strong | Accent subtle | On accent | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| | L1 | Teal Ledger | `teal-ledger` | `#F3F6F6` | `#EAF0F0` | `#F6F9F9` | `#FFFFFF` | `#10272A` | `#56686A` | `#D9E2E2` | `#137C7A` | `#0E5F5D` | `#DDF0EE` | `#FFFFFF` | | L2 | Indigo Root | `indigo-root` | `#F4F4FA` | `#EBEBF5` | `#F7F7FC` | `#FFFFFF` | `#1A1838` | `#5D5B78` | `#DEDEEB` | `#3D3BB8` | `#2C2A8F` | `#E6E6FA` | `#FFFFFF` | | L3 | **Moss Paper** (default) | `moss-paper` | `#F5F4EE` | `#EDECE3` | `#F9F8F3` | `#FFFFFE` | `#22261A` | `#5F6455` | `#E1DFD2` | `#4A7322` | `#375719` | `#E6EFD8` | `#FFFFFF` | | L4 | Coral Clay | `coral-clay` | `#F9F5F2` | `#F1EBE6` | `#FBF8F6` | `#FFFFFF` | `#2A1D17` | `#6E5D55` | `#EAE0D8` | `#B44529` | `#90361F` | `#F7E3DB` | `#FFFFFF` | | L5 | Spore Pink | `spore-pink` | `#F9F6F8` | `#F2ECF0` | `#FBF8FA` | `#FFFFFF` | `#2A1622` | `#6E5A66` | `#EADFE5` | `#B3285B` | `#8F1F48` | `#F7E0E9` | `#FFFFFF` | #### Dark | | Theme | id | Background | Sidebar | Surface | Raised | Text | Secondary | Separator | Accent | Accent strong | Accent subtle | On accent | |---|---|---|---|---|---|---|---|---|---|---|---|---|---| | D1 | Night Soil | `night-soil` | `#15132E` | `#1B1938` | `#211F42` | `#25234B` | `#ECEBFA` | `#A6A4C8` | `#34325C` | `#8E8CFF` | `#B4B3FF` | `#2C2A63` | `#15132E` | | D2 | Deep Teal | `deep-teal` | `#0D1819` | `#122021` | `#162729` | `#1A2E30` | `#E3F1F0` | `#93ADAC` | `#26403F` | `#55CFC9` | `#8BE3DE` | `#133C3B` | `#062423` | | D3 | Graphite Spore | `graphite-spore` | `#161618` | `#1D1D20` | `#222226` | `#28282C` | `#F1EFF1` | `#A3A0A7` | `#34343A` | `#FF86AE` | `#FFB0CA` | `#3E2030` | `#3A0A1E` | | D4 | **Forest Floor** (default) | `forest-floor` | `#10150F` | `#151C14` | `#1A2219` | `#1F291E` | `#E8EFE2` | `#9DAA95` | `#2C3929` | `#B8E26A` | `#D3F09A` | `#2C3B19` | `#1A2608` | | D5 | Ember Clay | `ember-clay` | `#191412` | `#201916` | `#261E1A` | `#2C231F` | `#F4ECE8` | `#B3A39B` | `#3B302B` | `#FF9A80` | `#FFBCA9` | `#44261D` | `#3D1206` | The full set, including derived roles, is in `themes/themes.json` and `themes/themes.csv`. ### 4. Contrast (WCAG 2.2 AA, checked for every theme) | Theme | On accent / accent | Link / raised | Text / background | Secondary / raised | Secondary / background | |---|---|---|---|---|---| | L1 Teal Ledger | 5.01:1 | 5.01:1 | 14.36:1 | 5.86:1 | 5.39:1 | | L2 Indigo Root | 8.35:1 | 8.35:1 | 15.56:1 | 6.5:1 | 5.93:1 | | L3 Moss Paper | 5.58:1 | 5.57:1 | 14.01:1 | 6.1:1 | 5.54:1 | | L4 Coral Clay | 5.49:1 | 5.49:1 | 15.06:1 | 6.25:1 | 5.77:1 | | L5 Spore Pink | 6.23:1 | 6.23:1 | 15.83:1 | 6.33:1 | 5.9:1 | | D1 Night Soil | 6.31:1 | 5.17:1 | 15.31:1 | 6.17:1 | 7.53:1 | | D2 Deep Teal | 8.69:1 | 7.55:1 | 15.58:1 | 5.97:1 | 7.59:1 | | D3 Graphite Spore | 7.47:1 | 6.48:1 | 15.8:1 | 5.69:1 | 7.01:1 | | D4 Forest Floor | 10.66:1 | 10.13:1 | 15.73:1 | 6.18:1 | 7.58:1 | | D5 Ember Clay | 7.91:1 | 7.45:1 | 15.66:1 | 6.31:1 | 7.5:1 | **Contrast modes:** - **Windows High Contrast, `forced-colors` on the web, GNOME High Contrast:** the Substratal theme is turned off and system colours apply. - **macOS/iOS Increase Contrast and Android high-contrast text:** tint with `accent-strong`, and use `text-secondary` wherever `text-tertiary` would carry information. ### 5. Platform implementation | Platform | Files | Mechanism | |---|---|---| | iOS / macOS | `theme/SubstratalTheme.swift`, `theme/Assets.xcassets/AccentColor` | `ThemeStore` (`@Observable`) supplies `\.palette` and `.tint` from the root modifier `.substratalTheme(store)`; `.preferredColorScheme` from Settings.theme. The AccentColor asset holds the defaults for launch and UIKit/AppKit bridges. | | Windows | `theme/SubstratalThemes.xaml`, `theme/Themes/{Light,Dark}/*.xaml`, `theme/ThemeService.cs` | Theme dictionaries override `SystemAccentColor*`, text, fill and stroke resources and add `Substratal*Brush`. `ThemeService` swaps the Light/Dark dictionaries and sets `RequestedTheme`. Mica is tinted with `background`. | | Android | `theme/SubstratalThemes.kt`, `theme/res/values{,-night}/substratal_theme.xml` | Ten Material 3 `ColorScheme`s. `SubstratalTheme(mode, light, dark)` picks one. Dynamic colour is off. The XML defaults cover the splash screen and system bars. | | Linux | `theme/{light,dark}/*.css`, `theme/README.md` | Redefines libadwaita named colours. A single `Gtk.CssProvider` reloads on `AdwStyleManager notify::dark`; none is loaded in high contrast. | | Web | `theme/themes.css`, `theme/themes.ts`, `theme/head-snippet.html` | `--sub-*` custom properties keyed by `data-scheme`, `data-light-theme` and `data-dark-theme` on ``. The inline head script prevents a flash of the wrong theme, and `theme-color` follows the theme. | ### 6. Previewing in the mockups Every `mockups/*.html` accepts: - `?mode=dark` - `?light=` (any light theme) - `?dark=` (any dark theme) For example, `APP-01-home.html?mode=dark&dark=night-soil`. The reference PNGs are in `screens/light/` and `screens/dark/`. --- # 00-shared/brand/BRAND.md Source: https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/brand/BRAND.md ## Substratal Apps — logo standard The logo is part of the standard for every Hub app. This folder is the **source of truth**. Each platform folder has a `brand-assets/` directory with the files that platform needs, generated from these masters. Don't redraw, re-colour or re-typeset them. ![Logo standard](logo-standard.png) ### 1. Concept A mushroom cap sits above a **ground line**. The visible apps sit above ground; the API grows below as **interlined roots**. Two root strands twist through each other and out to both sides. A **runner** crosses them, and its ends are nodes. Crossings go alternately over and under, so the roots read as one network in both directions. ### 2. Anatomy and colour | Part | Light (default) | On dark | Role in token file | |---|---|---|---| | Cap, stem, runner, runner nodes | `#E0457B` pink | `#FF86AE` | `A` | | Root strand 1 + node | `#3D3BB8` indigo | `#8E8CFF` | `B` | | Root strand 2 + node | `#8DBF2E` lime | `#C4EE6C` | `C` | | Ground line | `#1A1838` ink | `#FFFFFF` | `G` | The gaps at crossings are **transparent**: they are clip-path cut-outs, not painted background. That means the mark works on any surface. **Wordmark:** `[substratal]` set in **JetBrains Mono Bold**, tracking −0.03 em, shipped as **outlined paths**. The optional descriptor line is `◆ apps` in JetBrains Mono Medium. | Wordmark part | Light | On dark | |---|---|---| | `[` `]` brackets | `#E2674A` coral | `#FF9A80` | | `sub` | `#10272A` ink | `#FFFFFF` | | `stratal` | `#137C7A` teal | `#55CFC9` | | `◆` | `#C9A26A` sand | `#EBCB95` | | `apps` | `#10272A` at 85 % | `#FFFFFF` at 85 % | **Icon surfaces:** tint `#E8E8F7` (light icon background), night `#15132E` (dark icon background and dark tiles), paper `#F3F3FA`. All values are in `brand-tokens.json`. ### 3. Variants: which to use where | Variant | File (`svg/`) | Use | |---|---|---| | Mark | `mark.svg`, `mark-on-dark.svg` | App icons, avatars for `source: platform` notifications, places with no room for the name | | Small mark | `mark-small*.svg` | **Required at 24 px and below**: favicons, the Windows title bar, menu-bar/tray, notification small icons, Linux symbolic. It keeps the cap, the ground and one twist, with heavier strokes. | | Horizontal lockup | `lockup-horizontal*.svg` | Sign-in (AUTH-01), splash/about, marketing, store graphics | | Inline lockup | `lockup-inline*.svg` | App chrome: the web sidebar header and compact headers. It has no descriptor line. | | Stacked lockup | `lockup-stacked*.svg` | Launch screens, square spaces, empty-state hero | | Wordmark | `wordmark*.svg` | Only where the mark already appears nearby | | Mono | `*-black.svg`, `*-white.svg` | Template images (macOS menu bar), Android themed/monochrome icons, Web Push badge, single-colour print | | App icon | `app-icon.svg`, `app-icon-dark.svg` | Generic rounded tile. Each platform's own icon is in its `brand-assets/`. | PNG exports are in `png/`: the mark at 128–1024 px, every lockup at @1x/@2x/@3x, and the app icon tiles at 1024 px. ### 4. Size, clear space, backgrounds - **Clear space:** ¼ of the mark's height on every side, measured from the cap top to the root nodes. Nothing else enters that space. - **Minimum size:** - Full mark: 32 px. Below that, use the small mark, down to 16 px. - Horizontal lockup: 120 px wide. - Inline lockup: 20 px tall. - **Backgrounds:** - Light variants on white, paper, tint or any surface lighter than 75 % L\*. - On-dark variants on night, ink or any surface darker than 35 % L\*. - Mono in between, or on photos. - **Icon layout:** - The mark fills about **76 % of the tile**. - It is optically centred, nudged down 1 % so the cap doesn't feel top-heavy. - Android adaptive icons keep the mark inside the 66 dp safe circle (scale 0.94 on a 108 dp canvas). - Web maskable icons use 64 % so the mark stays inside the 80 % safe zone. ### 5. Don'ts - Don't recolour individual parts, swap the strand colours, or render the mark in the wordmark's palette. - Don't fill the crossing gaps or add outlines, shadows, gradients or glows. The only exceptions are the platform icon treatments in `brand-assets/` (the macOS body, Liquid Glass layers). - Don't re-set the wordmark in live text or another font, or drop the brackets. - Don't use the full mark below 32 px. Don't place the light mark on dark surfaces or the reverse. - Don't add "Hub" to the logo. The product is named **Substratal** in UI chrome, and the logo's accessible name is **"Substratal Apps"**. ### 6. Where the logo appears in the Hub (all platforms) | Place | Asset | Notes | |---|---|---| | App icon (launcher, Dock, taskbar, Start, store) | platform `brand-assets/` | iOS/macOS include dark and tinted appearances; Android includes an adaptive and themed monochrome icon; Windows has plated and unplated versions for the light and dark taskbar. | | Sign-in and 2FA (AUTH-01/02) | Horizontal lockup above the form | `role="img"` / accessibility label "Substratal Apps". Developer builds: long-pressing the lockup reveals the test-mode toggle (SCREEN-SPECS AUTH-01). | | Launch / splash | Stacked lockup or mark on the icon surface | iOS `UILaunchScreen`, Android `SplashScreen` API, Windows MSIX splash, PWA manifest colours. | | Web sidebar header | Inline lockup, 24 px tall | Collapsed sidebar shows the small mark only. | | Windows title bar | Small mark, 16 px, followed by the text "Substratal" | `AppWindow.SetIcon("Substratal.ico")`. Use `Substratal-dark.ico` when the title bar is dark. | | macOS menu-bar extra (optional) | `MenuBarIcon` template image | Black small mark; the system tints it. | | Notifications (system) | App icon (large), small mark (Android small icon / Web Push badge) | `ic_stat_substratal`, `badge-96.png` | | Inbox rows / detail for `source: platform` | App icon tile | Treat as decorative; the row already reads "Substratal". | | About / Settings › About | Horizontal lockup + version | | | macOS/GNOME window titles, sidebars | **No logo.** Text title only, per HIG | | ### 7. Files ``` brand/ ├── BRAND.md · brand-tokens.json · logo-standard.png ├── svg/ mark, mark-small, lockup-horizontal, lockup-inline, lockup-stacked, wordmark │ × {light, -on-dark, -black, -white}; app-icon.svg, app-icon-dark.svg └── png/ mark{,-on-dark}-{128,256,512,1024}.png · {,-on-dark}@{1,2,3}x.png · app-icon{,-dark}-1024.png ``` Platform kits: `ios/brand-assets`, `macos/brand-assets`, `windows/brand-assets`, `android/brand-assets`, `linux/brand-assets`, `web/brand-assets`. Each `PLATFORM-SPEC.md` has a "Brand & app icon" section explaining how to wire them in. --- # 00-shared/COMPONENTS.md Source: https://compositecode.github.io/substratalapps-applications-specs/spec/00-shared/COMPONENTS.md ## Components Semantic components used by `SCREEN-SPECS.md` (the `kind` column), each with a behavior contract and the native control on every platform. Build each once per platform as a reusable view; screens compose only these. ### 1. Behavior contracts (identical everywhere) | Component | Contract | |---|---| | **StatusBadge** | Icon + text label (never color only). Label/icon from `status-semantics.json`. Read by screen readers as "Status: {label}". Non-interactive. | | **AccessSwitch** | Reflects the *personal* entitlement path. ON→OFF opens ACX-03 confirm (title names person + app; optional reason). OFF→ON applies immediately. Optimistic; on error revert + error toast. After OFF success: toast with **Undo** for 10 s (PATCH back to `active`). Disabled with caption when the caller lacks rights or the row's only path is org-sourced ("Via {org}" link instead). | | **PathList** | Under a resolved status, lists every access path: source, status, org, member_decision, dates, disabled_reason. Collapsed by default when there is one path. | | **SecretReveal (KEY-03)** | Modal that cannot be dismissed (Escape, outside click, swipe, back) until "I've copied…" is checked. Monospaced selectable text, Copy button (sensitive clipboard, auto-clear 60 s). Secret never re-rendered after close. Screenshot/recording protection where available. | | **DestructiveConfirm** | Platform alert/dialog. Title is a question naming the object. Body states consequence. Destructive button uses destructive style and is **not** the default button. Optional reason field when the API accepts one. | | **EntityList / EntityTable** | Search (debounced 300 ms, server `q` when > 1 page), status filter chips, cursor pagination, virtualized, pull-to-refresh (touch) / ⌘R·Ctrl+R·F5 (keyboard). Preserves scroll, selection and filters on back-navigation. Empty / no-results / forbidden / error states. | | **MultiSelect + BulkBar** | Desktop/web: click, Shift-click (range), ⌘/Ctrl-click (toggle), ⌘/Ctrl+A, marquee on empty space in grids. Touch: "Select" button or long-press enters selection mode; checkboxes appear. A bulk bar shows count + allowed actions; actions run per row with progress and a partial-failure summary. | | **ContextMenu** | Right-click / Shift+F10 / Menu key (desktop), long-press (touch), ⋯ button (always visible on hover/focus, and on touch rows). Same items as the row's visible actions. | | **SchemaForm** | Renders an Application `settings_schema`: string+enum → select; string → text; string `format: date-time` → date-time picker; boolean → switch; integer/number → stepper or number field honoring min/max; object/array → read-only JSON with "Edit on desktop". Each row shows SourceCaption and Reset. | | **SourceCaption** | "Changed for this app · Reset" / "Using your global setting" / "App default". | | **IDChip** | Monospaced ID + copy button; copying announces "Copied". | | **Toast** | Single line + optional action, 5 s (10 s with Undo), pauses on hover/focus, announced politely. Max one visible; queue the rest. | | **Banner** | Persistent page-level notice (verify email, restricted plan, offline, maintenance, invitation). Dismissible only when informational. | | **UsageMeter** | Label, `current / limit`, bar; caution at ≥ 80 %, critical at 100 % (semantic roles, not colors). | | **CommandPalette (GLB-01)** | Opens with ⌘K / Ctrl+K or search button; fuzzy match across navigation, recent items, actions; server lookups for users/orgs (debounced); ID prefixes resolve directly; Enter runs, ⌘/Ctrl+Enter opens in new window/tab where supported. | | **OTPField** | 6 cells or one field; paste distributes; OS one-time-code autofill; auto-submit on completion. | | **ThemePicker** | Appearance segmented control (Light · Dark · Match system) plus two radio groups of five theme swatches (light, dark). Each swatch is a miniature of background, sidebar, raised card, accent bar and text lines, with the theme name and a "Default" tag on Moss Paper / Forest Floor. Selecting applies instantly to the whole app with no confirmation; keyboard: arrows move, Space selects; screen readers announce "{name} light theme, 3 of 5, selected". Mobile: 3 columns; desktop: 5. See THEMES.md. | | **BrandLockup** | The Substratal logo from `brand/` only: horizontal lockup on AUTH-01/02 and About; inline lockup in the web sidebar header; small mark at 24 px and below. Accessible name "Substratal Apps" (decorative when adjacent text already says Substratal). Light/on-dark variant follows the surface. Developer builds: long-press reveals the test-mode toggle. | | **NotificationRow** | App icon (Substratal mark for `source: platform`), title, 2-line body, relative time, unread dot, "Important" marker for `priority: high`. Opening marks read (optimistic). Swipe/hover actions: Read ⇄ Unread, Archive (Undo). Screen readers read "Unread, Important, {app}: {title}, {time}". | | **UnreadBadge** | Count from `unread-count` only (never computed from loaded pages). Caps display at 99+. Appears on the Inbox nav item, the bell, the app icon/Dock/taskbar/PWA badge. Announced when it changes while focused. | | **PushPrimer (NTF-04)** | Explains value before the OS prompt; "Turn on" triggers the OS prompt; "Not now" snoozes 14 days. Never shown before sign-in or more than once per snooze window. | | **DeviceRow** | App icon, device name (editable), provider, status (On / Off with reason), last registered; actions: Send test (Hub devices), Rename, Remove (confirm). Never shows a token — only `token_hint`. | | **PlanCard** | Plan name (Free / Standard), what it includes, coverage sources, subscription status badge and period end, primary action (Upgrade / Manage billing / Subscribe the organization). Price from remote config or `unit_amount`. | | **CheckoutHandoff** | Button with inline progress while creating the session → opens Stripe externally → on return shows "Finishing up…" and polls the subscription (2 s, max 30 s). Prevents double submits with the Idempotency-Key. | | **CredentialFileField** | Picks/drops/pastes a key file (`.p8`, FCM JSON), validates shape locally, shows only the filename + "Will be stored encrypted", sends once, discards from memory. | | **ImagePicker** | Camera, photo library, file picker, drag-drop, clipboard paste; crop to square; shows upload progress. (Hidden until OPEN-QUESTIONS #1 resolved.) | ### 2. Native mapping | Semantic | macOS (SwiftUI) | Windows (WinUI 3) | iOS (SwiftUI) | Android (Compose M3) | Linux (GTK4 + libadwaita) | Web | |---|---|---|---|---|---|---| | StatusBadge | `Label` in capsule | Border + FontIcon + TextBlock | `Label` in capsule | non-interactive chip / `Surface` pill | `GtkBox.status-badge` | ``-free pill (icon `aria-hidden`, text visible) | | AccessSwitch | `Toggle(.switch)` | `ToggleSwitch` | `Toggle` | `Switch` (row `toggleable`) | `GtkSwitch` / `AdwSwitchRow` | `