From 6678dd6861465bb35c950d6ef0bc14671b3ccd00 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 21 Oct 2026 20:05:14 -0700 Subject: [PATCH] Open POST /v1/applications to self-service registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Any User with a verified email can now submit their own Application into the review queue (Applications → The review lifecycle), owned by them or an Organization they're an org_admin of, starting in pending_review with the moderation fields staff-only. 10 submissions per User per day. Making it safe to open: - Applications gain a read-only self_registered flag (migration 0006). A self-registered Application is third-party: POST /v1/auth/app-tokens refuses it (403 embedded_login_not_allowed), as Auth → Getting an app token required once self-service shipped, and only applications.manage can turn on its receives_platform_notifications. - Platform-managed (free/subscription) Entitlements are only granted to Applications that have been approved, and approval grants them, so a standard submission no longer reaches every subscriber, with access.granted webhooks to an unreviewed developer, the moment it's created. openapi.yaml 1.0.0-draft.4, regenerated routes and MCP tools, spec pages, NFR rate limits, roadmap, plan-remaining.md, and the Changelog updated. Closes #32 Co-Authored-By: Claude Opus 5.5 --- docs/api-reference/applications.md | 22 +++- docs/api-reference/auth.md | 3 +- docs/changelog.md | 14 +++ docs/domain-model/applications.md | 9 +- docs/domain-model/database-schema.md | 1 + docs/domain-model/entitlements.md | 2 +- docs/non-functional-requirements.md | 1 + docs/openapi.yaml | 15 ++- docs/roadmap.md | 2 +- internal/api/gen/gen.go | 5 +- internal/core/applications.go | 101 +++++++++++++++-- internal/core/applications_test.go | 134 +++++++++++++++++++++++ internal/core/auth_flows.go | 7 ++ internal/core/common.go | 1 + internal/core/subscriptions.go | 12 +- internal/mcp/tools.json | 8 +- migrations/0006_self_registered_apps.sql | 12 ++ plan-remaining.md | 3 +- 18 files changed, 318 insertions(+), 34 deletions(-) create mode 100644 internal/core/applications_test.go create mode 100644 migrations/0006_self_registered_apps.sql diff --git a/docs/api-reference/applications.md b/docs/api-reference/applications.md index 8afc63b..828a1e3 100644 --- a/docs/api-reference/applications.md +++ b/docs/api-reference/applications.md @@ -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-v1authoauthauthorization-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 [`/v1/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 [`/v1/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..`, then match `[a-z0-9_.]{1,64}`. `description` is up to 255 characters. This is what `GET /v1/permissions` lists, and it's the only set an AppRole may draw from. | @@ -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 /v1/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 /v1/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__` is created with no permissions, and the owner then fills them in with `PATCH /v1/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 /v1/applications/{id}` @@ -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. | diff --git a/docs/api-reference/auth.md b/docs/api-reference/auth.md index 39544a2..48022fa 100644 --- a/docs/api-reference/auth.md +++ b/docs/api-reference/auth.md @@ -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-v1applications) 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: @@ -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. | diff --git a/docs/changelog.md b/docs/changelog.md index 0a5871a..efd5006 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,6 +12,20 @@ What's changed in this specification over time. Every decision is written into t --- +## 2026-10-21 — Self-service Application registration + +`POST /v1/applications` is open to developers, not just staff. See [API Reference → Applications](../api-reference/applications/#post-v1applications) and [The review lifecycle](../domain-model/applications/#the-review-lifecycle). `openapi.yaml` is now at `1.0.0-draft.4`. + +- **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 `0006_self_registered_apps`). A self-registered Application is third-party, even once approved: + - `POST /v1/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-20 — Test-mode Users are time-limited Test mode already never counted toward billing (decision #36, confirmed on 2026-10-18). Now every **test-mode User expires**, so a test account can't stand in for a free production account. See [Auth → Test-mode Users → Lifetime](../api-reference/auth/#lifetime). diff --git a/docs/domain-model/applications.md b/docs/domain-model/applications.md index 07685a0..b1b3561 100644 --- a/docs/domain-model/applications.md +++ b/docs/domain-model/applications.md @@ -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": {"": }}`: 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). | @@ -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 /v1/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-v1applications) for the endpoint-level detail. ## The review lifecycle @@ -112,7 +113,9 @@ pending_review ──approve──► approved ──suspend──► suspended rejected ──edit + resubmit──► pending_review ``` -- **`POST /v1/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 /v1/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 /v1/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. diff --git a/docs/domain-model/database-schema.md b/docs/domain-model/database-schema.md index f826569..4148ced 100644 --- a/docs/domain-model/database-schema.md +++ b/docs/domain-model/database-schema.md @@ -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` | | diff --git a/docs/domain-model/entitlements.md b/docs/domain-model/entitlements.md index a778d39..34931d3 100644 --- a/docs/domain-model/entitlements.md +++ b/docs/domain-model/entitlements.md @@ -63,7 +63,7 @@ The join between a User (or Organization) and an Application: does this party ow | A Subscription starts covering its holder (`active`, `trialing`, or `past_due`) | Grants a `subscription` row to each standard Application: personal for a User, org-wide with the Subscription's `member_scope` for an Organization. | | A Subscription stops covering (`canceled`, `unpaid`, `incomplete_expired`, `paused`) | Sets `ends_at` to now on its rows, which expires them. Resubscribing revives the same row while it's still live, or grants a new one. | | An org admin changes the Subscription's covered members | Updates `member_scope` / `member_overrides` on the Organization's rows. | -| An Application is created, or its `access_tier` changes | Grants or ends rows for that Application to match. | +| An Application is created approved, is approved, or its `access_tier` changes | Grants or ends rows for that Application to match. Only an Application that has been approved (`approved`, or `suspended`, since suspension doesn't cascade) gets them: never a submission still in review or rejected. | | Hourly | Repairs anything that drifted (`subscription-access` job). | Each change writes the usual Audit Event with actor `system`, fires the matching `entitlement.*` webhook, and fires `access.granted` / `access.revoked` for every User whose resolved access actually changed. diff --git a/docs/non-functional-requirements.md b/docs/non-functional-requirements.md index cd360c6..6ef0291 100644 --- a/docs/non-functional-requirements.md +++ b/docs/non-functional-requirements.md @@ -71,6 +71,7 @@ Per-API-key token bucket, returning `429` with a `Retry-After` header on exhaust | `POST /v1/auth/token/refresh` | 30 requests/minute per session | | `GET /v1/users/{id}/export` | 5 per user per day | | `POST /v1/organizations` | 10 per user per day | +| `POST /v1/applications`, self-service | 10 per user per day (staff with `applications.manage` aren't limited beyond the default) | | `POST /v1/users/{id}/notifications` | 600 requests/minute per API Key, and 60 notifications per recipient User per Application per rolling hour (`429 rate_limited`, `details.scope: "recipient"`). Platform alerts don't count toward the per-recipient limit. | | `POST /v1/users/{id}/devices/{deviceId}/test` | 10 per device per hour | diff --git a/docs/openapi.yaml b/docs/openapi.yaml index cc5019b..b374d08 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: Substratal Apps — Platform API - version: "1.0.0-draft.3" + version: "1.0.0-draft.4" summary: >- User accounts, auth, roles, per-app entitlements (the on/off access switch), profiles, settings, organizations, tenancy, subscription billing, @@ -209,7 +209,7 @@ paths: operationId: auth.createAppToken tags: [Auth] summary: Mint a per-app JWT from a platform session (embedded login) - description: "Requires a User token. Fails with 403 entitlement_required unless the user's resolved access to the Application is active, 409 application_not_available unless it's approved, and 403 tenant_suspended if its Tenant is suspended." + description: "Requires a User token. Fails with 403 embedded_login_not_allowed for a self-registered (third-party) Application, which must use the hosted flow; 403 entitlement_required unless the user's resolved access to the Application is active; 409 application_not_available unless it's approved; and 403 tenant_suspended if its Tenant is suspended." x-substratal-phase: mvp requestBody: required: true @@ -978,7 +978,14 @@ paths: post: operationId: applications.create tags: [Applications] - summary: Register an Application (applications.manage) + summary: Register an Application (applications.manage, or self-service into review) + description: >- + applications.manage creates any Application, approved by default (concierge onboarding). + Any other User with a verified email may submit their own: it's owned by the caller (or an + Organization they're an org_admin of), starts in pending_review, and can't set visibility, + access_tier, review_status, or review_notes (403 moderation_field_forbidden). Unverified + email is 403 email_not_verified; API Keys without applications.manage are 403 forbidden. + Self-service submissions are limited to 10 per User per day. x-substratal-phase: mvp parameters: - $ref: "#/components/parameters/IdempotencyKeyOptional" @@ -1000,6 +1007,7 @@ paths: "403": { $ref: "#/components/responses/Forbidden" } "409": { $ref: "#/components/responses/Conflict" } "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/TooManyRequests" } /applications/{id}: parameters: @@ -3068,6 +3076,7 @@ components: readOnly: true properties: stale_override_counts: { type: object, additionalProperties: { type: integer } } + self_registered: { type: boolean, readOnly: true, description: "true when a developer submitted it themselves, not staff. A self-registered Application must use the hosted sign-in flow (POST /v1/auth/app-tokens returns 403 embedded_login_not_allowed), and only applications.manage can turn on receives_platform_notifications for it." } test_mode: { type: boolean } created_at: { type: string, format: date-time } updated_at: { type: string, format: date-time } diff --git a/docs/roadmap.md b/docs/roadmap.md index b6719ec..11dd9ec 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -49,7 +49,7 @@ Enough to replace "someone manually emails a link and flips a flag in a spreadsh - Richer audit/compliance views — filtering, export, retention policy enforcement; see [Compliance & Data Protection](../compliance/) for what's driving this (SOC 2 readiness, GDPR data-subject requests). - SCIM-style provisioning for larger customers who want their own IdP to push user lifecycle events into the hub automatically. - The **hosted sign-in page** and the authorization-code + PKCE flow ([Auth → Getting an app token](../api-reference/auth/#getting-an-app-token)). The API side is specified now; this is the UI. It's required before any third-party Application goes live. -- Self-service Application registration and review queue for third-party developers — the marketplace phase. See [App developer/publisher model](../domain-model/applications/#the-review-lifecycle). +- Self-service Application registration and review queue for third-party developers — the marketplace phase. **Built 2026-10-21.** Self-registered Applications still need the hosted sign-in page above before they can go live. See [App developer/publisher model](../domain-model/applications/#the-review-lifecycle). **Ships:** the features that only matter once there are customers (or outside developers) big enough to need them — deliberately not pulled earlier, since building any of these before there's a real need tends to guess the shape wrong. diff --git a/internal/api/gen/gen.go b/internal/api/gen/gen.go index 4513122..0788832 100644 --- a/internal/api/gen/gen.go +++ b/internal/api/gen/gen.go @@ -2295,6 +2295,9 @@ type Application struct { ReviewNotes *string `json:"review_notes,omitempty"` ReviewStatus *ApplicationReviewStatus `json:"review_status,omitempty"` + // SelfRegistered true when a developer submitted it themselves, not staff. A self-registered Application must use the hosted sign-in flow (POST /v1/auth/app-tokens returns 403 embedded_login_not_allowed), and only applications.manage can turn on receives_platform_notifications for it. + SelfRegistered *bool `json:"self_registered,omitempty"` + // SettingsSchema JSON Schema 2020-12 subset — see Domain Model → Settings → settings_schema rules SettingsSchema *map[string]interface{} `json:"settings_schema,omitempty"` SettingsSchemaStats *struct { @@ -4682,7 +4685,7 @@ type ServerInterface interface { // ApplicationsList List the catalog (filtered by visibility rules) // (GET /applications) ApplicationsList(w http.ResponseWriter, r *http.Request, params ApplicationsListParams) - // ApplicationsCreate Register an Application (applications.manage) + // ApplicationsCreate Register an Application (applications.manage, or self-service into review) // (POST /applications) ApplicationsCreate(w http.ResponseWriter, r *http.Request, params ApplicationsCreateParams) // ApplicationsGet Fetch one Application diff --git a/internal/core/applications.go b/internal/core/applications.go index f58c2da..eba2639 100644 --- a/internal/core/applications.go +++ b/internal/core/applications.go @@ -38,6 +38,7 @@ type application struct { OwnerOrganizationID *string `json:"owner_organization_id"` ReviewStatus string `json:"review_status"` ReviewNotes *string `json:"review_notes"` + SelfRegistered bool `json:"self_registered"` TenantID string `json:"tenant_id"` TestMode bool `json:"test_mode"` CreatedAt time.Time `json:"created_at"` @@ -69,7 +70,7 @@ func (a appRow) full(stats map[string]int) application { SettingsSchemaStats: map[string]any{"stale_override_counts": stats}, Permissions: perms, AvailableAppRoles: nonNil(a.AvailableAppRoles), DefaultAppRole: a.DefaultAppRole, Visibility: a.Visibility, AccessTier: a.AccessTier, OwnerUserID: a.OwnerUserID, OwnerOrganizationID: a.OwnerOrganizationID, ReviewStatus: a.ReviewStatus, - ReviewNotes: a.ReviewNotes, TenantID: a.TenantID, TestMode: a.TestMode, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt} + ReviewNotes: a.ReviewNotes, SelfRegistered: a.SelfRegistered, TenantID: a.TenantID, TestMode: a.TestMode, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt} } func nonNil(s []string) []string { @@ -363,14 +364,37 @@ func validateAppFields(p httpx.Patch, slug string, testMode bool, f *httpx.Field func (s *Server) ApplicationsCreate(w http.ResponseWriter, r *http.Request, params gen.ApplicationsCreateParams) { s.serve(w, r, opts{op: "applications.create", idem: idemOptional}, func(c *Call) error { - if err := c.require("applications.manage"); err != nil { - return err + // applications.manage creates any Application (concierge onboarding). + // Any verified User may submit their own, into the review queue + // (Applications → The review lifecycle). + admin := c.can("applications.manage") + if !admin { + if !c.p.IsUser() { + return httpx.Forbidden("applications.manage") + } + if !c.p.EmailVerified { + return httpx.E(403, "email_not_verified", "Verify your email address before registering an Application.") + } + if err := c.hit("appcreate:"+c.p.UserID, perDay(10), false); err != nil { + return err + } } p, err := c.Patch() if err != nil { return err } - if err := p.RejectReadOnly("id", "tenant_id", "created_at", "updated_at", "settings_schema_stats", "test_mode"); err != nil { + if !admin { + for _, mf := range []string{"visibility", "access_tier", "review_status", "review_notes"} { + if p.Has(mf) { + return httpx.E(403, "moderation_field_forbidden", "Only applications.manage can set "+mf+".").With("field", mf) + } + } + if err := platformAlertsForbidden(p); err != nil { + return err + } + } + if err := p.RejectReadOnly("id", "tenant_id", "created_at", "updated_at", "settings_schema_stats", "test_mode", + "self_registered"); err != nil { return err } var f httpx.Fields @@ -389,6 +413,11 @@ func (s *Server) ApplicationsCreate(w http.ResponseWriter, r *http.Request, para } ownerUser, _ := p.String("owner_user_id", &f) ownerOrg, _ := p.String("owner_organization_id", &f) + if !admin { + if err := c.checkSelfServiceOwner(&ownerUser, ownerOrg); err != nil { + return err + } + } if (ownerUser == nil) == (ownerOrg == nil) { f.Add("owner_user_id", "required").Message = "exactly one of owner_user_id or owner_organization_id" } @@ -438,7 +467,9 @@ func (s *Server) ApplicationsCreate(w http.ResponseWriter, r *http.Request, para accessTier = *in.AccessTier } review := "approved" - if in.ReviewStatus != nil { + if !admin { + review = "pending_review" + } else if in.ReviewStatus != nil { review = *in.ReviewStatus } if (review == "rejected" || review == "suspended") && (in.ReviewNotes == nil || *in.ReviewNotes == "") { @@ -469,14 +500,15 @@ func (s *Server) ApplicationsCreate(w http.ResponseWriter, r *http.Request, para if _, err := c.q.Exec(c.ctx, `insert into applications (id, slug, name, description, icon_url, launch_url, redirect_uris, support_url, email_from_name, settings_schema, permissions, available_app_roles, default_app_role, visibility, access_tier, owner_user_id, owner_organization_id, review_status, review_notes, - tenant_id, test_mode, receives_platform_notifications) + tenant_id, test_mode, receives_platform_notifications, self_registered) values (:id, :slug, :name, :desc, :icon, :launch, :redir::text[], :support, :from, :schema::jsonb, - :perms::jsonb, :roles::text[], :defrole, :vis, :tier, :ou, :oo, :rs, :rn, :t, :tm, :rpn)`, + :perms::jsonb, :roles::text[], :defrole, :vis, :tier, :ou, :oo, :rs, :rn, :t, :tm, :rpn, :self)`, db.Args{"id": id, "slug": slug, "name": in.Name, "desc": in.Description, "icon": in.IconURL, "launch": in.LaunchURL, "redir": db.TextArray(in.RedirectURIs), "support": in.SupportURL, "from": in.EmailFromName, "schema": string(in.SettingsSchema), "perms": db.JSON(perms), "roles": db.TextArray(roles), "defrole": in.DefaultAppRole, "vis": visibility, "tier": accessTier, "ou": ownerUser, - "oo": ownerOrg, "rs": review, "rn": in.ReviewNotes, "t": t.ID, "tm": c.testMode(), "rpn": in.ReceivesPlatform}); err != nil { + "oo": ownerOrg, "rs": review, "rn": in.ReviewNotes, "t": t.ID, "tm": c.testMode(), "rpn": in.ReceivesPlatform, + "self": !admin}); err != nil { if _, ok := db.UniqueViolation(err); ok { return httpx.Conflict("slug_taken", "An Application with this slug already exists.") } @@ -502,6 +534,47 @@ func (s *Server) ApplicationsCreate(w http.ResponseWriter, r *http.Request, para }) } +// platformAlertsForbidden keeps a self-registered Application from turning +// on receives_platform_notifications: platform security alerts only go +// out under an Application staff created. +func platformAlertsForbidden(p httpx.Patch) error { + var on bool + if p.Has("receives_platform_notifications") && !p.IsNull("receives_platform_notifications") && + json.Unmarshal(p["receives_platform_notifications"], &on) == nil && on { + return httpx.E(403, "moderation_field_forbidden", + "Only applications.manage can turn on platform notifications for a self-registered Application."). + With("field", "receives_platform_notifications") + } + return nil +} + +// checkSelfServiceOwner applies a self-service submission's owner rule: the +// caller themselves (the default), or an Organization they're an active +// org_admin of. Anyone else's Application is a staff action. +func (c *Call) checkSelfServiceOwner(ownerUser **string, ownerOrg *string) error { + if *ownerUser == nil && ownerOrg == nil { + me := c.p.UserID + *ownerUser = &me + } + if *ownerUser != nil && **ownerUser != c.p.UserID { + return httpx.E(403, "moderation_field_forbidden", "You can only register an Application you own."). + With("field", "owner_user_id") + } + if ownerOrg != nil { + var n int + if err := db.One(c.ctx, c.q, &n, `select to_jsonb(count(*)) from organization_memberships + where organization_id = :o and user_id = :me and role = 'org_admin' and status = 'active'`, + db.Args{"o": *ownerOrg, "me": c.p.UserID}); err != nil { + return err + } + if n == 0 { + return httpx.E(403, "moderation_field_forbidden", "Only an org_admin can register an Application for an Organization."). + With("field", "owner_organization_id") + } + } + return nil +} + // seedAppRoles creates the empty role__ for each new name. func (c *Call) seedAppRoles(appID string, names []string) error { for _, n := range names { @@ -542,7 +615,8 @@ func (s *Server) ApplicationsUpdate(w http.ResponseWriter, r *http.Request, id s if err != nil { return err } - if err := p.RejectReadOnly("id", "slug", "tenant_id", "created_at", "updated_at", "settings_schema_stats", "test_mode"); err != nil { + if err := p.RejectReadOnly("id", "slug", "tenant_id", "created_at", "updated_at", "settings_schema_stats", "test_mode", + "self_registered"); err != nil { return err } if !admin { @@ -551,6 +625,11 @@ func (s *Server) ApplicationsUpdate(w http.ResponseWriter, r *http.Request, id s return httpx.E(403, "moderation_field_forbidden", "Only applications.manage can change "+mf+".").With("field", mf) } } + if a.SelfRegistered { + if err := platformAlertsForbidden(p); err != nil { + return err + } + } } var f httpx.Fields p.RequireNonNull(&f, "name", "launch_url", "redirect_uris", "settings_schema", "permissions", @@ -755,7 +834,9 @@ func (s *Server) ApplicationsUpdate(w http.ResponseWriter, r *http.Request, id s return err } } - if next.AccessTier != a.AccessTier { + // Platform-managed Entitlements follow access_tier, and exist only + // once an Application has been approved. + if next.AccessTier != a.AccessTier || reviewChanged { if err := c.reconcileAccess(accessScope{AppID: a.ID}); err != nil { return err } diff --git a/internal/core/applications_test.go b/internal/core/applications_test.go new file mode 100644 index 0000000..d957326 --- /dev/null +++ b/internal/core/applications_test.go @@ -0,0 +1,134 @@ +package core_test + +import ( + "testing" + + "github.com/CompositeCode/substratalapps.com/internal/testenv" +) + +// verifiedUser signs up, verifies the email, and signs in again so the +// access token carries email_verified. +func verifiedUser(e *testenv.Env) testenv.User { + e.T.Helper() + u := e.Signup() + e.Post("/v1/auth/email/verify", "", map[string]any{"token": e.Token(u.Email, "email_verify")}).Expect(204) + u.Token = e.Login(u.Email, u.Password).Token() + return u +} + +func appBody(slug string, extra map[string]any) map[string]any { + b := map[string]any{"slug": slug, "name": "Dev " + slug, "launch_url": "https://" + slug + ".example.com/launch"} + for k, v := range extra { + b[k] = v + } + return b +} + +// Applications → The review lifecycle: self-service registration into +// the review queue. +func TestSelfServiceApplicationRegistration(t *testing.T) { + e := testenv.New(t) + slug := func() string { return "dev-" + testenv.Unique() } + + // Unverified Users and keys without applications.manage can't submit. + unverified := e.Signup() + e.Post("/v1/applications", unverified.Token, appBody(slug(), nil)).ExpectErr(403, "email_not_verified") + key := e.Post("/v1/api-keys", e.Admin, map[string]any{"name": "ops", "scope": "platform", + "permissions": []string{"users.list"}}).Expect(201).Str("secret") + e.Post("/v1/applications", key, appBody(slug(), nil)).ExpectErr(403, "forbidden") + + dev := verifiedUser(e) + other := verifiedUser(e) + // Moderation fields and someone else's ownership are staff-only. + for _, extra := range []map[string]any{ + {"visibility": "internal"}, {"access_tier": "free"}, {"review_status": "approved"}, {"review_notes": "x"}, + {"owner_user_id": other.ID}, {"receives_platform_notifications": true}, + } { + e.Post("/v1/applications", dev.Token, appBody(slug(), extra)).ExpectErr(403, "moderation_field_forbidden") + } + + // A submission is the caller's own, pending review, on a new Tenant. + s := slug() + app := e.Post("/v1/applications", dev.Token, appBody(s, map[string]any{ + "available_app_roles": []string{"member"}, "default_app_role": "member"})).Expect(201).JSON() + appID := app["id"].(string) + if app["review_status"] != "pending_review" || app["owner_user_id"] != dev.ID || app["visibility"] != "public" || + app["access_tier"] != "standard" || app["tenant_id"] == nil || app["self_registered"] != true { + t.Fatalf("submission = %v", app) + } + if e.Get("/v1/tenants/"+app["tenant_id"].(string), dev.Token).Expect(200).Str("owner_user_id") != dev.ID { + t.Fatal("the submitter should own the new Tenant") + } + + // It's in the staff queue, hidden from everyone else, and can't be + // launched or granted yet. + queue := e.Get("/v1/applications?review_status=pending_review", e.Admin).Expect(200).JSON()["data"].([]any) + found := false + for _, a := range queue { + found = found || a.(map[string]any)["id"] == appID + } + if !found { + t.Fatal("submission missing from the review queue") + } + e.Get("/v1/applications/"+appID, dev.Token).Expect(200) + e.Get("/v1/applications/"+appID, other.Token).ExpectErr(404, "application_not_found") + e.PostIdem("/v1/users/"+dev.ID+"/entitlements", dev.Token, testenv.Unique(), + map[string]any{"application_id": appID, "source": "admin_grant"}).ExpectErr(409, "application_not_available") + + // Rejected, then resubmitted by editing, then approved. + e.Patch("/v1/applications/"+appID, e.Admin, map[string]any{"review_status": "rejected", "review_notes": "no icon"}).Expect(200) + if rs := e.Patch("/v1/applications/"+appID, dev.Token, map[string]any{"icon_url": "https://" + s + ".example.com/icon.png"}). + Expect(200).Str("review_status"); rs != "pending_review" { + t.Fatalf("after resubmit review_status = %s", rs) + } + e.Patch("/v1/applications/"+appID, e.Admin, map[string]any{"review_status": "approved"}).Expect(200) + e.Get("/v1/applications/"+appID, other.Token).Expect(200) + e.Grant(dev.Token, other.ID, appID) + + // Even approved, a self-registered Application is third-party: no + // embedded app tokens, and no platform security alerts. + e.Post("/v1/auth/app-tokens", other.Token, map[string]any{"application_id": appID}).ExpectErr(403, "embedded_login_not_allowed") + e.Patch("/v1/applications/"+appID, dev.Token, map[string]any{"receives_platform_notifications": true}). + ExpectErr(403, "moderation_field_forbidden") + e.Patch("/v1/applications/"+appID, e.Admin, map[string]any{"receives_platform_notifications": true}).Expect(200) + e.Patch("/v1/applications/"+appID, e.Admin, map[string]any{"self_registered": false}).ExpectErr(422, "read_only_field") + + // An org_admin can register one for their Organization; a member of + // someone else's can't. + org := e.Post("/v1/organizations", dev.Token, map[string]any{"name": "Dev Co " + testenv.Unique()}).Expect(201).Str("id") + orgApp := e.Post("/v1/applications", dev.Token, appBody(slug(), map[string]any{"owner_organization_id": org})).Expect(201).JSON() + if orgApp["owner_organization_id"] != org || orgApp["owner_user_id"] != nil { + t.Fatalf("org submission = %v", orgApp) + } + e.Post("/v1/applications", other.Token, appBody(slug(), map[string]any{"owner_organization_id": org})). + ExpectErr(403, "moderation_field_forbidden") + + // Staff creation is unchanged: approved by default, any owner. + staff := e.Post("/v1/applications", e.Admin, appBody(slug(), map[string]any{"owner_user_id": other.ID})).Expect(201).JSON() + if staff["review_status"] != "approved" || staff["self_registered"] != false { + t.Fatalf("staff create review_status = %v", staff["review_status"]) + } +} + +// Platform-managed Entitlements only exist for an Application that has +// been approved, so a submission in review never reaches anyone's account. +func TestPlatformAccessWaitsForApproval(t *testing.T) { + e := testenv.NewWith(t, testenv.Options{OwnDatabase: true}) + u := e.Signup() + app := e.App(system, map[string]any{"access_tier": "free", "review_status": "pending_review"}) + if n := len(e.Get("/v1/users/"+u.ID+"/entitlements?application_id="+app, e.Admin).JSON()["data"].([]any)); n != 0 { + t.Fatalf("a pending free Application granted %d Entitlements", n) + } + e.Patch("/v1/applications/"+app, e.Admin, map[string]any{"review_status": "approved"}).Expect(200) + if !allowed(e, u.ID, app) { + t.Fatal("approval should grant the free Application") + } + if n := accessEventsFor(e, "access.granted", u.ID, app); n != 1 { + t.Fatalf("access.granted fired %d times", n) + } + // Suspension doesn't cascade. + e.Patch("/v1/applications/"+app, e.Admin, map[string]any{"review_status": "suspended", "review_notes": "x"}).Expect(200) + if n := len(e.Get("/v1/users/"+u.ID+"/entitlements?application_id="+app, e.Admin).JSON()["data"].([]any)); n != 1 { + t.Fatalf("suspension left %d Entitlements", n) + } +} diff --git a/internal/core/auth_flows.go b/internal/core/auth_flows.go index dc69215..a7ab025 100644 --- a/internal/core/auth_flows.go +++ b/internal/core/auth_flows.go @@ -136,6 +136,13 @@ func (s *Server) AuthCreateAppToken(w http.ResponseWriter, r *http.Request) { } else if !ok { return httpx.NotFoundResource("application") } + // A self-registered Application is third-party: it must never + // handle a Substratal password, so it uses the hosted flow + // (Auth → Getting an app token). + if a.SelfRegistered { + return httpx.E(403, "embedded_login_not_allowed", + "This Application must use the hosted sign-in flow, not embedded login.").With("application_id", a.ID) + } u, err := c.findUser("u.id = :id", db.Args{"id": c.p.UserID}) if err != nil { return err diff --git a/internal/core/common.go b/internal/core/common.go index 62c86eb..c0a4cff 100644 --- a/internal/core/common.go +++ b/internal/core/common.go @@ -239,6 +239,7 @@ type appRow struct { OwnerOrganizationID *string `json:"owner_organization_id"` ReviewStatus string `json:"review_status"` ReviewNotes *string `json:"review_notes"` + SelfRegistered bool `json:"self_registered"` TenantID string `json:"tenant_id"` TestMode bool `json:"test_mode"` CreatedAt time.Time `json:"created_at"` diff --git a/internal/core/subscriptions.go b/internal/core/subscriptions.go index b12ba2c..53cec08 100644 --- a/internal/core/subscriptions.go +++ b/internal/core/subscriptions.go @@ -195,22 +195,28 @@ func nullable(s string) any { // exist against the live ones that do. Rows match on holder, Application, // and source; an ended row that's wanted again is revived rather than // replaced, so the one-live-row index never conflicts. +// Only Applications that have been approved get them: never a submission +// still in review or rejected (a suspended one keeps them, since +// suspension doesn't cascade). const platformAccessSQL = `with desired as ( select u.id as user_id, null::text as organization_id, a.id as application_id, a.tenant_id, 'free'::text as source, null::text as subscription_id, null::text as member_scope, null::text[] as member_overrides, u.test_mode - from users u join applications a on a.access_tier = 'free' and (not a.test_mode or a.test_mode = u.test_mode) + from users u join applications a on a.access_tier = 'free' and a.review_status in ('approved','suspended') + and (not a.test_mode or a.test_mode = u.test_mode) where u.status in ('active','invited','suspended') and :o::text is null and (:u::text is null or u.id = :u) and (:a::text is null or a.id = :a) union all select s.user_id, null, a.id, a.tenant_id, 'subscription', s.id, null, null, s.test_mode - from subscriptions s join applications a on a.access_tier = 'standard' and (not a.test_mode or a.test_mode = s.test_mode) + from subscriptions s join applications a on a.access_tier = 'standard' and a.review_status in ('approved','suspended') + and (not a.test_mode or a.test_mode = s.test_mode) join users u on u.id = s.user_id and u.status <> 'deleted' where s.status in ` + coveringStatusesSQL + ` and :o::text is null and (:u::text is null or s.user_id = :u) and (:a::text is null or a.id = :a) union all select null, s.organization_id, a.id, a.tenant_id, 'subscription', s.id, s.member_scope, s.member_overrides, s.test_mode - from subscriptions s join applications a on a.access_tier = 'standard' and (not a.test_mode or a.test_mode = s.test_mode) + from subscriptions s join applications a on a.access_tier = 'standard' and a.review_status in ('approved','suspended') + and (not a.test_mode or a.test_mode = s.test_mode) where s.organization_id is not null and s.status in ` + coveringStatusesSQL + ` and :u::text is null and (:o::text is null or s.organization_id = :o) and (:a::text is null or a.id = :a)), existing as ( diff --git a/internal/mcp/tools.json b/internal/mcp/tools.json index 1398a8f..44c59fd 100644 --- a/internal/mcp/tools.json +++ b/internal/mcp/tools.json @@ -499,8 +499,8 @@ }, { "name": "substratal_applications_create", - "title": "Register an Application (applications.manage)", - "description": "Register an Application (applications.manage)\n\nREST: POST /v1/applications", + "title": "Register an Application (applications.manage, or self-service into review)", + "description": "Register an Application (applications.manage, or self-service into review)\n\napplications.manage creates any Application, approved by default (concierge onboarding). Any other User with a verified email may submit their own: it's owned by the caller (or an Organization they're an org_admin of), starts in pending_review, and can't set visibility, access_tier, review_status, or review_notes (403 moderation_field_forbidden). Unverified email is 403 email_not_verified; API Keys without applications.manage are 403 forbidden. Self-service submissions are limited to 10 per User per day.\n\nREST: POST /v1/applications", "inputSchema": { "additionalProperties": false, "properties": { @@ -668,7 +668,7 @@ "idempotentHint": false, "openWorldHint": false, "readOnlyHint": false, - "title": "Register an Application (applications.manage)" + "title": "Register an Application (applications.manage, or self-service into review)" }, "x_operation_id": "applications.create", "x_method": "POST", @@ -1330,7 +1330,7 @@ { "name": "substratal_auth_createAppToken", "title": "Mint a per-app JWT from a platform session (embedded login)", - "description": "Mint a per-app JWT from a platform session (embedded login)\n\nRequires a User token. Fails with 403 entitlement_required unless the user's resolved access to the Application is active, 409 application_not_available unless it's approved, and 403 tenant_suspended if its Tenant is suspended.\n\nREST: POST /v1/auth/app-tokens", + "description": "Mint a per-app JWT from a platform session (embedded login)\n\nRequires a User token. Fails with 403 embedded_login_not_allowed for a self-registered (third-party) Application, which must use the hosted flow; 403 entitlement_required unless the user's resolved access to the Application is active; 409 application_not_available unless it's approved; and 403 tenant_suspended if its Tenant is suspended.\n\nREST: POST /v1/auth/app-tokens", "inputSchema": { "additionalProperties": false, "properties": { diff --git a/migrations/0006_self_registered_apps.sql b/migrations/0006_self_registered_apps.sql new file mode 100644 index 0000000..0cd58cd --- /dev/null +++ b/migrations/0006_self_registered_apps.sql @@ -0,0 +1,12 @@ +-- 0006_self_registered_apps: self-service Application registration +-- (Applications → The review lifecycle). +-- +-- self_registered marks an Application a developer submitted themselves, +-- rather than one staff created (concierge onboarding). Such an +-- Application must use the hosted sign-in flow: embedded app-token +-- issuance would hand a third party the User's Substratal password +-- (Auth → Getting an app token). +-- +-- Numbered after 0005_test_user_expiry, which ships first. + +alter table applications add column self_registered boolean not null default false; diff --git a/plan-remaining.md b/plan-remaining.md index 2477dd4..0125cc1 100644 --- a/plan-remaining.md +++ b/plan-remaining.md @@ -56,10 +56,9 @@ The API is complete, but the user-facing pages its emails and redirects point to Matches [Roadmap → Phase 3](https://compositecode.github.io/substratalapps.com/roadmap/#phase-3) — explicitly **not** built speculatively ahead of the triggers named in [Scale-out](#scale-out) and [Applications → The review lifecycle](https://compositecode.github.io/substratalapps.com/domain-model/applications/#the-review-lifecycle): - **SSO broker integration** (`sso_connections` becoming real, an actual WorkOS-or-equivalent wiring behind `POST /v1/auth/sso/{provider}/callback`) — the schema and the route exist from Phase 1 per [Auth → Native auth and per-Organization SSO](https://compositecode.github.io/substratalapps.com/api-reference/auth/#native-auth-and-per-organization-sso), deliberately built in advance precisely so this phase is additive wiring, not a migration. Triggered by a real Organization actually asking for it, not a phase-number default. -- Self-service Application registration + review queue. The *process* is already fully specified — reviewer model (no dedicated assignment, any `applications.manage` holder works the FIFO queue), a 5-business-day policy SLA, the `rejected` status with required `review_notes` and edit-to-resubmit, non-cascading suspension — see [Applications → The review lifecycle](https://compositecode.github.io/substratalapps.com/domain-model/applications/#the-review-lifecycle) and [API Reference → Applications → Reviewing a submission](https://compositecode.github.io/substratalapps.com/api-reference/applications/#reviewing-a-submission). What's actually deferred to this phase is just the self-service *submission* endpoint itself (`POST /v1/applications` opening up beyond platform-admin-only) — the review mechanics it feeds into are build-ready today. - SCIM-style provisioning. - Richer audit/compliance views (filtering, export) — driven by actual [Compliance](https://compositecode.github.io/substratalapps.com/compliance/) needs (a SOC 2 audit, a customer's data-export request), not built ahead of either. -- **The hosted sign-in page** above, if no first-party app has needed it sooner. +- **The hosted sign-in page** above, if no first-party app has needed it sooner. Self-service Application registration is built, so this is now what stands between a self-registered Application and going live. ## Dedicated tenancy infrastructure