Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 17 additions & 5 deletions docs/api-reference/applications.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ Applications that aren't `approved` are visible only to their owner, their own k
| `launch_url` | `https` URL. Where the future dashboard sends a user to open the app. |
| `redirect_uris` | 0–10 entries, each an exact-match string: an `https://` URL, or a private-use URI scheme for native apps (reverse-DNS, `com.example.app:/path`). `http://localhost` and `http://127.0.0.1` with any port are allowed for development only on a `test_mode` Application. Required (non-empty) before the hosted authorization-code flow will issue a code — see [Auth](../auth/#post-authoauthauthorization-codes). |
| `support_url`, `email_from_name` | Optional. Used in transactional emails sent on behalf of this app. `email_from_name` is 1–50 characters; the sending address is always Substratal's own. |
| `receives_platform_notifications` | Boolean, default `false`, writable by the owner. Whether this app's devices also get pushes for [platform alerts](../notifications/#platform-alerts). The app's push credentials are managed separately, under [`/applications/{id}/push-credentials`](../notifications/#push-credentials). |
| `receives_platform_notifications` | Boolean, default `false`, writable by the owner, except that only `applications.manage` can turn it on for a `self_registered` Application (`403 moderation_field_forbidden`). Whether this app's devices also get pushes for [platform alerts](../notifications/#platform-alerts). The app's push credentials are managed separately, under [`/applications/{id}/push-credentials`](../notifications/#push-credentials). |
| `settings_schema` | See [Domain Model → Settings → `settings_schema` rules](../../domain-model/settings/#settings_schema-rules). |
| `settings_schema_stats` | Read-only. `stale_override_counts` is a key → count map of how many users hold overrides that no longer validate. |
| `permissions` | The Application's own permission catalog, 0–100 entries. Each `key` must start with `app.<slug>.`, then match `[a-z0-9_.]{1,64}`. `description` is up to 255 characters. This is what `GET /permissions` lists, and it's the only set an AppRole may draw from. |
Expand Down Expand Up @@ -185,11 +185,22 @@ Returns the full Application object above.

The Roles `role_invoicer_admin` and `role_invoicer_member` now exist with no permissions.

- Requires `applications.manage`. Through Phase 2 this is how **every** Application comes to exist, including a paying customer's. Staff create it with the customer as owner, and the owner manages it from then on ("concierge onboarding").
- `slug`, `name`, `launch_url`, and exactly one of `owner_user_id`/`owner_organization_id` are required. Everything else is optional.
There are two kinds of caller:

- **`applications.manage` (staff)** creates any Application, for any owner, `approved` unless it says otherwise. This is concierge onboarding: staff create a paying customer's Application with the customer as owner, and the owner manages it from then on. `self_registered` is `false`.
- **Any other User with a verified email** can submit their own Application for review (self-service registration):
- It's owned by the caller. Omit both owner fields, or pass your own `owner_user_id`, or pass `owner_organization_id` for an Organization you're an active `org_admin` of. Anything else is `403 moderation_field_forbidden`.
- It starts in `pending_review`, `public`, and `standard`. Setting `visibility`, `access_tier`, `review_status`, `review_notes`, or turning on `receives_platform_notifications` is `403 moderation_field_forbidden`.
- `self_registered` is `true`. A self-registered Application is third-party, so it must use the [hosted sign-in flow](../auth/#getting-an-app-token): `POST /auth/app-tokens` returns `403 embedded_login_not_allowed` for it.
- An unverified email is `403 email_not_verified`. An API Key without `applications.manage` is `403 forbidden`. Each User can submit 10 per day.
- Until it's approved it's visible only to its owner and staff, it can't be granted or launched (`409 application_not_available`), and it gets no [platform-managed Entitlements](../../domain-model/entitlements/#platform-managed-entitlements). Staff work the queue with `GET /applications?review_status=pending_review`; see [Reviewing a submission](#reviewing-a-submission).

For either caller:

- `slug`, `name`, and `launch_url` are required, and staff must also pass exactly one of `owner_user_id`/`owner_organization_id`. Everything else is optional.
- Resolves the owner's [Tenant](../tenancy/), creating one if needed. A new Tenant gets `tier: shared`.
- **Seeds the app's Roles.** For each name in `available_app_roles`, a Role `role_<slug>_<name>` is created with no permissions, and the owner then fills them in with `PATCH /roles/{id}`.
- Grants the [platform-managed Entitlements](../../domain-model/entitlements/#platform-managed-entitlements) its `access_tier` calls for: every User for a free Application, every covered subscriber for a standard one.
- Grants the [platform-managed Entitlements](../../domain-model/entitlements/#platform-managed-entitlements) its `access_tier` calls for, once it's approved: every User for a free Application, every covered subscriber for a standard one.
- Writes `application.created`.

## `PATCH /applications/{id}`
Expand Down Expand Up @@ -260,7 +271,8 @@ Any other transition returns `409 invalid_review_transition`. A transition to `r
| `permission_in_use` | 409 | `PATCH` would remove a permission still referenced by a Role. |
| `invalid_settings_schema` | 422 | The schema breaks a [`settings_schema` rule](../../domain-model/settings/#settings_schema-rules). |
| `reserved_settings_key` | 422 | The schema declares `locale`, `timezone`, `theme`, or `notifications`. |
| `moderation_field_forbidden` | 403 | A non-admin owner's `PATCH` touches a moderation field. |
| `moderation_field_forbidden` | 403 | A non-admin owner's `PATCH`, or a self-service `POST`, touches a moderation field, names an owner other than the caller (or an Organization they're not an `org_admin` of), or turns on `receives_platform_notifications` for a self-registered Application. |
| `email_not_verified` | 403 | A self-service `POST` from a User whose email isn't verified. |
| `review_notes_required` | 422 | Transition to `rejected`/`suspended` without `review_notes`. |
| `invalid_review_transition` | 409 | A `review_status` change not in the table above. |
| `ownership_change_requires_migration` | 409 | An ownership change across non-`shared` Tenants. |
Expand Down
3 changes: 2 additions & 1 deletion docs/api-reference/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ Revokes the session(s): the refresh token and any app refresh tokens in them sto

## Getting an app token

Embedded login is allowed today because every Application is first-party, so no third party ever handles a Substratal password. It's blocked for any Application not owned by Substratal once self-service registration ships, and the hosted sign-in page must exist before any third-party Application goes live. Requiring the hosted page from Phase 1 instead was considered and not chosen: it would make the MVP depend on a UI project.
Embedded login is allowed for Applications staff create, which are either Substratal's own or a concierge customer's, so no unvetted third party ever handles a Substratal password. A [self-registered](../applications/#post-applications) Application is third-party: `app-tokens` refuses it with `403 embedded_login_not_allowed`, and it must use the hosted flow. The hosted sign-in page must exist before any self-registered Application can go live. Requiring the hosted page from Phase 1 instead was considered and not chosen: it would make the MVP depend on a UI project.

An **app token** is the per-Application JWT that [Trust Model](../../trust-model/) describes. Two ways to get one:

Expand All @@ -266,6 +266,7 @@ Both paths produce the identical app token. Either way, issuance **fails** if th
| Error | Status | When |
|---|---|---|
| `entitlement_required` | 403 | Resolved entitlement status isn't `active` (see [Access Control](../../access-control/)). `details.entitlement_status` says what it is, including `scheduled` for a grant whose `starts_at` hasn't arrived. |
| `embedded_login_not_allowed` | 403 | `app-tokens` only: the Application is `self_registered`, so it must use the hosted flow. |
| `application_not_available` | 409 | The Application's `review_status` is `suspended`/`rejected`/`pending_review` — see [Applications → review lifecycle](../../domain-model/applications/#the-review-lifecycle). A state conflict, not a permission failure, so it's the same `409` wherever this code appears. |
| `tenant_suspended` | 403 | The Application's Tenant is `suspended` by the platform. See [Tenancy](../../domain-model/tenancy/#fields). |
| `account_suspended` | 403 | The User is suspended. |
Expand Down
12 changes: 12 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ What's changed in this specification over time. Every decision is written into t

---

## 2026-10-22 — Self-service Application registration

`POST /applications` is open to developers, not just staff. See [API Reference → Applications](../api-reference/applications/#post-applications) and [The review lifecycle](../domain-model/applications/#the-review-lifecycle). `openapi.yaml` is now at `1.0.0-draft.7`.

- **Any User with a verified email** can submit an Application. It's owned by the caller, or by an Organization they're an `org_admin` of. It starts in `pending_review`, `public`, and `standard`, and the moderation fields are staff-only (`403 moderation_field_forbidden`). An unverified email is the new `403 email_not_verified`. Each User can submit 10 a day. Staff creation is unchanged.
- **Applications gain `self_registered`** (read-only, migration `0010_self_registered_apps`). A self-registered Application is third-party, even once approved:
- `POST /auth/app-tokens` refuses it with the new `403 embedded_login_not_allowed`, as [Auth → Getting an app token](../api-reference/auth/#getting-an-app-token) already required once self-service shipped. It must use the hosted flow.
- Only `applications.manage` can turn on its `receives_platform_notifications`, so platform security alerts never go out under an unvetted app.
- **Platform-managed Entitlements wait for approval.** `free` and `subscription` rows are granted only to an Application that has been approved, and approving one grants them. Before this, a `standard` submission would have been granted to every Standard subscriber the moment it was created, firing `access.granted` to an unreviewed developer.

Self-registered Applications still can't go live until the hosted sign-in page exists.

## 2026-10-22 — Test-mode User expiry implemented

[Test-mode Users → Lifetime](../api-reference/auth/#lifetime) is built, as specified on 2026-10-20. `openapi.yaml` is now at `1.0.0-draft.6`:
Expand Down
9 changes: 6 additions & 3 deletions docs/domain-model/applications.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ Every Application today is built by Substratal itself — `owner_user_id` is a S
| `access_tier` | enum | `free` \| `standard` (default). Which [Pricing](../../pricing/) tier unlocks it: `free` for every User, `standard` for Users covered by a Standard subscription. The platform keeps the matching [platform-managed Entitlements](../entitlements/#platform-managed-entitlements). Moderation, like `visibility`: only `applications.manage` sets it. |
| `owner_user_id` | string, nullable | The developer who registered and manages this Application. **Exactly one** of `owner_user_id`/`owner_organization_id` is always set — see the constraint below — so this is nullable only because `owner_organization_id` is the one set instead, never because an Application can be ownerless. A Substratal-run "system" Application still sets this to Substratal's own reserved internal account (`usr_01JAG0SYSTEM00000000000000`, the id used throughout this site's own examples below), not a null owner — [`tenant_id`](#fields) below has to resolve from *something*, and a genuinely ownerless row would have nothing to resolve it from. |
| `owner_organization_id` | string, nullable | Set instead of `owner_user_id` when an [Organization](../users-and-organizations/#organization), not an individual, owns the app. |
| `review_status` | enum | `approved` \| `pending_review` \| `rejected` \| `suspended`. Every Application created today is admin-created and defaults to `approved` — see [below](#who-can-manage-an-applications-catalog-entry). This exists now specifically so self-service submission doesn't need a breaking schema change later. |
| `review_status` | enum | `approved` \| `pending_review` \| `rejected` \| `suspended`. A staff-created Application defaults to `approved`; a self-service submission always starts in `pending_review`. See [The review lifecycle](#the-review-lifecycle). |
| `review_notes` | string, nullable | Required when `review_status` is set to `rejected` or `suspended`; optional on `approved`. The reviewer's reasoning, visible to the Application's owner. |
| `self_registered` | boolean | Read-only. `true` when a developer submitted the Application themselves rather than staff creating it. A self-registered Application is third-party: it must use the hosted sign-in flow, never embedded login, and only staff can turn on `receives_platform_notifications` for it. |
| `tenant_id` | string | Denormalized from the owner's [Tenant](../tenancy/) at creation — resolves (and creates, at `tier: shared`, if the owner doesn't have one yet) from whichever of `owner_user_id`/`owner_organization_id` is set. Determines where this Application's Entitlements, AppProfile, and AppSettings rows physically live. See [Tenancy](../tenancy/). |
| `settings_schema_stats` | object, read-only | `{"stale_override_counts": {"<key>": <count>}}`: how many users hold an override for each key that no longer validates against the current schema. See [Settings → Example: a schema change makes an override stale](../settings/#example-a-schema-change-makes-an-override-stale). |
| `test_mode` | boolean | Created by a test-mode key. See [Conventions → Authentication](../../api-reference/conventions/#authentication). |
Expand Down Expand Up @@ -100,7 +101,7 @@ Two distinct rights, not one:
- **The app's own owner** (`owner_user_id`, or any `org_admin` of `owner_organization_id`; ordinary members can read but not edit) can edit their own Application's configuration (`name`, `description`, `icon_url`, `launch_url`, `redirect_uris`, `settings_schema`, `permissions`, `available_app_roles`, `default_app_role`). The owner can also manage the app's [API Keys](../../api-reference/api-keys/), [webhooks](../../api-reference/webhooks/), Roles and Role assignments, and [Entitlements](../../api-reference/entitlements/) (personal and org-wide), and the owning Tenant's [billing](../../api-reference/billing/), without any platform permission. For Entitlements and Roles, the owner acting with their own User token can do exactly what the app's own key can with app-confined `entitlements.manage`/`roles.manage`, confined to this Application the same way. Through Phase 2, Substratal staff create the Application on the customer's behalf (concierge onboarding), and from then on the owner self-serves.
- **A platform role with `applications.manage`** (Substratal staff) can edit anything, including `visibility`, `access_tier`, and `review_status` — moderation, not configuration.

Today, with every Application first-party, these two rights are usually held by the same people and the distinction is invisible. It stops being invisible the moment a third-party developer registers their own app — `POST /applications` still requires platform `applications.manage` in the current spec (first-party only, per [The review lifecycle](#the-review-lifecycle)), and self-service registration into a `pending_review` queue is the marketplace-phase feature that `review_status` already models the shape of. See [API Reference → Applications](../../api-reference/applications/) for the endpoint-level detail.
These two rights matter most for a third-party developer who registers their own app through self-service registration: they own it, and can configure it, but only staff can approve it or change how it's offered. See [The review lifecycle](#the-review-lifecycle), and [API Reference → Applications](../../api-reference/applications/#post-applications) for the endpoint-level detail.

## The review lifecycle

Expand All @@ -112,7 +113,9 @@ pending_review ──approve──► approved ──suspend──► suspended
rejected ──edit + resubmit──► pending_review
```

- **`POST /applications` stays platform-admin-only through the MVP and [Phase 2](../../roadmap/#phase-2).** Self-service registration ships in [Phase 3](../../roadmap/#phase-3). The `owner_user_id`/`owner_organization_id`/`review_status` fields exist now specifically so that it won't need a breaking schema change.
- **Self-service registration:** any User with a verified email can submit their own Application with `POST /applications`. It starts in `pending_review`, owned by them or by an Organization they're an `org_admin` of, and marked `self_registered`. Staff (`applications.manage`) still create Applications directly, approved, for concierge onboarding.
- **Nothing reaches users before approval.** A submission in `pending_review` or `rejected` is visible only to its owner and staff, can't be granted or launched, and gets no platform-managed Entitlements. Approval grants those, the same as creating an approved Application does.
- **A self-registered Application is third-party, even once approved.** It must use the hosted sign-in flow, because embedded login would hand it the User's Substratal password, and it can't receive platform security alerts unless staff turn them on.
- **No dedicated reviewer assignment** — any platform User holding `applications.manage` can act on anything in the queue (`GET /applications?review_status=pending_review`). At this team's scale a FIFO queue is enough. Auto-assignment waits until submission volume makes the queue genuinely insufficient, which can't be known in advance.
- **A 5-business-day review target** is policy, not a system-enforced SLA: nothing automatically escalates or refunds over a miss. Revisit if real volume makes that insufficient.
- **`rejected` is not deletion.** It means "reviewed, declined," distinct from "never submitted." The record, and the owner's work configuring it, is retained with a required `review_notes`. The owner can edit and resubmit the same Application (`rejected → pending_review`) rather than starting over.
Expand Down
1 change: 1 addition & 0 deletions docs/domain-model/database-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ subscriptions (
| `owner_organization_id` | `text` | nullable, `references organizations(id)` |
| `review_status` | `text` | not null, default `'approved'`, `check (review_status in ('approved','pending_review','rejected','suspended'))` |
| `review_notes` | `text` | nullable — `check (review_notes is not null or review_status not in ('rejected','suspended'))` enforces it's required on exactly those two transitions, at the database level, not just in application code |
| `self_registered` | `boolean` | not null, default `false` — `true` for a self-service submission; never changes |
| `tenant_id` | `text` | not null, `references tenants(id)` — resolved from whichever owner column is set at insert time, see [Tenancy](../tenancy/) |
| `test_mode` | `boolean` | not null, default `false` |
| `created_at`, `updated_at` | `timestamptz` | |
Expand Down
Loading
Loading