From 2653fe0de7928967bf7150a04018ca0cdb07e5a7 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 21 Oct 2026 16:29:02 -0700 Subject: [PATCH 1/3] Move the manual go-live steps to [bespoke] GitHub issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remaining_manual_steps.md is now issues #18-#28, one per step, titled '[bespoke] N. …' in the order they need doing, each with the full instructions and a link to the step before it. The file is deleted, and README, infra/README.md, and plan-remaining.md link to the issues instead. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +- infra/README.md | 2 +- plan-remaining.md | 4 +- remaining_manual_steps.md | 116 -------------------------------------- 4 files changed, 5 insertions(+), 121 deletions(-) delete mode 100644 remaining_manual_steps.md diff --git a/README.md b/README.md index a7e4673..b1c1075 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ This repository is split deliberately, by audience: Root-level documents: - **[plan-remaining.md](plan-remaining.md)** — everything specified but not built yet: test-mode User expiry, the first-party web pages, Phase 3, dedicated tenancy infrastructure, scale-out triggers, and the rules every change follows. -- **[remaining_manual_steps.md](remaining_manual_steps.md)** — one-time setup still to do by hand to go live: the Lambda quota, the superadmin password, SES production access, Stripe, and legal. +- **[`[bespoke]` issues](https://github.com/CompositeCode/substratalapps.com/issues?q=is%3Aissue+%22%5Bbespoke%5D%22+sort%3Acreated-asc)** — the one-time setup still to do by hand to go live, as numbered GitHub issues in the order to do them (#18–#28): the Lambda quota, redeploy, the superadmin password, alert emails, SES production access, Stripe, push credentials, registering Applications, the plan job, legal, and the optional domain transfer. - **[infra/README.md](infra/README.md)** — the deployment architecture as built: AWS infrastructure (cost-capped, Aurora Serverless v2, Lambda, no VPC/NAT at Tier 0), the account setup, Stripe Billing, email, webhook and push delivery, audit archival, and local development. - **[LICENSE](LICENSE)** — MIT. @@ -187,7 +187,7 @@ For an urgent fix while `dev` holds unreleased work: ### One-time setup (Phase 0) -The steps above assume these exist. They're done once, by hand; what's still left of them is tracked in [remaining_manual_steps.md](remaining_manual_steps.md): +The steps above assume these exist. They're done once, by hand; what's still left of them is tracked as the numbered [`[bespoke]` issues](https://github.com/CompositeCode/substratalapps.com/issues?q=is%3Aissue+%22%5Bbespoke%5D%22+sort%3Acreated-asc): 1. A dedicated AWS account in `us-east-1` ([infra/README.md → AWS account & region](infra/README.md#aws-account--region)), with its default VPC in place (Aurora's subnets come from it; Lambda never joins a VPC), and a Route 53 hosted zone for `substratalapps.com`. 2. From your own machine, with admin credentials: `cd infra/terraform/bootstrap && terraform init && terraform apply -var billing_email=you@example.com`. This creates the **cost guardrails first** (warn and hard budgets with a circuit-breaker action, Cost Anomaly Detection, a billing alarm), then the state and artifact buckets, the GitHub OIDC provider, and the read-only `plan` and `deploy` roles. It's the only Terraform ever applied from a laptop. diff --git a/infra/README.md b/infra/README.md index 28aabec..9261c1b 100644 --- a/infra/README.md +++ b/infra/README.md @@ -1,6 +1,6 @@ # Deployment Architecture -Where the platform API runs, as built, and how it's billed. This is a deployment and operations document, not part of the [API specification](https://compositecode.github.io/substratalapps.com/), so it lives next to the Terraform that implements it (`infra/terraform/`) rather than on the docs site: an API consumer never needs to know how this is hosted to build against it. Work that isn't built yet (scale-out, dedicated tenancy infrastructure, MCP streaming) is in [plan-remaining.md](../plan-remaining.md); one-time setup steps still to do by hand are in [remaining_manual_steps.md](../remaining_manual_steps.md). +Where the platform API runs, as built, and how it's billed. This is a deployment and operations document, not part of the [API specification](https://compositecode.github.io/substratalapps.com/), so it lives next to the Terraform that implements it (`infra/terraform/`) rather than on the docs site: an API consumer never needs to know how this is hosted to build against it. Work that isn't built yet (scale-out, dedicated tenancy infrastructure, MCP streaming) is in [plan-remaining.md](../plan-remaining.md); one-time setup steps still to do by hand are in the [`[bespoke]` issues](https://github.com/CompositeCode/substratalapps.com/issues?q=is%3Aissue+%22%5Bbespoke%5D%22+sort%3Acreated-asc). Chosen and configured around one constraint above all others: **predictable, capped cost, with no surprises.** diff --git a/plan-remaining.md b/plan-remaining.md index 2477dd4..e1a2ef6 100644 --- a/plan-remaining.md +++ b/plan-remaining.md @@ -1,6 +1,6 @@ # Plan: what's left to build -Everything specified for the Platform API that isn't built yet, in one place. What *is* built is described by the [API specification](https://compositecode.github.io/substratalapps.com/) (the system of record), [README.md](README.md) (code, CI/CD, local development), and [infra/README.md](infra/README.md) (deployment architecture as built). One-time setup that has to be done by hand, rather than built, is in [remaining_manual_steps.md](remaining_manual_steps.md). +Everything specified for the Platform API that isn't built yet, in one place. What *is* built is described by the [API specification](https://compositecode.github.io/substratalapps.com/) (the system of record), [README.md](README.md) (code, CI/CD, local development), and [infra/README.md](infra/README.md) (deployment architecture as built). One-time setup that has to be done by hand, rather than built, is in the [`[bespoke]` issues](https://github.com/CompositeCode/substratalapps.com/issues?q=is%3Aissue+%22%5Bbespoke%5D%22+sort%3Acreated-asc). This replaces `PLAN.md`, `DEPLOYMENT.md`, and `ORIGINAL-SPEC-DRAFT.md`, which were retired on 2026-10-20 once everything else in them was built and documented where it belongs. They remain in git history. @@ -19,7 +19,7 @@ This replaces `PLAN.md`, `DEPLOYMENT.md`, and `ORIGINAL-SPEC-DRAFT.md`, which we ## Status -As of 2026-10-20, every operation in `openapi.yaml` is implemented, including subscriber billing, push notifications, and the hosted PKCE endpoints. SSO returns `501 not_implemented` until the broker integration, as specified. Production infrastructure exists in AWS (`substratal-prod`, `us-east-1`); the first deploy is finishing (see [remaining_manual_steps.md](remaining_manual_steps.md)). No product decisions are open: each is written into the spec page it governs and recorded in the [Changelog](https://compositecode.github.io/substratalapps.com/changelog/). +As of 2026-10-20, every operation in `openapi.yaml` is implemented, including subscriber billing, push notifications, and the hosted PKCE endpoints. SSO returns `501 not_implemented` until the broker integration, as specified. Production infrastructure exists in AWS (`substratal-prod`, `us-east-1`); the first deploy is finishing (see the [`[bespoke]` issues](https://github.com/CompositeCode/substratalapps.com/issues?q=is%3Aissue+%22%5Bbespoke%5D%22+sort%3Acreated-asc)). No product decisions are open: each is written into the spec page it governs and recorded in the [Changelog](https://compositecode.github.io/substratalapps.com/changelog/). ## Verify the build in production diff --git a/remaining_manual_steps.md b/remaining_manual_steps.md deleted file mode 100644 index 598aa1c..0000000 --- a/remaining_manual_steps.md +++ /dev/null @@ -1,116 +0,0 @@ -# Remaining manual steps - -One-time setup that has to be done by hand, in a console or from a laptop, to take the Platform API live. Code that still has to be built is in [plan-remaining.md](plan-remaining.md); how the deployment works is in [infra/README.md](infra/README.md). - -Already done (2026-10-20): the AWS account (`substratal-prod`, `196936048600`) and its management account; CLI access with `aws login`; DNS moved from Vercel to Route 53; the Organization SCP exception for `substratal-prod`; the bootstrap Terraform (budgets, anomaly detection, state and artifact buckets, GitHub OIDC roles); GitHub variables, the `production` environment limited to `main`, and the `main` ruleset; and a first partial deploy (Aurora, all five Lambdas, API Gateway, the certificate, SES domain). `https://api.substratalapps.com/v1/health` returns `200`. - -Work through these in order. The first four make the API usable; the rest make it ready for real customers. - -1. [Raise the Lambda concurrency quota](#1-raise-the-lambda-concurrency-quota) -2. [Re-run the production deploy](#2-re-run-the-production-deploy) -3. [Set the superadmin's password](#3-set-the-superadmins-password) -4. [Confirm the alert emails](#4-confirm-the-alert-emails) -5. [Get SES out of the sandbox](#5-get-ses-out-of-the-sandbox) -6. [Set up Stripe](#6-set-up-stripe) -7. [Add push credentials](#7-add-push-credentials) -8. [Register Substratal's Applications](#8-register-substratals-applications) -9. [Fix the pull-request plan job](#9-fix-the-pull-request-plan-job) -10. [Legal and compliance](#10-legal-and-compliance) -11. [Optional: transfer the domain registration](#11-optional-transfer-the-domain-registration) - ---- - -## 1. Raise the Lambda concurrency quota - -New AWS accounts can run only **10** concurrent Lambda executions, and AWS keeps 10 unreserved, so the webhook worker's reserved concurrency of 5 fails the deploy (`decreases account's UnreservedConcurrentExecution below its minimum value of [10]`). It would also cap the whole API at 10 simultaneous requests. - -1. Sign in to the **substratal-prod** console session, region **US East (N. Virginia)**. -2. Open https://us-east-1.console.aws.amazon.com/servicequotas/home/services/lambda/quotas/L-B99A9384 (**Service Quotas → AWS services → AWS Lambda → Concurrent executions**). -3. **Request increase at account level**, enter **1000**, and submit. -4. Watch it under **Service Quotas → Quota request history**. It's usually approved within hours, sometimes a couple of days, and AWS emails the account contact. - -From the CLI instead: `aws service-quotas request-service-quota-increase --service-code lambda --quota-code L-B99A9384 --desired-value 1000 --profile substratal-prod --region us-east-1`. - -## 2. Re-run the production deploy - -After the quota shows **1000** (`aws lambda get-account-settings --profile substratal-prod --query AccountLimit.ConcurrentExecutions`): - -- **Easiest:** GitHub → **Actions → Deploy → Run workflow**, branch `main`, **Run workflow**. -- Or from a terminal: `gh workflow run deploy.yml -R CompositeCode/substratalapps.com --ref main`. - -The run builds the Lambdas, applies Terraform (it picks up where the first run stopped), runs the migrations on the new cluster, makes sure the protected superadmin exists (`SUPERADMIN_EMAIL`), and smoke-tests `/v1/health` and JWKS. It takes about 5–10 minutes. Every later merge into `main` deploys the same way, automatically. - -## 3. Set the superadmin's password - -The deploy creates `adronhall@proton.me` as the **protected superadmin**: active, verified, holding `superadmin`, and impossible to suspend, delete, erase, or demote ([Users → The protected superadmin](https://compositecode.github.io/substratalapps.com/api-reference/users/#the-protected-superadmin)). It has no password until you set one. From a clone of the repository, signed in with `aws login --profile substratal-prod`: - -```bash -export AWS_PROFILE=substratal-prod AWS_REGION=us-east-1 -export DATABASE_BACKEND=dataapi \ - DB_CLUSTER_ARN=arn:aws:rds:us-east-1:196936048600:cluster:substratal-prod-db \ - DB_SECRET_ARN=$(aws rds describe-db-clusters --db-cluster-identifier substratal-prod-db \ - --query 'DBClusters[0].MasterUserSecret.SecretArn' --output text) -go run ./cmd/admin bootstrap -email adronhall@proton.me # prompts for the password (12+ characters) -``` - -Then sign in: `curl -s https://api.substratalapps.com/v1/auth/login -H 'content-type: application/json' -d '{"email":"adronhall@proton.me","password":"…"}'`. Turn on MFA right away ([Auth → MFA](https://compositecode.github.io/substratalapps.com/api-reference/auth/#mfa-totp)). - -## 4. Confirm the alert emails - -- **Budget and anomaly alerts:** SNS sent a subscription confirmation ("AWS Notification - Subscription Confirmation") for the `substratal-billing-alerts` topic to `adronhall@proton.me`. Click **Confirm subscription**; until then the $75 warning, the $150 hard stop, and anomaly alerts reach no one. -- **Operational alarms** (5xx rate, Lambda errors and throttles, Aurora near capacity, webhook DLQ, security log): the `alarm_email` Terraform variable is empty, so these alarms have no recipient. Decide the address; setting it is a one-line change to `infra/terraform/prod/variables.tf` and a confirmation click after the next deploy. - -## 5. Get SES out of the sandbox - -SES is still in the **sandbox**: it can only send to verified addresses, so signup verification, password resets, and invitations don't reach real users. - -1. **substratal-prod** console → **Amazon SES → Account dashboard → Request production access**. -2. Mail type **Transactional**; website `https://compositecode.github.io/substratalapps.com/`; describe the use: account verification, password resets, invitations, and security notices for users of Substratal's Applications, sent from `mail.substratalapps.com` (DKIM/SPF/DMARC configured), with bounces and complaints suppressed. -3. AWS usually answers within a day. - -Until then, verify any address you need to test with under **SES → Identities → Create identity → Email address**. - -## 6. Set up Stripe - -The billing code (Free and Standard at $6 per user per month) is built and deployed; Stripe itself isn't set up. See [Pricing](https://compositecode.github.io/substratalapps.com/pricing/) and [infra/README.md → Stripe Billing](infra/README.md#stripe-billing). - -1. **Activate the Stripe account:** business details, bank account, and the public statement descriptor. Live mode needs a privacy policy and terms of service URL (step 10). -2. **Enable Stripe Tax** and add your tax registrations (billing is tax-exclusive). -3. **Create the catalog**, test mode first: `STRIPE_SECRET_KEY=sk_test_… make stripe-catalog` creates the `substratal_standard` Product and the `standard_monthly_usd` Price ($6.00 per user, monthly). Repeat with the live key (`CONFIRM_LIVE=yes`) when going live. -4. **Configure the Customer Portal** (Settings → Billing → Customer portal): allow updating payment methods, viewing invoices, and canceling at period end. **Turn plan switching off.** -5. **Register the webhook endpoint** `https://api.substratalapps.com/internal/stripe/webhook` with these events: `checkout.session.completed`, `customer.subscription.created`, `customer.subscription.updated`, `customer.subscription.deleted`, `invoice.paid`, `invoice.payment_failed`. Copy its signing secret. -6. **Put the keys into the app secret** `substratal-prod/app` (Secrets Manager → `substratal-prod/app` → Retrieve secret value → Edit): set `stripe_secret_key` (a restricted key with Customers, Subscriptions, Checkout Sessions, Customer Portal, and Prices read access) and `stripe_webhook_secret`. Terraform never overwrites these. The Lambdas pick them up on their next cold start; re-running the deploy forces it. -7. **Decide test or live.** Production holds one key. With a test key, the whole flow can be tried end to end with Stripe's test cards; with the live key, test-mode Users can't check out (`409 test_mode_billing_unavailable`, by design). Start with test, then switch to live. - -## 7. Add push credentials - -Push notifications are merged (PR #12) and ship with the next successful deploy, which also applies migration `0003_push.sql`. Migrations `0003_push` and `0004_protected_superadmin` touch different tables, so it doesn't matter that `0004` reached `main` first. - -For each Application that sends pushes, its owner uploads credentials with `POST /v1/applications/{id}/push-credentials` (and rotates them with `PATCH …/push-credentials/{provider}`): an APNs key (`.p8`, key id, team id, bundle id), an FCM service account, or Web Push VAPID keys ([Push Notifications → Push credentials](https://compositecode.github.io/substratalapps.com/api-reference/notifications/#push-credentials)). - -## 8. Register Substratal's Applications - -For each of Substratal's apps, with the superadmin's token ([Applications → Owner onboarding](https://compositecode.github.io/substratalapps.com/api-reference/applications/#owner-onboarding-end-to-end)): - -1. `POST /v1/applications` with its slug, name, `launch_url`, `redirect_uris`, `permissions`, `available_app_roles`, and **`access_tier`** (`free` or `standard`, which decides who gets it under [Pricing](https://compositecode.github.io/substratalapps.com/pricing/)). -2. Give each seeded Role its permissions (`PATCH /v1/roles/role__`). -3. Create the app backend's key (`POST /v1/api-keys`, scope the app, `entitlements.manage` and `users.list`), plus a test-mode key. -4. Subscribe the backend to `access.revoked` and `role.removed` (`POST /v1/webhooks`): the required revocation integration. - -## 9. Fix the pull-request plan job - -The `plan` job on pull requests into `main` fails: the read-only plan role can't read the app secret's value (`secretsmanager:GetSecretValue`), which `terraform plan` needs to refresh `aws_secretsmanager_secret_version.app`. It isn't a required check, so it doesn't block merges, but its plan comment is the best preview of what a release changes in AWS. Choose one: - -- Allow the plan role `secretsmanager:GetSecretValue` on `substratal-prod/app` only (it then can read the Stripe keys, as anyone who can open a PR into `main` could), or -- Stop Terraform reading the value: keep the secret's initial value out of Terraform state and plan with the secret version untracked. This is a small Terraform change. - -## 10. Legal and compliance - -- **Privacy policy and terms of service**, published at stable URLs. Stripe live mode and SES production access both expect them. -- **Data processing agreements** with the sub-processors listed on [Compliance](https://compositecode.github.io/substratalapps.com/compliance/): AWS (AWS Artifact → Agreements), Stripe, and the push providers (Apple, Google, and the browser push services). - -## 11. Optional: transfer the domain registration - -DNS is on Route 53; the registration is still at Vercel (reseller for Tucows), renewing 2027-08-07. To consolidate: in Vercel, **Domains → substratalapps.com → Transfer out** to get the authorization code, then `aws route53domains transfer-domain --domain-name substratalapps.com --duration-in-years 1 --auth-code … --region us-east-1` with the registrant contact details (about $15, adds a year). Keep the Route 53 nameservers during the transfer. - -Also keep an eye on the Organization SCP exception for `substratal-prod` ([infra/README.md → AWS account & region](infra/README.md#aws-account--region)): if production calls suddenly fail with an explicit SCP deny in `us-east-1`, that exception was removed. From 60ec86763439d7e7845c721e40476c8b709dd891 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 21 Oct 2026 20:58:38 -0700 Subject: [PATCH 2/3] Note that the dashboard pages are built in the management app, not here The verify-email, reset-password, and other dashboard pages are being built in the management app (web, macOS, Windows, iOS, Android, and Linux), outside this repository, and tracked there. Co-Authored-By: Claude Opus 5.5 --- plan-remaining.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plan-remaining.md b/plan-remaining.md index 33ad612..06af98e 100644 --- a/plan-remaining.md +++ b/plan-remaining.md @@ -48,7 +48,7 @@ Specified 2026-10-20 ([Auth → Test-mode Users → Lifetime](https://compositec The API is complete, but the user-facing pages its emails and redirects point to don't exist yet. Both are clients of the API, not part of it: -- **Dashboard** (`dashboard.substratalapps.com`, `DASHBOARD_URL`): the email-verification, password-reset, invitation-acceptance, and email-change links land here. Also where a User subscribes to Standard and manages their account, devices, and inbox, and where org admins manage members and the Organization's subscription. It's the first-party browser origin in the API's CORS allow-list. **At minimum, the verify-email and reset-password pages are needed before email-based signup works for real users.** +- **Dashboard** (`dashboard.substratalapps.com`, `DASHBOARD_URL`): **being built now, outside this repository, and tracked there.** It's part of the management app, which has web, macOS, Windows, iOS, Android, and Linux versions. The email-verification, password-reset, invitation-acceptance, and email-change links land here. It's also where a User subscribes to Standard and manages their account, devices, and inbox, and where org admins manage members and the Organization's subscription. Its web version is the first-party browser origin in the API's CORS allow-list. The verify-email and reset-password pages are the minimum needed before email-based signup works for real users. Nothing for it is built in this repository; this API only needs to keep serving what those pages call. - **Hosted sign-in** (`auth.substratalapps.com/authorize`, `AUTHORIZE_URL`): the page behind the hosted authorization-code + PKCE flow ([Auth → Getting an app token](https://compositecode.github.io/substratalapps.com/api-reference/auth/#getting-an-app-token)). The endpoints are built; only the page is missing. Needed before any third-party Application, and by any first-party app that prefers it to embedded login. ## Phase 3: marketplace and scale features From 58fc9e0afc720ae3e9b33f1b758354791a234a39 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 21 Oct 2026 23:50:48 -0700 Subject: [PATCH 3/3] Declare Substratal-Version in openapi.yaml; 405 for a wrong method Header versioning (#37) is enforced by middleware, but the spec only mentioned it in prose, so generated clients didn't know to send it. - openapi.yaml: a shared ApiVersion header parameter (Substratal-Version, enum "1") on all 119 versioned operations, and a documented 400 on the 105 that lacked one. GET /health and POST /auth/oauth/token stay exempt. - Codegen: an OpenAPI Overlay (internal/api/gen/overlay.yaml) drops the parameter before oapi-codegen runs, since the middleware enforces it ahead of every route; handler signatures are unchanged. Route registrations in gen.go are re-sorted by the overlay's re-serialization; routing is by pattern, so behavior is identical. MCP tools unchanged. - A known path called with an unsupported method returns 405 method_not_allowed with Allow and details.allowed, instead of the catch-all 404. - Docs: Conventions (versioning, errors), OpenAPI overview, spec description, Changelog; plan-remaining's Phase 0 line no longer cites the old /v1/health. - Tests: 405 with Allow for /health and /users/me. Brings dev up to date with main (SCIM, alarm email, header versioning). Co-Authored-By: Claude Opus 5.5 --- docs/api-reference/conventions.md | 3 +- docs/changelog.md | 5 + docs/openapi.yaml | 314 ++++++++++++++++++++++++++++- docs/openapi/index.md | 2 +- internal/api/gen/gen.go | 184 +++++++++-------- internal/api/gen/oapi-codegen.yaml | 2 + internal/api/gen/overlay.yaml | 13 ++ internal/core/server.go | 29 ++- internal/core/versioning_test.go | 10 + internal/httpx/errors.go | 9 +- plan-remaining.md | 2 +- 11 files changed, 480 insertions(+), 93 deletions(-) create mode 100644 internal/api/gen/overlay.yaml diff --git a/docs/api-reference/conventions.md b/docs/api-reference/conventions.md index 774c36f..1954d78 100644 --- a/docs/api-reference/conventions.md +++ b/docs/api-reference/conventions.md @@ -217,6 +217,7 @@ A single resource is returned as a bare JSON object — no envelope: | `tenant_suspended` | 403 | The Tenant that owns the data being written (or the Application an app token is requested for) is `suspended` by the platform. See [Tenancy](../../domain-model/tenancy/#fields). | | `destructive_operation_restricted` | 403 | A `restrict_destructive` API Key attempted a destructive operation — see [API Keys](../api-keys/#agent-keys--restrict_destructive). | | `not_found` | 404 | Generic not-found for a path that matches no route. Resource pages define their own `_not_found` codes. | +| `method_not_allowed` | 405 | The path exists, but not for this method. The `Allow` header and `details.allowed` list the methods it does support. | | `version_conflict` | 409 | `If-Match` didn't match the current `ETag`. | | `idempotency_key_reused` | 409 | Same `Idempotency-Key`, different request body. | | `idempotency_key_in_flight` | 409 | The first request with this `Idempotency-Key` is still being processed. Retry after a second. | @@ -331,7 +332,7 @@ Versions are whole numbers, and `1` is the only one today. The URL never carries - **Browsers:** `Substratal-Version` is allowed in CORS requests and exposed on CORS responses. - **Changes.** Additive changes (new optional fields, new endpoints, new enum values) ship within a version. A breaking change ships as the next number, and the previous version keeps working for at least 6 months, with `Deprecation` and `Sunset` headers on every response to a request that names it. After its `Sunset` date it's retired, and requests naming it get `400 unsupported_api_version`. See [Non-Functional Requirements → Versioning](../../non-functional-requirements/#versioning) for what counts as breaking. - **Not the webhook version.** A webhook subscription's `api_version` (a date, such as `2026-10-05`) fixes the shape of the payloads it receives, and is versioned separately. See [Webhooks → Payload versions](../webhooks/#payload-versions). -- **Generated clients:** `openapi.yaml` doesn't declare the header on each operation, so configure your client to send it as a default header. +- **In `openapi.yaml`:** every versioned operation declares the header as the shared `ApiVersion` parameter, and documents the `400` it returns without one, so generated clients know to send it. The exempt operations don't declare it. ## Timestamps diff --git a/docs/changelog.md b/docs/changelog.md index 061d97a..a3a3efb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -12,6 +12,11 @@ What's changed in this specification over time. Every decision is written into t --- +## 2026-10-22 — Version header declared in `openapi.yaml`; 405 for a wrong method + +- **`openapi.yaml` declares `Substratal-Version`.** Every versioned operation now carries the shared `ApiVersion` header parameter (`Substratal-Version`, enum `1`) and documents its `400` (`api_version_required`, `unsupported_api_version`). Generated clients learn the header from the spec instead of needing it configured by hand. `GET /health` and `POST /auth/oauth/token` don't declare it, because they're exempt. Nothing changes on the wire: the API already required the header ([Conventions → Versioning](../api-reference/conventions/#versioning)). +- **`405 method_not_allowed`.** Calling a path that exists with a method it doesn't support (for example `PUT /health`) now returns `405` with an `Allow` header and `details.allowed`, instead of the catch-all `404 not_found`. See [Conventions → Errors](../api-reference/conventions/#errors). + ## 2026-10-21 — SCIM provisioning An Organization's identity provider can invite and remove its members with SCIM 2.0. See [API Reference → SCIM](../api-reference/scim/). `openapi.yaml` is now at `1.0.0-draft.5`, with 121 operations. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 1f8ef9f..e3d41fc 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -19,8 +19,9 @@ info: `details.supported`. Exempt: `GET /health`, `POST /auth/oauth/token` (called by standard OAuth libraries), the `/.well-known` endpoints, and SCIM under `/scim/v2` (called by identity providers). - The header isn't declared on each operation, so generated clients - should send it as a default header. See /api-reference/conventions/#versioning. + Every versioned operation declares it as the shared `ApiVersion` header + parameter, so generated clients know to send it. See + /api-reference/conventions/#versioning. Vendor extensions used here: `x-substratal-destructive` (`always` | `conditional`) marks operations @@ -84,6 +85,8 @@ paths: /auth/signup: post: operationId: auth.signup + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Auth] summary: Create an account with email and password description: "An email held by an invited User is rejected with 409 email_taken (details.reason invitation_pending) and the invitation is re-sent; invitations complete only through invitations/accept." @@ -103,6 +106,7 @@ paths: application_id: { type: string, description: "Informational: records which app the signup came from. Grants nothing." } test_mode: { type: boolean, default: false, description: "Create a test-mode User." } responses: + "400": { $ref: "#/components/responses/BadRequest" } "201": description: Account created and signed in content: @@ -115,6 +119,8 @@ paths: /auth/login: post: operationId: auth.login + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Auth] summary: Exchange email and password for a session (or an MFA challenge) security: [] @@ -131,6 +137,7 @@ paths: password: { type: string, format: password } test_mode: { type: boolean, default: false, description: "Sign in the test-mode account with this email." } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A session, or an MFA challenge when mfa_required is true content: @@ -146,6 +153,8 @@ paths: /auth/mfa/verify: post: operationId: auth.verifyMfa + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Auth] summary: Complete a login that returned an MFA challenge security: [] @@ -162,6 +171,7 @@ paths: code: { type: string, pattern: "^[0-9]{6}$" } recovery_code: { type: string } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Session issued content: @@ -173,6 +183,8 @@ paths: /auth/token/refresh: post: operationId: auth.refreshToken + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Auth] summary: Rotate a platform refresh token for a new access token security: [] @@ -187,6 +199,7 @@ paths: properties: refresh_token: { type: string } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: New session tokens (the old refresh token is now invalid) content: @@ -197,6 +210,8 @@ paths: /auth/logout: post: operationId: auth.logout + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Auth] summary: Revoke the current session, or every session x-substratal-phase: mvp @@ -217,6 +232,8 @@ paths: /auth/app-tokens: post: operationId: auth.createAppToken + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Auth] summary: Mint a per-app JWT from a platform session (embedded login) description: "Requires a User token. Fails with 403 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." @@ -246,6 +263,8 @@ paths: /auth/oauth/authorization-codes: post: operationId: auth.createAuthorizationCode + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Auth] summary: Mint a one-time authorization code for a hosted launch (PKCE) description: "Called by Substratal's hosted sign-in page on the signed-in user's behalf." @@ -264,6 +283,7 @@ paths: code_challenge_method: { type: string, enum: [S256] } state: { type: string, maxLength: 512 } responses: + "400": { $ref: "#/components/responses/BadRequest" } "201": description: Authorization code content: @@ -314,6 +334,8 @@ paths: /auth/email/verify: post: operationId: auth.verifyEmail + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Auth] summary: Confirm an email address from an emailed token security: [] @@ -334,6 +356,8 @@ paths: /auth/email/verify/resend: post: operationId: auth.resendEmailVerification + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Auth] summary: Re-send a verification email (always 202) security: [] @@ -349,12 +373,15 @@ paths: email: { type: string, format: email } test_mode: { type: boolean, default: false } responses: + "400": { $ref: "#/components/responses/BadRequest" } "202": { description: Accepted } "429": { $ref: "#/components/responses/TooManyRequests" } /auth/password/forgot: post: operationId: auth.forgotPassword + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Auth] summary: Send a password-reset email (always 202) security: [] @@ -370,12 +397,15 @@ paths: email: { type: string, format: email } test_mode: { type: boolean, default: false } responses: + "400": { $ref: "#/components/responses/BadRequest" } "202": { description: Accepted } "429": { $ref: "#/components/responses/TooManyRequests" } /auth/password/reset: post: operationId: auth.resetPassword + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Auth] summary: Set a new password from a reset token (revokes every session) security: [] @@ -398,6 +428,8 @@ paths: /auth/invitations/accept: post: operationId: auth.acceptInvitation + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Auth] summary: Set a password on an invited account, activate it, and sign in security: [] @@ -430,6 +462,7 @@ paths: security: [] x-substratal-phase: phase3 parameters: + - $ref: "#/components/parameters/ApiVersion" - name: provider in: path required: true @@ -439,6 +472,7 @@ paths: application/json: schema: { type: object } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Session issued content: @@ -455,6 +489,8 @@ paths: - $ref: "#/components/parameters/UserIdOrMe" post: operationId: auth.changePassword + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Auth] summary: Change password given the current one (self only) x-substratal-phase: mvp @@ -469,6 +505,7 @@ paths: current_password: { type: string, format: password } new_password: { type: string, format: password, minLength: 12, maxLength: 128 } responses: + "400": { $ref: "#/components/responses/BadRequest" } "204": { description: Changed; other sessions revoked } "401": { $ref: "#/components/responses/Unauthorized" } "422": { $ref: "#/components/responses/Unprocessable" } @@ -478,10 +515,13 @@ paths: - $ref: "#/components/parameters/UserIdOrMe" post: operationId: auth.enrollMfaTotp + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Auth] summary: Begin TOTP enrollment (self only) x-substratal-phase: mvp responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Pending TOTP secret content: @@ -495,11 +535,14 @@ paths: "409": { $ref: "#/components/responses/Conflict" } delete: operationId: auth.resetMfaTotp + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Auth] summary: Support reset of a user's TOTP (requires users.manage) x-substratal-phase: mvp x-substratal-destructive: always responses: + "400": { $ref: "#/components/responses/BadRequest" } "204": { description: MFA removed; sessions revoked } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } @@ -509,6 +552,8 @@ paths: - $ref: "#/components/parameters/UserIdOrMe" post: operationId: auth.confirmMfaTotp + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Auth] summary: Confirm TOTP enrollment with a first code; returns recovery codes once x-substratal-phase: mvp @@ -522,6 +567,7 @@ paths: properties: code: { type: string, pattern: "^[0-9]{6}$" } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Enabled content: @@ -539,6 +585,8 @@ paths: - $ref: "#/components/parameters/UserIdOrMe" post: operationId: auth.disableMfaTotp + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Auth] summary: Turn TOTP off given a current code or recovery code (self only) x-substratal-phase: mvp @@ -553,6 +601,7 @@ paths: code: { type: string, pattern: "^[0-9]{6}$", description: "A current TOTP code" } recovery_code: { type: string } responses: + "400": { $ref: "#/components/responses/BadRequest" } "204": { description: Disabled } "401": { $ref: "#/components/responses/Unauthorized" } "409": { $ref: "#/components/responses/Conflict" } @@ -567,9 +616,11 @@ paths: summary: List active sessions (self or users.manage) x-substratal-phase: mvp parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of sessions content: @@ -590,11 +641,14 @@ paths: schema: { type: string } delete: operationId: auth.revokeSession + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Auth] summary: Revoke one session x-substratal-phase: mvp x-substratal-destructive: always responses: + "400": { $ref: "#/components/responses/BadRequest" } "204": { description: Revoked } "404": { $ref: "#/components/responses/NotFound" } @@ -608,6 +662,7 @@ paths: description: "Requires users.list. An app-scoped key with users.list is forced to application_id=." x-substratal-phase: mvp parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" - { name: status, in: query, description: "deleted is allowed for users.manage only", schema: { type: string, enum: [active, invited, suspended, deleted] } } @@ -618,6 +673,7 @@ paths: - { name: created_after, in: query, schema: { type: string, format: date-time } } - { name: created_before, in: query, schema: { type: string, format: date-time } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of users content: @@ -631,6 +687,7 @@ paths: description: "Requires users.manage. Also creates a default Profile and Settings and assigns the member platform role, in one transaction." x-substratal-phase: mvp parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IdempotencyKeyOptional" requestBody: required: true @@ -645,6 +702,7 @@ paths: status: { type: string, enum: [invited, active], default: invited } send_invitation: { type: boolean, default: true } responses: + "400": { $ref: "#/components/responses/BadRequest" } "201": description: User created content: @@ -659,10 +717,13 @@ paths: - $ref: "#/components/parameters/UserIdOrMe" get: operationId: users.get + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Users] summary: Fetch one user x-substratal-phase: mvp responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The user headers: @@ -679,6 +740,7 @@ paths: x-substratal-destructive: conditional x-substratal-destructive-when: "status becomes suspended" parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IfMatch" requestBody: content: @@ -689,6 +751,7 @@ paths: email: { type: string, format: email } status: { type: string, enum: [active, suspended], description: "users.manage only. active on a deleted User restores it." } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Updated user content: @@ -699,11 +762,14 @@ paths: "422": { $ref: "#/components/responses/Unprocessable" } delete: operationId: users.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Users] summary: Soft-delete a user (self or users.manage) x-substratal-phase: mvp x-substratal-destructive: always responses: + "400": { $ref: "#/components/responses/BadRequest" } "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } @@ -713,6 +779,8 @@ paths: - $ref: "#/components/parameters/UserIdOrMe" post: operationId: users.suspend + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Users] summary: Suspend a user (users.manage) x-substratal-phase: mvp @@ -725,6 +793,7 @@ paths: properties: reason: { type: string, maxLength: 2000 } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: User suspended content: @@ -738,10 +807,13 @@ paths: - $ref: "#/components/parameters/UserIdOrMe" post: operationId: users.resendInvitation + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Users] summary: Re-send the invitation email to an invited user (users.manage) x-substratal-phase: mvp responses: + "400": { $ref: "#/components/responses/BadRequest" } "202": { description: Sent } "409": { $ref: "#/components/responses/Conflict" } @@ -750,11 +822,14 @@ paths: - $ref: "#/components/parameters/UserIdOrMe" get: operationId: users.export + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Users] summary: Export everything this API holds about this user description: "Self, or users.manage. GDPR Article 20 / CCPA right-to-know. Covers every table the hard-delete cascade clears, plus global Settings, which the cascade leaves in place." x-substratal-phase: mvp responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Full data export bundle content: @@ -767,6 +842,8 @@ paths: - $ref: "#/components/parameters/UserIdOrMe" post: operationId: users.requestErasure + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Users] summary: Request right-to-erasure (soft-delete now, hard-delete cascade in 7 days) x-substratal-phase: mvp @@ -779,6 +856,7 @@ paths: properties: reason: { type: string, maxLength: 2000 } responses: + "400": { $ref: "#/components/responses/BadRequest" } "202": description: Scheduled content: @@ -790,11 +868,14 @@ paths: - $ref: "#/components/parameters/UserIdOrMe" delete: operationId: users.cancelErasure + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Users] summary: Cancel a scheduled erasure inside its 7-day window (users.manage) x-substratal-phase: mvp x-substratal-destructive: always responses: + "400": { $ref: "#/components/responses/BadRequest" } "204": { description: Cancelled } "404": { $ref: "#/components/responses/NotFound" } @@ -805,10 +886,13 @@ paths: - $ref: "#/components/parameters/UserIdOrMe" get: operationId: profiles.get + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Profiles] summary: Fetch global Profile x-substratal-phase: mvp responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The global Profile headers: @@ -823,12 +907,14 @@ paths: summary: Update global Profile (self or users.manage) x-substratal-phase: mvp parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IfMatch" requestBody: content: application/json: schema: { $ref: "#/components/schemas/ProfileWrite" } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Updated Profile content: @@ -842,10 +928,13 @@ paths: - $ref: "#/components/parameters/UserIdOrMe" get: operationId: settings.get + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Settings] summary: Fetch global Settings (self, or platform users.list) x-substratal-phase: mvp responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The global Settings headers: @@ -859,12 +948,14 @@ paths: summary: Update global Settings (self or users.manage) x-substratal-phase: mvp parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IfMatch" requestBody: content: application/json: schema: { $ref: "#/components/schemas/SettingsWrite" } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Updated Settings content: @@ -879,10 +970,13 @@ paths: - $ref: "#/components/parameters/AppId" get: operationId: appProfiles.get + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Profiles] summary: Fetch per-app AppProfile (self, the app's own key, or platform users.list; defaults if none written yet) x-substratal-phase: phase2 responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The AppProfile headers: @@ -898,6 +992,7 @@ paths: summary: Update per-app AppProfile (custom is merged one level; null removes a key) x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IfMatch" requestBody: content: @@ -908,6 +1003,7 @@ paths: display_handle: { type: [string, "null"], minLength: 1, maxLength: 64 } custom: { type: object } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Updated AppProfile content: @@ -923,10 +1019,13 @@ paths: - $ref: "#/components/parameters/AppId" get: operationId: appSettings.get + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Settings] summary: Fetch fully resolved per-app settings (self, the app's own key, or platform users.list) x-substratal-phase: phase2 responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Resolved AppSettings headers: @@ -942,6 +1041,7 @@ paths: summary: Write per-app overrides (validated against settings_schema; null clears) x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IfMatch" requestBody: content: @@ -952,6 +1052,7 @@ paths: properties: overrides: { type: object } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Resolved AppSettings after the write content: @@ -970,6 +1071,7 @@ paths: summary: List the catalog (filtered by visibility rules) x-substratal-phase: mvp parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" - { name: visibility, in: query, schema: { $ref: "#/components/schemas/ApplicationVisibility" } } @@ -980,6 +1082,7 @@ paths: - { name: owner_user_id, in: query, schema: { type: string } } - { name: owner_organization_id, in: query, schema: { type: string } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of Applications (trimmed view) content: @@ -991,6 +1094,7 @@ paths: summary: Register an Application (applications.manage) x-substratal-phase: mvp parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IdempotencyKeyOptional" requestBody: required: true @@ -1002,6 +1106,7 @@ paths: - type: object required: [slug, name, launch_url] responses: + "400": { $ref: "#/components/responses/BadRequest" } "201": description: Application created content: @@ -1016,10 +1121,13 @@ paths: - { name: id, in: path, required: true, schema: { type: string } } get: operationId: applications.get + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Applications] summary: Fetch one Application x-substratal-phase: mvp responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The Application headers: @@ -1036,12 +1144,14 @@ paths: x-substratal-destructive: conditional x-substratal-destructive-when: "review_status becomes rejected or suspended, or available_app_roles/permissions shrink" parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IfMatch" requestBody: content: application/json: schema: { $ref: "#/components/schemas/ApplicationWrite" } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Updated Application content: @@ -1062,6 +1172,7 @@ paths: summary: A user's entitlements, personal and org-sourced x-substratal-phase: mvp parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" - { name: application_id, in: query, schema: { type: string } } @@ -1069,6 +1180,7 @@ paths: - { name: source, in: query, schema: { $ref: "#/components/schemas/EntitlementSource" } } - { name: include_inactive, in: query, schema: { type: boolean, default: false } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of Entitlements content: @@ -1081,6 +1193,7 @@ paths: description: "Requires entitlements.manage (platform, or app-confined to the key's own Application), or the Application's owner." x-substratal-phase: mvp parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true @@ -1096,6 +1209,7 @@ paths: starts_at: { type: string, format: date-time } ends_at: { type: [string, "null"], format: date-time } responses: + "400": { $ref: "#/components/responses/BadRequest" } "201": description: Entitlement created content: @@ -1112,6 +1226,7 @@ paths: summary: List entitlements across users (entitlements.manage or the Application's owner; app keys and owners confined) x-substratal-phase: mvp parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" - { name: application_id, in: query, schema: { type: string } } @@ -1123,6 +1238,7 @@ paths: - { name: ends_before, in: query, schema: { type: string, format: date-time } } - { name: include_inactive, in: query, schema: { type: boolean, default: false } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of Entitlements content: @@ -1135,10 +1251,13 @@ paths: - { name: id, in: path, required: true, schema: { type: string } } get: operationId: entitlements.get + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Entitlements] summary: Fetch one entitlement x-substratal-phase: mvp responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The Entitlement headers: @@ -1155,6 +1274,7 @@ paths: x-substratal-destructive: conditional x-substratal-destructive-when: "status becomes disabled or revoked" parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IfMatch" requestBody: required: true @@ -1171,6 +1291,7 @@ paths: member_scope: { $ref: "#/components/schemas/EntitlementMemberScope" } member_overrides: { type: array, maxItems: 1000, items: { type: string } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Updated Entitlement content: @@ -1182,11 +1303,14 @@ paths: "422": { $ref: "#/components/responses/Unprocessable" } delete: operationId: entitlements.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Entitlements] summary: Hard-remove a grant made in error (no order_id, under 24 hours old) x-substratal-phase: mvp x-substratal-destructive: always responses: + "400": { $ref: "#/components/responses/BadRequest" } "204": { description: Removed } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } @@ -1201,10 +1325,12 @@ paths: summary: List every Permission key the caller can see x-substratal-phase: mvp parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" - { name: scope, in: query, description: "'platform' or an application_id", schema: { type: string } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of Permissions content: @@ -1218,10 +1344,12 @@ paths: summary: List Roles x-substratal-phase: mvp parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" - { name: scope, in: query, description: "'platform' or an application_id", schema: { type: string } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of Roles content: @@ -1229,6 +1357,8 @@ paths: schema: { $ref: "#/components/schemas/RolePage" } post: operationId: roles.create + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Roles & Permissions] summary: Define a Role x-substratal-phase: mvp @@ -1238,6 +1368,7 @@ paths: application/json: schema: { $ref: "#/components/schemas/RoleCreate" } responses: + "400": { $ref: "#/components/responses/BadRequest" } "201": description: Role created content: @@ -1252,10 +1383,13 @@ paths: - { name: id, in: path, required: true, schema: { type: string } } get: operationId: roles.get + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Roles & Permissions] summary: Fetch one Role x-substratal-phase: mvp responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The Role headers: @@ -1272,6 +1406,7 @@ paths: x-substratal-destructive: conditional x-substratal-destructive-when: "permissions shrink" parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IfMatch" requestBody: required: true @@ -1283,6 +1418,7 @@ paths: description: { type: [string, "null"], maxLength: 2000 } permissions: { type: array, items: { type: string } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Updated Role content: @@ -1293,11 +1429,14 @@ paths: "422": { $ref: "#/components/responses/Unprocessable" } delete: operationId: roles.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Roles & Permissions] summary: Delete an unassigned, non-seed Role x-substratal-phase: mvp x-substratal-destructive: always responses: + "400": { $ref: "#/components/responses/BadRequest" } "204": { description: Deleted } "403": { $ref: "#/components/responses/Forbidden" } "409": { $ref: "#/components/responses/Conflict" } @@ -1311,10 +1450,12 @@ paths: summary: List a user's Role assignments (including implicit default_app_role) x-substratal-phase: mvp parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" - { name: application_id, in: query, schema: { type: string } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of UserRoleAssignments content: @@ -1327,10 +1468,13 @@ paths: - { name: roleId, in: path, required: true, schema: { type: string } } post: operationId: roles.assign + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Roles & Permissions] summary: Assign a Role (idempotent — 200 if already held) x-substratal-phase: mvp responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Already assigned content: @@ -1345,11 +1489,14 @@ paths: "404": { $ref: "#/components/responses/NotFound" } delete: operationId: roles.remove + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Roles & Permissions] summary: Remove a Role assignment (idempotent) x-substratal-phase: mvp x-substratal-destructive: always responses: + "400": { $ref: "#/components/responses/BadRequest" } "204": { description: Removed } "403": { $ref: "#/components/responses/Forbidden" } "409": { $ref: "#/components/responses/Conflict" } @@ -1360,10 +1507,13 @@ paths: - $ref: "#/components/parameters/AppId" get: operationId: permissions.getEffective + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Roles & Permissions] summary: The live access check (always 200) x-substratal-phase: phase2 responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Effective permissions content: @@ -1380,11 +1530,13 @@ paths: summary: List your Organizations (all, with organizations.manage) x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" - { name: status, in: query, schema: { type: string, enum: [active, suspended] } } - { name: q, in: query, schema: { type: string } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of Organizations content: @@ -1396,6 +1548,7 @@ paths: summary: Create an Organization (any active User; caller becomes org_admin) x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IdempotencyKeyOptional" requestBody: required: true @@ -1420,10 +1573,13 @@ paths: - { name: id, in: path, required: true, schema: { type: string } } get: operationId: organizations.get + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Organizations] summary: Fetch one Organization x-substratal-phase: phase2 responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The Organization headers: @@ -1440,6 +1596,7 @@ paths: x-substratal-destructive: conditional x-substratal-destructive-when: "status becomes suspended" parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IfMatch" requestBody: content: @@ -1451,6 +1608,7 @@ paths: status: { type: string, enum: [active, suspended] } reason: { type: string, maxLength: 2000 } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Updated Organization content: @@ -1467,12 +1625,14 @@ paths: summary: List members x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" - { name: role, in: query, schema: { $ref: "#/components/schemas/OrganizationMemberRole" } } - { name: membership_status, in: query, schema: { $ref: "#/components/schemas/OrganizationMembershipStatus" } } - { name: q, in: query, schema: { type: string } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of members content: @@ -1480,6 +1640,8 @@ paths: schema: { $ref: "#/components/schemas/OrganizationMemberPage" } post: operationId: organizations.addMember + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Organizations] summary: Invite a member by email (org admin), or add one directly by user_id (organizations.manage) description: "By email, creates a pending membership (and an invited User if none exists) and emails an invitation; the response is identical whether or not the email already had an account. By user_id (organizations.manage only), the membership is active immediately. Never fails on a plan limit." @@ -1498,6 +1660,7 @@ paths: - required: [user_id] - required: [email] responses: + "400": { $ref: "#/components/responses/BadRequest" } "201": description: Member invited (pending) or added (active) content: @@ -1512,6 +1675,8 @@ paths: - { name: id, in: path, required: true, schema: { type: string } } post: operationId: organizations.acceptMembership + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Organizations] summary: Accept a pending membership (the invited User's own token) x-substratal-phase: phase2 @@ -1531,6 +1696,8 @@ paths: - { name: userId, in: path, required: true, description: "A usr_ id, or 'me' to leave", schema: { type: string } } patch: operationId: organizations.updateMember + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Organizations] summary: Change a member's role x-substratal-phase: phase2 @@ -1546,6 +1713,7 @@ paths: properties: role: { $ref: "#/components/schemas/OrganizationMemberRole" } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Updated member content: @@ -1554,11 +1722,14 @@ paths: "409": { $ref: "#/components/responses/Conflict" } delete: operationId: organizations.removeMember + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Organizations] summary: Remove a member or withdraw an invitation; or leave or decline (userId = me) x-substratal-phase: phase2 x-substratal-destructive: always responses: + "400": { $ref: "#/components/responses/BadRequest" } "204": { description: Removed } "403": { $ref: "#/components/responses/Forbidden" } "409": { $ref: "#/components/responses/Conflict" } @@ -1568,11 +1739,14 @@ paths: - { name: id, in: path, required: true, schema: { type: string } } get: operationId: organizations.getScimToken + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Organizations] summary: Show the Organization's SCIM token (never its secret) description: "org_admin or organizations.manage. 404 scim_token_not_found when SCIM isn't set up." x-substratal-phase: phase3 responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The token content: @@ -1582,6 +1756,8 @@ paths: "404": { $ref: "#/components/responses/NotFound" } post: operationId: organizations.createScimToken + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Organizations] summary: Create (or rotate) the Organization's SCIM token description: >- @@ -1590,6 +1766,7 @@ paths: organization.scim_token_created. x-substratal-phase: phase3 responses: + "400": { $ref: "#/components/responses/BadRequest" } "201": description: The new token, with its secret content: @@ -1599,11 +1776,14 @@ paths: "404": { $ref: "#/components/responses/NotFound" } delete: operationId: organizations.deleteScimToken + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Organizations] summary: Turn SCIM off for the Organization description: "org_admin or organizations.manage. The token stops working at once. Writes organization.scim_token_revoked." x-substratal-phase: phase3 responses: + "400": { $ref: "#/components/responses/BadRequest" } "204": { description: Revoked } "403": { $ref: "#/components/responses/Forbidden" } "404": { $ref: "#/components/responses/NotFound" } @@ -1618,6 +1798,7 @@ paths: description: Same filters as GET /entitlements, scoped to this Organization. x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" - { name: application_id, in: query, schema: { type: string } } @@ -1626,6 +1807,7 @@ paths: - { name: ends_before, in: query, schema: { type: string, format: date-time } } - { name: include_inactive, in: query, schema: { type: boolean } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of Entitlements content: @@ -1638,6 +1820,7 @@ paths: description: "Requires entitlements.manage (platform, or the Application's own confined key) or the Application's owner. Never the org's own admin." x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true @@ -1654,6 +1837,7 @@ paths: member_scope: { $ref: "#/components/schemas/EntitlementMemberScope" } member_overrides: { type: array, maxItems: 1000, items: { type: string } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "201": description: Org-wide Entitlement created content: @@ -1672,6 +1856,7 @@ paths: summary: List Tenants (your own; all with tenants.manage) x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" - { name: tier, in: query, schema: { $ref: "#/components/schemas/TenantTier" } } @@ -1679,6 +1864,7 @@ paths: - { name: owner_user_id, in: query, schema: { type: string } } - { name: owner_organization_id, in: query, schema: { type: string } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of Tenants content: @@ -1690,10 +1876,13 @@ paths: - { name: id, in: path, required: true, schema: { type: string } } get: operationId: tenants.get + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Tenancy] summary: Fetch one Tenant x-substratal-phase: phase2 responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A Tenant content: @@ -1710,9 +1899,11 @@ paths: summary: List tier-change requests for a Tenant x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of tier-change requests content: @@ -1720,6 +1911,8 @@ paths: schema: { $ref: "#/components/schemas/TenantTierChangeRequestPage" } post: operationId: tenants.requestTierChange + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Tenancy] summary: Open a tier-change request on a customer's behalf (tenants.manage) x-substratal-phase: phase2 @@ -1736,6 +1929,7 @@ paths: requested_region: { $ref: "#/components/schemas/Region" } reason: { type: string, maxLength: 2000 } responses: + "400": { $ref: "#/components/responses/BadRequest" } "201": description: Tier-change request created content: @@ -1751,10 +1945,13 @@ paths: - { name: requestId, in: path, required: true, schema: { type: string } } get: operationId: tenants.getTierChangeRequest + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Tenancy] summary: Fetch one tier-change request x-substratal-phase: phase2 responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The request content: @@ -1768,6 +1965,7 @@ paths: x-substratal-phase: phase2 x-substratal-destructive: always parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IfMatch" requestBody: required: true @@ -1781,6 +1979,7 @@ paths: scheduled_for: { type: string, format: date-time } notes: { type: string, maxLength: 2000 } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Updated request content: @@ -1796,10 +1995,13 @@ paths: - $ref: "#/components/parameters/UserIdOrMe" get: operationId: billing.getUserSubscription + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Billing] summary: A User's plan, what covers it, and their own subscription (self or billing.manage) x-substratal-phase: phase2 responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The User's plan and individual subscription content: @@ -1815,6 +2017,7 @@ paths: summary: Start a Stripe Checkout to subscribe yourself to Standard (User token) x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true @@ -1835,6 +2038,8 @@ paths: /users/me/billing/portal-sessions: post: operationId: billing.createUserPortalSession + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Billing] summary: Open the Stripe Customer Portal for your own subscription (User token) x-substratal-phase: phase2 @@ -1858,10 +2063,13 @@ paths: - { name: id, in: path, required: true, schema: { type: string } } get: operationId: billing.getOrganizationSubscription + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Billing] summary: An Organization's plan and Standard subscription (org admin or billing.manage) x-substratal-phase: phase2 responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The Organization's plan and subscription headers: @@ -1877,6 +2085,7 @@ paths: summary: Choose which members the Organization's subscription covers (org admin) x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IfMatch" requestBody: required: true @@ -1888,6 +2097,7 @@ paths: member_scope: { $ref: "#/components/schemas/EntitlementMemberScope" } member_overrides: { type: [array, "null"], maxItems: 1000, items: { type: string } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Updated headers: @@ -1909,6 +2119,7 @@ paths: summary: Start a Stripe Checkout to subscribe the Organization to Standard (org admin, User token) x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IdempotencyKey" requestBody: required: true @@ -1939,6 +2150,8 @@ paths: - { name: id, in: path, required: true, schema: { type: string } } post: operationId: billing.createOrganizationPortalSession + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Billing] summary: Open the Stripe Customer Portal for the Organization's subscription (org admin or billing.manage, User token) x-substratal-phase: phase2 @@ -1968,6 +2181,7 @@ paths: summary: Query the audit log (hot window only) x-substratal-phase: mvp parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" - { name: target_user_id, in: query, schema: { type: string } } @@ -1986,6 +2200,7 @@ paths: - { name: since, in: query, schema: { type: string, format: date-time } } - { name: until, in: query, schema: { type: string, format: date-time } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of Audit Events headers: @@ -2002,10 +2217,13 @@ paths: - { name: id, in: path, required: true, schema: { type: string } } get: operationId: audit.get + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Audit] summary: Fetch one Audit Event x-substratal-phase: mvp responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The event content: @@ -2022,10 +2240,12 @@ paths: summary: List subscriptions visible to the caller x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" - { name: application_id, in: query, schema: { type: string } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of Webhook subscriptions content: @@ -2033,6 +2253,8 @@ paths: schema: { $ref: "#/components/schemas/WebhookPage" } post: operationId: webhooks.create + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Webhooks] summary: Subscribe a URL to event types x-substratal-phase: phase2 @@ -2055,6 +2277,7 @@ paths: description: { type: string, maxLength: 255 } scope: { type: string, description: "An application_id, or 'platform' (webhooks.manage only). Required for an owner's User token or a webhooks.manage caller; optional for an app key, where it defaults to (and must equal) the key's own Application." } responses: + "400": { $ref: "#/components/responses/BadRequest" } "201": description: Webhook created — includes the secret once content: @@ -2068,10 +2291,13 @@ paths: - { name: id, in: path, required: true, schema: { type: string } } get: operationId: webhooks.get + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Webhooks] summary: Fetch one subscription x-substratal-phase: phase2 responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The subscription content: @@ -2080,6 +2306,8 @@ paths: "404": { $ref: "#/components/responses/NotFound" } patch: operationId: webhooks.update + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Webhooks] summary: Change url, events, description, api_version, or re-enable x-substratal-phase: phase2 @@ -2096,6 +2324,7 @@ paths: api_version: { type: string } status: { type: string, enum: [healthy] } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Updated subscription content: @@ -2104,11 +2333,14 @@ paths: "422": { $ref: "#/components/responses/Unprocessable" } delete: operationId: webhooks.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Webhooks] summary: Unsubscribe x-substratal-phase: phase2 x-substratal-destructive: always responses: + "400": { $ref: "#/components/responses/BadRequest" } "204": { description: Unsubscribed } "404": { $ref: "#/components/responses/NotFound" } @@ -2117,10 +2349,13 @@ paths: - { name: id, in: path, required: true, schema: { type: string } } post: operationId: webhooks.rotateSecret + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Webhooks] summary: Issue a new signing secret (24-hour overlap) x-substratal-phase: phase2 responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: New secret content: @@ -2137,10 +2372,13 @@ paths: - { name: id, in: path, required: true, schema: { type: string } } post: operationId: webhooks.sendTest + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Webhooks] summary: Send a webhook.test event now x-substratal-phase: phase2 responses: + "400": { $ref: "#/components/responses/BadRequest" } "202": description: Enqueued content: @@ -2159,12 +2397,14 @@ paths: summary: Recent delivery attempts (30-day log) x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" - { name: event_id, in: query, schema: { type: string } } - { name: status, in: query, schema: { type: string, enum: [pending, succeeded, failed] } } - { name: since, in: query, schema: { type: string, format: date-time } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of deliveries content: @@ -2182,10 +2422,13 @@ paths: - { name: deliveryId, in: path, required: true, schema: { type: string } } post: operationId: webhooks.redeliver + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Webhooks] summary: Re-send one past event (same event id) x-substratal-phase: phase2 responses: + "400": { $ref: "#/components/responses/BadRequest" } "202": { description: Enqueued } "404": { $ref: "#/components/responses/NotFound" } @@ -2196,11 +2439,14 @@ paths: - { name: id, in: path, required: true, schema: { type: string } } get: operationId: pushCredentials.list + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Notifications] summary: List the push providers an Application has configured (the owner, the app's own key, or applications.manage) description: "Never returns key material. At most one credential per provider, so the list isn't paginated." x-substratal-phase: phase2 responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Every configured provider content: @@ -2214,6 +2460,7 @@ paths: description: "private_key (apns) and service_account (fcm) are write-only and stored KMS-encrypted. For web_push the platform generates the VAPID key pair and returns its public half. An API Key gets 400 user_token_required. A second credential for the same provider is 409 push_credential_exists; material that doesn't parse is 422 invalid_push_credential." x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IdempotencyKeyOptional" requestBody: required: true @@ -2243,10 +2490,13 @@ paths: schema: { $ref: "#/components/schemas/PushProvider" } get: operationId: pushCredentials.get + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Notifications] summary: Fetch one provider's configuration x-substratal-phase: phase2 responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The credential headers: @@ -2262,6 +2512,7 @@ paths: description: "Writable fields are the provider's own create fields, plus status (only failed → active). Any change to key material also returns a failed credential to active. The VAPID key pair can't be rotated in place: delete and recreate the web_push credential." x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IfMatch" requestBody: required: true @@ -2280,6 +2531,8 @@ paths: "422": { $ref: "#/components/responses/Unprocessable" } delete: operationId: pushCredentials.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Notifications] summary: Remove a push provider (User token only) description: "Pending deliveries to that provider are skipped. Removing web_push deactivates every web_push device of the Application (inactive_reason credential_removed), because browser subscriptions are bound to the VAPID key." @@ -2300,6 +2553,7 @@ paths: description: "Only active devices unless status or include_inactive=true is passed. An app key sees only its own Application's devices." x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" - { name: application_id, in: query, schema: { type: string } } @@ -2307,6 +2561,7 @@ paths: - { name: status, in: query, schema: { $ref: "#/components/schemas/DeviceStatus" } } - { name: include_inactive, in: query, schema: { type: boolean, default: false } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of Devices content: @@ -2315,6 +2570,8 @@ paths: "404": { $ref: "#/components/responses/NotFound" } post: operationId: devices.register + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Notifications] summary: Register a push device, or refresh an existing registration (self or the app's own key) description: "An idempotent upsert keyed on (application_id, provider, token). 201 for a new Device, 200 for a refresh of the caller's own. A token registered to a different User is moved: the old Device is deleted and a new one created (201). The User must have active access to application_id (403 entitlement_required). A User token ties the device to its session; an app key's registration has session_id null. At most 20 active devices per User per Application: a 21st deactivates the oldest (inactive_reason limit)." @@ -2325,6 +2582,7 @@ paths: application/json: schema: { $ref: "#/components/schemas/DeviceRegistration" } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Existing registration refreshed (and reactivated if it was inactive) content: @@ -2347,10 +2605,13 @@ paths: - { name: deviceId, in: path, required: true, schema: { type: string } } get: operationId: devices.get + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Notifications] summary: Fetch one device x-substratal-phase: phase2 responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The Device headers: @@ -2366,6 +2627,7 @@ paths: description: "Writing a token reactivates an inactive device. If the new token is already another Device of the same User and Application, that other registration is deleted." x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IfMatch" requestBody: required: true @@ -2373,6 +2635,7 @@ paths: application/json: schema: { $ref: "#/components/schemas/DeviceUpdate" } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The updated Device content: @@ -2383,12 +2646,15 @@ paths: "422": { $ref: "#/components/responses/Unprocessable" } delete: operationId: devices.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Notifications] summary: Unregister a device (self, the app's own key, or users.manage) description: "Hard delete. Pending deliveries to it are dropped. A users.manage delete of someone else's device writes the device.deleted Audit Event." x-substratal-phase: phase2 x-substratal-destructive: always responses: + "400": { $ref: "#/components/responses/BadRequest" } "204": { description: Unregistered } "404": { $ref: "#/components/responses/NotFound" } @@ -2398,6 +2664,8 @@ paths: - { name: deviceId, in: path, required: true, schema: { type: string } } post: operationId: devices.sendTest + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Notifications] summary: Send a test push to one device and wait for the provider's answer (self or the app's own key) description: "Synchronous, up to 10 seconds. Ignores the push preference, works on inactive devices, isn't stored in the inbox, and is limited to 10 per device per hour. 409 push_not_configured if the Application has no credential for the device's provider." @@ -2411,6 +2679,7 @@ paths: title: { type: string, minLength: 1, maxLength: 120 } body: { type: [string, "null"], maxLength: 1000 } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The provider's answer content: @@ -2430,6 +2699,7 @@ paths: description: "Newest first. Archived notifications are excluded unless state=archived or include_inactive=true. An app key sees only its own Application's notifications, never platform alerts." x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" - { name: state, in: query, schema: { $ref: "#/components/schemas/NotificationState" } } @@ -2441,6 +2711,7 @@ paths: - { name: created_before, in: query, schema: { type: string, format: date-time } } - { name: include_inactive, in: query, schema: { type: boolean, default: false } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of Notifications (without deliveries) content: @@ -2454,6 +2725,7 @@ paths: description: "Always creates the notification in the User's inbox; push.status says what happened to the push. The recipient must have active access to the Application (403 entitlement_required). The whole push payload must fit in 4 KB (422 notification_too_large). Categories under security., account., and billing. are reserved for platform alerts (422 reserved_category). Over 60 notifications per User per Application per hour is 429 rate_limited with details.scope recipient." x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IdempotencyKeyOptional" requestBody: required: true @@ -2461,6 +2733,7 @@ paths: application/json: schema: { $ref: "#/components/schemas/NotificationCreate" } responses: + "400": { $ref: "#/components/responses/BadRequest" } "201": description: Notification created; push queued or skipped content: @@ -2481,9 +2754,11 @@ paths: summary: Count unread notifications, in total and per Application (self or the app's own key) x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - { name: application_id, in: query, schema: { type: string } } - { name: source, in: query, schema: { $ref: "#/components/schemas/NotificationSource" } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Unread, unarchived counts content: @@ -2496,6 +2771,8 @@ paths: - $ref: "#/components/parameters/UserIdOrMe" post: operationId: notifications.markRead + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Notifications] summary: Mark every unread notification matching the filters as read (self or the app's own key) description: "Every filter is optional; an empty body marks everything the caller can see. before limits it to notifications created at or before that time. An app key's application_id is forced to its own." @@ -2511,6 +2788,7 @@ paths: category: { type: string } before: { type: string, format: date-time } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: How many were marked read content: @@ -2528,10 +2806,13 @@ paths: - { name: notificationId, in: path, required: true, schema: { type: string } } get: operationId: notifications.get + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [Notifications] summary: Fetch one notification, with its per-device deliveries x-substratal-phase: phase2 responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The Notification with deliveries (empty after 30 days) headers: @@ -2546,6 +2827,7 @@ paths: summary: Mark one notification read, unread, or archived (self or the app's own key) x-substratal-phase: phase2 parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/IfMatch" requestBody: required: true @@ -2557,6 +2839,7 @@ paths: properties: state: { $ref: "#/components/schemas/NotificationState" } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The updated Notification content: @@ -2575,6 +2858,7 @@ paths: summary: List keys the caller may manage x-substratal-phase: mvp parameters: + - $ref: "#/components/parameters/ApiVersion" - $ref: "#/components/parameters/Limit" - $ref: "#/components/parameters/Cursor" - { name: scope, in: query, schema: { type: string } } @@ -2582,6 +2866,7 @@ paths: - { name: intended_use, in: query, schema: { type: string, enum: [service, agent] } } - { name: include_inactive, in: query, description: "Include revoked and expired keys", schema: { type: boolean, default: false } } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: A page of API Keys content: @@ -2589,6 +2874,8 @@ paths: schema: { $ref: "#/components/schemas/ApiKeyPage" } post: operationId: apiKeys.create + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [API Keys] summary: Create a key (api_keys.manage, or the Application's owner for an app-scoped key) x-substratal-phase: mvp @@ -2598,6 +2885,7 @@ paths: application/json: schema: { $ref: "#/components/schemas/ApiKeyWrite" } responses: + "400": { $ref: "#/components/responses/BadRequest" } "201": description: Key created — includes the secret once content: @@ -2611,10 +2899,13 @@ paths: - { name: id, in: path, required: true, schema: { type: string } } get: operationId: apiKeys.get + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [API Keys] summary: Fetch one key (never the secret) x-substratal-phase: mvp responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: The key content: @@ -2623,6 +2914,8 @@ paths: "404": { $ref: "#/components/responses/NotFound" } patch: operationId: apiKeys.update + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [API Keys] summary: Change name, permissions, or restrict_destructive x-substratal-phase: mvp @@ -2639,6 +2932,7 @@ paths: permissions: { type: array, items: { type: string } } restrict_destructive: { type: boolean } responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: Updated key content: @@ -2648,11 +2942,14 @@ paths: "422": { $ref: "#/components/responses/Unprocessable" } delete: operationId: apiKeys.delete + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [API Keys] summary: Revoke permanently x-substratal-phase: mvp x-substratal-destructive: always responses: + "400": { $ref: "#/components/responses/BadRequest" } "204": { description: Revoked } "404": { $ref: "#/components/responses/NotFound" } @@ -2661,11 +2958,14 @@ paths: - { name: id, in: path, required: true, schema: { type: string } } post: operationId: apiKeys.rotate + parameters: + - $ref: "#/components/parameters/ApiVersion" tags: [API Keys] summary: Issue a new secret, invalidating the old one immediately (no overlap) x-substratal-phase: mvp x-substratal-destructive: always responses: + "400": { $ref: "#/components/responses/BadRequest" } "200": description: New secret issued content: @@ -2689,6 +2989,16 @@ components: schema: { type: string } parameters: + ApiVersion: + name: Substratal-Version + in: header + required: true + description: >- + The API version this request is written against (Conventions → Versioning). + Required on every operation except GET /health and POST /auth/oauth/token; + a missing value returns 400 api_version_required, an unknown one 400 + unsupported_api_version. Every versioned response echoes it. + schema: { type: string, enum: ["1"] } Limit: name: limit in: query diff --git a/docs/openapi/index.md b/docs/openapi/index.md index 74a8ff1..92451e6 100644 --- a/docs/openapi/index.md +++ b/docs/openapi/index.md @@ -131,7 +131,7 @@ To explore by hand, import `openapi.yaml` into Postman, Insomnia, Bruno, or any ## Versioning and change policy -The API version is the `Substratal-Version` request header (currently `1`), not part of the base URL. The document doesn't declare it on each operation, so configure a generated client to send it as a default header; the Interactive Reference sends it for you. `info.version` in the document (currently a `1.0.0-draft` pre-release) is the version of the document itself. +The API version is the `Substratal-Version` request header (currently `1`), not part of the base URL. Every versioned operation declares it as the shared `ApiVersion` header parameter (`components.parameters.ApiVersion`), so generated clients include it, and the Interactive Reference sends it for you. Most generators let you set it once as a default header instead of passing it per call. `info.version` in the document (currently a `1.0.0-draft` pre-release) is the version of the document itself. - **Additive changes** (new optional fields, new operations, new enum values) ship within the current version. Write clients to ignore fields they don't know, and to handle enum values they don't recognize. - **Breaking changes** ship as the next version number, and the previous version keeps working for at least 6 months. See [Non-Functional Requirements → Versioning](../non-functional-requirements/#versioning) for what counts as breaking. diff --git a/internal/api/gen/gen.go b/internal/api/gen/gen.go index 5aae124..66e0fce 100644 --- a/internal/api/gen/gen.go +++ b/internal/api/gen/gen.go @@ -1750,6 +1750,21 @@ func (e WebhookWithSecretStatus) Valid() bool { } } +// Defines values for ApiVersion. +const ( + N1 ApiVersion = "1" +) + +// Valid indicates whether the value is a known member of the ApiVersion enum. +func (e ApiVersion) Valid() bool { + switch e { + case N1: + return true + default: + return false + } +} + // Defines values for ApiKeysListParamsMode. const ( ApiKeysListParamsModeLive ApiKeysListParamsMode = "live" @@ -3433,6 +3448,9 @@ type WebhookWithSecret struct { // WebhookWithSecretStatus defines model for WebhookWithSecret.Status. type WebhookWithSecretStatus string +// ApiVersion defines model for ApiVersion. +type ApiVersion string + // AppId defines model for AppId. type AppId = string @@ -10288,127 +10306,127 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H ErrorHandlerFunc: options.ErrorHandlerFunc, } - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/health", wrapper.HealthCheck) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/signup", wrapper.AuthSignup) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api-keys", wrapper.ApiKeysList) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api-keys", wrapper.ApiKeysCreate) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/api-keys/{id}", wrapper.ApiKeysDelete) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api-keys/{id}", wrapper.ApiKeysGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/api-keys/{id}", wrapper.ApiKeysUpdate) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api-keys/{id}/rotate", wrapper.ApiKeysRotate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/applications", wrapper.ApplicationsList) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/applications", wrapper.ApplicationsCreate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/applications/{id}", wrapper.ApplicationsGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/applications/{id}", wrapper.ApplicationsUpdate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/applications/{id}/push-credentials", wrapper.PushCredentialsList) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/applications/{id}/push-credentials", wrapper.PushCredentialsCreate) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/applications/{id}/push-credentials/{provider}", wrapper.PushCredentialsDelete) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/applications/{id}/push-credentials/{provider}", wrapper.PushCredentialsGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/applications/{id}/push-credentials/{provider}", wrapper.PushCredentialsUpdate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/audit-events", wrapper.AuditList) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/audit-events/{id}", wrapper.AuditGet) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/app-tokens", wrapper.AuthCreateAppToken) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/email/verify", wrapper.AuthVerifyEmail) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/email/verify/resend", wrapper.AuthResendEmailVerification) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/invitations/accept", wrapper.AuthAcceptInvitation) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/login", wrapper.AuthLogin) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/mfa/verify", wrapper.AuthVerifyMfa) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/token/refresh", wrapper.AuthRefreshToken) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/logout", wrapper.AuthLogout) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/app-tokens", wrapper.AuthCreateAppToken) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/mfa/verify", wrapper.AuthVerifyMfa) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/oauth/authorization-codes", wrapper.AuthCreateAuthorizationCode) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/oauth/token", wrapper.AuthExchangeOAuthToken) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/email/verify", wrapper.AuthVerifyEmail) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/email/verify/resend", wrapper.AuthResendEmailVerification) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/password/forgot", wrapper.AuthForgotPassword) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/password/reset", wrapper.AuthResetPassword) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/invitations/accept", wrapper.AuthAcceptInvitation) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/signup", wrapper.AuthSignup) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/sso/{provider}/callback", wrapper.AuthSsoCallback) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/password", wrapper.AuthChangePassword) - m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/users/{id}/mfa/totp", wrapper.AuthResetMfaTotp) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/mfa/totp", wrapper.AuthEnrollMfaTotp) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/mfa/totp/confirm", wrapper.AuthConfirmMfaTotp) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/mfa/totp/disable", wrapper.AuthDisableMfaTotp) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/sessions", wrapper.AuthListSessions) - m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/users/{id}/sessions/{sessionId}", wrapper.AuthRevokeSession) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users", wrapper.UsersList) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users", wrapper.UsersCreate) - m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/users/{id}", wrapper.UsersDelete) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}", wrapper.UsersGet) - m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/users/{id}", wrapper.UsersUpdate) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/suspend", wrapper.UsersSuspend) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/invitation", wrapper.UsersResendInvitation) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/export", wrapper.UsersExport) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/erasure-requests", wrapper.UsersRequestErasure) - m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/users/{id}/erasure-requests/current", wrapper.UsersCancelErasure) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/profile", wrapper.ProfilesGet) - m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/users/{id}/profile", wrapper.ProfilesUpdate) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/settings", wrapper.SettingsGet) - m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/users/{id}/settings", wrapper.SettingsUpdate) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/apps/{appId}/profile", wrapper.AppProfilesGet) - m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/users/{id}/apps/{appId}/profile", wrapper.AppProfilesUpdate) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/apps/{appId}/settings", wrapper.AppSettingsGet) - m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/users/{id}/apps/{appId}/settings", wrapper.AppSettingsUpdate) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/applications", wrapper.ApplicationsList) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/applications", wrapper.ApplicationsCreate) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/applications/{id}", wrapper.ApplicationsGet) - m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/applications/{id}", wrapper.ApplicationsUpdate) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/entitlements", wrapper.EntitlementsListForUser) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/entitlements", wrapper.EntitlementsGrant) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/auth/token/refresh", wrapper.AuthRefreshToken) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/entitlements", wrapper.EntitlementsList) m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/entitlements/{id}", wrapper.EntitlementsDelete) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/entitlements/{id}", wrapper.EntitlementsGet) m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/entitlements/{id}", wrapper.EntitlementsUpdate) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/permissions", wrapper.PermissionsList) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/roles", wrapper.RolesList) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/roles", wrapper.RolesCreate) - m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/roles/{id}", wrapper.RolesDelete) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/roles/{id}", wrapper.RolesGet) - m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/roles/{id}", wrapper.RolesUpdate) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/roles", wrapper.RolesListForUser) - m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/users/{id}/roles/{roleId}", wrapper.RolesRemove) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/roles/{roleId}", wrapper.RolesAssign) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/apps/{appId}/effective-permissions", wrapper.PermissionsGetEffective) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/health", wrapper.HealthCheck) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/organizations", wrapper.OrganizationsList) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/organizations", wrapper.OrganizationsCreate) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/organizations/{id}", wrapper.OrganizationsGet) m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/organizations/{id}", wrapper.OrganizationsUpdate) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/organizations/{id}/billing/checkout-sessions", wrapper.BillingCreateOrganizationCheckoutSession) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/organizations/{id}/billing/portal-sessions", wrapper.BillingCreateOrganizationPortalSession) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/organizations/{id}/entitlements", wrapper.OrganizationsListEntitlements) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/organizations/{id}/entitlements", wrapper.OrganizationsGrantEntitlement) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/organizations/{id}/members", wrapper.OrganizationsListMembers) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/organizations/{id}/members", wrapper.OrganizationsAddMember) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/organizations/{id}/members/me/accept", wrapper.OrganizationsAcceptMembership) m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/organizations/{id}/members/{userId}", wrapper.OrganizationsRemoveMember) m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/organizations/{id}/members/{userId}", wrapper.OrganizationsUpdateMember) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/organizations/{id}/members/me/accept", wrapper.OrganizationsAcceptMembership) m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/organizations/{id}/scim-token", wrapper.OrganizationsDeleteScimToken) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/organizations/{id}/scim-token", wrapper.OrganizationsGetScimToken) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/organizations/{id}/scim-token", wrapper.OrganizationsCreateScimToken) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/organizations/{id}/entitlements", wrapper.OrganizationsListEntitlements) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/organizations/{id}/entitlements", wrapper.OrganizationsGrantEntitlement) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/organizations/{id}/subscription", wrapper.BillingGetOrganizationSubscription) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/organizations/{id}/subscription", wrapper.BillingUpdateOrganizationSubscription) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/permissions", wrapper.PermissionsList) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/roles", wrapper.RolesList) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/roles", wrapper.RolesCreate) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/roles/{id}", wrapper.RolesDelete) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/roles/{id}", wrapper.RolesGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/roles/{id}", wrapper.RolesUpdate) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/tenants", wrapper.TenantsList) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/tenants/{id}", wrapper.TenantsGet) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/tenants/{id}/tier-change-requests", wrapper.TenantsListTierChangeRequests) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/tenants/{id}/tier-change-requests", wrapper.TenantsRequestTierChange) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/tenants/{id}/tier-change-requests/{requestId}", wrapper.TenantsGetTierChangeRequest) m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/tenants/{id}/tier-change-requests/{requestId}", wrapper.TenantsUpdateTierChangeRequest) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/subscription", wrapper.BillingGetUserSubscription) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/me/billing/checkout-sessions", wrapper.BillingCreateUserCheckoutSession) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/me/billing/portal-sessions", wrapper.BillingCreateUserPortalSession) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/organizations/{id}/subscription", wrapper.BillingGetOrganizationSubscription) - m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/organizations/{id}/subscription", wrapper.BillingUpdateOrganizationSubscription) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/organizations/{id}/billing/checkout-sessions", wrapper.BillingCreateOrganizationCheckoutSession) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/organizations/{id}/billing/portal-sessions", wrapper.BillingCreateOrganizationPortalSession) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/audit-events", wrapper.AuditList) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/audit-events/{id}", wrapper.AuditGet) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/webhooks", wrapper.WebhooksList) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/webhooks", wrapper.WebhooksCreate) - m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/webhooks/{id}", wrapper.WebhooksDelete) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/webhooks/{id}", wrapper.WebhooksGet) - m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/webhooks/{id}", wrapper.WebhooksUpdate) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/webhooks/{id}/rotate-secret", wrapper.WebhooksRotateSecret) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/webhooks/{id}/test", wrapper.WebhooksSendTest) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/webhooks/{id}/deliveries", wrapper.WebhooksListDeliveries) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/webhooks/{id}/deliveries/{deliveryId}/redeliver", wrapper.WebhooksRedeliver) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/applications/{id}/push-credentials", wrapper.PushCredentialsList) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/applications/{id}/push-credentials", wrapper.PushCredentialsCreate) - m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/applications/{id}/push-credentials/{provider}", wrapper.PushCredentialsDelete) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/applications/{id}/push-credentials/{provider}", wrapper.PushCredentialsGet) - m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/applications/{id}/push-credentials/{provider}", wrapper.PushCredentialsUpdate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users", wrapper.UsersList) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users", wrapper.UsersCreate) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/users/{id}", wrapper.UsersDelete) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}", wrapper.UsersGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/users/{id}", wrapper.UsersUpdate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/apps/{appId}/effective-permissions", wrapper.PermissionsGetEffective) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/apps/{appId}/profile", wrapper.AppProfilesGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/users/{id}/apps/{appId}/profile", wrapper.AppProfilesUpdate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/apps/{appId}/settings", wrapper.AppSettingsGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/users/{id}/apps/{appId}/settings", wrapper.AppSettingsUpdate) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/devices", wrapper.DevicesList) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/devices", wrapper.DevicesRegister) m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/users/{id}/devices/{deviceId}", wrapper.DevicesDelete) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/devices/{deviceId}", wrapper.DevicesGet) m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/users/{id}/devices/{deviceId}", wrapper.DevicesUpdate) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/devices/{deviceId}/test", wrapper.DevicesSendTest) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/entitlements", wrapper.EntitlementsListForUser) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/entitlements", wrapper.EntitlementsGrant) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/erasure-requests", wrapper.UsersRequestErasure) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/users/{id}/erasure-requests/current", wrapper.UsersCancelErasure) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/export", wrapper.UsersExport) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/invitation", wrapper.UsersResendInvitation) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/users/{id}/mfa/totp", wrapper.AuthResetMfaTotp) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/mfa/totp", wrapper.AuthEnrollMfaTotp) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/mfa/totp/confirm", wrapper.AuthConfirmMfaTotp) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/mfa/totp/disable", wrapper.AuthDisableMfaTotp) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/notifications", wrapper.NotificationsList) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/notifications", wrapper.NotificationsSend) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/notifications/unread-count", wrapper.NotificationsUnreadCount) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/notifications/mark-read", wrapper.NotificationsMarkRead) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/notifications/{notificationId}", wrapper.NotificationsGet) m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/users/{id}/notifications/{notificationId}", wrapper.NotificationsUpdate) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api-keys", wrapper.ApiKeysList) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api-keys", wrapper.ApiKeysCreate) - m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/api-keys/{id}", wrapper.ApiKeysDelete) - m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/api-keys/{id}", wrapper.ApiKeysGet) - m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/api-keys/{id}", wrapper.ApiKeysUpdate) - m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/api-keys/{id}/rotate", wrapper.ApiKeysRotate) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/notifications/mark-read", wrapper.NotificationsMarkRead) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/notifications/unread-count", wrapper.NotificationsUnreadCount) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/password", wrapper.AuthChangePassword) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/profile", wrapper.ProfilesGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/users/{id}/profile", wrapper.ProfilesUpdate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/roles", wrapper.RolesListForUser) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/users/{id}/roles/{roleId}", wrapper.RolesRemove) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/roles/{roleId}", wrapper.RolesAssign) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/sessions", wrapper.AuthListSessions) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/users/{id}/sessions/{sessionId}", wrapper.AuthRevokeSession) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/settings", wrapper.SettingsGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/users/{id}/settings", wrapper.SettingsUpdate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/users/{id}/subscription", wrapper.BillingGetUserSubscription) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/{id}/suspend", wrapper.UsersSuspend) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/me/billing/checkout-sessions", wrapper.BillingCreateUserCheckoutSession) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/users/me/billing/portal-sessions", wrapper.BillingCreateUserPortalSession) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/webhooks", wrapper.WebhooksList) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/webhooks", wrapper.WebhooksCreate) + m.HandleFunc(http.MethodDelete+" "+options.BaseURL+"/webhooks/{id}", wrapper.WebhooksDelete) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/webhooks/{id}", wrapper.WebhooksGet) + m.HandleFunc(http.MethodPatch+" "+options.BaseURL+"/webhooks/{id}", wrapper.WebhooksUpdate) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/webhooks/{id}/deliveries", wrapper.WebhooksListDeliveries) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/webhooks/{id}/deliveries/{deliveryId}/redeliver", wrapper.WebhooksRedeliver) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/webhooks/{id}/rotate-secret", wrapper.WebhooksRotateSecret) + m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/webhooks/{id}/test", wrapper.WebhooksSendTest) return m } diff --git a/internal/api/gen/oapi-codegen.yaml b/internal/api/gen/oapi-codegen.yaml index eac8384..ff7b1d7 100644 --- a/internal/api/gen/oapi-codegen.yaml +++ b/internal/api/gen/oapi-codegen.yaml @@ -10,3 +10,5 @@ generate: std-http-server: true output-options: skip-prune: true + overlay: + path: overlay.yaml diff --git a/internal/api/gen/overlay.yaml b/internal/api/gen/overlay.yaml new file mode 100644 index 0000000..49395f9 --- /dev/null +++ b/internal/api/gen/overlay.yaml @@ -0,0 +1,13 @@ +# Applied to docs/openapi.yaml before generation only. Substratal-Version is +# enforced by the versioning middleware (internal/core/server.go) ahead of +# every generated route, so the generated handlers don't bind it: removing +# it here keeps every handler signature free of an unused parameter. The +# published spec still declares it on every operation. +overlay: 1.0.0 +info: + title: Generate without the version header + version: 1.0.0 +actions: + - target: "$.paths.*.*.parameters[?(@['$ref'] == '#/components/parameters/ApiVersion')]" + description: Drop the Substratal-Version header parameter from every operation + remove: true diff --git a/internal/core/server.go b/internal/core/server.go index ff2f427..124d3ae 100644 --- a/internal/core/server.go +++ b/internal/core/server.go @@ -143,13 +143,38 @@ func (s *Server) Handler() http.Handler { return s.middleware(versioned(mux)) } +// routeMethods are the methods any route uses, for working out Allow. +var routeMethods = []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} + +// allowedMethods lists the methods that do have a route at r's path, for a +// request whose own method doesn't (it fell through to the catch-all). +func allowedMethods(mux *http.ServeMux, r *http.Request) []string { + var out []string + for _, m := range routeMethods { + probe := r.Clone(r.Context()) + probe.Method = m + if _, p := mux.Handler(probe); p != "" && p != "/" { + out = append(out, m) + } + } + return out +} + // versioned requires Substratal-Version on every generated route before it // runs, so a missing version is reported ahead of any parameter or body -// error. Unmatched paths, the well-known endpoints, SCIM, and the -// unversioned routes pass through. +// error. A known path called with the wrong method gets 405 with Allow, +// rather than the catch-all 404. Unmatched paths, the well-known +// endpoints, SCIM, and the unversioned routes pass through. func versioned(mux *http.ServeMux) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, pattern := mux.Handler(r) + if pattern == "/" && r.Method != http.MethodOptions { + if allowed := allowedMethods(mux, r); len(allowed) > 0 { + w.Header().Set("Allow", strings.Join(allowed, ", ")) + httpx.WriteError(w, httpx.ErrMethodNotAllowed(allowed)) + return + } + } if pattern == "" || pattern == "/" || strings.HasPrefix(pattern, "GET /.well-known/") || strings.HasPrefix(pattern, "/scim/v2/") || unversioned[pattern] { mux.ServeHTTP(w, r) diff --git a/internal/core/versioning_test.go b/internal/core/versioning_test.go index d619a05..365aa03 100644 --- a/internal/core/versioning_test.go +++ b/internal/core/versioning_test.go @@ -44,6 +44,16 @@ func TestAPIVersionHeader(t *testing.T) { t.Fatal("oauth/token must not require Substratal-Version") } + // A known path with the wrong method is 405, naming the methods it has. + r = e.Do(testenv.Req{Method: "PUT", Path: "/health"}).ExpectErr(405, "method_not_allowed") + if r.Header.Get("Allow") != "GET" { + t.Fatalf("Allow = %q, want GET", r.Header.Get("Allow")) + } + r = e.Do(testenv.Req{Method: "PUT", Path: "/users/me", Token: u.Token}).ExpectErr(405, "method_not_allowed") + if r.Header.Get("Allow") != "GET, PATCH, DELETE" { + t.Fatalf("Allow = %q, want GET, PATCH, DELETE", r.Header.Get("Allow")) + } + // The /v1 prefix is gone, and an unknown path is still a 404. e.Get("/v1/users/me", u.Token).ExpectErr(404, "not_found") e.Do(testenv.Req{Method: "GET", Path: "/nope", Header: noVersion}).ExpectErr(404, "not_found") diff --git a/internal/httpx/errors.go b/internal/httpx/errors.go index 2202ce8..ce64270 100644 --- a/internal/httpx/errors.go +++ b/internal/httpx/errors.go @@ -63,9 +63,12 @@ var ( ErrUnsupportedAPIVersion = func(supported []string) *Error { return E(400, "unsupported_api_version", "The Substratal-Version header names a version this API doesn't serve.").With("supported", supported) } - ErrUnauthenticated = func() *Error { return E(401, "unauthenticated", "Missing, malformed, or expired credentials.") } - ErrSessionRevoked = func() *Error { return E(401, "session_revoked", "This session has been logged out or revoked.") } - ErrNotFound = func() *Error { return E(404, "not_found", "No route matches this path.") } + ErrUnauthenticated = func() *Error { return E(401, "unauthenticated", "Missing, malformed, or expired credentials.") } + ErrSessionRevoked = func() *Error { return E(401, "session_revoked", "This session has been logged out or revoked.") } + ErrNotFound = func() *Error { return E(404, "not_found", "No route matches this path.") } + ErrMethodNotAllowed = func(allowed []string) *Error { + return E(405, "method_not_allowed", "This path doesn't support that method.").With("allowed", allowed) + } ErrPayloadTooLarge = func() *Error { return E(413, "payload_too_large", "Request body exceeds 1 MB.") } ErrInternal = func() *Error { return E(500, "internal_error", "Unexpected server error. Quote the X-Request-Id to support.") diff --git a/plan-remaining.md b/plan-remaining.md index 195d0a6..eb3dcf6 100644 --- a/plan-remaining.md +++ b/plan-remaining.md @@ -25,7 +25,7 @@ As of 2026-10-20, every operation in `openapi.yaml` is implemented, including su The phase exit criteria have only been checked against the local devserver. Once production is up, check them there: -- **Phase 0:** `GET /health` returns `200` through the real Lambda and API Gateway path. (It returned `200` at the old `/v1/health` on 2026-10-20. Check `/health` again after the first deploy without the `/v1` prefix.) +- **Phase 0:** `GET /health` returns `200` through the real Lambda and API Gateway path. (Done: it returns `200` in production, verified 2026-10-22.) - **Phase 1:** the [Quickstart](https://compositecode.github.io/substratalapps.com/quickstart/) runs end to end against production: create a user, grant an app, check access, turn it off. Run `scripts/quickstart.sh` with `SUBSTRATAL_API=https://api.substratalapps.com` and a test-mode key. - **Phase 2:** a third-party-shaped app signs a user in through the embedded flow, receives `access.revoked` and kills the session, calls introspection, has its `settings_schema` validated and typed on write, and receives a push sent through its app key. A test-mode User and a test-mode Organization subscribe to Standard through Checkout (needs a Stripe test-mode key in a non-production environment, or temporarily in production), gain the standard Applications, and lapse back to Free.