openapi: 3.1.0
info:
  title: Plugwerk API
  description: |
    **Plugwerk** is a self-hosted plugin marketplace for the [PF4J](https://pf4j.org/) ecosystem.
    It lets teams publish, version, and distribute Java/Kotlin plugins to their own applications
    without relying on a public registry.

    ## Core Concepts

    ### Namespaces
    A **namespace** is the top-level organisational unit. Every plugin belongs to exactly one
    namespace. Namespaces are identified by a URL-safe **slug** (lowercase alphanumeric + hyphens,
    2–64 characters). You might use one namespace per product, team, or customer, e.g. `acme-core`.

    ### Plugins
    A **plugin** is a logical grouping of releases for a single PF4J plugin ID. The `pluginId`
    matches the `Plugin-Id` entry in the PF4J manifest (`MANIFEST.MF` or `plugin.properties`).
    Each plugin can have a human-readable name, description, icon, and categorisation metadata.

    ### Releases
    A **release** is a specific versioned artifact (JAR or ZIP) for a plugin. Versions follow
    [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`). Releases go through a
    lifecycle: `draft` → `published` → `deprecated` / `yanked`. Only `published` releases are
    returned to catalog and update-check consumers. A namespace owner can optionally require
    manual review before a release is published (see the **Reviews** tag).

    ### Plugin Descriptor (MANIFEST.MF)
    When you upload a release artifact, the server reads the `MANIFEST.MF` from the
    JAR/ZIP. Standard PF4J attributes are supplemented by custom `Plugin-*` attributes:

    | Attribute | Required | Description |
    |-----------|----------|-------------|
    | `Plugin-Id` | **Yes** | Unique plugin identifier |
    | `Plugin-Version` | **Yes** | SemVer version |
    | `Plugin-Name` | No | Human-readable display name |
    | `Plugin-Description` | No | Short description |
    | `Plugin-Provider` | No | Author / organisation |
    | `Plugin-License` | No | SPDX license identifier |
    | `Plugin-Requires` | No | SemVer range for host system |
    | `Plugin-Dependencies` | No | Comma-separated `pluginId@version` |
    | `Plugin-Tags` | No | Comma-separated tags |
    | `Plugin-Icon` | No | Icon URL or relative path |
    | `Plugin-Screenshots` | No | Comma-separated screenshot URLs |
    | `Plugin-Homepage` | No | Project homepage URL |
    | `Plugin-Repository` | No | Source code repository URL |

    Falls back to `plugin.properties` if `MANIFEST.MF` has no `Plugin-Id`.

    ## Authentication

    The API supports three authentication methods:

    ### Bearer Token (JWT — local login)
    Obtain a short-lived JWT by calling `POST /api/v1/auth/login` with your username and password.
    Pass the returned token in subsequent requests:
    ```
    Authorization: Bearer <token>
    ```
    Tokens are valid for 8 hours by default.

    ### Bearer Token (OIDC)
    Plugwerk acts as an OAuth2 Resource Server and can validate tokens issued by external
    OIDC providers (Keycloak, GitHub, Google, etc.). Configure providers via the **OidcProviders**
    admin endpoints. Pass the provider-issued token in the same `Authorization: Bearer` header.

    ### API Key
    Long-lived API keys are suitable for CI/CD pipelines. Pass the key in the request header:
    ```
    X-Api-Key: <your-api-key>
    ```
    Namespace-scoped access keys are provisioned by a namespace ADMIN; each key grants implicit
    ADMIN access to its issuing namespace.

    ## Quick Start

    1. **Login** — `POST /api/v1/auth/login` → receive `accessToken`
    2. **Create a namespace** — `POST /api/v1/namespaces` with `{ "slug": "my-ns" }`
    3. **Upload a release** — `POST /api/v1/namespaces/my-ns/plugin-releases` (multipart, artifact field) — the plugin is created automatically from the descriptor inside the artifact
    4. **Publish the release** — `PATCH /api/v1/namespaces/my-ns/plugins/{pluginId}/releases/{version}` with `{ "status": "published" }`
    5. **Clients poll for updates** — `POST /api/v1/namespaces/my-ns/updates/check`

    ## pf4j-update Compatibility
    The `GET /namespaces/{ns}/plugins.json` endpoint returns a response that is fully compatible
    with the [pf4j-update](https://github.com/pf4j/pf4j-update) `UpdateRepository` format.
    You can point any existing pf4j-update client directly at this URL as a drop-in replacement.

    ## Error Handling
    All errors return an `ErrorResponse` body with a machine-readable `error` code and a
    human-readable `message`. HTTP status codes follow REST conventions:
    - `400 Bad Request` — validation error in the request body or parameters
    - `401 Unauthorized` — missing or invalid authentication credentials
    - `404 Not Found` — the requested resource does not exist
    - `409 Conflict` — a resource with the same identifier already exists
    - `413 Payload Too Large` — the uploaded artifact exceeds the server's file size limit
    - `422 Unprocessable Entity` — the artifact was uploaded successfully but the plugin
      descriptor inside it is missing or invalid
  version: 1.0.0
  license:
    name: AGPL-3.0
    url: https://www.gnu.org/licenses/agpl-3.0.html

servers:
  - url: /api/v1
    description: Versioned REST API base path

security: []

tags:
  - name: Auth
    description: |
      Endpoints for obtaining authentication credentials.

      Call `POST /api/v1/auth/login` to exchange a username/password pair for a short-lived JWT
      Bearer token. Include this token in the `Authorization` header of all subsequent requests
      that require authentication.

      A refresh-cookie endpoint exists at `/api/v1/auth/refresh` but is not modelled in this
      spec because it returns `Set-Cookie`, which the OpenAPI generator does not represent.
  - name: AuthRegistration
    description: |
      Public self-registration flow (#420). Available only when the operator
      has enabled `auth.self_registration_enabled`; the endpoints return 404
      otherwise so a locked-down deployment never advertises that the
      registration surface exists at all. Split into its own tag so the
      generated `AuthRegistrationApi` interface and the
      `AuthRegistrationController` implementation can co-exist alongside
      the existing `AuthApi` / `AuthController` pair without ambiguous
      handler mappings on the shared `Auth` interface.
  - name: Namespaces
    description: |
      Create and list namespaces.

      A namespace is the root container for plugins. Every plugin upload, catalog query, and
      update-check is scoped to a namespace slug. Namespace slugs must be globally unique,
      lowercase, and contain only alphanumeric characters and hyphens (pattern: `[a-z0-9][a-z0-9-]{0,62}[a-z0-9]`).

      Namespace operations require authentication (Bearer token or API key).
  - name: Catalog
    description: |
      Read-only endpoints for discovering and downloading plugins.

      These endpoints are designed to be consumed by end-user tooling (IDE plugins, CLI tools,
      the Plugwerk client plugin). Most catalog endpoints are public (no authentication required)
      unless the namespace owner has enabled access control.

      The `plugins.json` endpoint (`GET /namespaces/{ns}/plugins.json`) is a drop-in replacement
      for the pf4j-update `UpdateRepository` plugin feed.
  - name: Management
    description: |
      Authenticated endpoints for publishing and maintaining plugins.

      Use these endpoints from your CI/CD pipeline or release tooling:
      - **Upload** a release artifact (JAR/ZIP); the plugin is created automatically from the embedded MANIFEST.MF descriptor
      - **Update** plugin metadata (description, tags, links)
      - **Change release status** to publish, deprecate, or yank a release

      All management endpoints require an API key (`X-Api-Key` header) or Bearer token.
  - name: Updates
    description: |
      Endpoints used by the Plugwerk client plugin (and any compatible tooling) to poll for
      available plugin updates.

      Send the list of currently installed plugins with their versions; receive back only the
      plugins that have a newer published release available. This endpoint does not require
      authentication unless the namespace is access-controlled.
  - name: Reviews
    description: |
      Admin workflow for reviewing and approving plugin releases before they are published.

      When review mode is enabled for a namespace, newly uploaded releases land in `draft`
      status and appear in the pending review queue. A namespace admin calls `approve` or
      `reject` to transition the release.

      These endpoints require authentication and admin-level permissions on the namespace.
  - name: AdminUsers
    description: |
      Server-level user management. Requires superadmin privileges.

      Create local Plugwerk users, toggle their enabled state, and reset passwords.
      Users created here have `passwordChangeRequired = true` by default, forcing them
      to choose a new password on first login.
  - name: NamespaceMembers
    description: |
      Manage which users hold a role within a specific namespace.

      Roles: `ADMIN` (full write access), `MEMBER` (upload/manage releases), `READ_ONLY` (browse only).
      Membership is keyed by `userId` (= `plugwerk_user.id` UUID); the API
      echoes `displayName` and (for local users) `username` for UI convenience.

      Requires `ADMIN` role on the namespace.
  - name: AccessKeys
    description: |
      Manage namespace-scoped API access keys.

      Access keys are long-lived credentials suitable for CI/CD pipelines and service accounts.
      They grant implicit ADMIN access to their namespace. The plain-text key is returned only
      once at creation time — only the SHA-256 hash is stored on the server.

      All endpoints require `ADMIN` role on the namespace.
  - name: UserSettings
    description: |
      Per-user settings for the authenticated user (ADR-0018).

      Each user can store preferences such as preferred language, theme, and default namespace.
      Settings are created lazily on first write and keyed by `plugwerk_user.id` (a UUID FK).
      Both INTERNAL and EXTERNAL users are supported uniformly because the identity hub assigns
      the same id type to both.
  - name: ServerConfig
    description: |
      Public, unauthenticated endpoint exposing non-sensitive server configuration.

      The frontend uses this endpoint to synchronise client-side validation rules
      (e.g. maximum upload file size) with the server without requiring authentication.
      The values returned here mirror the operator-configurable `plugwerk.*` properties.
  - name: OidcProviders
    description: |
      Manage external OIDC / OAuth2 providers.

      Plugwerk supports two integration modes for each enabled provider:

      - **Resource Server**: validates `Authorization: Bearer` tokens issued by the provider
        for direct API calls. The `GITHUB`, `GOOGLE`, and `FACEBOOK` types ship with
        pre-configured JWKS endpoints; the generic `OIDC` type requires an explicit `issuerUri`
        and works with any standards-compliant provider (Keycloak, Authentik, Auth0, Dex, …).
      - **Browser login (OAuth2 Authorization Code Flow)**: enabled providers appear on the
        Web UI's login page; clicking one starts the standard redirect dance via Spring
        Security's `oauth2Login` and provisions a Plugwerk user on first successful callback.

      All providers are disabled by default. Requires superadmin privileges.

paths:
  /config:
    get:
      tags:
        - ServerConfig
      summary: Get public server configuration
      description: |
        Returns non-sensitive, operator-configurable server settings that clients need
        to enforce local validation rules — most notably the maximum allowed upload file size.

        This endpoint is **public** and does not require authentication. The frontend
        calls it when the upload dialog opens so that client-side file size validation
        always matches the server-side limit, even when the operator has overridden the
        default via the `PLUGWERK_UPLOAD_MAX_FILE_SIZE_MB` environment variable.

        The response is intentionally small and stable — it will only grow with new
        top-level keys, never remove existing ones.
      operationId: getServerConfig
      security: []
      responses:
        '200':
          description: Current server configuration
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ServerConfigResponse'
              example:
                version: "1.0.0"
                upload:
                  maxFileSizeMb: 100
                general:
                  defaultTimezone: "UTC"
                auth:
                  oidcProviders:
                    - id: "keycloak-local"
                      name: "Keycloak Local"
                      loginUrl: "/oauth2/authorization/keycloak-local"
                      iconKind: "OIDC"
                  selfRegistrationEnabled: false

  /auth/login:
    post:
      tags:
        - Auth
      summary: Login with username and password
      description: |
        Authenticates the user and returns a signed JWT Bearer token.

        The token is valid for 8 hours (28 800 seconds) by default. Include it in all
        authenticated requests as `Authorization: Bearer <token>`.

        On success, the response also sets a `plugwerk_refresh` cookie:
        `HttpOnly`, `Secure`, `SameSite=Strict`, `Path=/api/v1/auth`, with a 7-day TTL.
        This cookie is consumed by `POST /api/v1/auth/refresh` to mint a fresh
        access token without re-prompting the user. The refresh endpoint is not
        modelled in this OpenAPI spec because it returns `Set-Cookie`.

        Returns `401 Unauthorized` if the username does not exist or the password is wrong.
        The response body is intentionally empty on `401` to avoid leaking user existence.
      operationId: login
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
            example:
              # Placeholder credentials — do NOT seed the bootstrap-default
              # `admin / changeme` pair here, even though it works on a fresh
              # install. Swagger UI's "Try it out" would otherwise auto-login
              # as superadmin from the public docs page.
              username: alice
              password: your-strong-password
      responses:
        '200':
          description: Login successful — returns a JWT Bearer token
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginResponse'
        '401':
          description: Invalid username or password

  /auth/register:
    post:
      tags:
        - AuthRegistration
      summary: Self-register a new account (when enabled by the operator)
      description: |
        Creates a new local user account. Available only when the operator
        has enabled `auth.self_registration_enabled`; returns 404 otherwise
        so the existence of the endpoint is not advertised on locked-down
        deployments. Public — no authentication required (#420).

        When `auth.self_registration_email_verification_required` is true
        (default), the new account ships `enabled=false` and a
        single-use verification link is emailed to the supplied address.
        The user can log in only after clicking that link. When the
        verification toggle is off, the account is enabled immediately
        and no email is sent.

        On a username or email collision the response shape and status
        are identical to the success path so attackers cannot enumerate
        existing accounts (no email is sent in the collision case — the
        legitimate owner of the address sees nothing). Password strength
        rules are identical to `POST /admin/users`.

        Rate-limited per client IP (10 requests / 60 s) and per email
        (5 requests / 3600 s). Returns 503 with a descriptive message
        when verification is required but SMTP is not configured.
      operationId: register
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RegisterRequest'
      responses:
        '200':
          description: Registration accepted — see `status` for whether verification is required
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RegisterResponse'
        '400':
          description: Invalid input (weak password, malformed email, missing field)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Self-registration is disabled on this server
        '429':
          description: Too many registration attempts — Retry-After header indicates the cool-off
        '503':
          description: Self-registration requires email infrastructure that is not configured
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /auth/verify-email:
    get:
      tags:
        - AuthRegistration
      summary: Consume an email-verification token
      description: |
        Validates the single-use token sent to the user's address during
        self-registration. On success the underlying account is flipped
        to `enabled=true` and the response says so; the same link cannot
        be used a second time. Returns 404 when self-registration is
        disabled (same disguise as `POST /auth/register`). Returns 400
        when the token is unknown, already consumed, or past its 24 h
        expiry. Public — no authentication required (#420).
      operationId: verifyEmail
      parameters:
        - name: token
          in: query
          required: true
          schema:
            type: string
            minLength: 8
            maxLength: 128
      responses:
        '200':
          description: Token consumed — the linked account is now enabled
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/VerifyEmailResponse'
        '400':
          description: Token is unknown, already consumed, or expired
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Self-registration is disabled on this server

  /auth/forgot-password:
    post:
      tags:
        - AuthPasswordReset
      summary: Request a password-reset email (when enabled by the operator)
      description: |
        Sends a single-use, time-limited password-reset link to the
        address registered for the supplied username or email. Available
        only when the operator has enabled `auth.password_reset_enabled`;
        returns 404 otherwise so the existence of the endpoint is not
        advertised on locked-down deployments. Public — no authentication
        required (#421).

        Always returns **204 No Content** on the success path, regardless
        of whether the supplied identifier matched anything. The legitimate
        owner of an in-use address sees an inbox message; an unknown
        identifier produces no signal at all. EXTERNAL (OIDC) users
        likewise receive no email — they reset their password upstream
        with their identity provider.

        Rate-limited per client IP. Returns 503 with an operator-actionable
        message when SMTP is not configured.
      operationId: forgotPassword
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ForgotPasswordRequest'
      responses:
        '204':
          description: Request accepted — if the account exists, a reset email is on its way
        '400':
          description: Malformed request body
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Password reset is disabled on this server
        '429':
          description: Too many forgot-password attempts — Retry-After header indicates the cool-off
        '503':
          description: Password reset requires email infrastructure that is not configured
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /auth/reset-password:
    post:
      tags:
        - AuthPasswordReset
      summary: Exchange a password-reset token for a new password
      description: |
        Validates the single-use token issued by `POST /forgot-password`
        and, on success, persists the new BCrypt hash and revokes every
        active session for the user (access tokens via
        `passwordInvalidatedBefore`, refresh-token family via cookie
        rotation). The same token cannot be redeemed twice. Returns 404
        when password reset is disabled (same disguise as
        `POST /auth/forgot-password`). Returns 400 when the token is
        unknown, already consumed, or past its expiry. Public — no
        authentication required (#421).

        Rate-limited per client IP and per token hash to mitigate
        brute-force attempts on a leaked link.
      operationId: resetPassword
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ResetPasswordRequest'
      responses:
        '204':
          description: Password updated and existing sessions revoked
        '400':
          description: Token is unknown / consumed / expired, or password fails strength rules
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Password reset is disabled on this server
        '429':
          description: Too many reset attempts — Retry-After header indicates the cool-off

  /namespaces:
    get:
      tags:
        - Namespaces
      summary: List all namespaces
      description: |
        Returns all namespaces visible to the authenticated caller.

        Superadmins see all namespaces. Regular users see only namespaces in which they
        hold a membership. Access-key principals see only the namespace the key was issued for.

        Requires authentication.
      operationId: listNamespaces
      security:
        - BearerAuth: []
        - ApiKeyAuth: []
      responses:
        '200':
          description: List of all namespaces
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/NamespaceSummary'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags:
        - Namespaces
      summary: Create a new namespace
      description: |
        Creates a new namespace with the given slug.

        The slug must be unique across the system, lowercase, and contain only alphanumeric
        characters and hyphens. It cannot start or end with a hyphen and must be at least
        2 characters long (pattern: `^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$`).

        If a namespace with the same slug already exists, `409 Conflict` is returned.

        Requires superadmin privileges.
      operationId: createNamespace
      security:
        - BearerAuth: []
        - ApiKeyAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NamespaceCreateRequest'
            example:
              slug: acme-core
              name: ACME Core Plugins
              description: Plugin repository for the ACME core platform
      responses:
        '201':
          description: Namespace created successfully
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NamespaceSummary'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: A namespace with this slug already exists

  /namespaces/{ns}:
    patch:
      tags:
        - Namespaces
      summary: Update a namespace
      description: |
        Updates namespace properties such as owner organisation, public catalog flag, or auto-approve setting.

        Requires superadmin privileges or ADMIN role in the namespace.
      operationId: updateNamespace
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NamespaceUpdateRequest'
      responses:
        '200':
          description: Namespace updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NamespaceSummary'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
        - Namespaces
      summary: Delete a namespace
      description: |
        Permanently deletes a namespace and all its plugins, releases, artifacts, members,
        and access keys. This action cannot be undone.

        Requires superadmin privileges.
      operationId: deleteNamespace
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
      responses:
        '204':
          description: Namespace deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /namespaces/{ns}/plugins:
    get:
      tags:
        - Catalog
      summary: List plugins in a namespace
      description: |
        Returns a paginated list of plugins in the given namespace.

        Results can be filtered by tag, status, and system version compatibility,
        and searched by a full-text query across plugin name, description, and tags.

        **Visibility rules (role-dependent):**
        - **Anonymous / API key:** Only `active` plugins with `published` or `deprecated` releases.
        - **Namespace member / read-only:** All plugins (incl. draft-only) except `suspended`.
        - **Namespace admin / system admin:** All plugins, all statuses (incl. suspended and draft-only).

        Plugins that have only draft releases are marked with `hasDraftOnly: true` in the response.

        For authenticated users with namespace access, the response includes
        `pendingReviewPluginCount` — the number of active plugins that have at least one
        draft release pending review — and `pendingReviewReleaseCount` — the total number
        of draft releases pending review across all plugins.

        This endpoint is publicly accessible unless the namespace has access control enabled.
      operationId: listPlugins
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
        - $ref: '#/components/parameters/PageQuery'
        - $ref: '#/components/parameters/SizeQuery'
        - $ref: '#/components/parameters/SortQuery'
        - $ref: '#/components/parameters/SearchQuery'
        - $ref: '#/components/parameters/TagFilter'
        - $ref: '#/components/parameters/StatusFilter'
        - $ref: '#/components/parameters/VersionFilter'
      responses:
        '200':
          description: Paginated list of plugins matching the given filters
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PluginPagedResponse'
        '404':
          $ref: '#/components/responses/NotFound'

  /namespaces/{ns}/plugins/{pluginId}:
    get:
      tags:
        - Catalog
      summary: Get plugin details
      description: |
        Returns full metadata for a single plugin, including the latest published release
        embedded as `latestRelease`.

        This endpoint is publicly accessible unless the namespace has access control enabled.
      operationId: getPlugin
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
        - $ref: '#/components/parameters/PluginIdPath'
      responses:
        '200':
          description: Plugin details including release summary
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PluginDto'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags:
        - Management
      summary: Update plugin metadata
      description: |
        Updates the editable metadata fields of an existing plugin (name, description,
        categories, tags, links). Only the fields present in the request body are updated —
        omitted fields retain their current values.

        Note: `pluginId` and `namespace` cannot be changed after creation.

        Requires at least `MEMBER` role in the namespace.
      operationId: updatePlugin
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
        - $ref: '#/components/parameters/PluginIdPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PluginUpdateRequest'
      responses:
        '200':
          description: Plugin updated — returns the full updated plugin
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PluginDto'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
        - Management
      summary: Delete plugin and all releases
      description: |
        Permanently deletes a plugin together with all its releases and stored artifacts.
        This action is irreversible. Requires `ADMIN` role in the namespace.
      operationId: deletePlugin
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
        - $ref: '#/components/parameters/PluginIdPath'
      responses:
        '204':
          description: Plugin and all releases deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /namespaces/{ns}/plugin-releases:
    post:
      tags:
        - Management
      summary: Upload a new release artifact
      description: |
        Uploads a plugin artifact (JAR or ZIP) and creates a new release.

        The server reads the `MANIFEST.MF` from the artifact to extract the plugin ID,
        version, and all other metadata. Falls back to `plugin.properties` if `MANIFEST.MF`
        has no `Plugin-Id` attribute.

        **Required MANIFEST.MF attributes:**
        ```
        Plugin-Id: com.example.my-plugin
        Plugin-Version: 1.2.0
        Plugin-Name: My Plugin
        Plugin-Description: Short description
        ```

        If the plugin ID from the descriptor is not yet known in this namespace, a new plugin
        entry is created automatically. If the plugin already exists, the new release is added
        to it. There is no need to pre-register a plugin before uploading.

        The artifact SHA-256 is computed server-side and stored with the release for
        integrity verification by the client plugin.

        New releases are created with status `draft`. Publish them explicitly via
        `PATCH /namespaces/{ns}/plugins/{pluginId}/releases/{version}`.

        **Descriptor validation:**
        All descriptor fields are validated on upload. Key constraints:
        - `Plugin-Id`: `^[a-zA-Z0-9._-]{1,128}$`
        - `Plugin-Version`: valid SemVer, max 100 characters
        - `Plugin-Name`: max 255 characters
        - `Plugin-Description`: max 10,000 characters
        - `Plugin-Provider`: max 255 characters
        - `Plugin-License`: max 100 characters
        - URL fields (`homepage`, `repository`, `icon`): max 2,048 characters, must be valid http/https
        - `tags`: max 50 entries, each max 64 characters
        - `pluginDependencies`: max 100 entries, each ID must match the plugin ID format
        - Plain-text fields must not contain HTML/script tags

        **File size limit:**
        The server enforces a maximum artifact size (default: 100 MB, configurable via
        `PLUGWERK_UPLOAD_MAX_FILE_SIZE_MB`). Files exceeding this limit are rejected with
        `413 Payload Too Large`. Use `GET /config` to query the current limit at runtime.

        Returns `413 Payload Too Large` if the artifact exceeds the file size limit.
        Returns `422 Unprocessable Entity` if the descriptor is missing or invalid.

        Requires at least `MEMBER` role in the namespace.
      operationId: uploadPluginRelease
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              $ref: '#/components/schemas/ReleaseUploadRequest'
      responses:
        '201':
          description: Release created with status `draft`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PluginReleaseDto'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /namespaces/{ns}/plugins/{pluginId}/releases:
    get:
      tags:
        - Catalog
      summary: List releases for a plugin
      description: |
        Returns a paginated list of all releases for the given plugin, ordered by version
        descending (newest first) by default.

        Draft releases are only included if the caller is authenticated and has management
        access to the namespace.
      operationId: listReleases
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
        - $ref: '#/components/parameters/PluginIdPath'
        - $ref: '#/components/parameters/PageQuery'
        - $ref: '#/components/parameters/SizeQuery'
      responses:
        '200':
          description: Paginated list of releases for the plugin
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReleasePagedResponse'
        '404':
          $ref: '#/components/responses/NotFound'

  /namespaces/{ns}/plugins/{pluginId}/releases/{version}:
    get:
      tags:
        - Catalog
      summary: Get a specific release
      description: |
        Returns the full details of a single release, including the SHA-256 checksum,
        compatibility requirements, and plugin dependencies.
      operationId: getRelease
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
        - $ref: '#/components/parameters/PluginIdPath'
        - $ref: '#/components/parameters/VersionPath'
      responses:
        '200':
          description: Release details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PluginReleaseDto'
        '404':
          $ref: '#/components/responses/NotFound'
    patch:
      tags:
        - Management
      summary: Update release status
      description: |
        Transitions a release to a new lifecycle status.

        Allowed transitions:
        - `draft` → `published` — makes the release visible in the catalog and eligible for
          update checks. If the namespace requires review, use the **Reviews** endpoints instead.
        - `published` → `deprecated` — the release remains downloadable but update checks
          will no longer recommend it.
        - `published` → `yanked` — the release is hidden from the catalog and update checks.
          Yanking is irreversible and typically used for releases with critical security issues.
        - `deprecated` → `yanked` — same as above.

        Note: `draft` → `yanked` is not allowed. Publish first, then yank if needed.

        Requires `ADMIN` role in the namespace.
      operationId: updateReleaseStatus
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
        - $ref: '#/components/parameters/PluginIdPath'
        - $ref: '#/components/parameters/VersionPath'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReleaseStatusUpdateRequest'
            example:
              status: published
      responses:
        '200':
          description: Release status updated — returns the updated release
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PluginReleaseDto'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
        - Management
      summary: Delete a single release
      description: |
        Permanently deletes a single plugin release and its stored artifact.
        If this is the last remaining release of the plugin, the plugin itself
        is also deleted. This action is irreversible. Requires `ADMIN` role in the namespace.
      operationId: deleteRelease
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
        - $ref: '#/components/parameters/PluginIdPath'
        - $ref: '#/components/parameters/VersionPath'
      responses:
        '204':
          description: |
            Release and artifact deleted. If this was the last remaining release,
            the plugin itself is also deleted.
          headers:
            X-Plugin-Deleted:
              schema:
                type: boolean
              description: >
                `true` if the plugin was also deleted because this was its
                last remaining release; `false` otherwise.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /namespaces/{ns}/plugins/{pluginId}/releases/{version}/download:
    get:
      tags:
        - Catalog
      summary: Download release artifact
      description: |
        Downloads the raw plugin artifact binary (JAR or ZIP).

        The response is a binary stream (`application/octet-stream`). Clients should verify
        the downloaded file against the `artifactSha256` value from the release metadata to
        ensure integrity. The Plugwerk client plugin performs this check automatically.

        Only `published` releases can be downloaded by unauthenticated callers. Authenticated
        callers with management access can also download `draft` releases.
      operationId: downloadRelease
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
        - $ref: '#/components/parameters/PluginIdPath'
        - $ref: '#/components/parameters/VersionPath'
      responses:
        '200':
          description: Plugin artifact binary stream
          content:
            application/octet-stream:
              schema:
                type: string
                format: binary
        '404':
          $ref: '#/components/responses/NotFound'

  /namespaces/{ns}/tags:
    get:
      tags:
        - Catalog
      summary: List all tags used in this namespace
      description: |
        Returns a sorted, deduplicated list of all tags currently assigned to
        plugins in the given namespace. Useful for populating filter dropdowns.
      operationId: listTags
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
      responses:
        '200':
          description: Sorted list of distinct tags
          content:
            application/json:
              schema:
                type: array
                items:
                  type: string
        '404':
          $ref: '#/components/responses/NotFound'

  /namespaces/{ns}/plugins.json:
    get:
      tags:
        - Catalog
      summary: pf4j-update compatible plugin feed
      description: |
        Returns the plugin catalog in the [pf4j-update](https://github.com/pf4j/pf4j-update)
        `plugins.json` format. This is a **drop-in replacement** for any existing
        `UpdateRepository` URL in a pf4j-update-based application.

        Only `published` releases are included. Each plugin entry contains all published
        releases with their download URLs, SHA-512 checksums, and `requires` (minimum host
        application version) values.

        Point your pf4j-update `UpdateRepository` at:
        ```
        https://your-server/api/v1/namespaces/{ns}/plugins.json
        ```
      operationId: getPluginsJson
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
      responses:
        '200':
          description: pf4j-update compatible plugin feed (JSON)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Pf4jPluginsJson'

  /namespaces/{ns}/updates/check:
    post:
      tags:
        - Updates
      summary: Check for available plugin updates
      description: |
        Accepts the list of currently installed plugins with their versions and returns
        only the plugins that have a newer published release available.

        This endpoint is designed to be called periodically by the Plugwerk client plugin
        (e.g. on application startup). Pass up to 500 installed plugins per request.

        A plugin is considered to have an update if a published release with a higher
        SemVer version exists in the namespace. Pre-release versions (e.g. `1.0.0-beta.1`)
        are only suggested as updates to other pre-release versions of the same minor series.

        This endpoint does not require authentication unless the namespace has access control enabled.
      operationId: checkForUpdates
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateCheckRequest'
            example:
              plugins:
                - pluginId: com.example.my-plugin
                  currentVersion: 1.0.0
                - pluginId: com.example.other-plugin
                  currentVersion: 2.3.1
      responses:
        '200':
          description: |
            List of plugins that have a newer published release available.
            Plugins that are already up to date are omitted from the response.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UpdateCheckResponse'

  /namespaces/{ns}/reviews/pending:
    get:
      tags:
        - Reviews
      summary: List releases pending review
      description: |
        Returns all releases in the namespace that are waiting for admin approval before
        they can be published.

        Releases appear here when:
        - The namespace has review mode enabled, AND
        - A new release artifact was uploaded (status is `draft`)

        Releases approved via `POST /reviews/{releaseId}/approve` are automatically
        transitioned to `published` and removed from this queue.

        Requires at least `MEMBER` role in the namespace.
      operationId: listPendingReviews
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
      responses:
        '200':
          description: List of releases pending review, ordered by submission date ascending
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/ReviewItemDto'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /namespaces/{ns}/reviews/{releaseId}/approve:
    post:
      tags:
        - Reviews
      summary: Approve a pending release
      description: |
        Approves a release that is pending review and transitions it to `published` status,
        making it immediately visible in the catalog and eligible for update checks.

        An optional `comment` can be included in the request body (e.g. review notes).

        Returns `404 Not Found` if the release does not exist or is not in `draft` status.

        Requires `ADMIN` role in the namespace.
      operationId: approveRelease
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
        - $ref: '#/components/parameters/ReleaseIdPath'
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReviewDecisionRequest'
            example:
              comment: Reviewed and approved. Artifact matches signed build.
      responses:
        '200':
          description: Release approved and transitioned to `published`
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PluginReleaseDto'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /namespaces/{ns}/reviews/{releaseId}/reject:
    post:
      tags:
        - Reviews
      summary: Reject a pending release
      description: |
        Rejects a release that is pending review.

        The release remains in `draft` status and is removed from the review queue.
        The publisher can fix issues and re-submit by uploading a new release artifact.

        A `comment` explaining the rejection reason is strongly recommended so the
        publisher understands what needs to be fixed.

        Requires `ADMIN` role in the namespace.
      operationId: rejectRelease
      security:
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
        - $ref: '#/components/parameters/ReleaseIdPath'
      requestBody:
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReviewDecisionRequest'
            example:
              comment: Artifact contains unsigned code. Please sign and re-upload.
      responses:
        '200':
          description: Release rejected — remains in `draft` status
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PluginReleaseDto'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /auth/logout:
    post:
      tags:
        - Auth
      summary: Logout (revoke current token)
      description: |
        Revokes the JWT used to authenticate this request and clears the refresh
        cookie. The token becomes immediately invalid and any subsequent request
        using it will receive `401 Unauthorized`.

        This is a best-effort operation — the server persists the token's `jti` claim
        in a revocation list that is checked on every authenticated request.

        Two response shapes (issue #352):

        * **Local-login sessions** → `204 No Content`. The browser stays on the
          Plugwerk SPA and routes to the login page on its own.
        * **OIDC-sourced sessions** → `200 OK` with a `LogoutResponse` body that
          contains an `endSessionUrl`. The frontend MUST navigate the browser to
          that URL — it is the IdP's RP-Initiated Logout endpoint, with the
          `id_token_hint` and `post_logout_redirect_uri` parameters already filled
          in. After the IdP destroys its session, the browser is bounced back to
          `${plugwerk.server.base-url}/login` and the SPA boots fresh. Without
          this step the upstream IdP keeps its session cookies and the next
          "Login with provider" click would silently re-authenticate the same
          user instead of showing the credential prompt.

        Requires authentication (Bearer token).
      operationId: logout
      security:
        - BearerAuth: []
      responses:
        '200':
          description: |
            OIDC session — caller must follow `endSessionUrl` for the IdP to
            destroy its session and bounce back to the Plugwerk login page.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LogoutResponse'
        '204':
          description: Local-login session — token revoked, no further client action required.
        '401':
          $ref: '#/components/responses/Unauthorized'

  /auth/change-password:
    post:
      tags:
        - Auth
      summary: Change own password
      description: |
        Changes the authenticated user's password. Requires the current password for verification.

        After a successful change, `passwordChangeRequired` is set to `false` on the user account.
        This endpoint must be called before accessing any other resource when the login response
        contains `passwordChangeRequired: true`.

        Requires authentication (Bearer token).
      operationId: changePassword
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ChangePasswordRequest'
      responses:
        '204':
          description: Password changed successfully
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequests'

  /users/me/settings:
    get:
      tags:
        - UserSettings
      summary: Get current user's settings
      description: |
        Returns all per-user settings for the authenticated user, with defaults filled in for
        keys that the user has not explicitly set.
      operationId: getUserSettings
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Current user settings
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserSettingsResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
    patch:
      tags:
        - UserSettings
      summary: Update current user's settings
      description: |
        Updates one or more per-user settings. Only the keys included in the request body are
        changed; all other keys retain their current values. Unknown keys are rejected with
        HTTP 400.
      operationId: updateUserSettings
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserSettingsUpdateRequest'
      responses:
        '200':
          description: Updated user settings
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserSettingsResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /admin/users:
    get:
      tags:
        - AdminUsers
      summary: List users (paginated)
      description: |
        Returns a paginated page of registered users. Requires superadmin
        privileges. Sortable by `username`, `email`, `createdAt`, or
        `lastLoginAt`. Sorts by `username,asc` when omitted.

        **Pagination is mandatory.** Issue #485 introduced the page wrapper to
        protect against unbounded result-set loads as the user base grows
        (LDAP/OIDC mass-provisioning). Default page size is 20, maximum 100.
      operationId: listUsers
      security:
        - BearerAuth: []
      parameters:
        - name: enabled
          in: query
          required: false
          description: Filter by enabled state. When omitted, every user matching the page is returned.
          schema:
            type: boolean
        - name: q
          in: query
          required: false
          description: |
            Case-insensitive substring search across `username`, `displayName`,
            and `email` (OR-joined). SQL wildcard characters (`%`, `_`, `\`)
            in the input are escaped and matched literally. Empty or omitted
            means "no text filter" — the unfiltered page is returned. Combines
            with `enabled`, `page`, `size`, `sort`. Introduced in #492 to
            replace the client-side 100-cap notnagel in the namespace
            member-add picker.
          schema:
            type: string
            minLength: 1
            maxLength: 200
        - $ref: '#/components/parameters/PageQuery'
        - $ref: '#/components/parameters/SizeQuery'
        - name: sort
          in: query
          required: false
          description: |
            `<field>,<direction>` where field ∈ {`username`, `email`,
            `createdAt`, `lastLoginAt`} and direction ∈ {`asc`, `desc`}.
            Defaults to `username,asc`.
          schema:
            type: string
            default: username,asc
            pattern: '^(username|email|createdAt|lastLoginAt),(asc|desc)$'
            maxLength: 32
      responses:
        '200':
          description: Paginated page of users
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserPagedResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags:
        - AdminUsers
      summary: Create a new user
      description: Creates a local user account. Requires superadmin privileges.
      operationId: createUser
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserCreateRequest'
      responses:
        '201':
          description: User created. The user must change the password on first login.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserDto'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Username already taken
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /admin/users/{userId}:
    patch:
      tags:
        - AdminUsers
      summary: Update user (enable/disable or reset password)
      description: Updates a user account (enable, disable, reset password). Requires superadmin privileges.
      operationId: updateUser
      security:
        - BearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UserUpdateRequest'
      responses:
        '200':
          description: User updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UserDto'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
        - AdminUsers
      summary: Delete user (superadmin cannot be deleted)
      description: |
        Removes a user account from the directory. Hard-delete — the row is gone
        and cannot be recovered. Memberships, refresh tokens, and personal access
        keys cascade via the FK chain.

        The bootstrap superadmin account is protected: attempting to delete it
        returns `403`. To rotate the superadmin, create a second superadmin,
        promote it, and then delete the original.
      operationId: deleteUser
      security:
        - BearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: User deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Cannot delete the superadmin account
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          $ref: '#/components/responses/NotFound'

  /admin/users/{userId}/reset-password:
    post:
      tags:
        - AdminUsers
      summary: Trigger an admin-initiated password reset
      operationId: adminResetUserPassword
      description: |
        Issues a single-use password-reset token for the target INTERNAL
        user, emails an admin-initiated reset link to the user's address,
        and revokes every active session — both access tokens (via
        `passwordInvalidatedBefore`) and the refresh-token family. Idempotent:
        re-running supersedes the previous unconsumed token. Requires
        superadmin privileges.

        Bypasses the `auth.password_reset_enabled` operator gate that gates
        the public forgot-password flow — admin-driven path is an
        independent trust chain (#421 covered the public surface; #450
        added this admin surface).

        SMTP-disabled fallback: if the SMTP server is not configured the
        endpoint still succeeds server-side (token is issued, sessions
        revoked) and returns the reset URL in the response body so the
        operator can deliver it out of band. The reset URL is omitted
        from the response when the email was sent successfully.

        Refusal cases (HTTP 400):
          - target user is EXTERNAL (OIDC) — credentials are managed by the
            upstream IdP,
          - target user is the caller themselves — superadmins must use
            `/auth/change-password` for self-service changes.
      security:
        - BearerAuth: []
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: |
            Reset triggered. `tokenIssued` is always `true` on this path;
            `emailSent` is `false` when SMTP is unconfigured or the send
            failed, in which case `resetUrl` carries the link the
            operator can deliver out of band.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AdminPasswordResetResponse'
        '400':
          description: Target user is EXTERNAL or the caller's own row
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /namespaces/{ns}/members/me:
    get:
      tags:
        - NamespaceMembers
      summary: Get the current user's role in a namespace
      description: |
        Returns the calling user's role in the specified namespace.
        Useful for frontends that need to conditionally render admin-only UI elements
        (e.g. review links, member management).
      operationId: getMyMembership
      security:
        - BearerAuth: []
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
      responses:
        '200':
          description: The caller's membership in this namespace
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NamespaceMembershipDto'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Caller has no role in this namespace
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          $ref: '#/components/responses/NotFound'

  /namespaces/{ns}/members:
    get:
      tags:
        - NamespaceMembers
      summary: List namespace members
      description: |
        Returns every user who has a direct membership in this namespace,
        with their role (`ADMIN` or `READ_ONLY`). Identity-only — does not
        expand the user's other namespace memberships or personal access
        keys.

        Callers see only namespaces they belong to (or all of them if
        superadmin).
      operationId: listNamespaceMembers
      security:
        - BearerAuth: []
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
      responses:
        '200':
          description: List of namespace members
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/NamespaceMemberDto'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Insufficient role in namespace
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags:
        - NamespaceMembers
      summary: Add a member to the namespace
      description: Grants a user a role in the namespace. Requires `ADMIN` role in the namespace.
      operationId: addNamespaceMember
      security:
        - BearerAuth: []
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NamespaceMemberCreateRequest'
      responses:
        '201':
          description: Member added
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NamespaceMemberDto'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Subject already has a role in this namespace
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /namespaces/{ns}/members/{userId}:
    put:
      tags:
        - NamespaceMembers
      summary: Update a member's role
      description: Changes a member's role in the namespace. Requires `ADMIN` role in the namespace.
      operationId: updateNamespaceMember
      security:
        - BearerAuth: []
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
        - name: userId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/NamespaceMemberUpdateRequest'
      responses:
        '200':
          description: Role updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NamespaceMemberDto'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
        - NamespaceMembers
      summary: Remove a member from the namespace
      description: Revokes a member's access to the namespace. Requires `ADMIN` role in the namespace.
      operationId: removeNamespaceMember
      security:
        - BearerAuth: []
        - ApiKeyAuth: []
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
        - name: userId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Member removed
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /namespaces/{ns}/access-keys:
    get:
      tags:
        - AccessKeys
      summary: List access keys
      description: Lists all access keys for the namespace. Key values are never returned — only metadata. Requires ADMIN role.
      operationId: listAccessKeys
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
      responses:
        '200':
          description: List of access keys
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/AccessKeyDto'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    post:
      tags:
        - AccessKeys
      summary: Generate a new access key
      description: |
        Generates a new access key for the namespace. The plain-text key is returned
        only in this response and cannot be retrieved later. Store it securely.
        Requires ADMIN role.
      operationId: createAccessKey
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AccessKeyCreateRequest'
      responses:
        '201':
          description: Access key created. The plain key is included only in this response.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AccessKeyCreateResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /namespaces/{ns}/access-keys/{keyId}:
    delete:
      tags:
        - AccessKeys
      summary: Revoke an access key
      description: Permanently revokes an access key. Requires ADMIN role.
      operationId: revokeAccessKey
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/NamespaceSlug'
        - name: keyId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Access key revoked
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /admin/oidc-providers:
    get:
      tags:
        - OidcProviders
      summary: List all OIDC providers
      description: Returns all configured OIDC identity providers. Requires superadmin privileges.
      operationId: listOidcProviders
      security:
        - BearerAuth: []
      responses:
        '200':
          description: List of configured OIDC providers (client secrets are never returned)
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/OidcProviderDto'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    post:
      tags:
        - OidcProviders
      summary: Create a new OIDC provider
      description: Registers a new OIDC identity provider. Requires superadmin privileges.
      operationId: createOidcProvider
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OidcProviderCreateRequest'
      responses:
        '201':
          description: Provider created (disabled by default)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OidcProviderDto'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /admin/oidc-providers/discover:
    post:
      tags:
        - OidcProviders
      summary: Probe an issuer URI for OIDC discovery support
      description: |
        Attempts to resolve `${issuerUri}/.well-known/openid-configuration` and
        returns the discovered authorization / token / user-info / JWK set
        endpoints when discovery succeeds. Used by the admin UI to (a) confirm
        that a candidate issuer URI works before saving an OIDC provider and
        (b) steer operators away from `OAUTH2` when their provider
        supports OIDC discovery.

        Does NOT persist anything — this is a read-only probe. Requires
        superadmin privileges.
      operationId: discoverOidcEndpoints
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OidcDiscoveryRequest'
      responses:
        '200':
          description: |
            Discovery completed (may have failed — check `success` in body).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OidcDiscoveryResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /admin/oidc-providers/{providerId}:
    patch:
      tags:
        - OidcProviders
      summary: Update an OIDC provider
      description: |
        Patches one or more configuration fields on an existing OIDC provider.
        Patchable fields: `enabled`, `name`, `clientId`, `clientSecret`, `issuerUri`, `scope`.

        `providerType` is intentionally NOT patchable — switching the type would
        invalidate every previously issued access token (different `iss` / `aud`
        claim shape) and would tempt operators to "convert" rather than create
        a fresh provider. To change `providerType`, delete and recreate.

        Blank or omitted `clientSecret` keeps the stored encrypted value
        untouched, so an empty password input cannot accidentally null out the
        secret.

        Requires superadmin privileges.
      operationId: updateOidcProvider
      security:
        - BearerAuth: []
      parameters:
        - name: providerId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OidcProviderUpdateRequest'
      responses:
        '200':
          description: Provider updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OidcProviderDto'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
        - OidcProviders
      summary: Delete an OIDC provider
      description: Permanently removes an OIDC provider. Requires superadmin privileges.
      operationId: deleteOidcProvider
      security:
        - BearerAuth: []
      parameters:
        - name: providerId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Provider deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /admin/settings:
    get:
      tags:
        - AdminSettings
      summary: List all application settings
      description: |
        Returns a snapshot of every admin-manageable application setting along with its
        effective value, source (DATABASE or DEFAULT), and a `restartPending` flag for
        keys that require a server restart and whose DB value has changed since boot.
        Requires superadmin privileges. See ADR-0016.
      operationId: listApplicationSettings
      security:
        - BearerAuth: []
      responses:
        '200':
          description: All settings
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApplicationSettingsResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    patch:
      tags:
        - AdminSettings
      summary: Update one or more application settings
      description: |
        Writes a partial set of `key -> value` pairs to the `application_setting` table.
        Each value is validated against its SettingKey's validator; invalid values are
        rejected with HTTP 400 and no partial write occurs. Requires superadmin privileges.
      operationId: updateApplicationSettings
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ApplicationSettingsUpdateRequest'
      responses:
        '200':
          description: Updated snapshot of all settings
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApplicationSettingsResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /admin/email/test:
    post:
      tags:
        - AdminEmail
      summary: Send a test email
      description: |
        Sends a small fixed-body test message to the given recipient using the
        currently-configured SMTP settings. Surfaces SMTP errors (auth failed,
        host unreachable, etc.) as 5xx with a kuratierte message — never the
        raw exception text. Requires superadmin privileges (#253).

        When `smtp.enabled=false` or the configuration is incomplete, returns
        400 with a hint pointing at the missing field.
      operationId: sendTestEmail
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SendTestEmailRequest'
      responses:
        '200':
          description: Test email accepted by the SMTP server
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SendTestEmailResponse'
        '400':
          description: SMTP not configured or recipient invalid
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '502':
          description: SMTP server rejected the message
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /admin/email/templates:
    get:
      tags:
        - AdminEmailTemplates
      summary: List all mail templates
      description: |
        Returns one entry per registered template (`MailTemplate` enum), each
        carrying the current effective subject + plaintext body + optional HTML
        body together with the seeded enum defaults so the UI can render a
        diff. `source` is `DATABASE` when an admin override exists for the
        application's default language and `DEFAULT` when render falls back
        to the enum literal.

        Requires superadmin (#438).
      operationId: listMailTemplates
      security:
        - BearerAuth: []
      responses:
        '200':
          description: List of templates with effective + default values
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MailTemplateListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /admin/email/templates/{key}:
    get:
      tags:
        - AdminEmailTemplates
      summary: Get a single mail template
      description: |
        Returns one template by registry key with effective + default values
        and the placeholder reference list. 404 when the key does not exist
        in the `MailTemplate` enum (the registry is closed; admins cannot add
        new templates at runtime). Requires superadmin (#438).
      operationId: getMailTemplate
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/MailTemplateKey'
      responses:
        '200':
          description: Single template with effective + default values
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MailTemplateResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    put:
      tags:
        - AdminEmailTemplates
      summary: Update a mail template
      description: |
        Persists the override for the application's default language. Validates
        every `{{placeholder}}` reference in subject + bodyPlain + bodyHtml
        against the registry's allowed list and rejects unknown names with
        400. Blank `bodyHtml` is rejected — pass `null` to drop HTML. Requires
        superadmin (#438).
      operationId: updateMailTemplate
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/MailTemplateKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MailTemplateUpdateRequest'
      responses:
        '200':
          description: Updated template snapshot with effective + default values
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MailTemplateResponse'
        '400':
          description: Validation failure (unknown key, unknown placeholder, blank bodyHtml)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
    delete:
      tags:
        - AdminEmailTemplates
      summary: Reset a mail template to its enum default
      description: |
        Removes the database override row so render falls through to the
        hard-coded `MailTemplate` enum default. Idempotent — succeeds with
        204 even when no override row exists. Requires superadmin (#438).
      operationId: resetMailTemplate
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/MailTemplateKey'
      responses:
        '204':
          description: Override removed (or never existed)
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /admin/email/templates/{key}/preview:
    post:
      tags:
        - AdminEmailTemplates
      summary: Render a draft mail template with sample values
      description: |
        Renders the supplied draft (subject + bodyPlain + optional bodyHtml)
        through the same Mustache pipeline as production sends, without
        persisting anything. Sample variables default to the registry's
        seeded demo values (`MailTemplate.previewSampleVars`); the caller
        can override any subset to test specific scenarios — admin types
        a new `username` value, refresh, see the preview update.

        Returns 400 with the same diagnostics as `PUT` when the draft
        references undocumented placeholders, has malformed Mustache
        syntax, or has a blank required field. Requires superadmin (#438).
      operationId: previewMailTemplate
      security:
        - BearerAuth: []
      parameters:
        - $ref: '#/components/parameters/MailTemplateKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/MailTemplatePreviewRequest'
      responses:
        '200':
          description: Rendered preview + the sample variable map actually used
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MailTemplatePreviewResponse'
        '400':
          description: Validation failure (unknown placeholder, malformed Mustache, blank field)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /admin/storage/consistency:
    get:
      tags:
        - AdminStorageConsistency
      summary: Scan storage vs DB and return inconsistencies (#190)
      description: |
        Walks the entire `plugin_release` table and the configured storage
        backend, returning a [`ConsistencyReport`](#/components/schemas/ConsistencyReport)
        with two lists:

        * `missingArtifacts`: DB rows whose `artifactKey` does not exist in
          storage (caused by storage outage during upload, accidental
          delete in the storage console, or an incomplete `fs ↔ s3`
          migration).
        * `orphanedArtifacts`: storage objects with no DB row pointing at
          them (caused by best-effort post-commit storage cleanup
          failing — see #481).

        Synchronous and uncached. A `max-keys-per-scan` circuit breaker
        (default 100 000, see
        `plugwerk.storage.consistency.max-keys-per-scan`) aborts the scan
        with `409 CONFLICT` rather than hanging the admin UI on a runaway
        bucket. An async-scan endpoint is **out of scope** for the first
        iteration.

        Requires superadmin.
      operationId: getStorageConsistencyReport
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Consistency snapshot.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConsistencyReport'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Scan aborted by the max-keys-per-scan circuit breaker.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /admin/storage/consistency/releases/{releaseId}:
    delete:
      tags:
        - AdminStorageConsistency
      summary: Remove a `plugin_release` row whose artifact is missing (#190)
      description: |
        Deletes the DB row and best-effort the storage object (idempotent —
        re-running on a vanished row succeeds with 204). Used to clear
        `missingArtifacts` entries from the consistency report. Requires
        superadmin.
      operationId: deleteOrphanedRelease
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: releaseId
          required: true
          schema:
            type: string
            format: uuid
          description: ID of the orphaned release row to remove.
      responses:
        '204':
          description: Row removed (or already gone).
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /admin/storage/consistency/releases:
    delete:
      tags:
        - AdminStorageConsistency
      summary: Bulk-delete `plugin_release` rows whose artifacts are missing (#190)
      description: |
        Removes the supplied release rows (and best-effort their storage
        entries) in one round-trip. Each delete is idempotent and is
        skipped if the artifact is now present in storage (someone restored
        it between scan and delete). Returns the partition of deleted vs
        skipped release IDs. Requires superadmin.
      operationId: deleteOrphanedReleases
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrphanedReleaseDeletionRequest'
      responses:
        '200':
          description: Partition of deleted vs skipped release IDs.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkReleaseDeletionResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /admin/storage/consistency/artifacts:
    delete:
      tags:
        - AdminStorageConsistency
      summary: Bulk-delete orphaned storage objects (#190)
      description: |
        Deletes the supplied storage keys after re-checking that no
        `plugin_release` row references them inside the same transaction.
        Returns the partition of deleted vs skipped keys (skipped =
        became referenced between scan and delete, or storage backend
        failed).

        Bypasses the reaper's grace-period guard because the admin is
        making an explicit choice to delete. The narrow TOCTOU window
        between the recheck and the storage `delete` is accepted as the
        admin-action cost. Requires superadmin.
      operationId: deleteOrphanedArtifacts
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OrphanedArtifactDeletionRequest'
      responses:
        '200':
          description: Partition of deleted vs skipped keys.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkArtifactDeletionResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /admin/branding/{slot}:
    parameters:
      - in: path
        name: slot
        required: true
        schema:
          type: string
          enum: [logo-light, logo-dark, logomark]
        description: Branding slot to operate on.
    post:
      tags:
        - AdminBranding
      summary: Upload a custom logo asset for the given slot (#254)
      description: |
        Replaces the asset stored for this branding slot. SVG payloads are
        sanitised server-side (scripts, foreignObject, event-handler
        attributes, external `href` / `src` references are stripped); PNG
        and WebP payloads are size- and dimension-checked.

        Per-slot rules:

          - `logo-light` / `logo-dark`: max 512 KB, ~4:1 aspect tolerance.
          - `logomark`: max 256 KB, 1:1 (square) aspect.

        The previous asset in the slot (if any) is replaced atomically — the
        public `GET /branding/{slot}` switches to the new bytes within the
        same transaction. Superadmin only.
      operationId: uploadBrandingAsset
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
              required:
                - file
      responses:
        '200':
          description: Asset stored and now serves on the public endpoint.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BrandingAssetDto'
        '400':
          description: Validation failed (size, dimensions, aspect, sanitiser).
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
    delete:
      tags:
        - AdminBranding
      summary: Reset the given slot back to the bundled default (#254)
      description: |
        Deletes the custom asset for this slot. Idempotent — returns `204`
        even when no row was stored, so the operator never sees an error
        for "reset what was already the default". The public
        `GET /branding/{slot}` endpoint then returns `404`, prompting the
        client to fall back to the bundled SVG. Superadmin only.
      operationId: resetBrandingAsset
      security:
        - BearerAuth: []
      responses:
        '204':
          description: Custom asset removed (or never existed — idempotent).
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /branding/{slot}:
    get:
      tags:
        - Branding
      summary: Public asset for the given slot (#254)
      description: |
        Returns the operator-uploaded asset for the slot, or a 404 when
        the slot is at its default — the frontend falls back to the
        bundled SVG in that case. Response carries an `ETag` matching
        the asset's SHA-256 and `Cache-Control: public, max-age=...`
        so the file caches aggressively in browsers and proxies. The
        frontend pins the URL with `?v=<sha256>` so a reset/replace
        invalidates immediately.
      operationId: getBrandingAsset
      security: []
      parameters:
        - in: path
          name: slot
          required: true
          schema:
            type: string
            enum: [logo-light, logo-dark, logomark]
      responses:
        '200':
          description: The asset bytes.
          headers:
            ETag:
              schema:
                type: string
            Cache-Control:
              schema:
                type: string
          content:
            image/svg+xml: {}
            image/png: {}
            image/webp: {}
        '304':
          description: Conditional GET — cached copy still valid.
        '404':
          description: No custom asset for this slot — use the bundled default.

  /admin/configuration:
    get:
      tags:
        - AdminConfiguration
      summary: Effective plugwerk.* configuration tree (#522)
      description: |
        Read-only snapshot of the bound `PlugwerkProperties` bean for the
        admin dashboard. The tree mirrors the yaml hierarchy. Sensitive
        fields (jwt-secret, S3 access/secret key, scrape-password, …) are
        replaced by an object `{ "_secret": true, "configured": <bool> }`
        — the plaintext is never sent over the wire. Requires superadmin.

        Values cannot be edited via this endpoint; operators must change
        `application.yml` / `PLUGWERK_*` env vars and restart.
      operationId: getEffectiveConfiguration
      security:
        - BearerAuth: []
      responses:
        '200':
          description: Effective plugwerk.* configuration tree.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: true
                description: |
                  Nested object that mirrors the `plugwerk.*` yaml
                  hierarchy. Leaf values are primitives or the redacted
                  marker for sensitive fields.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /admin/scheduler/jobs:
    get:
      tags:
        - AdminScheduler
      summary: List all registered scheduled jobs (#516)
      description: |
        Returns the per-job dashboard payload: registry metadata (name,
        description, cron, supportsDryRun) joined with admin-controlled
        runtime state (enabled, dryRun, lastRun*, runCountTotal). Requires
        superadmin.
      operationId: listSchedulerJobs
      security:
        - BearerAuth: []
      responses:
        '200':
          description: All registered jobs.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/SchedulerJobDto'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /admin/scheduler/jobs/{name}:
    patch:
      tags:
        - AdminScheduler
      summary: Toggle enabled/dry-run on a scheduled job (#516)
      description: |
        Mutates the admin-controlled flags for [name]. Cron and tuning
        constants live in `application.yml` and cannot be changed here.
        Returns the updated dashboard row. Requires superadmin.
      operationId: updateSchedulerJob
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: name
          required: true
          schema:
            type: string
            maxLength: 64
          description: Job identifier as returned by the list endpoint.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SchedulerJobUpdateRequest'
      responses:
        '200':
          description: Updated job row.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SchedulerJobDto'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: No registered job with that name.

  /admin/scheduler/jobs/{name}/run:
    post:
      tags:
        - AdminScheduler
      summary: Trigger an off-schedule execution of [name] (#516)
      description: |
        Invokes the job's body once, off-schedule, through the same
        ShedLock path the cron tick uses — if a regular tick is mid-flight
        the request is rejected so the two cannot collide. Honours the
        `enabled` toggle (a disabled job's run-now is rejected with the
        body returning `SKIPPED_DISABLED`). Requires superadmin.
      operationId: runSchedulerJobNow
      security:
        - BearerAuth: []
      parameters:
        - in: path
          name: name
          required: true
          schema:
            type: string
            maxLength: 64
      responses:
        '200':
          description: Outcome of the off-schedule invocation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SchedulerJobRunResult'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: No registered job with that name.
        '409':
          description: A concurrent execution is already in progress.

components:
  parameters:
    NamespaceSlug:
      name: ns
      in: path
      required: true
      schema:
        type: string
        pattern: '^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$'
        maxLength: 64
      description: |
        The namespace slug. Must be lowercase alphanumeric with optional hyphens,
        between 2 and 64 characters. Example: `acme-core`

    PluginIdPath:
      name: pluginId
      in: path
      required: true
      schema:
        type: string
        pattern: '^[a-zA-Z0-9._-]{1,128}$'
        maxLength: 128
      description: |
        The PF4J plugin identifier. Must match the `Plugin-Id` entry in the plugin manifest.
        Supports alphanumeric characters, dots, underscores, and hyphens.
        Example: `com.example.my-plugin`

    VersionPath:
      name: version
      in: path
      required: true
      schema:
        type: string
        pattern: '^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$'
      description: |
        Semantic version string (`MAJOR.MINOR.PATCH`). Pre-release and build metadata
        suffixes are supported per SemVer 2.0. Examples: `1.2.3`, `2.0.0-beta.1`, `1.0.0+build.42`

    ReleaseIdPath:
      name: releaseId
      in: path
      required: true
      schema:
        type: string
        format: uuid
      description: The internal UUID of the release (from `PluginReleaseDto.id`)

    MailTemplateKey:
      name: key
      in: path
      required: true
      schema:
        type: string
        pattern: '^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$'
        maxLength: 64
      description: |
        Registry key for a `MailTemplate` enum entry, e.g.
        `auth.password_reset`, `auth.registration_verification`.

    PageQuery:
      name: page
      in: query
      schema:
        type: integer
        default: 0
        minimum: 0
      description: Zero-based page index. The first page is `0`.

    SizeQuery:
      name: size
      in: query
      schema:
        type: integer
        default: 20
        minimum: 1
        maximum: 100
      description: Number of items per page. Maximum is 100.

    SortQuery:
      name: sort
      in: query
      schema:
        type: string
        default: name,asc
        pattern: '^[a-zA-Z]{1,32},(asc|desc)$'
        maxLength: 40
      description: |
        Sort field and direction, formatted as `field,direction`.
        Examples: `name,asc`, `downloadCount,desc`, `createdAt,desc`

    SearchQuery:
      name: q
      in: query
      schema:
        type: string
      description: |
        Full-text search query. Matched against plugin name, description, and tags.
        Example: `reporting export`

    TagFilter:
      name: tag
      in: query
      schema:
        type: string
      description: |
        Filter by tag. Only plugins that have this tag assigned are returned.
        Example: `export`

    StatusFilter:
      name: status
      in: query
      schema:
        type: string
        enum:
          - active
          - suspended
          - archived
      description: |
        Filter by plugin lifecycle status. When omitted, visibility is role-dependent:
        - Anonymous / API key: only `active` plugins with published releases
        - Authenticated namespace member: all statuses except `suspended`, including draft-only
        - Namespace admin / system admin: all plugins, all statuses
        Explicit values:
        - `active` — only active plugins
        - `suspended` — only suspended plugins (admin-only)
        - `archived` — deprecated/end-of-life plugins only

    VersionFilter:
      name: version
      in: query
      schema:
        type: string
      description: |
        Filter by system version compatibility. Only plugins whose latest release
        declares a `requiresSystemVersion` satisfying this constraint are returned.
        Example: `>=3.0.0`

  schemas:
    RegisterRequest:
      type: object
      description: |
        Self-registration payload (#420). All fields apply the same
        validation as `POST /admin/users`: email is normalised to lower
        case before uniqueness checks, password must satisfy the
        configured strength rules, username and email must be unique
        across `INTERNAL` users (case-insensitive on email).
      properties:
        username:
          type: string
          minLength: 1
          maxLength: 64
          description: The chosen account username
        email:
          type: string
          format: email
          maxLength: 254
          description: Address that receives the verification link (when verification is required)
        password:
          type: string
          format: password
          minLength: 12
          maxLength: 256
          description: |
            Plaintext password — same minimum length (12) as admin-create
            via `POST /admin/users`. The bean-validation layer enforces
            the floor; future strength checks (zxcvbn, banned-passwords)
            land in the service.
        displayName:
          type: string
          nullable: true
          maxLength: 128
          description: Optional human-readable label; defaults to the username when omitted
      required:
        - username
        - email
        - password

    RegisterResponse:
      type: object
      description: |
        Outcome of the self-registration call. The `status` field
        discriminates whether the user can log in immediately or must
        click the verification link first. The shape is intentionally
        identical when a username/email collision was silently swallowed
        for anti-enumeration reasons.
      properties:
        status:
          type: string
          enum:
            - VERIFICATION_PENDING
            - ACTIVE
          description: |
            `VERIFICATION_PENDING` when the verification email has been
            queued and the account stays disabled until the link is
            clicked. `ACTIVE` when the verification toggle is off and
            the account is enabled immediately.
      required:
        - status

    VerifyEmailResponse:
      type: object
      description: Result of consuming an email-verification token.
      properties:
        status:
          type: string
          enum:
            - VERIFIED
          description: Confirms the linked account is now `enabled=true`.
      required:
        - status

    ForgotPasswordRequest:
      type: object
      description: |
        Self-service password-reset trigger payload (#421). Either a
        username or an email address is acceptable; the server resolves
        them against the same user table. EXTERNAL (OIDC) users get no
        email — they reset upstream with their identity provider.
      properties:
        usernameOrEmail:
          type: string
          minLength: 1
          maxLength: 254
          description: Username or email address of the account whose password should be reset
      required:
        - usernameOrEmail

    ResetPasswordRequest:
      type: object
      description: |
        Exchanges a password-reset token (delivered by email) for a new
        password (#421). The token is single-use; on success every
        active session for the user is revoked.
      properties:
        token:
          type: string
          minLength: 8
          maxLength: 128
          description: The opaque token from the reset link
        newPassword:
          type: string
          format: password
          minLength: 12
          maxLength: 256
          description: |
            Plaintext password — same minimum length (12) as the
            `POST /admin/users` and `POST /auth/register` flows. Future
            strength checks (zxcvbn, banned-passwords) will land in the
            shared validation layer.
      required:
        - token
        - newPassword

    LoginRequest:
      type: object
      description: Credentials for password-based login
      properties:
        username:
          type: string
          minLength: 1
          description: The account username
        password:
          type: string
          format: password
          minLength: 1
          description: The account password (transmitted over HTTPS, never stored in plain text)
      required:
        - username
        - password

    LoginResponse:
      type: object
      description: Successful login response containing the access token
      properties:
        accessToken:
          type: string
          description: |
            Signed JWT Bearer token. Include in subsequent requests as:
            `Authorization: Bearer <accessToken>`
        tokenType:
          type: string
          description: Token type, always `Bearer`
          example: Bearer
        expiresIn:
          type: integer
          format: int64
          description: "Token validity duration in seconds (default: 28800 = 8 hours)"
          example: 28800
        userId:
          type: string
          format: uuid
          description: |
            Stable Plugwerk user identifier (`plugwerk_user.id`). Used as the
            JWT `sub` claim. Authoritative reference for "which user is this?"
            in audit logs and FK lookups. Issue #351.
        displayName:
          type: string
          description: |
            Human-readable label for the user. For local users this defaults
            to the username; for OIDC users it is sourced from the `name`
            or `preferred_username` ID-token claim at first login.
        username:
          type: string
          nullable: true
          description: |
            Local-login username. NULL for OIDC users — their authenticating
            identity lives with the upstream provider. Frontend should prefer
            `displayName` for display and only show `username` as a secondary
            label on local-account profile pages.
        email:
          type: string
          description: |
            User's email address. Mandatory after #351; populated for both
            INTERNAL and EXTERNAL accounts.
        source:
          type: string
          enum: [INTERNAL, EXTERNAL]
          description: |
            Discriminator for the account's authentication mechanism (#351).
            `INTERNAL` accounts have credentials on the Plugwerk server
            (username + password). `EXTERNAL` accounts are sourced from an
            upstream identity provider (OIDC, OAuth2, …). Used by the frontend
            to decide whether to surface password-change UI (INTERNAL only).
        passwordChangeRequired:
          type: boolean
          description: |
            When `true`, the user must change their password before accessing any resource.
            The frontend should redirect to the change-password page immediately after login.
            Always `false` for OIDC users (they have no Plugwerk-side password).
        isSuperadmin:
          type: boolean
          description: |
            When `true`, the user has global superadmin privileges.
            Used by the frontend to conditionally show admin UI elements.
      required:
        - accessToken
        - tokenType
        - expiresIn
        - userId
        - displayName
        - email
        - source
        - passwordChangeRequired
        - isSuperadmin

    LogoutResponse:
      type: object
      description: |
        Body returned by `POST /auth/logout` for OIDC-sourced sessions (issue #352).
        Local-login sessions return `204 No Content` and never produce this body.
      properties:
        endSessionUrl:
          type: string
          description: |
            Pre-built RP-Initiated Logout URL pointing at the upstream IdP's
            `end_session_endpoint`. Already carries the `id_token_hint` and
            `post_logout_redirect_uri` query parameters; the frontend MUST
            navigate the browser to this URL to terminate the IdP session.
            After the IdP redirects back, the SPA boots fresh on the login
            page.

            Modeled as a plain string (not `format: uri`) because consumers
            navigate the browser directly to it via `window.location.assign`
            — they neither parse the value nor relativize it, so introducing
            a typed URI just adds friction at every call site without buying
            any safety the IdP allow-list does not already enforce.
          example: https://auth.example.com/realms/plugwerk/protocol/openid-connect/logout?id_token_hint=eyJ...&post_logout_redirect_uri=https%3A%2F%2Fapp.example.com%2Flogin
      required:
        - endSessionUrl

    NamespaceSummary:
      type: object
      description: Summary of a namespace
      properties:
        slug:
          type: string
          description: URL-safe identifier for the namespace, unique across the system
          example: acme-core
        name:
          type: string
          description: Human-readable display name of the namespace
          example: ACME Core Plugins
        description:
          type: string
          description: Optional longer description of the namespace
          example: Plugin repository for the ACME core platform
        publicCatalog:
          type: boolean
          description: Whether the catalog is publicly browsable without authentication
        autoApproveReleases:
          type: boolean
          description: Whether new plugin releases are auto-approved without manual review
        createdAt:
          type: string
          format: date-time
          description: Timestamp when the namespace was created
      required:
        - slug
        - name

    NamespaceCreateRequest:
      type: object
      description: Request body for creating a new namespace
      properties:
        slug:
          type: string
          pattern: '^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$'
          description: |
            Globally unique, URL-safe namespace identifier. Lowercase alphanumeric with hyphens,
            2–64 characters, cannot start or end with a hyphen.
          example: acme-core
        name:
          type: string
          description: Human-readable display name of the namespace
          example: ACME Core Plugins
        description:
          type: string
          description: Optional longer description of the namespace
        publicCatalog:
          type: boolean
          default: false
          description: Whether the plugin catalog of this namespace is publicly visible without authentication.
        autoApproveReleases:
          type: boolean
          default: false
          description: |
            When enabled, uploaded releases are published immediately instead of going through the
            draft → approval workflow.
      required:
        - slug
        - name

    NamespaceUpdateRequest:
      type: object
      description: Request body for updating namespace properties
      properties:
        name:
          type: string
          description: Human-readable display name of the namespace
        description:
          type: string
          description: Optional longer description of the namespace
        publicCatalog:
          type: boolean
          description: Whether the catalog is publicly browsable without authentication
        autoApproveReleases:
          type: boolean
          description: Whether new plugin releases are auto-approved without manual review
      required:
        - name

    ConsistencyReport:
      type: object
      description: |
        Bidirectional reconciliation snapshot between `plugin_release` rows
        and the configured storage backend (#190).
      properties:
        missingArtifacts:
          type: array
          items:
            $ref: '#/components/schemas/MissingArtifact'
        orphanedArtifacts:
          type: array
          items:
            $ref: '#/components/schemas/OrphanedArtifact'
        scannedAt:
          type: string
          format: date-time
          description: Wall-clock timestamp the scan started.
        totalDbRows:
          type: integer
          format: int32
          description: Distinct artifact keys across `plugin_release`.
        totalStorageObjects:
          type: integer
          format: int32
          description: Objects walked in storage (subject to the max-keys circuit breaker).
      required:
        - missingArtifacts
        - orphanedArtifacts
        - scannedAt
        - totalDbRows
        - totalStorageObjects

    MissingArtifact:
      type: object
      description: DB row whose `artifactKey` does not exist in storage.
      properties:
        releaseId:
          type: string
          format: uuid
        namespaceSlug:
          type: string
          description: |
            Slug of the namespace the orphaned release belongs to. Surfaced
            so the admin UI can disambiguate `pluginId`s that exist in
            multiple namespaces (the global plugin identifier in the catalog
            is `namespace/pluginId`).
        pluginId:
          type: string
        version:
          type: string
        artifactKey:
          type: string
      required:
        - releaseId
        - namespaceSlug
        - pluginId
        - version
        - artifactKey

    OrphanedArtifact:
      type: object
      description: |
        Storage object with no `plugin_release` row pointing at it.
        `ageHours` is computed at scan time against `lastModified` so the
        UI renders deterministic values across re-renders; the reaper
        (#496) uses the same field to enforce its grace-period guard.
      properties:
        key:
          type: string
        lastModified:
          type: string
          format: date-time
        ageHours:
          type: integer
          format: int64
          description: Whole hours since the object was last modified at scan time.
        sizeBytes:
          type: integer
          format: int64
      required:
        - key
        - lastModified
        - ageHours
        - sizeBytes

    OrphanedReleaseDeletionRequest:
      type: object
      description: Bulk-delete payload for orphaned `plugin_release` rows.
      properties:
        releaseIds:
          type: array
          minItems: 1
          maxItems: 1000
          items:
            type: string
            format: uuid
      required:
        - releaseIds

    BulkReleaseDeletionResult:
      type: object
      description: |
        Partition of the requested release IDs into successfully deleted
        vs skipped (either the release row had reappeared in storage
        between scan and delete, the row was already gone, or storage
        delete failed). Skipped IDs are still returned so the admin UI
        can re-render the report without re-fetching.
      properties:
        deleted:
          type: array
          items:
            type: string
            format: uuid
        skipped:
          type: array
          items:
            type: string
            format: uuid
      required:
        - deleted
        - skipped

    OrphanedArtifactDeletionRequest:
      type: object
      description: Bulk-delete payload for orphaned storage keys.
      properties:
        keys:
          type: array
          minItems: 1
          maxItems: 1000
          items:
            type: string
      required:
        - keys

    BulkArtifactDeletionResult:
      type: object
      description: |
        Partition of the requested keys into successfully deleted vs
        skipped (either re-referenced between scan and delete, or storage
        backend failed).
      properties:
        deleted:
          type: array
          items:
            type: string
        skipped:
          type: array
          items:
            type: string
      required:
        - deleted
        - skipped

    BrandingAssetDto:
      type: object
      description: |
        Metadata for an uploaded branding asset (#254). The bytes are
        served separately via `GET /branding/{slot}` — this DTO only
        carries the bits the admin UI needs to refresh its preview
        and pin a cache-busting URL.
      properties:
        slot:
          type: string
          enum: [logo-light, logo-dark, logomark]
        contentType:
          type: string
        sizeBytes:
          type: integer
          format: int64
        sha256:
          type: string
        uploadedAt:
          type: string
          format: date-time
      required:
        - slot
        - contentType
        - sizeBytes
        - sha256
        - uploadedAt

    SchedulerJobOutcome:
      type: string
      description: |
        Result of a single scheduler-job invocation, recorded in
        `scheduler_job.last_run_outcome` and returned by the run-now
        endpoint.
      enum:
        - SUCCESS
        - FAILED
        - SKIPPED_DISABLED
        - SKIPPED_LOCK
        - ABORTED_LIMIT

    SchedulerJobDto:
      type: object
      description: |
        Dashboard row for a single registered scheduled job (#516).
        Combines compile-time metadata (description, cron, dry-run
        support) with admin-controlled runtime state.
      properties:
        name:
          type: string
          description: Stable job identifier (matches the ShedLock lock name).
        description:
          type: string
        cronExpression:
          type: string
          description: |
            Cron pattern as configured in application.yml. Read-only —
            the UI shows it but cannot edit it.
        supportsDryRun:
          type: boolean
        enabled:
          type: boolean
        dryRun:
          type: boolean
          nullable: true
          description: |
            null = no admin override, use the yaml default. Non-null
            overrides the yaml default for jobs where supportsDryRun is
            true; the value is ignored for other jobs.
        lastRunAt:
          type: string
          format: date-time
          nullable: true
        lastRunOutcome:
          $ref: '#/components/schemas/SchedulerJobOutcome'
          nullable: true
        lastRunDurationMs:
          type: integer
          format: int64
          nullable: true
        lastRunMessage:
          type: string
          nullable: true
        runCountTotal:
          type: integer
          format: int64
      required:
        - name
        - description
        - cronExpression
        - supportsDryRun
        - enabled
        - runCountTotal

    SchedulerJobUpdateRequest:
      type: object
      description: |
        Patch payload for `PATCH /admin/scheduler/jobs/{name}`. Both
        fields are optional — only fields explicitly present in the
        request body are updated. `dryRun: null` is a legitimate value
        and clears the override (= "honour the yaml default").
      properties:
        enabled:
          type: boolean
        dryRun:
          type: boolean
          nullable: true

    SchedulerJobRunResult:
      type: object
      description: |
        Reply payload for the run-now endpoint. The outcome distinguishes
        a real run from the various skip reasons so the admin UI can
        give specific feedback ("disabled, flip the toggle first" vs
        "another instance is running it right now").
      properties:
        name:
          type: string
        outcome:
          $ref: '#/components/schemas/SchedulerJobOutcome'
        durationMs:
          type: integer
          format: int64
          nullable: true
        message:
          type: string
          nullable: true
      required:
        - name
        - outcome

    ErrorResponse:
      type: object
      description: |
        Standard error response body returned for all 4xx and 5xx
        responses. The `status`, `error`, and `message` fields vary by
        endpoint and failure mode — schema-level examples would be
        misleading because the same envelope is reused for everything
        from `400 BAD_REQUEST` validation failures to `503 SERVICE_UNAVAILABLE`
        infrastructure outages. Per-response `examples` blocks on
        individual endpoints carry the realistic shape for that case.
      properties:
        status:
          type: integer
          description: HTTP status code (mirrors the response status).
        error:
          type: string
          description: |
            Short machine-readable error code (e.g. `NOT_FOUND`,
            `BAD_REQUEST`, `SERVICE_UNAVAILABLE`).
        message:
          type: string
          description: Human-readable description of what went wrong.
        timestamp:
          type: string
          format: date-time
          description: ISO-8601 timestamp of when the error occurred.
      required:
        - status
        - error
        - message
        - timestamp

    PluginDto:
      type: object
      description: Full plugin metadata including release summary
      properties:
        id:
          type: string
          format: uuid
          description: Internal UUID of the plugin record (immutable)
        pluginId:
          type: string
          description: PF4J plugin identifier (e.g. `com.example.my-plugin`). Unique within the namespace.
        name:
          type: string
          description: Human-readable display name of the plugin
        description:
          type: string
          description: Short description of what the plugin does
        provider:
          type: string
          description: Plugin provider or organisation name
        license:
          type: string
          description: SPDX license identifier (e.g. `Apache-2.0`, `MIT`, `GPL-3.0-only`)
        namespace:
          type: string
          description: Slug of the namespace this plugin belongs to
        status:
          type: string
          enum:
            - active
            - suspended
            - archived
          description: |
            Lifecycle status of the plugin:
            - `active` — has at least one published release, visible in catalog
            - `suspended` — temporarily unavailable (e.g. security review in progress)
            - `archived` — no longer maintained, kept for historical download access
        tags:
          type: array
          items:
            type: string
          description: Free-form tags for discovery and filtering
        latestRelease:
          allOf:
            - $ref: '#/components/schemas/PluginReleaseDto'
          nullable: true
          description: |
            Full metadata of the latest published release, or `null` if no published
            release exists. Embedded to avoid extra round-trips when rendering catalog
            cards (includes version, artifactSize, status, downloadCount, etc.).
        downloadCount:
          type: integer
          format: int64
          description: Total number of artifact downloads across all releases (plugin-level aggregate)
        icon:
          type: string
          format: uri
          description: "URL to the plugin icon image (recommended size: 128×128 px)"
        homepage:
          type: string
          format: uri
          description: URL of the plugin's project homepage or documentation site
        repository:
          type: string
          format: uri
          description: URL of the source code repository (e.g. GitHub)
        hasDraftOnly:
          type: boolean
          nullable: true
          description: |
            `true` when the plugin has at least one draft release but no published release.
            `null` or `false` for plugins with at least one published release.
            Used to render a "Pending Review" indicator in the catalog.
        createdAt:
          type: string
          format: date-time
          description: Timestamp when the plugin was first created
        updatedAt:
          type: string
          format: date-time
          description: Timestamp of the most recent metadata update
      required:
        - id
        - pluginId
        - name
        - status

    PluginReleaseDto:
      type: object
      description: Full metadata for a single versioned plugin release
      properties:
        id:
          type: string
          format: uuid
          description: Internal UUID of the release record (used in review endpoints)
        pluginId:
          type: string
          description: PF4J plugin identifier this release belongs to
        version:
          type: string
          description: SemVer version string (e.g. `1.2.3`)
        changelog:
          type: string
          description: Release notes / changelog text for this version (Markdown supported)
        artifactSha256:
          type: string
          description: |
            SHA-256 hex digest of the uploaded artifact binary.
            Clients should verify downloaded artifacts against this value.
        artifactSize:
          type: integer
          format: int64
          description: Size of the artifact in bytes
        fileFormat:
          type: string
          enum:
            - jar
            - zip
          description: Artifact packaging format (jar or zip)
        requiresSystemVersion:
          type: string
          description: |
            SemVer range expression for the minimum required host application version.
            Uses the Maven/PF4J range syntax, e.g. `>=2.0.0 & <4.0.0`.
        pluginDependencies:
          type: array
          items:
            $ref: '#/components/schemas/PluginDependencyDto'
          description: List of other plugins this release depends on (with version constraints)
        status:
          type: string
          enum:
            - draft
            - published
            - deprecated
            - yanked
          description: |
            Lifecycle status of this release:
            - `draft` — uploaded but not yet visible to catalog consumers
            - `published` — visible in catalog and eligible for update checks
            - `deprecated` — still downloadable but no longer recommended for new installations
            - `yanked` — hidden from catalog; typically used for releases with critical issues
        downloadCount:
          type: integer
          format: int64
          description: Number of times this specific release artifact has been downloaded
        createdAt:
          type: string
          format: date-time
          description: Timestamp when the release was created (artifact uploaded)
      required:
        - id
        - pluginId
        - version
        - status

    PluginDependencyDto:
      type: object
      description: A dependency on another plugin, with a version constraint
      properties:
        id:
          type: string
          description: PF4J plugin identifier of the required plugin
        version:
          type: string
          description: |
            SemVer range constraint for the required plugin version.
            Examples: `>=1.0.0`, `>=1.0.0 & <2.0.0`
      required:
        - id
        - version

    PluginUpdateRequest:
      type: object
      description: |
        Request body for updating plugin metadata. All fields are optional — only fields
        present in the request body are updated (partial update / PATCH semantics).
      properties:
        name:
          type: string
          description: New display name
        description:
          type: string
          description: New description
        tags:
          type: array
          items:
            type: string
          description: Replaces the full list of tags (not merged)
        license:
          type: string
          description: New SPDX license expression
        icon:
          type: string
          format: uri
          description: New icon URL
        homepage:
          type: string
          format: uri
          description: New homepage URL
        repository:
          type: string
          format: uri
          description: New source repository URL
        status:
          type: string
          enum:
            - active
            - suspended
            - archived
          description: |
            New lifecycle status for the plugin:
            - `active` — plugin is visible in the catalog
            - `suspended` — temporarily hidden (e.g. security review)
            - `archived` — no longer maintained

    ReleaseUploadRequest:
      type: object
      description: Multipart form data for uploading a plugin release artifact
      properties:
        artifact:
          type: string
          format: binary
          description: |
            The plugin artifact file (JAR or ZIP). Must contain a valid MANIFEST.MF with
            `Plugin-Id` and `Plugin-Version` attributes (or `plugin.properties` as fallback)
            for the server to extract version and plugin metadata.
      required:
        - artifact

    ReleaseStatusUpdateRequest:
      type: object
      description: Request body for transitioning a release to a new lifecycle status
      properties:
        status:
          type: string
          enum:
            - published
            - deprecated
            - yanked
          description: |
            Target status for the release:
            - `published` — make the release visible in the catalog (from `draft`)
            - `deprecated` — mark as superseded but keep it downloadable
            - `yanked` — remove from catalog permanently (irreversible)
      required:
        - status

    UpdateCheckRequest:
      type: object
      description: List of currently installed plugins to check for updates
      properties:
        plugins:
          type: array
          maxItems: 500
          items:
            $ref: '#/components/schemas/InstalledPluginInfo'
          description: |
            The installed plugins to check. Up to 500 entries per request.
            Plugins not found in the namespace are silently ignored.
      required:
        - plugins

    InstalledPluginInfo:
      type: object
      description: An installed plugin with its current version
      properties:
        pluginId:
          type: string
          description: PF4J plugin identifier
        currentVersion:
          type: string
          description: The currently installed SemVer version (e.g. `1.0.0`)
      required:
        - pluginId
        - currentVersion

    UpdateCheckResponse:
      type: object
      description: Update check result — contains only plugins that have a newer version available
      properties:
        updates:
          type: array
          items:
            $ref: '#/components/schemas/PluginUpdateInfo'
          description: |
            List of plugins with available updates. Plugins that are already at the latest
            published version are omitted. An empty array means everything is up to date.
      required:
        - updates

    PluginUpdateInfo:
      type: object
      description: Information about an available plugin update
      properties:
        pluginId:
          type: string
          description: PF4J plugin identifier
        currentVersion:
          type: string
          description: The version currently installed on the client
        latestVersion:
          type: string
          description: The latest published version available on the server
        release:
          $ref: '#/components/schemas/PluginReleaseDto'
          description: Full release details for the latest available version
      required:
        - pluginId
        - currentVersion
        - latestVersion
        - release

    Pf4jPluginsJson:
      type: object
      description: |
        Plugin feed in the [pf4j-update](https://github.com/pf4j/pf4j-update) `plugins.json`
        format. This is the root object returned by the `GET /plugins.json` endpoint.
      properties:
        plugins:
          type: array
          items:
            $ref: '#/components/schemas/Pf4jPluginInfo'
          description: List of all plugins with their published releases
      required:
        - plugins

    Pf4jPluginInfo:
      type: object
      description: Plugin entry in pf4j-update format
      properties:
        id:
          type: string
          description: PF4J plugin identifier
        description:
          type: string
          description: Short plugin description
        provider:
          type: string
          description: Plugin provider or organisation name
        projectUrl:
          type: string
          description: Homepage or project URL
        releases:
          type: array
          items:
            $ref: '#/components/schemas/Pf4jReleaseInfo'
          description: List of published releases for this plugin, newest first
      required:
        - id
        - releases

    Pf4jReleaseInfo:
      type: object
      description: Release entry in pf4j-update format
      properties:
        version:
          type: string
          description: SemVer version string
        date:
          type: string
          format: date
          description: Release date (ISO-8601, e.g. `2026-01-15`)
        url:
          type: string
          format: uri
          description: Direct download URL for the release artifact
        requires:
          type: string
          description: |
            Minimum host application version required, in pf4j-update range format.
            Corresponds to `requiresSystemVersion` in the Plugwerk release model.
        sha512sum:
          type: string
          description: SHA-512 hex digest of the artifact for integrity verification
      required:
        - version
        - url

    ReviewItemDto:
      type: object
      description: A release that is pending admin review
      properties:
        releaseId:
          type: string
          format: uuid
          description: Internal UUID of the release (use this in approve/reject endpoints)
        pluginId:
          type: string
          description: PF4J plugin identifier
        pluginName:
          type: string
          description: Human-readable name of the plugin
        version:
          type: string
          description: SemVer version of the release awaiting review
        submittedBy:
          type: string
          description: Username of the account that uploaded the release
        submittedAt:
          type: string
          format: date-time
          description: Timestamp when the artifact was uploaded
        artifactSha256:
          type: string
          description: SHA-256 hex digest of the artifact (for manual verification before approving)
      required:
        - releaseId
        - pluginId
        - pluginName
        - version
        - submittedAt

    ReviewDecisionRequest:
      type: object
      description: |
        Optional comment to attach to an approve or reject decision. The
        same schema is reused for both verdicts via `$ref` — the
        per-endpoint examples on `POST /reviews/{releaseId}/approve` and
        `POST /reviews/{releaseId}/reject` carry the contextually correct
        sample comment for each verb.
      properties:
        comment:
          type: string
          description: |
            Human-readable comment explaining the decision. Strongly recommended for
            rejections so the publisher knows what needs to be fixed.

    PluginPagedResponse:
      type: object
      description: Paginated response containing a list of plugins
      properties:
        content:
          type: array
          items:
            $ref: '#/components/schemas/PluginDto'
          description: Plugin entries on this page
        totalElements:
          type: integer
          format: int64
          description: Total number of plugins matching the query (across all pages)
        page:
          type: integer
          format: int32
          description: Current zero-based page index
        size:
          type: integer
          format: int32
          description: Number of items per page
        totalPages:
          type: integer
          format: int32
          description: Total number of pages
        pendingReviewPluginCount:
          type: integer
          format: int64
          nullable: true
          description: |
            Number of active plugins that have at least one draft release pending review.
            Only populated for authenticated users with namespace access; `null` for
            anonymous requests.
        pendingReviewReleaseCount:
          type: integer
          format: int64
          nullable: true
          description: |
            Total number of draft releases pending review across all plugins in the
            namespace. This reflects the actual size of the review queue.
            Only populated for authenticated users with namespace access; `null` for
            anonymous requests.
      required:
        - content
        - totalElements
        - page
        - size
        - totalPages

    ReleasePagedResponse:
      type: object
      description: Paginated response containing a list of releases
      properties:
        content:
          type: array
          items:
            $ref: '#/components/schemas/PluginReleaseDto'
          description: Release entries on this page
        totalElements:
          type: integer
          format: int64
          description: Total number of releases matching the query (across all pages)
        page:
          type: integer
          format: int32
          description: Current zero-based page index
        size:
          type: integer
          format: int32
          description: Number of items per page
        totalPages:
          type: integer
          format: int32
          description: Total number of pages
      required:
        - content
        - totalElements
        - page
        - size
        - totalPages

    UserPagedResponse:
      type: object
      description: Paginated response containing a list of users
      properties:
        content:
          type: array
          items:
            $ref: '#/components/schemas/UserDto'
          description: User entries on this page
        totalElements:
          type: integer
          format: int64
          description: Total number of users matching the query (across all pages)
        page:
          type: integer
          format: int32
          description: Current zero-based page index
        size:
          type: integer
          format: int32
          description: Number of items per page
        totalPages:
          type: integer
          format: int32
          description: Total number of pages
      required:
        - content
        - totalElements
        - page
        - size
        - totalPages

    ChangePasswordRequest:
      type: object
      properties:
        currentPassword:
          type: string
        newPassword:
          type: string
          minLength: 12
      required:
        - currentPassword
        - newPassword

    UserDto:
      type: object
      description: |
        Plugwerk user account. After the identity-hub split (#351), Plugwerk has
        two flavours of accounts discriminated by `source` — INTERNAL password-
        based accounts and EXTERNAL accounts sourced from an upstream identity
        provider (OIDC, OAuth2, …).
      properties:
        id:
          type: string
          format: uuid
        displayName:
          type: string
          description: |
            Human-readable label. For local users this defaults to the username;
            for OIDC users it is sourced from the `name` / `preferred_username`
            ID-token claim at first login.
        source:
          type: string
          enum: [INTERNAL, EXTERNAL]
          description: |
            Discriminator for the account's authentication mechanism (#351).
            `INTERNAL` accounts have `username` and a Plugwerk-side password.
            `EXTERNAL` accounts authenticate against an upstream provider —
            `username` is `null`, `passwordChangeRequired` is always `false`.
        username:
          type: string
          nullable: true
          description: |
            Local-login username. Always present and unique for `source = INTERNAL`,
            always `null` for `source = EXTERNAL`.
        email:
          type: string
          description: |
            Email address. Mandatory after #351. Case-insensitively unique
            within `source = INTERNAL` accounts only — EXTERNAL accounts may
            share emails (Plugwerk does not perform identity linking).
        enabled:
          type: boolean
        passwordChangeRequired:
          type: boolean
        isSuperadmin:
          type: boolean
          description: When true the user has global superadmin privileges and cannot be deleted
        createdAt:
          type: string
          format: date-time
        lastLoginAt:
          type: string
          format: date-time
          nullable: true
          description: |
            Timestamp of the most recent fresh, credential-validated login —
            either a successful `POST /auth/login` (INTERNAL) or a successful
            external (OIDC / OAuth2) callback. NOT bumped on `/auth/refresh`,
            on resource-server bearer traffic, or on `X-Api-Key` requests.
            `null` for accounts that have never logged in (e.g. provisioned
            via `POST /admin/users` and never used yet).
        providerName:
          type: string
          nullable: true
          description: |
            Operator-configured display name of the OIDC / OAuth2 provider that
            authenticates this user — `OidcProviderEntity.name`, the same string
            shown on the login page. Populated for `source = EXTERNAL` rows by
            joining `oidc_identity → oidc_provider`. `null` for INTERNAL users
            and (defensively) for any EXTERNAL user whose `oidc_identity` row
            is missing — this should not occur given the schema's foreign-key
            and `UNIQUE(user_id)` constraints, but the field is nullable so
            the API does not 500 on inconsistent rows.

            For UI display only — disambiguates two EXTERNAL users with the
            same `displayName` coming from different providers (e.g. one
            Google account and one Keycloak account both labelled "Alice").
            Do not parse.
        namespaceMembershipCount:
          type: integer
          format: int32
          description: Number of namespaces this user is a member of
      required:
        - id
        - displayName
        - source
        - email
        - enabled
        - passwordChangeRequired
        - isSuperadmin
        - createdAt

    UserCreateRequest:
      type: object
      description: |
        Admin-driven creation of an internal Plugwerk user (`source = INTERNAL`).
        EXTERNAL users are never created via this endpoint — they materialise on
        first OIDC / OAuth2 callback via `OidcIdentityService.upsertOnLogin`.
      properties:
        username:
          type: string
          minLength: 3
          maxLength: 255
        email:
          type: string
          format: email
          description: |
            Mandatory after #351. Case-insensitively unique among INTERNAL users.
        displayName:
          type: string
          maxLength: 255
          description: |
            Optional display label. Defaults to `username` when omitted.
        password:
          type: string
          minLength: 12
      required:
        - username
        - email
        - password

    UserUpdateRequest:
      type: object
      description: Partial update — include only the fields to change
      properties:
        enabled:
          type: boolean
          nullable: true
        newPassword:
          type: string
          minLength: 12
          nullable: true

    AdminPasswordResetResponse:
      type: object
      description: |
        Outcome of `POST /admin/users/{userId}/reset-password` (#450).

        Server-side side effects (token issue, dual session revocation,
        structured audit log) always happen on the success path. The
        boolean fields tell the caller whether the user was reachable
        through the normal email channel.
      properties:
        tokenIssued:
          type: boolean
          description: |
            Always `true` on the 200 response. Surfaced for forward
            compatibility — a future enhancement might return 200 with
            `tokenIssued=false` if the operator turns off the admin-driven
            reset flow per-deployment.
        emailSent:
          type: boolean
          description: |
            `true` when SMTP was configured and the mail provider accepted
            the message; `false` when SMTP is disabled or the send failed
            (server log carries the reason in the latter case).
        expiresAt:
          type: string
          format: date-time
          description: |
            Absolute UTC timestamp at which the issued reset token expires.
            Operators surfacing the link out-of-band can use this to tell
            the user how long they have to act.
        resetUrl:
          type: string
          nullable: true
          description: |
            Absolute reset URL (`{webBaseUrl}/reset-password?token=…`).
            Populated only when `emailSent` is `false`, so the operator can
            deliver the link out-of-band (Slack, phone, in-person). Omitted
            on the happy path so the link does not appear in API logs.
      required:
        - tokenIssued
        - emailSent
        - expiresAt

    NamespaceMembershipDto:
      type: object
      description: The calling user's role in a specific namespace
      properties:
        role:
          $ref: '#/components/schemas/NamespaceRole'
      required:
        - role

    NamespaceMemberDto:
      type: object
      description: |
        A user's role assignment within a namespace. After the identity-hub
        split (#351) the membership is keyed by `userId` (= `plugwerk_user.id`),
        not by a free-text subject. `displayName` and `username` are echoed
        for UI convenience so the caller does not have to perform a second
        lookup against the admin user endpoint.
      properties:
        userId:
          type: string
          format: uuid
        displayName:
          type: string
        username:
          type: string
          nullable: true
          description: Local-login username; null for OIDC users.
        source:
          type: string
          enum: [INTERNAL, EXTERNAL]
          description: |
            Discriminator for the underlying account's authentication
            mechanism (mirrors `UserDto.source`). `INTERNAL` accounts have
            credentials on the Plugwerk server; `EXTERNAL` accounts are
            sourced from an upstream identity provider (OIDC, OAuth2, …).
            Used by the members table to render a provider-name secondary
            label under EXTERNAL members.
        providerName:
          type: string
          nullable: true
          description: |
            Operator-configured display name of the OIDC / OAuth2 provider —
            same data as `UserDto.providerName`, surfaced here so the
            namespace members table can render it without an extra lookup
            against the admin user endpoint. Populated for EXTERNAL members;
            `null` for INTERNAL members and (defensively) for any EXTERNAL
            member whose `oidc_identity` row is missing.

            For UI display only — disambiguates two same-named EXTERNAL
            members coming from different providers. Do not parse.
        role:
          $ref: '#/components/schemas/NamespaceRole'
        createdAt:
          type: string
          format: date-time
      required:
        - userId
        - displayName
        - source
        - role
        - createdAt

    NamespaceMemberCreateRequest:
      type: object
      properties:
        userId:
          type: string
          format: uuid
          description: |
            Plugwerk user identifier (`plugwerk_user.id`) of the user being
            added to the namespace. Caller is responsible for resolving the
            id (e.g. via the admin user listing endpoint).
        role:
          $ref: '#/components/schemas/NamespaceRole'
      required:
        - userId
        - role

    NamespaceMemberUpdateRequest:
      type: object
      properties:
        role:
          $ref: '#/components/schemas/NamespaceRole'
      required:
        - role

    NamespaceRole:
      type: string
      enum:
        - ADMIN
        - MEMBER
        - READ_ONLY
      description: |
        Namespace-scoped role:
        - `ADMIN`: full write access including member and access key management
        - `MEMBER`: upload releases, manage plugin metadata, view review queue
        - `READ_ONLY`: browse catalog and download artifacts only

    OidcProviderDto:
      type: object
      description: OIDC provider configuration (client secret is never returned)
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        providerType:
          $ref: '#/components/schemas/OidcProviderType'
        enabled:
          type: boolean
        clientId:
          type: string
        issuerUri:
          type: string
          nullable: true
        scope:
          type: string
        authorizationUri:
          type: string
          nullable: true
          description: Authorization endpoint URL — meaningful only when providerType is OAUTH2.
        tokenUri:
          type: string
          nullable: true
          description: Token endpoint URL — meaningful only when providerType is OAUTH2.
        userInfoUri:
          type: string
          nullable: true
          description: User-info endpoint URL — meaningful only when providerType is OAUTH2.
        jwkSetUri:
          type: string
          nullable: true
          description: |
            JWK set URL for resource-server JWT validation — meaningful only when providerType
            is OAUTH2 and the provider issues JWT access tokens. Leave null for opaque-
            token providers.
        subjectAttribute:
          type: string
          nullable: true
          description: |
            User-info JSON key for the stable user identifier — meaningful only when providerType
            is OAUTH2. Null means application default (`sub`).
        emailAttribute:
          type: string
          nullable: true
          description: |
            User-info JSON key for the user's email — meaningful only when providerType is
            OAUTH2. Null means application default (`email`).
        displayNameAttribute:
          type: string
          nullable: true
          description: |
            User-info JSON key for the user's display name — meaningful only when providerType
            is OAUTH2. Null means application default (`name`).
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
      required:
        - id
        - name
        - providerType
        - enabled
        - clientId
        - scope
        - createdAt
        - updatedAt

    OidcProviderCreateRequest:
      type: object
      properties:
        name:
          type: string
        providerType:
          $ref: '#/components/schemas/OidcProviderType'
        clientId:
          type: string
        clientSecret:
          type: string
        issuerUri:
          type: string
          nullable: true
          description: Required for the Generic OIDC provider type
        scope:
          type: string
          default: openid email profile
        authorizationUri:
          type: string
          nullable: true
          description: |
            OAuth2 authorize-endpoint URL. Required when providerType is OAUTH2;
            ignored otherwise. Validated for http(s) scheme by the server.
        tokenUri:
          type: string
          nullable: true
          description: |
            OAuth2 token-endpoint URL. Required when providerType is OAUTH2;
            ignored otherwise. Validated for http(s) scheme by the server.
        userInfoUri:
          type: string
          nullable: true
          description: |
            OAuth2 user-info endpoint URL. Required when providerType is OAUTH2;
            ignored otherwise. Validated for http(s) scheme by the server.
        jwkSetUri:
          type: string
          nullable: true
          description: |
            JWK set URL for resource-server JWT validation. Optional; only meaningful when
            providerType is OAUTH2. Leave null for opaque-token providers.
        subjectAttribute:
          type: string
          nullable: true
          description: |
            User-info JSON key carrying the stable user identifier. Defaults to `sub` when
            null. Meaningful only when providerType is OAUTH2.
        emailAttribute:
          type: string
          nullable: true
          description: |
            User-info JSON key carrying the user's email. Defaults to `email` when null.
            Meaningful only when providerType is OAUTH2.
        displayNameAttribute:
          type: string
          nullable: true
          description: |
            User-info JSON key carrying the user's display name. Defaults to `name` when null.
            Meaningful only when providerType is OAUTH2.
      required:
        - name
        - providerType
        - clientId
        - clientSecret

    OidcProviderUpdateRequest:
      type: object
      description: |
        Partial update — include only fields you want to change. `providerType` is
        intentionally NOT editable: switching a row's type would require re-validating
        every previously issued access token (different `iss` claim, possibly different
        `aud` claim) and would tempt operators to "convert" rather than create a fresh
        provider. To change `providerType`, delete and recreate the provider.
      properties:
        enabled:
          type: boolean
          nullable: true
        name:
          type: string
          minLength: 1
          maxLength: 255
          nullable: true
          description: Display name shown on the login page.
        clientId:
          type: string
          minLength: 1
          maxLength: 255
          nullable: true
          description: |
            OAuth2 client ID. Changing this on an enabled provider invalidates all existing
            access tokens issued by that provider (the `aud` claim no longer matches), so
            users must re-login.
        clientSecret:
          type: string
          minLength: 8
          writeOnly: true
          nullable: true
          description: |
            Provide to rotate the client secret. Blank or omitted leaves the existing
            encrypted value untouched. Never returned in responses.
        issuerUri:
          type: string
          format: uri
          nullable: true
          description: |
            OIDC issuer URI for type `OIDC` (Generic OIDC). Reachability is verified
            lazily by the registration refresh after the patch is saved — a temporarily
            unreachable issuer does NOT roll back the patch (operators may legitimately
            patch the URI before the upstream is reachable, e.g. during a realm cutover).
            Cannot be cleared via PATCH; to remove, delete and recreate the provider.
        scope:
          type: string
          minLength: 1
          nullable: true
          description: |
            Space-separated OAuth2 scopes. For type `OIDC` (Generic OIDC) the scope set
            must include `openid`; otherwise the patch is rejected.
        authorizationUri:
          type: string
          format: uri
          nullable: true
          description: |
            OAuth2 authorize-endpoint URL. Meaningful only for OAUTH2 providers.
        tokenUri:
          type: string
          format: uri
          nullable: true
          description: |
            OAuth2 token-endpoint URL. Meaningful only for OAUTH2 providers.
        userInfoUri:
          type: string
          format: uri
          nullable: true
          description: |
            OAuth2 user-info endpoint URL. Meaningful only for OAUTH2 providers.
        jwkSetUri:
          type: string
          format: uri
          nullable: true
          description: |
            JWK set URL. Meaningful only for OAUTH2 providers that issue JWT access
            tokens. Cannot be cleared via PATCH; to remove, delete and recreate the provider.
        subjectAttribute:
          type: string
          minLength: 1
          nullable: true
          description: |
            User-info JSON key carrying the stable subject identifier. Meaningful only for
            OAUTH2 providers. Default (`sub`) is used when null.
        emailAttribute:
          type: string
          minLength: 1
          nullable: true
          description: |
            User-info JSON key carrying the user's email. Meaningful only for OAUTH2
            providers. Default (`email`) is used when null.
        displayNameAttribute:
          type: string
          minLength: 1
          nullable: true
          description: |
            User-info JSON key carrying the user's display name. Meaningful only for
            OAUTH2 providers. Default (`name`) is used when null.

    OidcDiscoveryRequest:
      type: object
      properties:
        issuerUri:
          type: string
          minLength: 1
          description: |
            Candidate OIDC issuer URI. The server appends `/.well-known/openid-configuration`
            (per RFC 8414 / OpenID Connect Discovery 1.0) and tries to fetch it.
      required:
        - issuerUri

    OidcDiscoveryResponse:
      type: object
      description: |
        Result of a discovery probe. `success = true` means the configuration document
        was fetched and parsed; the four endpoint fields are populated from it.
        `success = false` means the probe failed — `error` carries the operator-actionable
        reason (DNS failure, 404, malformed JSON, missing required keys, …).
      properties:
        success:
          type: boolean
        authorizationUri:
          type: string
          nullable: true
        tokenUri:
          type: string
          nullable: true
        userInfoUri:
          type: string
          nullable: true
        jwkSetUri:
          type: string
          nullable: true
        error:
          type: string
          nullable: true
      required:
        - success

    OidcProviderType:
      type: string
      enum:
        - OIDC
        - GITHUB
        - GOOGLE
        - FACEBOOK
        - OAUTH2
      description: |
        Provider type. Pre-configured providers (GITHUB, GOOGLE, FACEBOOK) have well-known
        endpoints embedded — only clientId and clientSecret are required. The `OIDC` type
        ("Generic OIDC" in the admin UI) requires an explicit issuerUri and works with any
        standards-compliant OIDC provider (Keycloak, Authentik, Auth0, Dex, …). The
        OAUTH2 type lets the operator configure authorizationUri, tokenUri,
        userInfoUri (and optionally jwkSetUri) plus the JSON attribute names the user-info
        response uses for subject / email / display name — any OAuth2 source that does not
        map to one of the four hard-wired vendors (GitLab, Bitbucket, custom enterprise
        IdPs etc.) can be wired in this way without a code change.

    ServerConfigResponse:
      type: object
      description: |
        Public, non-sensitive server configuration that clients use to enforce local
        validation rules. This response is intentionally small and stable — new top-level
        keys may be added in future versions, but existing keys will not be removed.
      properties:
        version:
          type: string
          description: |
            Server version string (e.g. "1.0.0-SNAPSHOT" or "1.0.0").
            Sourced from build metadata at startup. Returns "unknown" if
            build information is not available.
          example: "1.0.0"
        upload:
          type: object
          description: Upload-related configuration
          properties:
            maxFileSizeMb:
              type: integer
              description: |
                Maximum allowed plugin artifact file size in megabytes. Files exceeding
                this limit are rejected with HTTP 413 (Payload Too Large). The server
                operator can change this value via the `PLUGWERK_UPLOAD_MAX_FILE_SIZE_MB`
                environment variable. Default: `100`.
              example: 100
          required:
            - maxFileSizeMb
        general:
          type: object
          description: General application-wide configuration
          properties:
            defaultTimezone:
              type: string
              description: |
                Server-configured default timezone (IANA identifier, e.g. `UTC`,
                `Europe/Berlin`). Used as the display-time fallback for any user who has
                not set a personal timezone preference.
              example: "UTC"
            siteName:
              type: string
              description: |
                Operator-branded display name surfaced by the UI in the browser tab
                title, top bar, footer, and onboarding (#234). DB-backed via
                `general.site_name` in `application_setting` (ADR-0016); the admin
                write path validates length 1..255. The frontend falls back to
                `Plugwerk` if the value is empty.
              example: "Plugwerk"
          required:
            - defaultTimezone
            - siteName
        auth:
          type: object
          description: |
            Authentication-related public configuration. Used by the login page to
            render OIDC provider buttons in addition to the local username/password
            form. Only public, non-sensitive fields appear here — never client secrets,
            issuer URIs, or any other operator-only data.
          properties:
            oidcProviders:
              type: array
              description: |
                List of OIDC providers that are currently `enabled` in the database
                and registered in Spring Security's OAuth2 client registry. Empty
                when no provider has been activated. The frontend renders one
                "Login with `<name>`" button per entry on the login page (issue #79).
                The endpoint-level example on `GET /config` shows a representative
                payload — the schema deliberately carries no example so it does
                not drift behind newer `OidcProviderLoginInfo` fields.
              items:
                $ref: '#/components/schemas/OidcProviderLoginInfo'
            selfRegistrationEnabled:
              type: boolean
              description: |
                Whether the operator has enabled the public self-registration
                flow (#420). The login page conditionally renders the
                "Don't have an account? Sign up" link when this is true. The
                backend enforces the gate independently — flipping this on the
                client cannot bypass the controller check.
              example: false
            passwordResetEnabled:
              type: boolean
              description: |
                Whether the operator has enabled the public forgot-password
                flow (#421). The login page conditionally renders the
                "Forgot password?" link when this is true. The backend
                enforces the gate independently — flipping this on the
                client cannot bypass the controller checks on
                `/auth/forgot-password` and `/auth/reset-password`.
              example: false
          required:
            - oidcProviders
            - selfRegistrationEnabled
            - passwordResetEnabled
      required:
        - version
        - upload
        - general
        - auth

    OidcProviderLoginInfo:
      type: object
      description: |
        Public, non-sensitive metadata about an enabled OIDC provider for the
        login page (issue #79). The `loginUrl` is a relative path — the frontend
        navigates the browser to it; Spring Security's OAuth2 client filter
        intercepts the request and starts the Authorization Code Flow with PKCE.
      properties:
        id:
          type: string
          description: |
            Spring Security `registrationId`. Stable identifier; URL-safe slug
            derived from the provider entity. Same value as the `{registrationId}`
            in the redirect URI `/login/oauth2/code/{registrationId}`.
          example: "keycloak-local"
        name:
          type: string
          description: |
            Human-readable display name of the provider, as configured in the
            Plugwerk Admin UI. Shown verbatim on the "Login with …" button.
          example: "Keycloak Local"
        loginUrl:
          type: string
          description: |
            Relative URL the frontend navigates the browser to in order to start
            the OIDC flow. Always `/oauth2/authorization/{id}` for the local
            client; never an absolute URL — keeps the same-origin guarantees
            of the open-redirect guard from issue #274 trivially satisfied.
          example: "/oauth2/authorization/keycloak-local"
        accountPickerLoginUrl:
          type: string
          nullable: true
          description: |
            Relative URL the frontend navigates the browser to when the user
            wants to pick a *different* upstream account (issue #410). Same
            shape as `loginUrl` but with the OIDC `prompt` query parameter
            appended so the upstream provider re-displays its account-picker
            / re-auth screen instead of silently re-using the existing IdP
            session.

            Per provider type:

            - `OIDC`, `GOOGLE`, `FACEBOOK` → `?prompt=select_account`
            - `OAUTH2` (generic) → `?prompt=login` (best-effort — many but
              not all OAuth2 providers honour this)
            - `GITHUB` → `null` (no `prompt` support; see `accountSwitchHintUrl`
              below for the textual fallback the frontend renders instead)

            The backend whitelists the value strictly via
            [PromptAwareOAuth2AuthorizationRequestResolver] — only
            `select_account` and `login` are forwarded upstream.
          example: "/oauth2/authorization/keycloak-local?prompt=select_account"
        accountSwitchHintUrl:
          type: string
          nullable: true
          description: |
            Absolute upstream URL the user can follow to terminate their
            session at the IdP when the provider does not support OIDC
            `prompt` (issue #410). Today only set for `GITHUB`
            (`https://github.com/logout`); `null` for every other provider
            type because they all surface their account-switch affordance
            through `accountPickerLoginUrl` instead.

            The frontend renders this as a small inline hint underneath
            the primary button — never a clickable button. The link only
            terminates the GitHub session; the user still has to come
            back to Plugwerk and click the primary button to actually
            sign in with a different account.
          example: "https://github.com/logout"
        iconKind:
          type: string
          enum: [github, google, facebook, oidc, oauth2]
          description: |
            Stable, brand-agnostic identifier the frontend uses to pick
            the right icon glyph for the provider button on the login
            page. Exposed as a fixed enum (rather than the internal
            `OidcProviderType`) so the public `/config` surface stays
            decoupled from the domain enum's evolution.

            Mapping:
            - `github` → GitHub vendor mark
            - `google` → Google "G" vendor mark
            - `facebook` → Facebook "f" vendor mark
            - `oidc` → key icon (Generic OIDC: Keycloak, Authentik, …)
            - `oauth2` → key icon (Generic OAuth2: GitLab, Bitbucket, …)
          example: "github"
      required:
        - id
        - name
        - loginUrl
        - iconKind

    AccessKeyDto:
      type: object
      description: Metadata for a namespace access key (the key value is never returned)
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
          description: Optional human-readable name to identify the key
        keyPrefix:
          type: string
          description: First 8 characters of the plaintext key for identification (e.g. pwk_abcd)
        revoked:
          type: boolean
        expiresAt:
          type: string
          format: date-time
        createdAt:
          type: string
          format: date-time
      required:
        - id
        - name
        - revoked
        - createdAt

    AccessKeyCreateRequest:
      type: object
      description: Request body for generating a new namespace access key
      properties:
        name:
          type: string
          description: Human-readable name identifying the key (must be unique within the namespace)
        expiresAt:
          type: string
          format: date-time
          description: Optional expiry date
      required:
        - name

    AccessKeyCreateResponse:
      type: object
      description: |
        Response returned when a new access key is created. The plain-text key is included
        only in this response and cannot be retrieved later.
      properties:
        id:
          type: string
          format: uuid
        key:
          type: string
          description: The plain-text access key. Only returned once — store it securely.
        name:
          type: string
          description: Optional human-readable name to identify the key
        createdAt:
          type: string
          format: date-time
      required:
        - id
        - key
        - name
        - createdAt

    ApplicationSettingDto:
      type: object
      description: |
        One admin-manageable application setting (ADR-0016).
      properties:
        key:
          type: string
          description: Stable dotted identifier, e.g. `upload.max_file_size_mb`
        value:
          type: string
          description: Raw string value (parsed according to `valueType` on the client)
        description:
          type: string
          nullable: true
          description: |
            Human-readable description of the setting, persisted in the
            `application_setting.setting_desc` column. Intended for display as inline help
            in the Admin UI. May be `null` for legacy rows or keys that have no description.
        valueType:
          type: string
          enum: [STRING, INTEGER, BOOLEAN, ENUM, PASSWORD]
          description: |
            Discriminator for client-side parsing. `PASSWORD`-typed settings
            are stored encrypted at rest; their `value` field in GET responses
            is always the masking sentinel `***` (or empty if unset). The Admin
            UI renders them as `<input type="password">`. Writing the literal
            `***` is a no-op (the masking sentinel is recognised on the write
            path to avoid round-tripping the sentinel as the new password) — see #253.
        source:
          type: string
          enum: [DATABASE, DEFAULT]
          description: Where the effective value came from
        requiresRestart:
          type: boolean
          description: True if changing this setting requires a server restart to take effect
        restartPending:
          type: boolean
          description: |
            True if `requiresRestart` is true AND the database value has changed since the
            JVM started. UI hint only — the running server still uses the boot-time value.
        allowedValues:
          type: array
          description: For ENUM settings, the full set of accepted values. Null otherwise.
          items:
            type: string
        minInt:
          type: integer
          description: For INTEGER settings, the inclusive minimum. Null otherwise.
        maxInt:
          type: integer
          description: For INTEGER settings, the inclusive maximum. Null otherwise.
      required:
        - key
        - value
        - valueType
        - source
        - requiresRestart
        - restartPending

    ApplicationSettingsResponse:
      type: object
      description: Full snapshot of every known setting.
      properties:
        settings:
          type: array
          items:
            $ref: '#/components/schemas/ApplicationSettingDto'
      required:
        - settings

    MailTemplateResponse:
      type: object
      description: |
        Single mail template view with effective values, seeded enum defaults,
        and the placeholder reference list. Effective values are sourced
        either from the database override (when present) or fall back to the
        enum default — `source` discriminates which.
      properties:
        key:
          type: string
          description: Registry key (e.g. `auth.password_reset`).
        friendlyName:
          type: string
          description: Humanised registry key for display, e.g. `Auth · Password Reset`.
        locale:
          type: string
          description: |
            BCP-47 tag of the resolved variant. v1 always returns the
            application's default language; locale-specific overrides are a
            future i18n feature.
        subject:
          type: string
          description: Currently effective subject line (override or default).
        bodyPlain:
          type: string
          description: Currently effective plaintext body (override or default).
        bodyHtml:
          type: string
          nullable: true
          description: |
            Currently effective HTML body, or `null` when neither the override
            nor the default ships an HTML alternative. When set, mail sends
            assemble a `multipart/alternative` MIME message.
        defaultSubject:
          type: string
          description: Hard-coded enum default subject. Always present.
        defaultBodyPlain:
          type: string
          description: Hard-coded enum default plaintext body. Always present.
        defaultBodyHtml:
          type: string
          nullable: true
          description: Hard-coded enum default HTML body, or `null`.
        placeholders:
          type: array
          items:
            type: string
          description: |
            Allowed `{{var}}` names for this template. Update requests are
            validated against this list and reject unknown references with 400.
        source:
          type: string
          enum: [DATABASE, DEFAULT]
          description: |
            `DATABASE` when an admin override exists for `locale`;
            `DEFAULT` when render falls back to the enum literal.
        updatedAt:
          type: string
          format: date-time
          nullable: true
          description: Timestamp of the last override update, or `null` when never overridden.
        updatedBy:
          type: string
          nullable: true
          description: Username of the admin who last edited, or `null` when never overridden.
      required:
        - key
        - friendlyName
        - locale
        - subject
        - bodyPlain
        - defaultSubject
        - defaultBodyPlain
        - placeholders
        - source

    MailTemplateListResponse:
      type: object
      description: Snapshot of every registered mail template (#438).
      properties:
        templates:
          type: array
          items:
            $ref: '#/components/schemas/MailTemplateResponse'
      required:
        - templates

    MailTemplatePreviewRequest:
      type: object
      description: |
        Draft to render through the Mustache pipeline. `subject` and
        `bodyPlain` are required (same as PUT); `bodyHtml` is optional —
        omit or pass `null` to skip the HTML preview. `sampleVars`
        overrides any subset of the registry-provided demo values; missing
        keys fall back to the registry default.
      properties:
        subject:
          type: string
          minLength: 1
          maxLength: 255
        bodyPlain:
          type: string
          minLength: 1
        bodyHtml:
          type: string
          nullable: true
        sampleVars:
          type: object
          additionalProperties:
            type: string
          description: |
            Overrides for individual placeholder values, keyed by
            placeholder name (without the `{{ }}` braces).
          example:
            username: "Bob"
            resetLink: "https://example.com/r?t=xyz"
      required:
        - subject
        - bodyPlain

    MailTemplatePreviewResponse:
      type: object
      description: |
        Rendered preview with the actual sample-variable map used. The map
        echoes registry defaults merged with caller overrides so the UI
        can pre-fill the editable sample-vars form on the next refresh.
      properties:
        subject:
          type: string
          description: Rendered subject line.
        bodyPlain:
          type: string
          description: Rendered plaintext body.
        bodyHtml:
          type: string
          nullable: true
          description: Rendered HTML body, or `null` when no HTML draft was supplied.
        sampleVars:
          type: object
          additionalProperties:
            type: string
          description: |
            The variable map actually used to render this preview. Equal
            to the registry's seeded demo values overlaid with any caller
            overrides.
      required:
        - subject
        - bodyPlain
        - sampleVars

    MailTemplateUpdateRequest:
      type: object
      description: |
        Override a mail template for the application's default language.
        `bodyHtml` is optional — pass `null` (or omit) to drop the HTML
        alternative; blank strings are rejected.
      properties:
        subject:
          type: string
          minLength: 1
          maxLength: 255
        bodyPlain:
          type: string
          minLength: 1
        bodyHtml:
          type: string
          nullable: true
          description: |
            HTML body; `null` means plaintext-only delivery. Blank strings
            are rejected — use `null` to clear.
      required:
        - subject
        - bodyPlain

    SendTestEmailRequest:
      type: object
      description: Recipient for a one-shot test message (#253).
      properties:
        target:
          type: string
          format: email
          description: Email address to deliver the test message to.
          minLength: 3
          maxLength: 254
      required:
        - target

    SendTestEmailResponse:
      type: object
      description: Outcome of a successful test send (#253).
      properties:
        message:
          type: string
          description: Human-readable confirmation, e.g. "Test email sent to alice@example.com".
      required:
        - message

    ApplicationSettingsUpdateRequest:
      type: object
      description: |
        Partial write — only the keys present in `settings` are updated. Unknown keys are
        rejected with HTTP 400.
      properties:
        settings:
          type: object
          additionalProperties:
            type: string
          description: Map of `setting_key` to raw string value
          example:
            upload.max_file_size_mb: "200"
            tracking.enabled: "false"
      required:
        - settings

    UserSettingsResponse:
      type: object
      description: All per-user settings with defaults filled in for unset keys.
      properties:
        settings:
          type: object
          additionalProperties:
            type: string
          description: Map of setting key to effective value
          example:
            preferred_language: "en"
            default_namespace: "acme-core"
            theme: "system"
      required:
        - settings

    UserSettingsUpdateRequest:
      type: object
      description: |
        Partial write — only the keys present in `settings` are updated. Unknown keys are
        rejected with HTTP 400.
      properties:
        settings:
          type: object
          additionalProperties:
            type: string
          description: Map of setting key to raw string value
          example:
            preferred_language: "en"
            theme: "dark"
      required:
        - settings

  responses:
    BadRequest:
      description: The request body or parameters failed validation
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Unauthorized:
      description: Authentication credentials are missing or invalid
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    Forbidden:
      description: Insufficient permissions for this operation
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    NotFound:
      description: The requested resource does not exist
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    PayloadTooLarge:
      description: |
        The uploaded artifact exceeds the server's maximum allowed file size.
        Use `GET /config` to query the current limit. The default is 100 MB,
        configurable via the `PLUGWERK_UPLOAD_MAX_FILE_SIZE_MB` environment variable.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    TooManyRequests:
      description: |
        The caller has exceeded the endpoint's rate limit. The `Retry-After`
        header indicates how many seconds to wait before retrying.
      headers:
        Retry-After:
          description: Seconds to wait before retrying the request.
          schema:
            type: integer
            format: int64
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
    UnprocessableEntity:
      description: |
        The artifact was received but the plugin descriptor is missing or invalid.
        Check that MANIFEST.MF contains valid `Plugin-Id` and `Plugin-Version` attributes.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'

  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        Short-lived JWT token obtained via `POST /api/v1/auth/login`.
        Include as `Authorization: Bearer <token>`. Default validity: 8 hours.
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: |
        Long-lived API key for CI/CD pipelines and service accounts.
        Include as `X-Api-Key: <your-api-key>` header.
        Namespace-scoped access keys are provisioned by a namespace ADMIN;
        each key grants implicit ADMIN access to its issuing namespace.
