{
  "openapi": "3.1.0",
  "info": {
    "title": "Gatehouse HTTP API",
    "version": "0.2.0",
    "summary": "SAML sign-in, SCIM provisioning, the security report card and service endpoints.",
    "description": "Gatehouse is a reference application for sign-in and access control. Its HTTP surface is the\nSAML 2.0 endpoints that sign people in, the SCIM 2.0 endpoints a directory calls to keep people in\nsync, a public report card that grades another site's security headers, and two small service\nendpoints. Everything a person does inside the product goes through Better Auth under\n`/api/auth/*` and server actions; those are not part of this contract.\n\n**Authentication.** It depends on the operation, and each one says which. SCIM takes a workspace\ntoken, `Authorization: Bearer gh_scim_…`, that an owner or admin creates in the console. The\ncron endpoint takes the deployment's `CRON_SECRET` the same way. The report card, the health\ncheck, the SAML and identity provider metadata and the SCIM discovery documents are public. SAML\nsign-in is authenticated by the identity provider's signature, not by a header.\n\n**Errors.** The JSON endpoints (`/api/health`, `/api/report-card`, `/api/cron/reseed`) answer\n`{ \"error\": { \"code\", \"message\" } }`: branch on `code`, show `message`. SCIM answers the RFC 7644\nerror body with `application/scim+json`. The browser-facing SAML endpoints do not answer JSON:\na failure redirects to `/sign-in?error=sso_<code>` and the sign-in page says what happened.\n\n**Rate limits.** Every public endpoint is limited, and a refused request is `429` with\n`Retry-After` in seconds. The report card allows 10 checks an hour per address.\nSCIM allows 120 requests a minute per token, 20 failed token checks a minute per\naddress and 60 discovery requests a minute per address. SAML sign-in starts are limited to\n10 a minute per address and responses to 20. The cron endpoint allows 6 runs an hour. An\naddress is the first entry of `X-Forwarded-For`, kept only as a keyed hash.\n\n**Caching.** Responses are `no-store` except the two metadata documents, which may be cached\nfor five minutes.\n\n**The shared demo.** This deployment's demo workspace is shared by every visitor and is reset\neach night. It accepts SCIM people only on the reserved `.example` domain, holds at most 25 of\nthem, and refuses changes to its three demo people."
  },
  "servers": [
    {
      "url": "https://auth.phoenixcodes.org",
      "description": "This deployment"
    }
  ],
  "tags": [
    {
      "name": "Service",
      "description": "Liveness and this description."
    },
    {
      "name": "Report card",
      "description": "Grade the security headers of a public site: one letter per header."
    },
    {
      "name": "SAML",
      "description": "Gatehouse as a SAML 2.0 service provider: its metadata, the start of a sign-in and the endpoint that receives the identity provider's response."
    },
    {
      "name": "Built-in IdP",
      "description": "The demo's own identity provider, in the same codebase. A real deployment would point a connection at Okta or Entra instead."
    },
    {
      "name": "SCIM",
      "description": "SCIM 2.0 Users (RFC 7643, RFC 7644): the calls a directory makes to create, update and deactivate people in one workspace."
    },
    {
      "name": "Maintenance",
      "description": "Called by the platform's scheduler, not by people."
    }
  ],
  "paths": {
    "/api/health": {
      "get": {
        "operationId": "getHealth",
        "tags": [
          "Service"
        ],
        "summary": "Check that the service is up",
        "description": "Answers 200 when the deployment can reach its database and 503 when it cannot. The database answer is remembered for five seconds. The body never carries an error message, a host name or a setting.",
        "security": [],
        "responses": {
          "200": {
            "description": "Everything passed.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                },
                "example": {
                  "status": "ok",
                  "version": "0.2.0",
                  "commit": "905d177",
                  "time": "2026-10-23T09:14:03.000Z",
                  "checks": {
                    "database": {
                      "status": "ok",
                      "latencyMs": 4
                    }
                  }
                }
              }
            }
          },
          "503": {
            "description": "The database did not answer in time.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Health"
                },
                "example": {
                  "status": "degraded",
                  "version": "0.2.0",
                  "commit": "905d177",
                  "time": "2026-10-23T09:14:03.000Z",
                  "checks": {
                    "database": {
                      "status": "unavailable",
                      "latencyMs": 3001
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/openapi.json": {
      "get": {
        "operationId": "getOpenApiDocument",
        "tags": [
          "Service"
        ],
        "summary": "Read this description",
        "description": "This document, as OpenAPI 3.1 JSON. Public, and readable from any origin (CORS `*`) so a tool can import it.",
        "security": [],
        "responses": {
          "200": {
            "description": "The OpenAPI document.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        }
      }
    },
    "/api/report-card": {
      "post": {
        "operationId": "gradeSite",
        "tags": [
          "Report card"
        ],
        "summary": "Grade a site's security headers",
        "description": "Fetches the address once from this server (following up to five redirects, each one checked again), grades nine security headers and returns one letter for each and one overall. The fetch refuses private and reserved network addresses, ports other than 80 and 443, and addresses with credentials, and gives up after 3 seconds a hop and 8 overall. Limited to 10 checks an hour per address. Nothing about the site is kept except the letters and a keyed hash of the address.",
        "security": [],
        "requestBody": {
          "required": true,
          "description": "Up to 4 KB of JSON.",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ReportCardRequest"
              },
              "example": {
                "url": "https://example.com"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The graded card.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReportCard"
                },
                "example": {
                  "requestedUrl": "https://example.com/",
                  "finalUrl": "https://example.com/",
                  "host": "example.com",
                  "status": 200,
                  "redirects": [],
                  "overall": {
                    "grade": "D",
                    "score": 56,
                    "note": null
                  },
                  "checks": [
                    {
                      "id": "hsts",
                      "title": "Strict-Transport-Security",
                      "grade": "B",
                      "score": 80,
                      "applicable": true,
                      "seen": [
                        "max-age=31536000"
                      ],
                      "finding": "https is required for six months or more, but not on subdomains.",
                      "fix": "Add includeSubDomains if every subdomain can be served over https.",
                      "example": "Strict-Transport-Security: max-age=63072000; includeSubDomains; preload"
                    },
                    {
                      "id": "csp",
                      "title": "Content-Security-Policy",
                      "grade": "F",
                      "score": 0,
                      "applicable": true,
                      "seen": [],
                      "finding": "There is no Content-Security-Policy, so a single injected script can run with the page's full power.",
                      "fix": "Start with a policy that allows only your own scripts and shuts the rest, then loosen it where it breaks something.",
                      "example": "Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-…'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'"
                    },
                    {
                      "id": "framing",
                      "title": "Framing (X-Frame-Options, frame-ancestors)",
                      "grade": "A",
                      "score": 94,
                      "applicable": true,
                      "seen": [
                        "X-Frame-Options: SAMEORIGIN"
                      ],
                      "finding": "Only this site can frame its own pages.",
                      "fix": "Add frame-ancestors to the Content-Security-Policy as well; it is the current standard and X-Frame-Options is the fallback.",
                      "example": "Content-Security-Policy: frame-ancestors 'none'\nX-Frame-Options: DENY"
                    },
                    {
                      "id": "nosniff",
                      "title": "X-Content-Type-Options",
                      "grade": "A+",
                      "score": 100,
                      "applicable": true,
                      "seen": [
                        "nosniff"
                      ],
                      "finding": "Browsers trust the declared content type and don't guess.",
                      "fix": null,
                      "example": null
                    },
                    {
                      "id": "referrer",
                      "title": "Referrer-Policy",
                      "grade": "A+",
                      "score": 100,
                      "applicable": true,
                      "seen": [
                        "strict-origin-when-cross-origin"
                      ],
                      "finding": "Other sites learn this site's name only; full addresses stay inside the site.",
                      "fix": null,
                      "example": null
                    },
                    {
                      "id": "permissions",
                      "title": "Permissions-Policy",
                      "grade": "D",
                      "score": 45,
                      "applicable": true,
                      "seen": [],
                      "finding": "Nothing limits which powerful browser features (camera, microphone, location) the page or its frames can ask for.",
                      "fix": "Send a Permissions-Policy that switches off every feature you don't use.",
                      "example": "Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=()"
                    },
                    {
                      "id": "cookies",
                      "title": "Cookies",
                      "grade": "A",
                      "score": 94,
                      "applicable": false,
                      "seen": [],
                      "finding": "The site set no cookies on this request, so there is nothing to grade.",
                      "fix": null,
                      "example": null
                    },
                    {
                      "id": "coop",
                      "title": "Cross-Origin-Opener-Policy",
                      "grade": "D",
                      "score": 45,
                      "applicable": true,
                      "seen": [],
                      "finding": "Pages that open this one (or that it opens) keep a handle on its window, which cross-site attacks can use.",
                      "fix": "Send Cross-Origin-Opener-Policy: same-origin unless a sign-in popup from another site needs the link.",
                      "example": "Cross-Origin-Opener-Policy: same-origin"
                    },
                    {
                      "id": "corp",
                      "title": "Cross-Origin-Resource-Policy",
                      "grade": "D",
                      "score": 45,
                      "applicable": true,
                      "seen": [],
                      "finding": "Other sites can load this response as an image or script and, with the right bug in the browser, read it.",
                      "fix": "Send Cross-Origin-Resource-Policy: same-origin, or same-site if sibling subdomains use it.",
                      "example": "Cross-Origin-Resource-Policy: same-origin"
                    }
                  ],
                  "gradedAt": "2026-10-23T09:14:03.000Z"
                }
              }
            }
          },
          "400": {
            "description": "The body is not JSON (`invalid_json`), the address cannot be used (`invalid_url`) or it points at a network this service will not contact (`blocked_address`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                },
                "example": {
                  "error": {
                    "code": "blocked_address",
                    "message": "That address points at a private network."
                  }
                }
              }
            }
          },
          "413": {
            "description": "The body is over 4 KB (`payload_too_large`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "415": {
            "description": "The body was not sent as `application/json` (`unsupported_media_type`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "422": {
            "description": "The body is not an object with a \"url\" string (`invalid_request`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "description": "Too many checks from this address (`rate_limited`).",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                },
                "example": {
                  "error": {
                    "code": "rate_limited",
                    "message": "Ten checks an hour are allowed."
                  }
                }
              }
            }
          },
          "502": {
            "description": "The site could not be fetched: `unreachable`, `tls_error`, `bad_redirect` or `too_many_redirects`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "504": {
            "description": "The site did not answer in time (`timeout`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    },
    "/api/saml/metadata": {
      "get": {
        "operationId": "getSamlServiceProviderMetadata",
        "tags": [
          "SAML"
        ],
        "summary": "Read Gatehouse's SAML metadata",
        "description": "The service provider's entity id (`https://auth.phoenixcodes.org/api/saml/metadata`), its assertion consumer service (`https://auth.phoenixcodes.org/api/saml/acs`, HTTP-POST) and the certificate that verifies its signed requests. Give this URL to an identity provider that can import metadata.",
        "security": [],
        "responses": {
          "200": {
            "description": "SAML 2.0 metadata.",
            "headers": {
              "Cache-Control": {
                "schema": {
                  "type": "string",
                  "examples": [
                    "public, max-age=300"
                  ]
                }
              }
            },
            "content": {
              "application/xml": {
                "schema": {
                  "type": "string"
                },
                "example": "<md:EntityDescriptor …/>"
              }
            }
          },
          "404": {
            "description": "This deployment has no service-provider key, so SAML is off.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string",
                  "description": "A sentence."
                },
                "example": "SAML is not configured on this deployment."
              }
            }
          }
        }
      }
    },
    "/api/saml/login": {
      "get": {
        "operationId": "startSamlSignIn",
        "tags": [
          "SAML"
        ],
        "summary": "Start a SAML sign-in",
        "description": "Builds a signed AuthnRequest for a connection, records it, and redirects the browser to the identity provider (HTTP-Redirect binding). A signed `gh_flow` cookie ties the response to this browser. Limited to 10 a minute per address. If the connection is unknown, switched off or rate-limited, the browser is sent to the sign-in page with `?error=`. Anywhere else a sign-in begins by following a link to this URL; there is no JSON.",
        "security": [],
        "parameters": [
          {
            "name": "tenant",
            "in": "query",
            "required": false,
            "description": "The workspace's slug. Defaults to the demo workspace.",
            "schema": {
              "type": "string",
              "pattern": "^[a-z0-9-]{1,60}$",
              "default": "penrose"
            }
          },
          {
            "name": "connection",
            "in": "query",
            "required": false,
            "description": "The connection's slug within the workspace.",
            "schema": {
              "type": "string",
              "pattern": "^[a-z0-9-]{1,60}$",
              "default": "penrose-idp"
            }
          },
          {
            "name": "next",
            "in": "query",
            "required": false,
            "description": "A path on this site to open after sign-in. Anything else is replaced by `/app`.",
            "schema": {
              "type": "string",
              "maxLength": 200,
              "examples": [
                "/app/sso"
              ]
            }
          }
        ],
        "responses": {
          "302": {
            "description": "To the identity provider's sign-on URL, with `SAMLRequest`, `SigAlg`, `Signature` and `RelayState` in the query.",
            "headers": {
              "Location": {
                "description": "The identity provider.",
                "schema": {
                  "type": "string"
                }
              },
              "Set-Cookie": {
                "$ref": "#/components/headers/SetCookie"
              }
            }
          },
          "303": {
            "description": "Back to `/sign-in?error=` with one of: sso_unavailable, sso_rate_limited, sso_unknown, sso_failed.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              }
            }
          }
        }
      }
    },
    "/api/saml/acs": {
      "post": {
        "operationId": "consumeSamlResponse",
        "tags": [
          "SAML"
        ],
        "summary": "Receive the identity provider's response",
        "description": "The assertion consumer service (HTTP-POST binding). The browser delivers the identity provider's signed response here. Gatehouse checks it against the request it recorded (signature, issuer, audience, recipient, time window, one use, the browser that asked), maps its attributes to a person and a role, provisions or links them and starts a session. Limited to 20 a minute per address. Both outcomes are redirects, so this is called by a browser, not by a client library.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "required": [
                  "SAMLResponse"
                ],
                "properties": {
                  "SAMLResponse": {
                    "type": "string",
                    "description": "The base64-encoded SAML response. Refused above 256 KB."
                  },
                  "RelayState": {
                    "type": "string",
                    "description": "Echoed from the request; only used as a hint."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "303": {
            "description": "Signed in: to the path saved when the sign-in began, with the session cookies. Refused: to `/sign-in?error=sso_<code>`, where the code is one of sso_unavailable, sso_rate_limited, sso_unknown, sso_failed, sso_bad_request, sso_unsolicited, sso_unknown_request, sso_browser_mismatch, sso_connection_unavailable, sso_invalid_xml, sso_invalid_signature, sso_issuer_mismatch, sso_audience_mismatch, sso_recipient_mismatch, sso_request_mismatch, sso_outside_validity, sso_idp_error, sso_replayed, sso_expired, sso_attributes_invalid, sso_provisioning_refused, sso_invalid_response.",
            "headers": {
              "Location": {
                "description": "The saved path or the sign-in page.",
                "schema": {
                  "type": "string"
                }
              },
              "Set-Cookie": {
                "$ref": "#/components/headers/SetCookie"
              }
            }
          },
          "404": {
            "description": "This deployment has no service-provider key, so SAML is off.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string",
                  "description": "A sentence."
                },
                "example": "SAML is not configured on this deployment."
              }
            }
          }
        }
      }
    },
    "/idp/metadata": {
      "get": {
        "operationId": "getBuiltInIdpMetadata",
        "tags": [
          "Built-in IdP"
        ],
        "summary": "Read the demo identity provider's metadata",
        "description": "The built-in provider's entity id (`https://auth.phoenixcodes.org/idp/metadata`), its sign-on URL (`https://auth.phoenixcodes.org/idp/sso`) and its signing certificate.",
        "security": [],
        "responses": {
          "200": {
            "description": "SAML 2.0 metadata.",
            "content": {
              "application/xml": {
                "schema": {
                  "type": "string"
                },
                "example": "<md:EntityDescriptor …/>"
              }
            }
          },
          "404": {
            "description": "The built-in provider is not configured on this deployment.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string",
                  "description": "A sentence."
                },
                "example": "The demo identity provider is not configured here."
              }
            }
          }
        }
      }
    },
    "/idp/sso": {
      "get": {
        "operationId": "receiveAuthnRequest",
        "tags": [
          "Built-in IdP"
        ],
        "summary": "Receive a signed sign-in request",
        "description": "The built-in provider's single sign-on endpoint (HTTP-Redirect binding). It verifies the request's signature against the service provider's certificate, checks the issuer, the assertion consumer URL, the destination and the request's age, then hands the browser to its own sign-in form with a short-lived signed ticket. Limited to 30 a minute per address.",
        "security": [],
        "parameters": [
          {
            "name": "SAMLRequest",
            "in": "query",
            "required": true,
            "description": "The deflated, base64-encoded AuthnRequest.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "SigAlg",
            "in": "query",
            "required": true,
            "description": "The signature algorithm URI.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "Signature",
            "in": "query",
            "required": true,
            "description": "The base64 signature over the query string.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "RelayState",
            "in": "query",
            "required": false,
            "description": "Echoed back to the service provider.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "303": {
            "description": "To `/idp/login?t=<ticket>`.",
            "headers": {
              "Location": {
                "schema": {
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "description": "No signed request in the query.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string",
                  "description": "A sentence."
                },
                "example": "A signed SAMLRequest is required."
              }
            }
          },
          "403": {
            "description": "The signature does not verify, or the request names the wrong service provider, destination or address, or is stale.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string",
                  "description": "A sentence."
                },
                "example": "The AuthnRequest signature could not be verified."
              }
            }
          },
          "404": {
            "description": "The built-in provider is not configured on this deployment.",
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string",
                  "description": "A sentence."
                },
                "example": "The demo identity provider is not configured here."
              }
            }
          },
          "429": {
            "description": "Too many requests from this address.",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "text/plain": {
                "schema": {
                  "type": "string",
                  "description": "A sentence."
                },
                "example": "Too many requests."
              }
            }
          }
        }
      }
    },
    "/api/scim/v2/ServiceProviderConfig": {
      "get": {
        "operationId": "getScimServiceProviderConfig",
        "tags": [
          "SCIM"
        ],
        "summary": "What this SCIM service supports",
        "description": "Patch and filter are supported; bulk, sort, ETags and password changes are not. No token is needed; requests are limited per address.",
        "security": [],
        "responses": {
          "200": {
            "description": "The document.",
            "content": {
              "application/scim+json": {
                "schema": {
                  "type": "object"
                },
                "example": {
                  "schemas": [
                    "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"
                  ],
                  "documentationUri": "https://auth.phoenixcodes.org/docs/scim",
                  "patch": {
                    "supported": true
                  },
                  "bulk": {
                    "supported": false,
                    "maxOperations": 0,
                    "maxPayloadSize": 0
                  },
                  "filter": {
                    "supported": true,
                    "maxResults": 200
                  },
                  "changePassword": {
                    "supported": false
                  },
                  "sort": {
                    "supported": false
                  },
                  "etag": {
                    "supported": false
                  },
                  "authenticationSchemes": [
                    {
                      "type": "oauthbearertoken",
                      "name": "Workspace token",
                      "description": "A token an owner or admin creates under Single sign-on, Provisioning. Send it as Authorization: Bearer <token>.",
                      "specUri": "https://www.rfc-editor.org/info/rfc6750",
                      "documentationUri": "https://auth.phoenixcodes.org/docs/scim#tokens",
                      "primary": true
                    }
                  ],
                  "meta": {
                    "resourceType": "ServiceProviderConfig",
                    "location": "https://auth.phoenixcodes.org/api/scim/v2/ServiceProviderConfig"
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/ScimRateLimited"
          }
        }
      }
    },
    "/api/scim/v2/ResourceTypes": {
      "get": {
        "operationId": "listScimResourceTypes",
        "tags": [
          "SCIM"
        ],
        "summary": "The resource types it holds",
        "description": "One: User. No token is needed; requests are limited per address.",
        "security": [],
        "responses": {
          "200": {
            "description": "The document.",
            "content": {
              "application/scim+json": {
                "schema": {
                  "type": "object"
                },
                "example": {
                  "schemas": [
                    "urn:ietf:params:scim:api:messages:2.0:ListResponse"
                  ],
                  "totalResults": 1,
                  "startIndex": 1,
                  "itemsPerPage": 1,
                  "Resources": [
                    {
                      "schemas": [
                        "urn:ietf:params:scim:schemas:core:2.0:ResourceType"
                      ],
                      "id": "User",
                      "name": "User",
                      "endpoint": "/Users",
                      "description": "A person with access to the workspace.",
                      "schema": "urn:ietf:params:scim:schemas:core:2.0:User",
                      "meta": {
                        "resourceType": "ResourceType",
                        "location": "https://auth.phoenixcodes.org/api/scim/v2/ResourceTypes/User"
                      }
                    }
                  ]
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/ScimRateLimited"
          }
        }
      }
    },
    "/api/scim/v2/Schemas": {
      "get": {
        "operationId": "listScimSchemas",
        "tags": [
          "SCIM"
        ],
        "summary": "The User schema",
        "description": "The attributes of the User resource that this service reads and writes. No token is needed; requests are limited per address.",
        "security": [],
        "responses": {
          "200": {
            "description": "The document.",
            "content": {
              "application/scim+json": {
                "schema": {
                  "type": "object"
                },
                "example": {
                  "schemas": [
                    "urn:ietf:params:scim:api:messages:2.0:ListResponse"
                  ],
                  "totalResults": 1,
                  "startIndex": 1,
                  "itemsPerPage": 1,
                  "Resources": [
                    {
                      "schemas": [
                        "urn:ietf:params:scim:schemas:core:2.0:Schema"
                      ],
                      "id": "urn:ietf:params:scim:schemas:core:2.0:User",
                      "name": "User",
                      "description": "A person with access to the workspace (the subset Gatehouse stores).",
                      "attributes": [
                        {
                          "name": "id",
                          "type": "string",
                          "multiValued": false,
                          "description": "Gatehouse's id for this person's membership of the workspace.",
                          "required": false,
                          "caseExact": true,
                          "mutability": "readOnly",
                          "returned": "always",
                          "uniqueness": "server"
                        },
                        {
                          "name": "externalId",
                          "type": "string",
                          "multiValued": false,
                          "description": "The directory's own id for this person.",
                          "required": false,
                          "caseExact": true,
                          "mutability": "readWrite",
                          "returned": "default",
                          "uniqueness": "none"
                        },
                        {
                          "name": "userName",
                          "type": "string",
                          "multiValued": false,
                          "description": "The e-mail address the person signs in with.",
                          "required": true,
                          "caseExact": false,
                          "mutability": "immutable",
                          "returned": "always",
                          "uniqueness": "server"
                        },
                        {
                          "name": "name",
                          "type": "complex",
                          "multiValued": false,
                          "description": "The person's name.",
                          "required": false,
                          "caseExact": false,
                          "mutability": "readWrite",
                          "returned": "default",
                          "uniqueness": "none",
                          "subAttributes": [
                            {
                              "name": "formatted",
                              "type": "string",
                              "multiValued": false,
                              "description": "The display name.",
                              "required": false,
                              "caseExact": false,
                              "mutability": "readWrite",
                              "returned": "default",
                              "uniqueness": "none"
                            }
                          ]
                        },
                        {
                          "name": "displayName",
                          "type": "string",
                          "multiValued": false,
                          "description": "The display name. The same string as name.formatted.",
                          "required": false,
                          "caseExact": false,
                          "mutability": "readWrite",
                          "returned": "default",
                          "uniqueness": "none"
                        },
                        {
                          "name": "emails",
                          "type": "complex",
                          "multiValued": true,
                          "description": "The sign-in address, as userName.",
                          "required": false,
                          "caseExact": false,
                          "mutability": "readOnly",
                          "returned": "default",
                          "uniqueness": "none",
                          "subAttributes": [
                            {
                              "name": "value",
                              "type": "string",
                              "multiValued": false,
                              "description": "The address.",
                              "required": false,
                              "caseExact": false,
                              "mutability": "readOnly",
                              "returned": "default",
                              "uniqueness": "none"
                            },
                            {
                              "name": "type",
                              "type": "string",
                              "multiValued": false,
                              "description": "Always work.",
                              "required": false,
                              "caseExact": false,
                              "mutability": "readOnly",
                              "returned": "default",
                              "uniqueness": "none"
                            },
                            {
                              "name": "primary",
                              "type": "boolean",
                              "multiValued": false,
                              "description": "Always true.",
                              "required": false,
                              "caseExact": false,
                              "mutability": "readOnly",
                              "returned": "default",
                              "uniqueness": "none"
                            }
                          ]
                        },
                        {
                          "name": "active",
                          "type": "boolean",
                          "multiValued": false,
                          "description": "False once the person is deactivated: they keep their history and cannot sign in to the workspace.",
                          "required": false,
                          "caseExact": false,
                          "mutability": "readWrite",
                          "returned": "default",
                          "uniqueness": "none"
                        }
                      ],
                      "meta": {
                        "resourceType": "Schema",
                        "location": "https://auth.phoenixcodes.org/api/scim/v2/Schemas/urn:ietf:params:scim:schemas:core:2.0:User"
                      }
                    }
                  ]
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/ScimRateLimited"
          }
        }
      }
    },
    "/api/scim/v2/Users": {
      "get": {
        "operationId": "listScimUsers",
        "tags": [
          "SCIM"
        ],
        "summary": "List or find people",
        "description": "People of the token's workspace, oldest first. `filter` takes comparisons joined by `and` and `or`, `not(…)`, parentheses and the operators `eq ne co sw ew pr` over `id`, `externalId`, `userName`, `displayName`, `name.formatted`, `emails.value` and `active` (at most 400 characters). Paging is 1-based: `startIndex` and `count` (default 100, at most 200).",
        "security": [
          {
            "scimToken": []
          }
        ],
        "parameters": [
          {
            "name": "filter",
            "in": "query",
            "required": false,
            "description": "A SCIM filter.",
            "schema": {
              "type": "string",
              "maxLength": 400,
              "examples": [
                "userName eq \"ada@acme.example\""
              ]
            }
          },
          {
            "name": "startIndex",
            "in": "query",
            "required": false,
            "description": "The 1-based index of the first match to return.",
            "schema": {
              "type": "integer",
              "minimum": 1,
              "default": 1
            }
          },
          {
            "name": "count",
            "in": "query",
            "required": false,
            "description": "How many people to return.",
            "schema": {
              "type": "integer",
              "minimum": 0,
              "maximum": 200,
              "default": 100
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A ListResponse.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              }
            },
            "content": {
              "application/scim+json": {
                "schema": {
                  "$ref": "#/components/schemas/ScimUserList"
                },
                "example": {
                  "schemas": [
                    "urn:ietf:params:scim:api:messages:2.0:ListResponse"
                  ],
                  "totalResults": 1,
                  "startIndex": 1,
                  "itemsPerPage": 1,
                  "Resources": [
                    {
                      "schemas": [
                        "urn:ietf:params:scim:schemas:core:2.0:User"
                      ],
                      "id": "mem_k3m9x2q7b4n8d1ya",
                      "externalId": "00u1abc",
                      "userName": "ada@acme.example",
                      "name": {
                        "formatted": "Ada Lovelace"
                      },
                      "displayName": "Ada Lovelace",
                      "emails": [
                        {
                          "value": "ada@acme.example",
                          "type": "work",
                          "primary": true
                        }
                      ],
                      "active": true,
                      "meta": {
                        "resourceType": "User",
                        "created": "2026-10-23T09:14:03.000Z",
                        "lastModified": "2026-10-23T09:14:03.000Z",
                        "location": "https://auth.phoenixcodes.org/api/scim/v2/Users/mem_k3m9x2q7b4n8d1ya"
                      }
                    }
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ScimBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/ScimUnauthorized"
          },
          "429": {
            "$ref": "#/components/responses/ScimRateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ScimInternal"
          }
        }
      },
      "post": {
        "operationId": "createScimUser",
        "tags": [
          "SCIM"
        ],
        "summary": "Create a person",
        "description": "Creates a person and adds them to the token's workspace. `userName` is the e-mail address they sign in with. A token never attaches an account that already exists: a taken address is `409 uniqueness`, because otherwise whoever controls a SAML connection could provision a victim's address and then assert it. The shared demo workspace accepts only `.example` addresses.",
        "security": [
          {
            "scimToken": []
          }
        ],
        "requestBody": {
          "required": true,
          "description": "Up to 16 KB, as `application/scim+json` or `application/json`.",
          "content": {
            "application/scim+json": {
              "schema": {
                "$ref": "#/components/schemas/ScimCreateUser"
              },
              "example": {
                "schemas": [
                  "urn:ietf:params:scim:schemas:core:2.0:User"
                ],
                "userName": "ada@acme.example",
                "displayName": "Ada Lovelace",
                "externalId": "00u1abc"
              }
            },
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ScimCreateUser"
              },
              "example": {
                "schemas": [
                  "urn:ietf:params:scim:schemas:core:2.0:User"
                ],
                "userName": "ada@acme.example",
                "displayName": "Ada Lovelace",
                "externalId": "00u1abc"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The person that was created.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              },
              "Location": {
                "description": "Where to read the person.",
                "schema": {
                  "type": "string"
                }
              }
            },
            "content": {
              "application/scim+json": {
                "schema": {
                  "$ref": "#/components/schemas/ScimUser"
                },
                "example": {
                  "schemas": [
                    "urn:ietf:params:scim:schemas:core:2.0:User"
                  ],
                  "id": "mem_k3m9x2q7b4n8d1ya",
                  "externalId": "00u1abc",
                  "userName": "ada@acme.example",
                  "name": {
                    "formatted": "Ada Lovelace"
                  },
                  "displayName": "Ada Lovelace",
                  "emails": [
                    {
                      "value": "ada@acme.example",
                      "type": "work",
                      "primary": true
                    }
                  ],
                  "active": true,
                  "meta": {
                    "resourceType": "User",
                    "created": "2026-10-23T09:14:03.000Z",
                    "lastModified": "2026-10-23T09:14:03.000Z",
                    "location": "https://auth.phoenixcodes.org/api/scim/v2/Users/mem_k3m9x2q7b4n8d1ya"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ScimBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/ScimUnauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ScimForbidden"
          },
          "409": {
            "$ref": "#/components/responses/ScimConflict"
          },
          "413": {
            "$ref": "#/components/responses/ScimTooLarge"
          },
          "415": {
            "$ref": "#/components/responses/ScimUnsupportedMediaType"
          },
          "429": {
            "$ref": "#/components/responses/ScimRateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ScimInternal"
          }
        }
      }
    },
    "/api/scim/v2/Users/{id}": {
      "get": {
        "operationId": "getScimUser",
        "tags": [
          "SCIM"
        ],
        "summary": "Read a person",
        "description": "One person of the token's workspace.",
        "security": [
          {
            "scimToken": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The person's id, as returned in `id`. It is only valid in the token's workspace.",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 64,
              "examples": [
                "mem_k3m9x2q7b4n8d1ya"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "The person.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              }
            },
            "content": {
              "application/scim+json": {
                "schema": {
                  "$ref": "#/components/schemas/ScimUser"
                },
                "example": {
                  "schemas": [
                    "urn:ietf:params:scim:schemas:core:2.0:User"
                  ],
                  "id": "mem_k3m9x2q7b4n8d1ya",
                  "externalId": "00u1abc",
                  "userName": "ada@acme.example",
                  "name": {
                    "formatted": "Ada Lovelace"
                  },
                  "displayName": "Ada Lovelace",
                  "emails": [
                    {
                      "value": "ada@acme.example",
                      "type": "work",
                      "primary": true
                    }
                  ],
                  "active": true,
                  "meta": {
                    "resourceType": "User",
                    "created": "2026-10-23T09:14:03.000Z",
                    "lastModified": "2026-10-23T09:14:03.000Z",
                    "location": "https://auth.phoenixcodes.org/api/scim/v2/Users/mem_k3m9x2q7b4n8d1ya"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ScimUnauthorized"
          },
          "404": {
            "$ref": "#/components/responses/ScimNotFound"
          },
          "429": {
            "$ref": "#/components/responses/ScimRateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ScimInternal"
          }
        }
      },
      "patch": {
        "operationId": "patchScimUser",
        "tags": [
          "SCIM"
        ],
        "summary": "Change a person",
        "description": "Changes `active`, `displayName` (or `name.formatted`) and `externalId`, as one to 20 operations applied together: a refused one leaves the person unchanged. Both the explicit-path shape and the shapes Okta (no path, an object of values) and Microsoft Entra (`\"True\"` and `\"False\"` as strings) send are accepted. `userName` is read-only. Setting `active` to false ends the person's sessions. The workspace's last active owner and the shared demo people cannot be deactivated.",
        "security": [
          {
            "scimToken": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The person's id, as returned in `id`. It is only valid in the token's workspace.",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 64,
              "examples": [
                "mem_k3m9x2q7b4n8d1ya"
              ]
            }
          }
        ],
        "requestBody": {
          "required": true,
          "description": "Up to 16 KB, as `application/scim+json` or `application/json`.",
          "content": {
            "application/scim+json": {
              "schema": {
                "$ref": "#/components/schemas/ScimPatch"
              },
              "example": {
                "schemas": [
                  "urn:ietf:params:scim:api:messages:2.0:PatchOp"
                ],
                "Operations": [
                  {
                    "op": "replace",
                    "path": "active",
                    "value": false
                  }
                ]
              }
            },
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ScimPatch"
              },
              "example": {
                "schemas": [
                  "urn:ietf:params:scim:api:messages:2.0:PatchOp"
                ],
                "Operations": [
                  {
                    "op": "replace",
                    "path": "active",
                    "value": false
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The person after the change.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              }
            },
            "content": {
              "application/scim+json": {
                "schema": {
                  "$ref": "#/components/schemas/ScimUser"
                },
                "example": {
                  "schemas": [
                    "urn:ietf:params:scim:schemas:core:2.0:User"
                  ],
                  "id": "mem_k3m9x2q7b4n8d1ya",
                  "externalId": "00u1abc",
                  "userName": "ada@acme.example",
                  "name": {
                    "formatted": "Ada Lovelace"
                  },
                  "displayName": "Ada Lovelace",
                  "emails": [
                    {
                      "value": "ada@acme.example",
                      "type": "work",
                      "primary": true
                    }
                  ],
                  "active": false,
                  "meta": {
                    "resourceType": "User",
                    "created": "2026-10-23T09:14:03.000Z",
                    "lastModified": "2026-10-23T09:14:03.000Z",
                    "location": "https://auth.phoenixcodes.org/api/scim/v2/Users/mem_k3m9x2q7b4n8d1ya"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/ScimBadRequest"
          },
          "401": {
            "$ref": "#/components/responses/ScimUnauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ScimForbidden"
          },
          "404": {
            "$ref": "#/components/responses/ScimNotFound"
          },
          "413": {
            "$ref": "#/components/responses/ScimTooLarge"
          },
          "415": {
            "$ref": "#/components/responses/ScimUnsupportedMediaType"
          },
          "429": {
            "$ref": "#/components/responses/ScimRateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ScimInternal"
          }
        }
      },
      "delete": {
        "operationId": "deleteScimUser",
        "tags": [
          "SCIM"
        ],
        "summary": "Deactivate a person",
        "description": "Deactivates; it does not delete. The row, the history and the audit trail stay, the person's sessions end, and a later `PATCH` can reactivate them. Repeating it is harmless.",
        "security": [
          {
            "scimToken": []
          }
        ],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "description": "The person's id, as returned in `id`. It is only valid in the token's workspace.",
            "schema": {
              "type": "string",
              "minLength": 1,
              "maxLength": 64,
              "examples": [
                "mem_k3m9x2q7b4n8d1ya"
              ]
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Deactivated.",
            "headers": {
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimitLimit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimitRemaining"
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/ScimUnauthorized"
          },
          "403": {
            "$ref": "#/components/responses/ScimForbidden"
          },
          "404": {
            "$ref": "#/components/responses/ScimNotFound"
          },
          "429": {
            "$ref": "#/components/responses/ScimRateLimited"
          },
          "500": {
            "$ref": "#/components/responses/ScimInternal"
          }
        }
      }
    },
    "/api/cron/reseed": {
      "get": {
        "operationId": "reseedDemo",
        "tags": [
          "Maintenance"
        ],
        "summary": "Reset the shared demo",
        "description": "Puts the demo workspace back to its fixtures, removes people provisioned into it and sweeps stale sign-in flows, requests and report-card rows. Vercel Cron calls it nightly with `Authorization: Bearer <CRON_SECRET>`. A deployment without a `CRON_SECRET` keeps the route closed. Limited to 6 runs an hour.",
        "security": [
          {
            "cronSecret": []
          }
        ],
        "responses": {
          "200": {
            "description": "Reset. A deployment with the demo switched off answers `{ ok: true, skipped }` instead.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReseedResult"
                },
                "example": {
                  "ok": true,
                  "removedTenants": [
                    "ten_penrose"
                  ],
                  "removedUsers": 0,
                  "swept": {
                    "flows": 3,
                    "requests": 3,
                    "reportCards": 0
                  },
                  "seeded": {
                    "tenants": 1,
                    "users": 3,
                    "memberships": 3,
                    "policies": 8,
                    "ssoConnections": 1,
                    "authEvents": 8
                  },
                  "ms": 412
                }
              }
            }
          },
          "401": {
            "description": "No bearer token, or the wrong one (`unauthorized`).",
            "headers": {
              "WWW-Authenticate": {
                "$ref": "#/components/headers/WwwAuthenticate"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                },
                "example": {
                  "error": {
                    "code": "unauthorized",
                    "message": "Send the cron secret as a bearer token."
                  }
                }
              }
            }
          },
          "429": {
            "description": "Run too often (`rate_limited`).",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "500": {
            "description": "The reset failed (`reseed_failed`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "503": {
            "description": "This deployment has no `CRON_SECRET`, so the route is closed (`not_configured`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      },
      "post": {
        "operationId": "reseedDemoPost",
        "tags": [
          "Maintenance"
        ],
        "summary": "Reset the shared demo (POST)",
        "description": "The same as `GET`, for callers that prefer to POST.",
        "security": [
          {
            "cronSecret": []
          }
        ],
        "responses": {
          "200": {
            "description": "Reset.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReseedResult"
                }
              }
            }
          },
          "401": {
            "description": "No bearer token, or the wrong one (`unauthorized`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "429": {
            "description": "Run too often (`rate_limited`).",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/RetryAfter"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "500": {
            "description": "The reset failed (`reseed_failed`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          },
          "503": {
            "description": "No `CRON_SECRET` (`not_configured`).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ApiError"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "scimToken": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "gh_scim_ token",
        "description": "A workspace token that an owner or admin creates under Single sign-on, Provisioning. 256 random bits, shown once, revocable. It acts inside its workspace only."
      },
      "cronSecret": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "CRON_SECRET",
        "description": "The deployment's shared secret for scheduled calls."
      }
    },
    "schemas": {
      "ApiError": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": {
                "type": "string",
                "description": "A stable, lower-case identifier. Branch on this, not on the message.",
                "examples": [
                  "rate_limited"
                ]
              },
              "message": {
                "type": "string",
                "description": "A sentence for a person. Wording may change."
              }
            },
            "required": [
              "code",
              "message"
            ],
            "additionalProperties": false
          }
        },
        "required": [
          "error"
        ],
        "additionalProperties": false
      },
      "Health": {
        "type": "object",
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ok",
              "degraded"
            ],
            "description": "`ok` when every check passed. `degraded` (with HTTP 503) when one failed."
          },
          "version": {
            "type": "string",
            "description": "The released version of this application."
          },
          "commit": {
            "description": "The short commit the deployment was built from, when known.",
            "type": [
              "string",
              "null"
            ]
          },
          "time": {
            "type": "string",
            "format": "date-time"
          },
          "checks": {
            "type": "object",
            "properties": {
              "database": {
                "type": "object",
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "ok",
                      "unavailable"
                    ]
                  },
                  "latencyMs": {
                    "type": "integer",
                    "minimum": 0,
                    "description": "Round trip of `select 1`."
                  }
                },
                "required": [
                  "status",
                  "latencyMs"
                ],
                "additionalProperties": false
              }
            },
            "required": [
              "database"
            ],
            "additionalProperties": false
          }
        },
        "required": [
          "status",
          "version",
          "commit",
          "time",
          "checks"
        ],
        "additionalProperties": false
      },
      "ReseedResult": {
        "type": "object",
        "properties": {
          "ok": {
            "type": "boolean",
            "const": true
          },
          "removedTenants": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Ids of the demo workspaces removed."
          },
          "removedUsers": {
            "type": "integer",
            "minimum": 0,
            "description": "Shared demo people created by provisioning since the last reset."
          },
          "swept": {
            "type": "object",
            "properties": {
              "flows": {
                "type": "integer",
                "minimum": 0
              },
              "requests": {
                "type": "integer",
                "minimum": 0
              },
              "reportCards": {
                "type": "integer",
                "minimum": 0
              }
            },
            "required": [
              "flows",
              "requests",
              "reportCards"
            ],
            "additionalProperties": false
          },
          "seeded": {
            "type": "object",
            "properties": {
              "tenants": {
                "type": "integer",
                "minimum": 0
              },
              "users": {
                "type": "integer",
                "minimum": 0
              },
              "memberships": {
                "type": "integer",
                "minimum": 0
              },
              "policies": {
                "type": "integer",
                "minimum": 0
              },
              "ssoConnections": {
                "type": "integer",
                "minimum": 0
              },
              "authEvents": {
                "type": "integer",
                "minimum": 0
              }
            },
            "required": [
              "tenants",
              "users",
              "memberships",
              "policies",
              "ssoConnections",
              "authEvents"
            ],
            "additionalProperties": false
          },
          "ms": {
            "type": "integer",
            "minimum": 0,
            "description": "How long the reset took."
          }
        },
        "required": [
          "ok",
          "removedTenants",
          "removedUsers",
          "swept",
          "seeded",
          "ms"
        ],
        "additionalProperties": false
      },
      "ReportCardRequest": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "minLength": 1,
            "maxLength": 2048,
            "description": "A public http or https address. A bare host name such as example.com is read as https. Only ports 80 and 443; no credentials; no private or reserved network addresses.",
            "examples": [
              "https://example.com"
            ]
          }
        },
        "required": [
          "url"
        ]
      },
      "CheckResult": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "enum": [
              "hsts",
              "csp",
              "framing",
              "nosniff",
              "referrer",
              "permissions",
              "cookies",
              "coop",
              "corp"
            ]
          },
          "title": {
            "type": "string"
          },
          "grade": {
            "type": "string",
            "enum": [
              "A+",
              "A",
              "B",
              "C",
              "D",
              "F"
            ],
            "description": "A+ is exactly what current guidance recommends; F is missing, switched off, or defeats its own purpose."
          },
          "score": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100
          },
          "applicable": {
            "type": "boolean",
            "description": "False when there was nothing to judge (a site that sets no cookies). It does not count toward the overall grade."
          },
          "seen": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The header values seen, as sent, shortened to 400 characters. Cookie values are never included."
          },
          "finding": {
            "type": "string",
            "description": "One plain sentence about what was found."
          },
          "fix": {
            "description": "One plain sentence about what to change, or null.",
            "type": [
              "string",
              "null"
            ]
          },
          "example": {
            "description": "A header line to paste in, or null.",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "id",
          "title",
          "grade",
          "score",
          "applicable",
          "seen",
          "finding",
          "fix",
          "example"
        ],
        "additionalProperties": false
      },
      "ReportCard": {
        "type": "object",
        "properties": {
          "requestedUrl": {
            "type": "string"
          },
          "finalUrl": {
            "type": "string",
            "description": "The address that answered, after redirects."
          },
          "host": {
            "type": "string"
          },
          "status": {
            "type": "integer"
          },
          "redirects": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "from": {
                  "type": "string"
                },
                "to": {
                  "type": "string"
                },
                "status": {
                  "type": "integer"
                }
              },
              "required": [
                "from",
                "to",
                "status"
              ],
              "additionalProperties": false
            }
          },
          "overall": {
            "type": "object",
            "properties": {
              "grade": {
                "type": "string",
                "enum": [
                  "A+",
                  "A",
                  "B",
                  "C",
                  "D",
                  "F"
                ],
                "description": "A+ is exactly what current guidance recommends; F is missing, switched off, or defeats its own purpose."
              },
              "score": {
                "type": "integer",
                "minimum": 0,
                "maximum": 100
              },
              "note": {
                "type": [
                  "string",
                  "null"
                ]
              }
            },
            "required": [
              "grade",
              "score",
              "note"
            ],
            "additionalProperties": false
          },
          "checks": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CheckResult"
            }
          },
          "gradedAt": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "requestedUrl",
          "finalUrl",
          "host",
          "status",
          "redirects",
          "overall",
          "checks",
          "gradedAt"
        ],
        "additionalProperties": false
      },
      "ScimUser": {
        "type": "object",
        "properties": {
          "schemas": {
            "type": "array",
            "prefixItems": [
              {
                "type": "string",
                "const": "urn:ietf:params:scim:schemas:core:2.0:User"
              }
            ],
            "items": false,
            "minItems": 1,
            "maxItems": 1
          },
          "id": {
            "type": "string",
            "description": "The membership id: stable, and only meaningful inside the token's workspace."
          },
          "externalId": {
            "description": "The directory's own id, when it sent one.",
            "type": "string"
          },
          "userName": {
            "type": "string",
            "description": "The e-mail address, in lower case."
          },
          "name": {
            "type": "object",
            "properties": {
              "formatted": {
                "type": "string"
              }
            },
            "required": [
              "formatted"
            ],
            "additionalProperties": false
          },
          "displayName": {
            "type": "string"
          },
          "emails": {
            "minItems": 1,
            "maxItems": 1,
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "value": {
                  "type": "string"
                },
                "type": {
                  "type": "string",
                  "const": "work"
                },
                "primary": {
                  "type": "boolean",
                  "const": true
                }
              },
              "required": [
                "value",
                "type",
                "primary"
              ],
              "additionalProperties": false
            }
          },
          "active": {
            "type": "boolean"
          },
          "meta": {
            "type": "object",
            "properties": {
              "resourceType": {
                "type": "string",
                "const": "User"
              },
              "created": {
                "type": "string",
                "format": "date-time"
              },
              "lastModified": {
                "type": "string",
                "format": "date-time"
              },
              "location": {
                "type": "string"
              }
            },
            "required": [
              "resourceType",
              "created",
              "lastModified",
              "location"
            ],
            "additionalProperties": false
          }
        },
        "required": [
          "schemas",
          "id",
          "userName",
          "name",
          "displayName",
          "emails",
          "active",
          "meta"
        ],
        "additionalProperties": false
      },
      "ScimUserList": {
        "type": "object",
        "properties": {
          "schemas": {
            "type": "array",
            "prefixItems": [
              {
                "type": "string",
                "const": "urn:ietf:params:scim:api:messages:2.0:ListResponse"
              }
            ],
            "items": false,
            "minItems": 1,
            "maxItems": 1
          },
          "totalResults": {
            "type": "integer",
            "minimum": 0,
            "description": "Matches, before paging."
          },
          "startIndex": {
            "type": "integer",
            "minimum": 1
          },
          "itemsPerPage": {
            "type": "integer",
            "minimum": 0
          },
          "Resources": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ScimUser"
            }
          }
        },
        "required": [
          "schemas",
          "totalResults",
          "startIndex",
          "itemsPerPage",
          "Resources"
        ],
        "additionalProperties": false
      },
      "ScimError": {
        "type": "object",
        "properties": {
          "schemas": {
            "type": "array",
            "prefixItems": [
              {
                "type": "string",
                "const": "urn:ietf:params:scim:api:messages:2.0:Error"
              }
            ],
            "items": false,
            "minItems": 1,
            "maxItems": 1
          },
          "status": {
            "type": "string",
            "description": "The HTTP status, as a string (RFC 7644)."
          },
          "scimType": {
            "type": "string",
            "enum": [
              "invalidFilter",
              "tooMany",
              "uniqueness",
              "mutability",
              "invalidSyntax",
              "invalidPath",
              "noTarget",
              "invalidValue",
              "invalidVers",
              "sensitive"
            ]
          },
          "detail": {
            "type": "string",
            "description": "A sentence about what was wrong."
          }
        },
        "required": [
          "schemas",
          "status",
          "detail"
        ],
        "additionalProperties": false
      },
      "ScimCreateUser": {
        "type": "object",
        "properties": {
          "schemas": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Must include the core User schema URN."
          },
          "userName": {
            "type": "string",
            "minLength": 3,
            "maxLength": 254,
            "description": "The e-mail address the person signs in with. Unique, compared without case."
          },
          "externalId": {
            "description": "The directory's own id for this person.",
            "type": "string",
            "minLength": 1,
            "maxLength": 200
          },
          "displayName": {
            "type": "string",
            "minLength": 1,
            "maxLength": 120
          },
          "name": {
            "description": "Gatehouse keeps one display name: formatted, or given and family name joined.",
            "type": "object",
            "properties": {
              "formatted": {
                "type": "string",
                "minLength": 1,
                "maxLength": 120
              },
              "givenName": {
                "type": "string",
                "minLength": 1,
                "maxLength": 80
              },
              "familyName": {
                "type": "string",
                "minLength": 1,
                "maxLength": 80
              }
            },
            "additionalProperties": {}
          },
          "emails": {
            "description": "Accepted and ignored: the address is userName.",
            "maxItems": 10,
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "value": {
                  "type": "string",
                  "maxLength": 254
                },
                "primary": {
                  "type": "boolean"
                }
              },
              "additionalProperties": {}
            }
          },
          "active": {
            "description": "Defaults to true. The strings \"True\" and \"False\" (Microsoft Entra) are accepted.",
            "type": [
              "boolean",
              "string"
            ]
          }
        },
        "required": [
          "schemas",
          "userName"
        ],
        "additionalProperties": {}
      },
      "ScimPatch": {
        "type": "object",
        "properties": {
          "schemas": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "Operations": {
            "minItems": 1,
            "maxItems": 20,
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "op": {
                  "type": "string",
                  "description": "add, replace or remove, in any letter case."
                },
                "path": {
                  "description": "active, displayName, name.formatted or externalId. Omit it and send an object in `value` (the shape Okta uses).",
                  "type": "string",
                  "maxLength": 200
                },
                "value": {
                  "description": "The new value, or an object of paths and values when `path` is omitted."
                }
              },
              "required": [
                "op"
              ],
              "additionalProperties": {}
            },
            "description": "Applied together: one that is refused leaves the person unchanged."
          }
        },
        "required": [
          "schemas",
          "Operations"
        ],
        "additionalProperties": {}
      }
    },
    "headers": {
      "RetryAfter": {
        "description": "Seconds to wait before trying again.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimitLimit": {
        "description": "Requests allowed per minute for this token.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimitRemaining": {
        "description": "Requests left in the current window.",
        "schema": {
          "type": "integer"
        }
      },
      "SetCookie": {
        "description": "Cookies the browser must keep. Their values are never shown here or in the flow inspector.",
        "schema": {
          "type": "string"
        }
      },
      "WwwAuthenticate": {
        "description": "How to authenticate.",
        "schema": {
          "type": "string",
          "examples": [
            "Bearer realm=\"cron\""
          ]
        }
      }
    },
    "responses": {
      "ScimBadRequest": {
        "description": "The body is not JSON (`invalidSyntax`), a value or filter is not valid (`invalidValue`, `invalidFilter`), an operation names a path this service does not support (`invalidPath`, `noTarget`) or one that cannot change (`mutability`).",
        "content": {
          "application/scim+json": {
            "schema": {
              "$ref": "#/components/schemas/ScimError"
            },
            "example": {
              "schemas": [
                "urn:ietf:params:scim:api:messages:2.0:Error"
              ],
              "status": "400",
              "scimType": "invalidFilter",
              "detail": "Invalid filter: unexpected end of input."
            }
          }
        }
      },
      "ScimUnauthorized": {
        "description": "No token, a malformed one, an unknown one or a revoked one.",
        "headers": {
          "WWW-Authenticate": {
            "schema": {
              "type": "string",
              "examples": [
                "Bearer realm=\"gatehouse-scim\""
              ]
            }
          }
        },
        "content": {
          "application/scim+json": {
            "schema": {
              "$ref": "#/components/schemas/ScimError"
            },
            "example": {
              "schemas": [
                "urn:ietf:params:scim:api:messages:2.0:Error"
              ],
              "status": "401",
              "detail": "A valid workspace token is required."
            }
          }
        }
      },
      "ScimForbidden": {
        "description": "The change is refused by a rule: the last active owner, the shared demo people, or the demo workspace being full.",
        "content": {
          "application/scim+json": {
            "schema": {
              "$ref": "#/components/schemas/ScimError"
            },
            "example": {
              "schemas": [
                "urn:ietf:params:scim:api:messages:2.0:Error"
              ],
              "status": "403",
              "detail": "This person is the workspace's last active owner. Deactivating them would lock everyone out."
            }
          }
        }
      },
      "ScimNotFound": {
        "description": "No such person in this workspace. A person of another workspace looks the same.",
        "content": {
          "application/scim+json": {
            "schema": {
              "$ref": "#/components/schemas/ScimError"
            },
            "example": {
              "schemas": [
                "urn:ietf:params:scim:api:messages:2.0:Error"
              ],
              "status": "404",
              "detail": "No user with id mem_k3m9x2q7b4n8d1ya in this workspace."
            }
          }
        }
      },
      "ScimConflict": {
        "description": "An account with that `userName` already exists (`uniqueness`).",
        "content": {
          "application/scim+json": {
            "schema": {
              "$ref": "#/components/schemas/ScimError"
            },
            "example": {
              "schemas": [
                "urn:ietf:params:scim:api:messages:2.0:Error"
              ],
              "status": "409",
              "scimType": "uniqueness",
              "detail": "An account with that userName already exists."
            }
          }
        }
      },
      "ScimTooLarge": {
        "description": "The body is over 16 KB.",
        "content": {
          "application/scim+json": {
            "schema": {
              "$ref": "#/components/schemas/ScimError"
            }
          }
        }
      },
      "ScimUnsupportedMediaType": {
        "description": "The body was not sent as `application/scim+json` or `application/json`.",
        "content": {
          "application/scim+json": {
            "schema": {
              "$ref": "#/components/schemas/ScimError"
            }
          }
        }
      },
      "ScimRateLimited": {
        "description": "Too many requests or too many failed token checks (`tooMany`).",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/RetryAfter"
          }
        },
        "content": {
          "application/scim+json": {
            "schema": {
              "$ref": "#/components/schemas/ScimError"
            },
            "example": {
              "schemas": [
                "urn:ietf:params:scim:api:messages:2.0:Error"
              ],
              "status": "429",
              "scimType": "tooMany",
              "detail": "Too many requests for this token. Wait before trying again."
            }
          }
        }
      },
      "ScimInternal": {
        "description": "Our side. The body carries no detail.",
        "content": {
          "application/scim+json": {
            "schema": {
              "$ref": "#/components/schemas/ScimError"
            }
          }
        }
      }
    }
  }
}