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>
    ```
    API keys are managed by the server administrator.

    ## 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 and refreshing 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.
  - 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 SDK). 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 SDK (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 authentication with a server-admin account.

      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).
      The `userSubject` is either a local username or an OIDC `sub` claim.

      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: 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 for token validation.

      Plugwerk acts as an OAuth2 Resource Server: it validates `Authorization: Bearer` tokens
      issued by any enabled provider. Each provider type has pre-configured JWKS endpoints;
      only `GENERIC_OIDC` and `KEYCLOAK` require an explicit `issuerUri`.

      All providers are disabled by default. Requires server-admin authentication.

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:
                upload:
                  maxFileSizeMb: 100

  /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>`.

        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:
              username: admin
              password: changeme
      responses:
        "200":
          description: Login successful — returns a JWT Bearer token
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/LoginResponse"
        "401":
          description: Invalid username or password

  /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 SDK.

        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 SDK 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 SDK
        (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. 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.

        Requires authentication (Bearer token).
      operationId: logout
      security:
        - BearerAuth: []
      responses:
        "204":
          description: Token revoked successfully
        "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"

  /admin/users:
    get:
      tags:
        - AdminUsers
      summary: List all users
      description: Returns all registered users. Requires superadmin privileges.
      operationId: listUsers
      security:
        - BearerAuth: []
      parameters:
        - name: enabled
          in: query
          required: false
          description: Filter by enabled state. When omitted, all users are returned.
          schema:
            type: boolean
      responses:
        "200":
          description: List of all local users
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/UserDto"
        "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)
      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"

  /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
      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/{userSubject}:
    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: userSubject
          in: path
          required: true
          schema:
            type: string
      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: userSubject
          in: path
          required: true
          schema:
            type: string
      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/{providerId}:
    patch:
      tags:
        - OidcProviders
      summary: Enable or disable an OIDC provider
      description: Updates an OIDC provider configuration. 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"

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`)

    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:
    LoginRequest:
      type: object
      description: Credentials for password-based login
      properties:
        username:
          type: string
          minLength: 1
          description: The account username
          example: admin
        password:
          type: string
          format: password
          minLength: 1
          description: The account password (transmitted over HTTPS, never stored in plain text)
          example: changeme
      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
        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.
          example: false
        isSuperadmin:
          type: boolean
          description: |
            When `true`, the user has global superadmin privileges.
            Used by the frontend to conditionally show admin UI elements.
          example: false
      required:
        - accessToken
        - tokenType
        - expiresIn
        - passwordChangeRequired
        - isSuperadmin

    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

    ErrorResponse:
      type: object
      description: Standard error response body returned for all 4xx and 5xx responses
      properties:
        status:
          type: integer
          description: HTTP status code (mirrors the response status)
          example: 404
        error:
          type: string
          description: Short machine-readable error code (e.g. `NOT_FOUND`, `VALIDATION_ERROR`)
          example: NOT_FOUND
        message:
          type: string
          description: Human-readable description of what went wrong
          example: Plugin 'com.example.my-plugin' not found in namespace 'acme-core'
        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
      properties:
        comment:
          type: string
          description: |
            Human-readable comment explaining the decision. Strongly recommended for
            rejections so the publisher knows what needs to be fixed.
          example: Approved after manual artifact verification.

    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

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

    UserDto:
      type: object
      description: Local Plugwerk user account
      properties:
        id:
          type: string
          format: uuid
        username:
          type: string
        email:
          type: string
          nullable: true
        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
        namespaceMembershipCount:
          type: integer
          format: int32
          description: Number of namespaces this user is a member of
      required:
        - id
        - username
        - enabled
        - passwordChangeRequired
        - isSuperadmin
        - createdAt

    UserCreateRequest:
      type: object
      properties:
        username:
          type: string
          minLength: 3
          maxLength: 255
        email:
          type: string
          format: email
          nullable: true
        password:
          type: string
          minLength: 12
      required:
        - username
        - 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

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

    NamespaceMemberDto:
      type: object
      properties:
        userSubject:
          type: string
          description: Local username or OIDC sub claim
        role:
          $ref: "#/components/schemas/NamespaceRole"
        createdAt:
          type: string
          format: date-time
      required:
        - userSubject
        - role
        - createdAt

    NamespaceMemberCreateRequest:
      type: object
      properties:
        userSubject:
          type: string
        role:
          $ref: "#/components/schemas/NamespaceRole"
      required:
        - userSubject
        - 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
        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 GENERIC_OIDC and KEYCLOAK provider types
        scope:
          type: string
          default: openid email profile
      required:
        - name
        - providerType
        - clientId
        - clientSecret

    OidcProviderUpdateRequest:
      type: object
      description: Partial update — include only fields to change
      properties:
        enabled:
          type: boolean
          nullable: true
        clientSecret:
          type: string
          nullable: true
          description: Provide to rotate the client secret

    OidcProviderType:
      type: string
      enum:
        - GENERIC_OIDC
        - KEYCLOAK
        - GITHUB
        - GOOGLE
        - FACEBOOK
      description: |
        Provider type. Pre-configured providers (GITHUB, GOOGLE, FACEBOOK) have well-known
        endpoints embedded — only clientId and clientSecret are required.
        GENERIC_OIDC and KEYCLOAK require an explicit issuerUri.

    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
      required:
        - version
        - upload

    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

  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"
    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.
        API keys are provisioned by the server administrator.
