openapi: 3.1.0
info:
  title: qgis-publish-service API
  version: 1.0.0
  description: |
    Complete HTTP contract for qgis-publish-service and the bundled, independently
    deployed qcarta-tiles component. qgis-publish-service paths are relative to
    BASE_PATH, whose implementation removes trailing slashes only.

    Central authentication is optional. When enabled, QGIS routes use the
    `acugis_session` cookie and service/resource ACLs. The qgis authstore does not
    attach a personal-access-token TokenStore, so Bearer PAT authentication is not
    active on QGIS routes. qcarta-tiles uses a separate Bearer token only for cache
    purge.
servers:
  - url: http://localhost:8080{basePath}
    description: "qgis-publish-service"
    variables:
      basePath:
        default: ""
        description: "BASE_PATH including its leading slash when non-empty."
security: []
tags:
  - {name: Discovery}
  - {name: Projects}
  - {name: Project data}
  - {name: Features}
  - {name: Browser maps}
  - {name: Ingest}
  - {name: Operational}
  - {name: Configuration}
  - {name: Proxies}
  - {name: UI and static}
  - {name: qcarta-tiles}
paths:
  /api/discovery:
    get:
      tags: [Discovery]
      security: [{cookieAuth: []}, {}]
      summary: Discover visible published maps
      responses:
        "200": {description: "Discovery document", content: {application/json: {schema: {$ref: "#/components/schemas/Discovery"}}}}
        "500": {$ref: "#/components/responses/InternalError"}
  /api/auth/context:
    get:
      tags: [Discovery]
      security: [{cookieAuth: []}, {}]
      summary: Get viewer authentication context
      responses:
        "200": {description: "Authentication context", content: {application/json: {schema: {$ref: "#/components/schemas/AuthContext"}}}}
  /api/qgis/evaluate-expression:
    post:
      tags: [Project data]
      security: [{cookieAuth: []}, {}]
      summary: Evaluate a supported QGIS expression
      parameters:
        - {name: projectId, in: query, schema: {type: string}, description: "Fallback when omitted from the body."}
      requestBody:
        required: true
        content: {application/json: {schema: {$ref: "#/components/schemas/ExpressionRequest"}}}
      responses:
        "200": {description: "Evaluation result; expression errors use ok=false", content: {application/json: {schema: {$ref: "#/components/schemas/ExpressionResult"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "403": {$ref: "#/components/responses/Forbidden"}
  /api/resource-sync:
    post:
      tags: [Projects]
      security: [{cookieAuth: []}]
      summary: Reconcile project resources with Central
      description: "Requires qgis_publish admin permission when auth is enabled."
      responses:
        "200":
          description: "Reconciliation statistics"
          content:
            application/json:
              schema:
                type: object
                required: [ok, service, registered, already_exists, errors]
                properties:
                  ok: {type: boolean}
                  service: {type: string}
                  registered: {type: integer}
                  already_exists: {type: integer}
                  errors: {type: integer}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "500": {$ref: "#/components/responses/InternalError"}
  /projects:
    get:
      tags: [Projects]
      security: [{cookieAuth: []}, {}]
      summary: List visible projects
      description: "With an HTML-only Accept header, redirects to /projects/; otherwise returns JSON."
      responses:
        "200": {description: "Projects", content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/Project"}}}}}
        "302": {description: "HTML UI redirect"}
        "500": {$ref: "#/components/responses/InternalError"}
  /projects/upload:
    post:
      tags: [Projects]
      security: [{cookieAuth: []}]
      summary: Publish a QGIS project upload
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties: {file: {type: string, format: binary, description: ".qgs, .qgz, or .zip; default limit 512 MiB."}}
      responses:
        "200": {description: "Published project", content: {application/json: {schema: {$ref: "#/components/schemas/UploadResult"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "422": {$ref: "#/components/responses/Unprocessable"}
        "500": {$ref: "#/components/responses/InternalError"}
  /projects/generate:
    post:
      tags: [Projects]
      security: [{cookieAuth: []}]
      summary: Generate a project from a raster
      requestBody:
        required: true
        content:
          application/json: {schema: {$ref: "#/components/schemas/GenerateRequest"}}
          multipart/form-data:
            schema:
              type: object
              required: [file, name]
              properties:
                file: {type: string, format: binary, description: ".tif or .tiff"}
                name: {type: string, minLength: 1}
                crs: {type: string}
      responses:
        "200": {description: "Generated project", content: {application/json: {schema: {$ref: "#/components/schemas/GenerateResult"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "422": {$ref: "#/components/responses/Unprocessable"}
        "500": {$ref: "#/components/responses/InternalError"}
  /projects/{id}:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    get:
      tags: [Projects]
      security: [{cookieAuth: []}, {}]
      summary: Get project configuration
      responses:
        "200": {description: "Project", content: {application/json: {schema: {$ref: "#/components/schemas/Project"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
    delete:
      tags: [Projects]
      security: [{cookieAuth: []}]
      summary: Delete a project
      description: "Requires project admin permission in addition to service edit permission."
      responses:
        "200": {description: "Deleted", content: {text/plain: {schema: {type: string, const: Project deleted}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
        "500": {$ref: "#/components/responses/InternalError"}
  /projects/{id}/update:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    post:
      tags: [Projects]
      security: [{cookieAuth: []}]
      summary: Snapshot and replace a project bundle
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties: {file: {type: string, format: binary, description: ".qgs, .qgz, or .zip"}}
      responses:
        "200": {description: "Update result", content: {application/json: {schema: {$ref: "#/components/schemas/UpdateResult"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
        "422": {$ref: "#/components/responses/Unprocessable"}
        "500": {$ref: "#/components/responses/InternalError"}
  /projects/{id}/versions:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    get:
      tags: [Projects]
      security: [{cookieAuth: []}, {}]
      summary: List project versions
      responses:
        "200":
          description: "Versions"
          content:
            application/json:
              schema:
                type: object
                required: [project_id, versions]
                properties:
                  project_id: {type: string}
                  versions: {type: array, items: {type: object, additionalProperties: true, description: "VersionInfo from the versions store."}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "500": {$ref: "#/components/responses/InternalError"}
  /projects/{id}/versions/{version}/download:
    parameters: [{$ref: "#/components/parameters/ProjectId"}, {$ref: "#/components/parameters/Version"}]
    get:
      tags: [Projects]
      security: [{cookieAuth: []}, {}]
      summary: Download a version ZIP
      responses:
        "200": {description: "ZIP archive", content: {application/zip: {schema: {type: string, format: binary}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /projects/{id}/versions/{version}/restore:
    parameters: [{$ref: "#/components/parameters/ProjectId"}, {$ref: "#/components/parameters/Version"}]
    post:
      tags: [Projects]
      security: [{cookieAuth: []}]
      summary: Restore a project version
      responses:
        "200": {description: "Restore result", content: {application/json: {schema: {type: object, required: [success, project_id, restored_version, pre_restore_snapshot], properties: {success: {type: boolean, const: true}, project_id: {type: string}, restored_version: {type: integer}, pre_restore_snapshot: {type: integer}}}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
        "500": {$ref: "#/components/responses/InternalError"}
  /projects/{id}/download:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    get:
      tags: [Projects]
      security: [{cookieAuth: []}, {}]
      summary: Download current runtime bundle
      responses:
        "200": {description: "ZIP archive", content: {application/zip: {schema: {type: string, format: binary}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /projects/{id}/render-mode:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    post:
      tags: [Projects]
      security: [{cookieAuth: []}]
      summary: Set render mode
      requestBody: {required: true, content: {application/json: {schema: {type: object, required: [render_mode], properties: {render_mode: {type: string, examples: [direct, cache, xyz]}}}}}}
      responses:
        "200": {description: "Normalized mode", content: {application/json: {schema: {type: object, required: [render_mode], properties: {render_mode: {type: string}}}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /projects/{id}/legend-settings:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    post:
      tags: [Projects]
      security: [{cookieAuth: []}]
      summary: Update legend and popup settings
      requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/LegendSettings"}}}}
      responses:
        "200": {description: "Updated project", content: {application/json: {schema: {$ref: "#/components/schemas/Project"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /projects/{id}/basemap-settings:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    post:
      tags: [Projects]
      security: [{cookieAuth: []}]
      summary: Set project basemaps
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [basemap_ids, initial_basemap]
              properties:
                basemap_ids: {type: array, items: {$ref: "#/components/schemas/BasemapId"}}
                initial_basemap: {type: string, maxLength: 64}
      responses:
        "200": {description: "Updated project", content: {application/json: {schema: {$ref: "#/components/schemas/Project"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /projects/{id}/seed:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    post:
      tags: [Projects, qcarta-tiles]
      security: [{cookieAuth: []}]
      summary: Seed a project's tile cache
      requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/ProjectSeedRequest"}}}}
      responses:
        "200": {description: "Seed accepted", content: {application/json: {schema: {$ref: "#/components/schemas/SeedAccepted"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
        "502": {$ref: "#/components/responses/BadGateway"}
  /api/projects/{id}/runtime/reload:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    post:
      tags: [Projects]
      security: [{cookieAuth: []}]
      summary: Purge runtime tile cache
      responses:
        "200": {description: "Purge result", content: {application/json: {schema: {type: object, required: [success, project, removed], properties: {success: {type: boolean, const: true}, project: {type: string}, removed: {type: integer}}}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "404": {$ref: "#/components/responses/NotFound"}
        "502": {$ref: "#/components/responses/BadGateway"}
        "503": {$ref: "#/components/responses/Unavailable"}
  /api/projects/{id}/forms:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    get:
      tags: [Project data]
      security: [{cookieAuth: []}, {}]
      summary: Get forms
      parameters: [{name: layer, in: query, schema: {type: string}}]
      responses:
        "200": {description: "Full form map or one form with editing metadata", content: {application/json: {schema: {$ref: "#/components/schemas/DynamicObject"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /api/projects/{id}/layers:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    get:
      tags: [Project data]
      security: [{cookieAuth: []}, {}]
      summary: Get QGIS-derived layer metadata
      responses:
        "200": {description: "Generated layer metadata; shape follows QGIS source content", content: {application/json: {schema: {$ref: "#/components/schemas/DynamicObject"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /api/projects/{id}/reports:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    get:
      tags: [Project data]
      security: [{cookieAuth: []}, {}]
      summary: List report definitions
      responses:
        "200": {description: "Reports", content: {application/json: {schema: {$ref: "#/components/schemas/ReportsResponse"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
    put:
      tags: [Project data]
      security: [{cookieAuth: []}]
      summary: Replace report definitions
      requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/ReportsFile"}}}}
      responses:
        "200": {description: "Saved reports", content: {application/json: {schema: {$ref: "#/components/schemas/ReportsResponse"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /api/projects/{id}/metadata:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    get:
      tags: [Project data]
      security: [{cookieAuth: []}, {}]
      summary: Get project metadata
      responses:
        "200": {description: "Metadata", content: {application/json: {schema: {$ref: "#/components/schemas/ProjectMetadata"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
        "503": {$ref: "#/components/responses/Unavailable"}
    put:
      tags: [Project data]
      security: [{cookieAuth: []}]
      summary: Create or update metadata
      parameters: [{name: create, in: query, schema: {oneOf: [{type: boolean}, {type: integer, enum: [1]}]}, description: "1 or true enables create-only behavior."}]
      requestBody: {content: {application/json: {schema: {$ref: "#/components/schemas/ProjectMetadataInput"}}}}
      responses:
        "200": {description: "Updated metadata", content: {application/json: {schema: {$ref: "#/components/schemas/ProjectMetadata"}}}}
        "201": {description: "Created metadata", content: {application/json: {schema: {$ref: "#/components/schemas/ProjectMetadata"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
        "409": {$ref: "#/components/responses/Conflict"}
        "503": {$ref: "#/components/responses/Unavailable"}
    delete:
      tags: [Project data]
      security: [{cookieAuth: []}]
      summary: Delete metadata
      responses:
        "204": {description: "Deleted"}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
        "503": {$ref: "#/components/responses/Unavailable"}
  /api/projects/{id}/relation-reference:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    get:
      tags: [Project data]
      security: [{cookieAuth: []}, {}]
      summary: Resolve RelationReference options
      parameters:
        - {$ref: "#/components/parameters/Layer"}
        - {$ref: "#/components/parameters/Field"}
        - {name: search, in: query, schema: {type: string}}
        - {name: value, in: query, schema: {type: string}}
        - {name: filter_ctx, in: query, schema: {type: string}, description: "JSON object; invalid JSON is ignored."}
        - {name: parent, in: query, schema: {type: string}, description: "JSON object; invalid JSON is ignored."}
        - {name: limit, in: query, schema: {type: integer, minimum: 1, maximum: 200, default: 50}}
      responses:
        "200": {description: "Options", content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/RelationOption"}}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
        "502": {$ref: "#/components/responses/BadGateway"}
  /api/projects/{id}/lookup:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    get:
      tags: [Project data]
      security: [{cookieAuth: []}, {}]
      summary: Read lookup rows
      parameters:
        - {$ref: "#/components/parameters/Layer"}
        - {name: fields, in: query, required: true, schema: {type: string, pattern: '^[A-Za-z_][A-Za-z0-9_]*(,[A-Za-z_][A-Za-z0-9_]*)*$'}}
        - {name: limit, in: query, schema: {type: integer, minimum: 1, maximum: 10000, default: 5000}}
      responses:
        "200": {description: "Dynamic rows keyed by requested columns", content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/DynamicObject"}}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "502": {$ref: "#/components/responses/BadGateway"}
  /api/projects/{id}/pivot-data:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    get:
      tags: [Project data]
      security: [{cookieAuth: []}, {}]
      summary: Build a PostGIS pivot matrix
      parameters:
        - {$ref: "#/components/parameters/Layer"}
        - {name: row_field, in: query, required: true, schema: {$ref: "#/components/schemas/Identifier"}}
        - {name: col_field, in: query, schema: {$ref: "#/components/schemas/Identifier"}}
        - {name: value_field, in: query, required: true, schema: {$ref: "#/components/schemas/Identifier"}}
        - {name: aggregation, in: query, schema: {type: string, enum: [COUNT, SUM, AVG, MIN, MAX], default: SUM}}
        - {$ref: "#/components/parameters/Scope"}
        - {$ref: "#/components/parameters/Selection"}
      responses:
        "200": {description: "Pivot result", content: {application/json: {schema: {$ref: "#/components/schemas/Pivot"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "502": {$ref: "#/components/responses/BadGateway"}
  /api/projects/{id}/outliers-data:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    get:
      tags: [Project data]
      security: [{cookieAuth: []}, {}]
      summary: Calculate outliers
      parameters:
        - {$ref: "#/components/parameters/Layer"}
        - {$ref: "#/components/parameters/Field"}
        - {$ref: "#/components/parameters/LabelField"}
        - {name: threshold, in: query, schema: {type: number, exclusiveMinimum: 0, default: 2}}
        - {$ref: "#/components/parameters/Scope"}
        - {$ref: "#/components/parameters/Selection"}
      responses:
        "200": {description: "Outlier analysis", content: {application/json: {schema: {$ref: "#/components/schemas/Outliers"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "502": {$ref: "#/components/responses/BadGateway"}
  /api/projects/{id}/hotspots-data:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    get:
      tags: [Project data]
      security: [{cookieAuth: []}, {}]
      summary: Calculate geometry-neighbor hotspots
      parameters:
        - {$ref: "#/components/parameters/Layer"}
        - {$ref: "#/components/parameters/Field"}
        - {$ref: "#/components/parameters/LabelField"}
        - {name: threshold, in: query, schema: {type: number, exclusiveMinimum: 0, default: 1.5}}
        - {$ref: "#/components/parameters/Scope"}
        - {$ref: "#/components/parameters/Selection"}
      responses:
        "200": {description: "Hotspot analysis", content: {application/json: {schema: {$ref: "#/components/schemas/Hotspots"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "502": {$ref: "#/components/responses/BadGateway"}
  /api/projects/{id}/features/create:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    post:
      tags: [Features]
      security: [{cookieAuth: []}]
      summary: Insert a PostGIS feature
      requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/FeatureCreate"}}}}
      responses:
        "200": {description: "Created feature", content: {application/json: {schema: {$ref: "#/components/schemas/FeatureResult"}}}}
        "400": {$ref: "#/components/responses/FeatureError"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/FeatureError"}
        "404": {$ref: "#/components/responses/NotFound"}
        "409": {$ref: "#/components/responses/FeatureError"}
        "501": {$ref: "#/components/responses/FeatureError"}
        "502": {$ref: "#/components/responses/FeatureError"}
  /api/projects/{id}/features/update:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    post:
      tags: [Features]
      security: [{cookieAuth: []}]
      summary: Update PostGIS feature attributes
      requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/FeatureUpdate"}}}}
      responses:
        "200": {description: "Updated feature", content: {application/json: {schema: {$ref: "#/components/schemas/FeatureResult"}}}}
        "400": {$ref: "#/components/responses/FeatureError"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/FeatureError"}
        "404": {$ref: "#/components/responses/FeatureError"}
        "409": {$ref: "#/components/responses/FeatureError"}
        "501": {$ref: "#/components/responses/FeatureError"}
        "502": {$ref: "#/components/responses/FeatureError"}
  /api/projects/{id}/features/delete:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    post:
      tags: [Features]
      security: [{cookieAuth: []}]
      summary: Delete a PostGIS feature
      requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/FeatureDelete"}}}}
      responses:
        "200": {description: "Deleted", content: {application/json: {schema: {$ref: "#/components/schemas/SuccessError"}}}}
        "400": {$ref: "#/components/responses/FeatureError"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/FeatureError"}
        "409": {$ref: "#/components/responses/FeatureError"}
        "501": {$ref: "#/components/responses/FeatureError"}
        "502": {$ref: "#/components/responses/FeatureError"}
  /api/projects/{id}/attachments:
    parameters: [{$ref: "#/components/parameters/ProjectId"}]
    post:
      tags: [Features]
      security: [{cookieAuth: []}]
      summary: Upload a feature attachment
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: {type: string, format: binary, description: "Max 25 MiB; jpg/jpeg/png/gif/webp/pdf/txt/doc/docx/zip."}
                layer: {type: string}
                feature_id: {type: string}
                field: {type: string, description: "Accepted but unused."}
      responses:
        "200": {description: "Attachment path", content: {application/json: {schema: {$ref: "#/components/schemas/AttachmentResult"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
        "500": {$ref: "#/components/responses/InternalError"}
  /project-data/{id}/{path}:
    parameters:
      - {$ref: "#/components/parameters/ProjectId"}
      - {name: path, in: path, required: true, schema: {type: string, minLength: 1}, description: "Go `{path...}` wildcard; may contain slashes; `..` is rejected."}
    get:
      tags: [Features]
      security: [{cookieAuth: []}, {}]
      summary: Download a project attachment
      responses:
        "200": {description: "File bytes", content: {application/octet-stream: {schema: {type: string, format: binary}}, image/png: {schema: {type: string, format: binary}}, image/jpeg: {schema: {type: string, format: binary}}, application/pdf: {schema: {type: string, format: binary}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
    head:
      tags: [Features]
      security: [{cookieAuth: []}, {}]
      summary: Inspect a project attachment
      responses:
        "200": {description: "File headers"}
        "400": {$ref: "#/components/responses/BadRequest"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /api/feature-relations:
    get:
      tags: [Project data]
      security: [{cookieAuth: []}, {}]
      summary: Load related records
      parameters:
        - {name: project, in: query, required: true, schema: {type: string}}
        - {name: layer, in: query, required: true, schema: {type: string}}
        - {name: field, in: query, required: true, schema: {type: string}}
        - {name: value, in: query, required: true, schema: {type: string}}
        - {name: debug, in: query, schema: {type: integer, enum: [1]}}
      responses:
        "200": {description: "Dynamic relation records and optional diagnostics", content: {application/json: {schema: {$ref: "#/components/schemas/DynamicObject"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /api/feature-relations.php:
    get:
      tags: [Project data]
      security: [{cookieAuth: []}, {}]
      summary: Load related records (legacy alias)
      parameters:
        - {name: project, in: query, required: true, schema: {type: string}}
        - {name: layer, in: query, required: true, schema: {type: string}}
        - {name: field, in: query, required: true, schema: {type: string}}
        - {name: value, in: query, required: true, schema: {type: string}}
        - {name: debug, in: query, schema: {type: integer, enum: [1]}}
      responses:
        "200": {description: "Relations", content: {application/json: {schema: {$ref: "#/components/schemas/DynamicObject"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /pgservice:
    get:
      tags: [Configuration]
      security: [{cookieAuth: []}]
      summary: List pg_service definitions
      responses:
        "200": {description: "Services including persisted passwords", content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/PGService"}}}}}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
    post:
      tags: [Configuration]
      security: [{cookieAuth: []}]
      summary: Create or replace a pg_service
      requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/PGService"}}}}
      responses:
        "200": {description: "Saved service", content: {application/json: {schema: {$ref: "#/components/schemas/PGService"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
  /pgservice/test:
    post:
      tags: [Configuration]
      security: [{cookieAuth: []}]
      summary: Test PostgreSQL connectivity
      requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/PGTest"}}}}
      responses:
        "200": {description: "Connectivity result; failures use success=false", content: {application/json: {schema: {$ref: "#/components/schemas/SuccessError"}}}}
        "400": {description: "Invalid request", content: {application/json: {schema: {$ref: "#/components/schemas/SuccessError"}}}}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
  /pgservice/{name}:
    parameters: [{$ref: "#/components/parameters/ServiceName"}]
    delete:
      tags: [Configuration]
      security: [{cookieAuth: []}]
      summary: Delete a pg_service
      responses:
        "200": {description: "Result", content: {application/json: {schema: {$ref: "#/components/schemas/Ok"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
  /basemaps:
    get:
      tags: [Configuration]
      summary: List basemaps
      responses:
        "200": {description: "Basemaps", content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/Basemap"}}}}}
        "503": {$ref: "#/components/responses/Unavailable"}
    post:
      tags: [Configuration]
      security: [{cookieAuth: []}]
      summary: Create or replace a basemap
      requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/Basemap"}}}}
      responses:
        "200": {description: "Saved basemap", content: {application/json: {schema: {$ref: "#/components/schemas/Basemap"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "503": {$ref: "#/components/responses/Unavailable"}
  /basemaps/{id}:
    parameters: [{name: id, in: path, required: true, schema: {$ref: "#/components/schemas/BasemapId"}}]
    delete:
      tags: [Configuration]
      security: [{cookieAuth: []}]
      summary: Delete a basemap
      responses:
        "200": {description: "Result", content: {application/json: {schema: {$ref: "#/components/schemas/Ok"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
        "503": {$ref: "#/components/responses/Unavailable"}
  /api/browser-maps:
    get:
      tags: [Browser maps]
      security: [{cookieAuth: []}]
      summary: List visible browser maps
      responses:
        "200": {description: "Summaries with map_config omitted", content: {application/json: {schema: {type: array, items: {$ref: "#/components/schemas/BrowserMap"}}}}}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "503": {$ref: "#/components/responses/Unavailable"}
    post:
      tags: [Browser maps]
      security: [{cookieAuth: []}]
      summary: Create a browser map
      requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/BrowserMapCreate"}}}}
      responses:
        "201": {description: "Created map", content: {application/json: {schema: {$ref: "#/components/schemas/BrowserMap"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "503": {$ref: "#/components/responses/Unavailable"}
  /api/browser-maps/{id}:
    parameters: [{$ref: "#/components/parameters/MapId"}]
    get:
      tags: [Browser maps]
      security: [{cookieAuth: []}, {}]
      summary: Get a browser map
      responses:
        "200": {description: "Map", content: {application/json: {schema: {$ref: "#/components/schemas/BrowserMap"}}}}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
        "503": {$ref: "#/components/responses/Unavailable"}
    put:
      tags: [Browser maps]
      security: [{cookieAuth: []}]
      summary: Update a browser map
      requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/BrowserMapUpdate"}}}}
      responses:
        "200": {description: "Updated map", content: {application/json: {schema: {$ref: "#/components/schemas/BrowserMap"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
    delete:
      tags: [Browser maps]
      security: [{cookieAuth: []}]
      summary: Delete a browser map
      responses:
        "200": {description: "Result", content: {application/json: {schema: {$ref: "#/components/schemas/Ok"}}}}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /api/browser-maps/{id}/duplicate:
    parameters: [{$ref: "#/components/parameters/MapId"}]
    post:
      tags: [Browser maps]
      security: [{cookieAuth: []}]
      summary: Duplicate a visible map
      responses:
        "201": {description: "Duplicate", content: {application/json: {schema: {$ref: "#/components/schemas/BrowserMap"}}}}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /api/browser-maps/{id}/thumbnail:
    parameters: [{$ref: "#/components/parameters/MapId"}]
    post:
      tags: [Browser maps]
      security: [{cookieAuth: []}]
      summary: Set map thumbnail
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties: {file: {type: string, format: binary, description: "PNG, JPEG, or WebP; max 5 MiB file data."}}
      responses:
        "200": {description: "Updated map", content: {application/json: {schema: {$ref: "#/components/schemas/BrowserMap"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
  /api/browser-maps/datasets/{datasetId}/resolve:
    parameters: [{name: datasetId, in: path, required: true, schema: {type: string, minLength: 1}}]
    get:
      tags: [Browser maps]
      security: [{cookieAuth: []}]
      summary: Resolve GeoVault rendering endpoints
      responses:
        "200": {description: "Resolution", content: {application/json: {schema: {$ref: "#/components/schemas/DatasetResolve"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
        "502": {$ref: "#/components/responses/BadGateway"}
  /api/map-builder/geovault/{path}:
    parameters: [{$ref: "#/components/parameters/ProxyPath"}]
    get:
      tags: [Browser maps, Proxies]
      security: [{cookieAuth: []}]
      summary: Proxy allow-listed GeoVault browsing APIs
      description: "Paths are restricted to datasets-summary, datasets/*, api/folders*, api/datasets/*, metadata*, and preview*. Queries pass through."
      responses:
        default: {$ref: "#/components/responses/DynamicJSON"}
  /api/ingest/sessions:
    get:
      tags: [Ingest]
      security: [{cookieAuth: []}]
      summary: List current user's recent sessions
      responses:
        "200": {description: "Up to 20 sessions", content: {application/json: {schema: {type: object, required: [sessions], properties: {sessions: {type: array, items: {$ref: "#/components/schemas/IngestSession"}}}}}}}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "503": {$ref: "#/components/responses/Unavailable"}
    post:
      tags: [Ingest]
      security: [{cookieAuth: []}]
      summary: Create a session
      responses:
        "201": {description: "Session", content: {application/json: {schema: {$ref: "#/components/schemas/IngestSession"}}}}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "503": {$ref: "#/components/responses/Unavailable"}
  /api/ingest/sessions/{id}:
    parameters: [{$ref: "#/components/parameters/SessionId"}]
    get:
      tags: [Ingest]
      security: [{cookieAuth: []}]
      summary: Get an owned session
      responses:
        "200": {description: "Session", content: {application/json: {schema: {$ref: "#/components/schemas/IngestSession"}}}}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
    delete:
      tags: [Ingest]
      security: [{cookieAuth: []}]
      summary: Delete a session
      responses:
        "200": {description: "Result", content: {application/json: {schema: {$ref: "#/components/schemas/Ok"}}}}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /api/ingest/sessions/{id}/sources:
    parameters: [{$ref: "#/components/parameters/SessionId"}]
    post:
      tags: [Ingest]
      security: [{cookieAuth: []}]
      summary: Add an ingest source
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema: {type: object, required: [file], properties: {file: {type: string, format: binary, description: "Default max 512 MiB."}}}
          application/json:
            schema:
              type: object
              required: [source_type, source_name, metadata]
              properties:
                source_type: {type: string, enum: [postgis, geovault, url, s3, esri]}
                source_name: {type: string}
                metadata: {$ref: "#/components/schemas/DynamicObject"}
      responses:
        "201": {description: "Source", content: {application/json: {schema: {$ref: "#/components/schemas/IngestSource"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /api/ingest/sessions/{id}/sources/{sourceId}:
    parameters: [{$ref: "#/components/parameters/SessionId"}, {$ref: "#/components/parameters/SourceId"}]
    get:
      tags: [Ingest]
      security: [{cookieAuth: []}]
      summary: Get a source
      responses:
        "200": {description: "Source", content: {application/json: {schema: {$ref: "#/components/schemas/IngestSource"}}}}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
    patch:
      tags: [Ingest]
      security: [{cookieAuth: []}]
      summary: Patch source name, status, or metadata
      requestBody: {required: true, content: {application/json: {schema: {type: object, properties: {source_name: {type: string}, status: {$ref: "#/components/schemas/SourceStatus"}, metadata: {$ref: "#/components/schemas/DynamicObject"}}}}}}
      responses:
        "200": {description: "Updated source", content: {application/json: {schema: {$ref: "#/components/schemas/IngestSource"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
    delete:
      tags: [Ingest]
      security: [{cookieAuth: []}]
      summary: Delete a source
      responses:
        "200": {description: "Result", content: {application/json: {schema: {$ref: "#/components/schemas/Ok"}}}}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /api/ingest/sessions/{id}/sources/{sourceId}/preview:
    parameters: [{$ref: "#/components/parameters/SessionId"}, {$ref: "#/components/parameters/SourceId"}]
    get:
      tags: [Ingest]
      security: [{cookieAuth: []}]
      summary: Preview a source
      parameters: [{name: layer, in: query, schema: {type: string}}]
      responses:
        "200": {description: "GeoJSON FeatureCollection or GeoVault preview", content: {application/json: {schema: {$ref: "#/components/schemas/DynamicObject"}}, application/geo+json: {schema: {$ref: "#/components/schemas/DynamicObject"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
        "422": {$ref: "#/components/responses/Unprocessable"}
        "502": {$ref: "#/components/responses/BadGateway"}
  /api/ingest/sessions/{id}/sources/{sourceId}/file:
    parameters: [{$ref: "#/components/parameters/SessionId"}, {$ref: "#/components/parameters/SourceId"}]
    get:
      tags: [Ingest]
      security: [{cookieAuth: []}]
      summary: Download temporary source file
      responses:
        "200": {description: "Source file", content: {application/octet-stream: {schema: {type: string, format: binary}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /api/ingest/sessions/{id}/sources/{sourceId}/actions/{action}:
    parameters:
      - {$ref: "#/components/parameters/SessionId"}
      - {$ref: "#/components/parameters/SourceId"}
      - {name: action, in: path, required: true, schema: {type: string, enum: [store_library, import_postgis, publish_qgis]}}
    post:
      tags: [Ingest]
      security: [{cookieAuth: []}]
      summary: Run a source destination action
      description: "Options and results are destination/external-protocol specific and therefore permissive."
      requestBody: {content: {application/json: {schema: {$ref: "#/components/schemas/DynamicObject"}}}}
      responses:
        "200": {description: "Updated source and action result", content: {application/json: {schema: {$ref: "#/components/schemas/DynamicObject"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
        "422": {$ref: "#/components/responses/Unprocessable"}
  /api/ingest/postgis/connect:
    post:
      tags: [Ingest]
      security: [{cookieAuth: []}]
      summary: Validate and save a PostgreSQL connection
      requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/PostGISConnect"}}}}
      responses:
        "200": {description: "Result; connectivity failures may use success=false", content: {application/json: {schema: {$ref: "#/components/schemas/PostGISConnectResult"}}}}
        "400": {description: "Invalid request", content: {application/json: {schema: {$ref: "#/components/schemas/PostGISConnectResult"}}}}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
  /api/ingest/postgis/discover:
    post:
      tags: [Ingest]
      security: [{cookieAuth: []}]
      summary: Discover spatial tables
      requestBody: {required: true, content: {application/json: {schema: {type: object, required: [connection_id, pg_service_name], properties: {connection_id: {type: integer, description: "Legacy alternative."}, pg_service_name: {type: string, description: "Preferred identifier."}}}}}}
      responses:
        "200": {description: "Discovery result; operational failures may use success=false", content: {application/json: {schema: {$ref: "#/components/schemas/PostGISDiscovery"}}}}
        "400": {description: "Invalid request", content: {application/json: {schema: {$ref: "#/components/schemas/PostGISDiscovery"}}}}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
  /api/quick-import:
    post:
      tags: [Ingest]
      security: [{cookieAuth: []}]
      summary: Proxy GeoVault quick import and install pg_service
      description: "JSON or multipart request shape is owned by GeoVault `/api/quick-import`."
      requestBody:
        required: true
        content:
          application/json: {schema: {$ref: "#/components/schemas/DynamicObject"}}
          multipart/form-data: {schema: {type: object, additionalProperties: true}}
      responses:
        "200": {description: "Normalized result", content: {application/json: {schema: {$ref: "#/components/schemas/QuickImportResult"}}}}
        default: {$ref: "#/components/responses/DynamicJSON"}
  /api/quick-import/postgis-service/{path}:
    parameters: [{$ref: "#/components/parameters/ProxyPath"}]
    get:
      tags: [Ingest, Proxies]
      security: [{cookieAuth: []}]
      summary: Proxy GeoVault postgis-service reads
      responses: {default: {$ref: "#/components/responses/DynamicJSON"}}
    post:
      tags: [Ingest, Proxies]
      security: [{cookieAuth: []}]
      summary: Proxy GeoVault postgis-service writes
      requestBody: {content: {application/json: {schema: {}}}}
      responses: {default: {$ref: "#/components/responses/DynamicJSON"}}
  /api/quick-import/geovault/{path}:
    parameters: [{$ref: "#/components/parameters/ProxyPath"}]
    get:
      tags: [Ingest, Proxies]
      security: [{cookieAuth: []}]
      summary: Proxy GeoVault API reads
      responses: {default: {$ref: "#/components/responses/DynamicJSON"}}
    post:
      tags: [Ingest, Proxies]
      security: [{cookieAuth: []}]
      summary: Proxy GeoVault API writes
      requestBody: {content: {application/json: {schema: {}}}}
      responses: {default: {$ref: "#/components/responses/DynamicJSON"}}
  /api/geosync-publish:
    post:
      tags: [Projects]
      security: [{cookieAuth: []}]
      summary: Publish a GeoSync project
      requestBody: {required: true, content: {multipart/form-data: {schema: {$ref: "#/components/schemas/GeoSyncRequest"}}}}
      responses:
        "200": {description: "Published project", content: {application/json: {schema: {$ref: "#/components/schemas/GeoSyncResult"}}}}
        "400": {$ref: "#/components/responses/GeoSyncError"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "422": {$ref: "#/components/responses/GeoSyncError"}
        "500": {$ref: "#/components/responses/GeoSyncError"}
  /api/operational/import:
    post:
      tags: [Operational]
      security: [{cookieAuth: []}]
      summary: Import operational data
      description: "JSON or multipart with source_type uses quick import; multipart without it accepts only .gpkg for native runtime import."
      requestBody:
        required: true
        content:
          application/json: {schema: {$ref: "#/components/schemas/DynamicObject"}}
          multipart/form-data:
            schema:
              type: object
              required: [file]
              additionalProperties: true
              properties:
                file: {type: string, format: binary}
                source_type: {type: string}
                display_name: {type: string}
                slug: {type: string}
                auto_publish: {type: boolean}
                rewrite_project_id: {type: string}
      responses:
        "200": {description: "Runtime or quick-import result", content: {application/json: {schema: {$ref: "#/components/schemas/DynamicObject"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "422": {$ref: "#/components/responses/Unprocessable"}
        "503": {$ref: "#/components/responses/Unavailable"}
  /api/operational/datasets:
    get:
      tags: [Operational]
      security: [{cookieAuth: []}]
      summary: List runtime datasets
      responses:
        "200": {description: "Datasets", content: {application/json: {schema: {type: object, required: [datasets], properties: {datasets: {type: array, items: {$ref: "#/components/schemas/RuntimeDataset"}}}}}}}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "503": {$ref: "#/components/responses/Unavailable"}
  /api/operational/datasets/{id}:
    parameters: [{name: id, in: path, required: true, schema: {type: string, minLength: 1}}]
    get:
      tags: [Operational]
      security: [{cookieAuth: []}]
      summary: Get runtime dataset details
      responses:
        "200": {description: "Dataset, tables, and relations", content: {application/json: {schema: {$ref: "#/components/schemas/RuntimeDetails"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
        "503": {$ref: "#/components/responses/Unavailable"}
  /api/operational/datasets/{id}/rewrite-project:
    parameters: [{name: id, in: path, required: true, schema: {type: string}}]
    post:
      tags: [Operational]
      security: [{cookieAuth: []}]
      summary: Disabled datasource rewrite endpoint
      deprecated: true
      responses:
        "410": {description: "Rewrite disabled", content: {application/json: {schema: {$ref: "#/components/schemas/Error"}}}}
  /qgis/{projectId}:
    parameters: [{name: projectId, in: path, required: true, schema: {type: string, pattern: '^[A-Za-z0-9_-]+$'}}]
    get:
      tags: [Proxies]
      security: [{cookieAuth: []}, {}]
      summary: Proxy QGIS Server
      description: "Server supplies MAP; raw QGIS WMS/WFS query segments are preserved. FILTER/CQL_FILTER/EXP_FILTER aliases are validated. Response status, headers, media type, and bytes come from QGIS."
      parameters:
        - {$ref: "#/components/parameters/QGISService"}
        - {$ref: "#/components/parameters/QGISRequest"}
        - {name: VERSION, in: query, schema: {type: string}}
        - {name: LAYERS, in: query, schema: {type: string}}
        - {name: TYPENAME, in: query, schema: {type: string}}
        - {name: BBOX, in: query, schema: {type: string}}
        - {name: CRS, in: query, schema: {type: string}}
        - {name: SRS, in: query, schema: {type: string}}
        - {name: FILTER, in: query, schema: {type: string}}
        - {name: CQL_FILTER, in: query, schema: {type: string}}
        - {name: EXP_FILTER, in: query, schema: {type: string}}
      responses: {default: {$ref: "#/components/responses/DynamicHTTP"}}
    head:
      tags: [Proxies]
      security: [{cookieAuth: []}, {}]
      summary: Proxy QGIS response headers
      responses: {default: {description: "QGIS status and headers"}}
  /maps/view/{id}:
    parameters: [{name: id, in: path, required: true, schema: {type: string}}]
    get:
      tags: [Browser maps]
      summary: Redirect to browser map viewer
      responses: {"302": {description: "Redirect to `/ui/maps/view/?id=...`"}}
  /cache/{path}:
    parameters: [{$ref: "#/components/parameters/ProxyPath"}]
    get:
      tags: [Proxies, qcarta-tiles]
      summary: Proxy qcarta-tiles GET
      description: "Removes the BASE_PATH `/cache` mount before forwarding."
      responses: {default: {$ref: "#/components/responses/DynamicHTTP"}}
    post:
      tags: [Proxies, qcarta-tiles]
      summary: Proxy qcarta-tiles POST
      requestBody: {content: {application/octet-stream: {schema: {type: string, format: binary}}, application/json: {schema: {}}, text/plain: {schema: {type: string}}}}
      responses: {default: {$ref: "#/components/responses/DynamicHTTP"}}
  /routing/{path}:
    parameters: [{$ref: "#/components/parameters/ProxyPath"}]
    get:
      tags: [Proxies]
      summary: Proxy OSRM GET
      description: "Removes the BASE_PATH `/routing` mount and passes the external OSRM protocol through."
      responses: {default: {$ref: "#/components/responses/DynamicHTTP"}}
    post:
      tags: [Proxies]
      summary: Proxy OSRM POST
      requestBody: {content: {application/octet-stream: {schema: {type: string, format: binary}}, application/json: {schema: {}}}}
      responses: {default: {$ref: "#/components/responses/DynamicHTTP"}}
  /publish-service-bootstrap.js:
    get:
      tags: [UI and static]
      summary: Get base-path browser bootstrap
      responses:
        "200":
          description: "JavaScript defining the publish base path and URL helper."
          content:
            application/javascript:
              schema: {type: string}
  /ui/publish-service-bootstrap.js:
    get:
      tags: [UI and static]
      summary: Get base-path browser bootstrap alias
      responses:
        "200":
          description: "JavaScript bootstrap used by nested UI pages."
          content:
            application/javascript:
              schema: {type: string}
  /seed.html:
    get:
      tags: [UI and static]
      security: [{cookieAuth: []}, {}]
      summary: Redirect legacy seed UI
      responses:
        "302":
          description: "Redirect to /ui/seed.html while preserving the query string."
  /shared/ui/{path}:
    parameters: [{$ref: "#/components/parameters/ProxyPath"}]
    get:
      tags: [UI and static]
      summary: Serve shared UI assets
      responses:
        "200": {description: "Static asset", content: {application/octet-stream: {schema: {type: string, format: binary}}}}
        "404": {$ref: "#/components/responses/NotFound"}
    head:
      tags: [UI and static]
      summary: Inspect shared UI asset
      responses:
        "200": {description: "Static asset headers"}
        "404": {$ref: "#/components/responses/NotFound"}
  /ui/{path}:
    parameters: [{$ref: "#/components/parameters/ProxyPath"}]
    get:
      tags: [UI and static]
      security: [{cookieAuth: []}, {}]
      summary: Serve publish UI assets
      description: "Viewer assets are public. Management assets require the applicable qgis_publish permission when central authentication is enabled."
      responses:
        "200": {description: "Static asset", content: {application/octet-stream: {schema: {type: string, format: binary}}}}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
    head:
      tags: [UI and static]
      security: [{cookieAuth: []}, {}]
      summary: Inspect publish UI asset
      responses:
        "200": {description: "Static asset headers"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /:
    get:
      tags: [UI and static]
      security: [{cookieAuth: []}, {}]
      summary: Enter the publish service
      responses:
        "302": {description: "Redirect to the workspace UI or projects entry point, depending on BASE_PATH."}
    head:
      tags: [UI and static]
      security: [{cookieAuth: []}, {}]
      summary: Inspect the service root
      responses:
        "302": {description: "Redirect headers."}
  /{path}:
    parameters:
      - name: path
        in: path
        required: true
        schema: {type: string, minLength: 1}
        description: "Opaque fallback path served from the configured publish UI root. Nested paths are accepted by the Go file server."
    get:
      tags: [UI and static]
      security: [{cookieAuth: []}, {}]
      summary: Serve a legacy static UI path
      responses:
        "200": {description: "Static asset", content: {application/octet-stream: {schema: {type: string, format: binary}}}}
        "302": {description: "Root-style legacy redirect."}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
    head:
      tags: [UI and static]
      security: [{cookieAuth: []}, {}]
      summary: Inspect a legacy static UI path
      responses:
        "200": {description: "Static asset headers"}
        "302": {description: "Redirect headers."}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "403": {$ref: "#/components/responses/Forbidden"}
        "404": {$ref: "#/components/responses/NotFound"}
  /api/wms:
    get:
      tags: [qcarta-tiles]
      servers: [{url: "http://localhost:8011", description: "qcarta-tiles direct API"}]
      summary: Proxy and cache QGIS WMS
      parameters:
        - {name: MAP, in: query, required: true, schema: {type: string}}
        - {$ref: "#/components/parameters/QGISService"}
        - {$ref: "#/components/parameters/QGISRequest"}
        - {name: VERSION, in: query, schema: {type: string, default: 1.1.1}}
        - {name: LAYERS, in: query, schema: {type: string}}
        - {name: STYLES, in: query, schema: {type: string}}
        - {name: BBOX, in: query, schema: {type: string}}
        - {name: WIDTH, in: query, schema: {type: integer}}
        - {name: HEIGHT, in: query, schema: {type: integer}}
        - {name: FORMAT, in: query, schema: {type: string}}
        - {name: CRS, in: query, schema: {type: string}}
        - {name: SRS, in: query, schema: {type: string}}
        - {name: CACHE, in: query, schema: {type: integer, enum: [0, 1]}}
        - {name: META, in: query, schema: {type: integer, minimum: 1}}
        - {name: BUFFER, in: query, schema: {type: integer, minimum: 0}}
      responses: {default: {$ref: "#/components/responses/DynamicHTTP"}}
  /mproxy/service:
    get:
      tags: [qcarta-tiles]
      servers: [{url: "http://localhost:8011"}]
      summary: Proxy and cache WMS (legacy alias)
      parameters:
        - {name: MAP, in: query, required: true, schema: {type: string}}
        - {$ref: "#/components/parameters/QGISService"}
        - {$ref: "#/components/parameters/QGISRequest"}
      responses: {default: {$ref: "#/components/responses/DynamicHTTP"}}
  /api/tiles/{z}/{x}/{y}.png:
    parameters:
      - {name: z, in: path, required: true, schema: {type: integer, minimum: 0, maximum: 30}}
      - {name: x, in: path, required: true, schema: {type: integer, minimum: 0}}
      - {name: y, in: path, required: true, schema: {type: integer, minimum: 0}}
    get:
      tags: [qcarta-tiles]
      servers: [{url: "http://localhost:8011"}]
      summary: Render an XYZ PNG tile
      parameters:
        - {name: MAP, in: query, required: true, schema: {type: string}}
        - {name: LAYERS, in: query, schema: {type: string}}
        - {name: STYLES, in: query, schema: {type: string}}
        - {name: META, in: query, schema: {type: integer, minimum: 1}}
        - {name: BUFFER, in: query, schema: {type: integer, minimum: 0}}
        - {name: CACHE, in: query, schema: {type: integer, enum: [0, 1]}}
      responses:
        "200":
          description: "256x256 PNG"
          headers:
            X-Cache: {schema: {type: string, enum: [HIT, MISS, BYPASS]}}
            X-Tile-Z: {schema: {type: integer}}
            X-Tile-X: {schema: {type: integer}}
            X-Tile-Y: {schema: {type: integer}}
            X-Tile-SHA256: {schema: {type: string}}
          content: {image/png: {schema: {type: string, format: binary}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "500": {$ref: "#/components/responses/InternalError"}
        "502": {$ref: "#/components/responses/BadGateway"}
  /api/wmts:
    get:
      tags: [qcarta-tiles]
      servers: [{url: "http://localhost:8011"}]
      summary: WMTS placeholder
      responses: {"501": {description: "Not implemented", content: {text/plain: {schema: {type: string}}}}}
  /api/seed:
    post:
      tags: [qcarta-tiles]
      servers: [{url: "http://localhost:8011"}]
      summary: Start asynchronous seed
      requestBody: {required: true, content: {application/json: {schema: {$ref: "#/components/schemas/QcartaSeedRequest"}}}}
      responses:
        "200": {description: "Accepted", content: {application/json: {schema: {$ref: "#/components/schemas/SeedAccepted"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
  /api/seed/status:
    get:
      tags: [qcarta-tiles]
      servers: [{url: "http://localhost:8011"}]
      summary: Get seed progress
      parameters: [{name: id, in: query, required: true, schema: {type: string, minLength: 1}}]
      responses:
        "200": {description: "Job", content: {application/json: {schema: {$ref: "#/components/schemas/SeedJob"}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "404": {$ref: "#/components/responses/NotFound"}
  /api/prewarm:
    post:
      tags: [qcarta-tiles]
      servers: [{url: "http://localhost:8011"}]
      summary: Prewarm WMS metadata
      requestBody: {required: true, content: {application/json: {schema: {type: object, required: [map], properties: {map: {type: string, minLength: 1}}}}}}
      responses:
        "200": {description: "Timings", content: {application/json: {schema: {type: object, required: [ok, map, project_key, get_capabilities_ms, get_project_settings_ms], properties: {ok: {type: boolean, const: true}, map: {type: string}, project_key: {type: string}, get_capabilities_ms: {type: number}, get_project_settings_ms: {type: number}}}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "502": {$ref: "#/components/responses/BadGateway"}
  /api/health:
    get:
      tags: [qcarta-tiles]
      servers: [{url: "http://localhost:8011"}]
      summary: Health check
      responses: {"200": {description: "Healthy", content: {text/plain: {schema: {type: string, const: OK}}}}}
  /health:
    get:
      tags: [qcarta-tiles]
      servers: [{url: "http://localhost:8011"}]
      summary: Health check alias
      responses: {"200": {description: "Healthy", content: {text/plain: {schema: {type: string, const: OK}}}}}
  /api/metrics:
    get:
      tags: [qcarta-tiles]
      servers: [{url: "http://localhost:8011"}]
      summary: Cache counters
      responses:
        "200": {description: "Counters", content: {application/json: {schema: {type: object, required: [cache_hits, cache_misses, cache_bypass], properties: {cache_hits: {type: integer}, cache_misses: {type: integer}, cache_bypass: {type: integer}}}}}}
  /admin/cache/purge:
    post:
      tags: [qcarta-tiles]
      servers: [{url: "http://localhost:8011"}]
      security: [{qcartaBearer: []}]
      summary: Purge cache
      requestBody: {content: {text/plain: {schema: {type: string, default: all, description: "`all` or a project key; empty defaults to all."}}}}
      responses:
        "200": {description: "Purge result", content: {application/json: {schema: {type: object, required: [success, removed, scope], properties: {success: {type: boolean, const: true}, removed: {type: integer}, scope: {type: string}}}}}}
        "400": {$ref: "#/components/responses/BadRequest"}
        "401": {$ref: "#/components/responses/Unauthorized"}
        "500": {$ref: "#/components/responses/InternalError"}
components:
  securitySchemes:
    cookieAuth:
      type: apiKey
      in: cookie
      name: acugis_session
      description: "Central session cookie. QGIS Bearer PATs are unsupported because this auth provider has no TokenStore."
    qcartaBearer:
      type: http
      scheme: bearer
      bearerFormat: opaque
      description: "QCARTA_CACHE_PURGE_TOKEN; valid only for qcarta cache purge."
  examples:
    ErrorExample:
      summary: "JSON error response"
      value:
        error: "invalid request"
    FeatureCreateExample:
      summary: "Create a feature without geometry"
      value:
        layer: "parcels"
        attributes:
          status: "active"
    QcartaSeedExample:
      summary: "Seed one Web Mercator extent"
      value:
        map: "/var/data/projects/example/project.qgz"
        min_zoom: 8
        max_zoom: 10
        bbox: [-1000000, 5000000, -900000, 5100000]
  parameters:
    ProjectId: {name: id, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 240}, description: "Rejects two-dot segments, slash, backslash, and NUL."}
    Version: {name: version, in: path, required: true, schema: {type: integer, minimum: 1}}
    MapId: {name: id, in: path, required: true, schema: {type: string, minLength: 1}}
    SessionId: {name: id, in: path, required: true, schema: {type: string, minLength: 1}}
    SourceId: {name: sourceId, in: path, required: true, schema: {type: string, minLength: 1}}
    ServiceName: {name: name, in: path, required: true, schema: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9_.-]+$'}}
    Layer: {name: layer, in: query, required: true, schema: {type: string, minLength: 1}}
    Field: {name: field, in: query, required: true, schema: {$ref: "#/components/schemas/Identifier"}}
    LabelField: {name: label_field, in: query, schema: {$ref: "#/components/schemas/Identifier"}}
    Scope: {name: scope, in: query, schema: {type: string, enum: [layer, selection], default: layer}}
    Selection: {name: selection, in: query, schema: {type: string}, description: "JSON array required and non-empty for selection scope."}
    ProxyPath: {name: path, in: path, required: true, schema: {type: string, minLength: 1}, description: "Go `{path...}` wildcard; may contain slashes."}
    QGISService: {name: SERVICE, in: query, schema: {type: string, examples: [WMS, WFS]}}
    QGISRequest: {name: REQUEST, in: query, schema: {type: string, examples: [GetCapabilities, GetMap, GetFeature]}}
  responses:
    BadRequest: {description: "Invalid request", content: {text/plain: {schema: {type: string}}, application/json: {schema: {$ref: "#/components/schemas/Error"}}}}
    Unauthorized: {description: "Authentication required", content: {text/plain: {schema: {type: string}}, application/json: {schema: {$ref: "#/components/schemas/Error"}}}}
    Forbidden: {description: "Permission denied", content: {text/plain: {schema: {type: string}}, application/json: {schema: {$ref: "#/components/schemas/Error"}}}}
    NotFound: {description: "Not found", content: {text/plain: {schema: {type: string}}, application/json: {schema: {$ref: "#/components/schemas/Error"}}}}
    Conflict: {description: "Conflict", content: {text/plain: {schema: {type: string}}, application/json: {schema: {$ref: "#/components/schemas/Error"}}}}
    Unprocessable: {description: "Payload could not be processed", content: {text/plain: {schema: {type: string}}, application/json: {schema: {$ref: "#/components/schemas/Error"}}}}
    InternalError: {description: "Internal error", content: {text/plain: {schema: {type: string}}, application/json: {schema: {$ref: "#/components/schemas/Error"}}}}
    BadGateway: {description: "Upstream failure", content: {text/plain: {schema: {type: string}}, application/json: {schema: {$ref: "#/components/schemas/Error"}}}}
    Unavailable: {description: "Optional backing service unavailable", content: {text/plain: {schema: {type: string}}, application/json: {schema: {$ref: "#/components/schemas/Error"}}}}
    FeatureError: {description: "Feature operation failed", content: {application/json: {schema: {$ref: "#/components/schemas/SuccessError"}}}}
    GeoSyncError: {description: "Publication failed", content: {application/json: {schema: {type: object, required: [success, error], properties: {success: {type: boolean, const: false}, error: {type: string}}}}}}
    DynamicJSON: {description: "Downstream status and JSON body; fields belong to the selected external GeoVault endpoint.", content: {application/json: {schema: {}}}}
    DynamicHTTP: {description: "Downstream status, headers, media type, and payload pass through without application-level constraints.", content: {application/octet-stream: {schema: {type: string, format: binary}}, application/json: {schema: {}}, application/xml: {schema: {type: string}}, text/plain: {schema: {type: string}}, image/png: {schema: {type: string, format: binary}}}}
  schemas:
    DynamicObject: {type: object, additionalProperties: true}
    Identifier: {type: string, pattern: '^[A-Za-z_][A-Za-z0-9_]*$'}
    Error: {type: object, required: [error], properties: {error: {type: string}}}
    Ok: {type: object, required: [ok], properties: {ok: {type: boolean, const: true}}}
    SuccessError: {type: object, required: [success], properties: {success: {type: boolean}, error: {type: string}, feature: {type: object, additionalProperties: true}, feature_id: {}}}
    Discovery:
      type: object
      required: [service, resource_type, items]
      properties:
        service: {type: string, const: qgis-publish-service}
        resource_type: {type: string, const: map}
        items:
          type: array
          items:
            type: object
            required: [id, title, description, url, category]
            properties: {id: {type: string}, title: {type: string}, description: {type: string}, url: {type: string}, thumbnail: {type: string}, category: {type: string}}
    AuthContext:
      type: object
      required: [authenticated, auth_enabled, user]
      properties:
        authenticated: {type: boolean}
        auth_enabled: {type: boolean}
        user:
          oneOf:
            - {type: "null"}
            - type: object
              required: [id, username, roles, groups, attributes]
              properties: {id: {type: string}, username: {type: string}, email: {type: string}, roles: {type: array, items: {type: string}}, groups: {type: array, items: {type: string}}, attributes: {$ref: "#/components/schemas/DynamicObject"}}
    ExpressionRequest:
      type: object
      required: [projectId, layer, expression, feature, context]
      properties:
        projectId: {type: string}
        layer: {type: string}
        expression: {type: string}
        feature: {$ref: "#/components/schemas/DynamicObject"}
        context:
          type: object
          required: [user]
          properties:
            user:
              oneOf:
                - {type: "null"}
                - type: object
                  required: [username, roles, groups, attributes]
                  properties: {username: {type: string}, roles: {type: array, items: {type: string}}, groups: {type: array, items: {type: string}}, attributes: {$ref: "#/components/schemas/DynamicObject"}}
    ExpressionResult: {type: object, required: [ok], properties: {ok: {type: boolean}, value: {}, error: {type: string}}}
    Project:
      type: object
      required: [id, qgs_path, render_mode, layer_configs]
      properties:
        id: {type: string}
        name: {type: string}
        path: {type: string}
        qgs_path: {type: string}
        authoring_qgs_relative: {type: string}
        server_qgs_relative: {type: string}
        render_mode: {type: string}
        created_by: {type: integer, format: int64}
        owner: {type: string}
        created_at: {type: string}
        updated_at: {type: string}
        auto_layer_legends: {type: boolean}
        basemap_ids: {type: array, items: {type: string}}
        initial_basemap: {type: string}
        layer_configs: {type: object, additionalProperties: true, description: "Values contain interactive_legend, optional source/mode/config, and title_field."}
        popupCharts: {type: array, items: {$ref: "#/components/schemas/PopupChart"}}
        runtime_tables: {type: array, items: {$ref: "#/components/schemas/DynamicObject"}}
        runtime_dataset_id: {type: string}
        layer_tree: {$ref: "#/components/schemas/DynamicObject"}
        summary: {type: string}
        public: {type: boolean}
        restricted: {type: boolean}
        editable: {type: boolean}
        can_admin: {type: boolean}
    UploadResult:
      type: object
      required: [id, project_name, project_dir, qgs_relative, authoring_qgs_relative, project_qgs, authoring_qgs, map_param_hint]
      properties: {id: {type: string}, project_name: {type: string}, project_dir: {type: string}, qgs_relative: {type: string}, authoring_qgs_relative: {type: string}, project_qgs: {type: string}, authoring_qgs: {type: string}, map_param_hint: {type: string}}
    UpdateResult:
      type: object
      required: [id, qgs_relative, project_name, snapshot_version, snapshot_at, map_param_hint]
      properties: {id: {type: string}, qgs_relative: {type: string}, project_name: {type: string}, snapshot_version: {type: integer}, snapshot_at: {type: string, format: date-time}, map_param_hint: {type: string}}
    GenerateRequest: {type: object, required: [raster_path, name], properties: {raster_path: {type: string, minLength: 1}, name: {type: string, minLength: 1}, crs: {type: string}}}
    GenerateResult: {type: object, required: [id, project_path, map_param_hint], properties: {id: {type: string}, project_path: {type: string}, map_param_hint: {type: string}}}
    PopupChart:
      type: object
      required: [id, layerId, title]
      properties: {id: {type: string}, layerId: {type: string}, title: {type: string}, chartType: {type: string}, dataSource: {type: string}, sourceLayerId: {type: string}, relationship: {$ref: "#/components/schemas/DynamicObject"}, fields: {type: array, items: {type: string}}, categoryField: {type: string}, valueField: {type: string}, aggregation: {type: string}}
    LegendSettings:
      type: object
      description: "At least one of auto_layer_legends, popupCharts, or layer_title_fields is required."
      properties: {auto_layer_legends: {type: boolean}, resync: {type: boolean, default: true}, popupCharts: {type: array, items: {$ref: "#/components/schemas/PopupChart"}}, layer_title_fields: {type: object, additionalProperties: {type: string}}}
    BBox:
      type: array
      minItems: 4
      maxItems: 4
      prefixItems: [{type: number}, {type: number}, {type: number}, {type: number}]
      items: false
      description: "EPSG:3857 array in minx, miny, maxx, maxy order."
    ProjectSeedRequest: {type: object, required: [min_zoom, max_zoom, bbox], properties: {min_zoom: {type: integer, minimum: 0, maximum: 30}, max_zoom: {type: integer, minimum: 0, maximum: 30}, bbox: {$ref: "#/components/schemas/BBox"}}}
    QcartaSeedRequest:
      allOf:
        - {$ref: "#/components/schemas/ProjectSeedRequest"}
        - {type: object, required: [map], properties: {map: {type: string, minLength: 1}}}
    SeedAccepted: {type: object, required: [job_id], properties: {job_id: {type: string}}}
    SeedJob: {type: object, required: [id, total, completed, status], properties: {id: {type: string}, total: {type: integer}, completed: {type: integer}, status: {type: string, enum: [running, done, error]}, message: {type: string}}}
    ProjectMetadataInput:
      type: object
      properties: {title: {type: string}, summary: {type: string}, description: {type: string}, category: {type: string}, tags: {type: array, items: {type: string}}, organization: {type: string}, contact_name: {type: string}, contact_email: {type: string}, license: {type: string}, attribution: {type: string}, citation: {type: string}, status: {type: string}, version: {type: string}, language: {type: string}, purpose: {type: string}, lineage: {type: string}, uuid: {type: string}}
    ProjectMetadata:
      allOf:
        - {$ref: "#/components/schemas/ProjectMetadataInput"}
        - {type: object, required: [id, project_id, created_at, updated_at], properties: {id: {type: integer, format: int64}, project_id: {type: string}, created_at: {type: string, format: date-time}, updated_at: {type: string, format: date-time}}}
    Report:
      type: object
      required: [id, title, reportName, datasource]
      properties:
        id: {type: string, minLength: 1, pattern: '^[^\s/\\]+$'}
        title: {type: string, minLength: 1}
        reportName: {type: string, minLength: 1}
        datasource: {type: string, minLength: 1}
        selectionMode: {type: string, enum: [single, multi]}
        layers: {type: array, items: {type: string}}
        popupAction: {type: boolean}
        parameters: {type: object, additionalProperties: {type: string, pattern: '^\$(selection|feature)\.'}}
        popupParameters: {type: object, additionalProperties: {type: string, pattern: '^\$(selection|feature)\.'}}
    ReportsFile: {type: object, required: [reports], properties: {reports: {type: array, items: {$ref: "#/components/schemas/Report"}}}}
    ReportsResponse: {type: object, required: [project_id, reports], properties: {project_id: {type: string}, reports: {type: array, items: {$ref: "#/components/schemas/Report"}}}}
    RelationOption: {type: object, required: [value, label], properties: {value: {type: string}, label: {type: string}}}
    Pivot: {type: object, required: [row_field, value_field, aggregation, row_labels, col_labels, cells], properties: {row_field: {type: string}, col_field: {type: string}, value_field: {type: string}, aggregation: {type: string, enum: [COUNT, SUM, AVG, MIN, MAX]}, row_labels: {type: array, items: {type: string}}, col_labels: {type: array, items: {type: string}}, cells: {type: array, items: {type: array, items: {type: number}}}}}
    Outliers: {type: object, required: [field, threshold, mean, std_dev, rows], properties: {field: {type: string}, label_field: {type: string}, threshold: {type: number}, mean: {type: number}, std_dev: {type: number}, rows: {type: array, items: {type: object, required: [feature_id, feature_label, value, z_score, classification], properties: {feature_id: {}, feature_label: {type: string}, value: {type: number}, z_score: {type: number}, classification: {type: string, enum: [High Outlier, Low Outlier, Normal]}}}}}}
    Hotspots: {type: object, required: [field, threshold, rows], properties: {field: {type: string}, label_field: {type: string}, threshold: {type: number}, rows: {type: array, items: {type: object, required: [feature_id, feature_label, value, neighbor_avg, score, classification], properties: {feature_id: {}, feature_label: {type: string}, value: {type: number}, neighbor_avg: {type: number}, score: {type: number}, classification: {type: string, enum: [Hotspot, Coldspot, Normal]}, neighbor_count: {type: integer}}}}}}
    FeatureId: {oneOf: [{type: string}, {type: integer}, {type: number}]}
    Geometry: {type: object, required: [type], properties: {type: {type: string, enum: [Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon]}, coordinates: {type: array, items: {}}}}
    FeatureCreate: {type: object, required: [layer, attributes], properties: {layer: {type: string, minLength: 1}, attributes: {type: object, additionalProperties: true}, geometry: {$ref: "#/components/schemas/Geometry"}}, description: "Attributes may be empty only when geometry is supplied."}
    FeatureUpdate: {type: object, required: [layer, feature_id, attributes], properties: {layer: {type: string, minLength: 1}, feature_id: {$ref: "#/components/schemas/FeatureId"}, attributes: {type: object, minProperties: 1, additionalProperties: true}}}
    FeatureDelete: {type: object, required: [layer, feature_id], properties: {layer: {type: string, minLength: 1}, feature_id: {$ref: "#/components/schemas/FeatureId"}}}
    FeatureResult: {type: object, required: [success], properties: {success: {type: boolean}, error: {type: string}, feature: {$ref: "#/components/schemas/DynamicObject"}, feature_id: {}}}
    AttachmentResult: {type: object, required: [success], properties: {success: {type: boolean}, path: {type: string}, error: {type: string}}}
    PGService: {type: object, required: [name, host, port, dbname, user, password], properties: {name: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9_.-]+$'}, host: {type: string}, port: {type: string}, dbname: {type: string}, user: {type: string}, password: {type: string, format: password}, sslmode: {type: string}}}
    PGTest: {type: object, required: [host, port, dbname, user, password, sslmode], properties: {host: {type: string, minLength: 1}, port: {type: string, default: "5432"}, dbname: {type: string, minLength: 1}, user: {type: string}, password: {type: string, format: password}, sslmode: {type: string, enum: [disable, require, verify-ca, verify-full], default: disable}}}
    BasemapId: {type: string, minLength: 1, maxLength: 64, pattern: '^[A-Za-z0-9_.-]+$'}
    Basemap: {type: object, required: [id, title, thumb, source_type, sort_order, is_default], properties: {id: {$ref: "#/components/schemas/BasemapId"}, title: {type: string, minLength: 1}, thumb: {type: string}, source_type: {type: string, enum: [xyz, osm]}, url: {type: string, description: "Required for xyz."}, cross_origin: {type: string}, attributions: {type: string}, sort_order: {type: integer}, is_default: {type: boolean}}}
    BrowserConfig:
      type: object
      required: [version, layers]
      properties:
        version: {type: integer, const: 1}
        view: {type: object, properties: {projection: {type: string}, center: {type: array, items: {type: number}}, zoom: {type: number}, rotation: {type: number}}}
        basemap: {type: object, properties: {id: {type: string}}}
        layers:
          type: array
          items:
            type: object
            required: [id, datasetId, title, visible, opacity, order]
            properties: {id: {type: string}, datasetId: {type: string}, title: {type: string}, visible: {type: boolean}, opacity: {type: number}, order: {type: integer}, renderMode: {type: string}, style: {}, labels: {}, popup: {}}
    BrowserMap:
      type: object
      required: [id, owner_user_id, owner_username, title, map_config, visibility, created_at, updated_at]
      properties: {id: {type: string}, owner_user_id: {type: integer, format: int64}, owner_username: {type: string}, title: {type: string}, description: {type: string}, map_config: {oneOf: [{$ref: "#/components/schemas/BrowserConfig"}, {type: "null"}]}, thumbnail_path: {type: string}, visibility: {type: string, enum: [public, restricted]}, created_at: {type: string, format: date-time}, updated_at: {type: string, format: date-time}, public: {type: boolean}, restricted: {type: boolean}, editable: {type: boolean}, can_admin: {type: boolean}}
    BrowserMapCreate: {type: object, required: [title, description, map_config, visibility], properties: {title: {type: string}, description: {type: string}, map_config: {$ref: "#/components/schemas/BrowserConfig"}, visibility: {type: string, enum: [public, restricted]}}}
    BrowserMapUpdate: {type: object, properties: {title: {type: string}, description: {type: string}, map_config: {$ref: "#/components/schemas/BrowserConfig"}, visibility: {type: string, enum: [public, restricted]}}}
    DatasetResolve: {type: object, required: [datasetId, title, type, renderMode, clientStyleable, endpoints], properties: {datasetId: {type: string}, title: {type: string}, type: {type: string}, geometryType: {type: string}, fileType: {type: string}, datasourceType: {type: string}, latestVersion: {type: integer}, bbox: {type: array, items: {type: number}}, crs: {type: string}, renderMode: {type: string, enum: [unavailable, xyz, wmts, wms, vector-tile, geojson]}, clientStyleable: {type: boolean}, endpoints: {$ref: "#/components/schemas/DynamicObject"}, error: {type: string}}}
    SourceStatus: {type: string, enum: [pending, analyzing, ready, importing, publishing, queued, complete, failed]}
    IngestSource:
      type: object
      required: [id, session_id, source_type, source_name, metadata, status, created_at, updated_at]
      properties: {id: {type: string}, session_id: {type: string}, source_type: {type: string, enum: [file, geopackage, shapefile, geojson, geotiff, csv, qgis_project, postgis, geovault, url, s3, esri, raster, unknown]}, source_name: {type: string}, metadata: {$ref: "#/components/schemas/DynamicObject"}, status: {$ref: "#/components/schemas/SourceStatus"}, temporary_location: {type: string}, created_at: {type: string, format: date-time}, updated_at: {type: string, format: date-time}}
    IngestSession: {type: object, required: [id, user_id, status, created_at, updated_at], properties: {id: {type: string}, user_id: {type: integer, format: int64}, status: {type: string, enum: [active, complete, failed, cancelled]}, created_at: {type: string, format: date-time}, updated_at: {type: string, format: date-time}, sources: {type: array, items: {$ref: "#/components/schemas/IngestSource"}}}}
    PostGISConnect: {type: object, required: [connection_name, host, port, database, username, password, sslmode], properties: {connection_name: {type: string}, host: {type: string, minLength: 1}, port: {type: string, default: "5432"}, database: {type: string, minLength: 1}, username: {type: string}, password: {type: string, format: password, minLength: 1}, sslmode: {type: string}}}
    PostGISConnectResult: {type: object, required: [success], properties: {success: {type: boolean}, error: {type: string}, pg_service_name: {type: string}, connection_id: {type: integer}, connection_name: {type: string}, host: {type: string}, port: {type: string}, database: {type: string}, username: {type: string}, sslmode: {type: string}}}
    PostGISDiscovery: {type: object, required: [success], properties: {success: {type: boolean}, error: {type: string}, connection_id: {type: integer}, connection_name: {type: string}, pg_service_name: {type: string}, host: {type: string}, port: {type: string}, database: {type: string}, schemas: {type: array, items: {type: string}}, tables: {type: array, items: {type: object, required: [schema, name, geometry_column, geometry_type, srid, feature_count], properties: {schema: {type: string}, name: {type: string}, geometry_column: {type: string}, geometry_type: {type: string}, srid: {type: integer}, feature_count: {type: integer, format: int64}, extent: {type: array, minItems: 4, maxItems: 4, items: {type: number}}, crs: {type: string}}}}}}
    QuickImportResult: {type: object, required: [success, source_type, database_id, tables, schema, pg_service_name, pg_service], properties: {success: {type: boolean}, source_type: {type: string}, database_id: {type: integer}, tables: {type: array, items: {type: string}}, schema: {type: string}, pg_service_name: {type: string}, pg_service: {type: object, required: [pg_service_name, host, port, dbname, user, password, sslmode], properties: {pg_service_name: {type: string}, host: {type: string}, port: {type: integer}, dbname: {type: string}, user: {type: string}, password: {type: string, format: password}, sslmode: {type: string}}}}}
    GeoSyncRequest: {type: object, required: [file, geosync_project_id, host, dbname, schema], properties: {file: {type: string, format: binary, description: ".zip, .qgs, or .qgz"}, geosync_project_id: {type: string}, publisher_project_id: {type: string}, host: {type: string}, port: {type: string, default: "5432"}, dbname: {type: string}, username: {type: string}, password: {type: string, format: password}, schema: {type: string}, sslmode: {type: string}}}
    GeoSyncResult: {type: object, required: [success, publisher_project_id, viewer_url, pg_service_name], properties: {success: {type: boolean, const: true}, publisher_project_id: {type: string}, viewer_url: {type: string}, pg_service_name: {type: string}}}
    RuntimeDataset: {type: object, required: [id, slug, display_name, runtime_db_name, pg_service_name, source_file, imported_at, updated_at, project_ids, status], properties: {id: {type: string}, slug: {type: string}, display_name: {type: string}, runtime_db_name: {type: string}, pg_service_name: {type: string}, source_file: {type: string}, imported_at: {type: string, format: date-time}, updated_at: {type: string, format: date-time}, owner_user_id: {type: integer, format: int64}, owner_username: {type: string}, project_ids: {type: array, items: {type: string}}, status: {type: string, enum: [pending, importing, ready, failed]}, status_message: {type: string}, table_count: {type: integer}, relation_count: {type: integer}}}
    RuntimeTable: {type: object, required: [id, runtime_dataset_id, original_table_name, imported_table_name, has_geometry, source_provider], properties: {id: {type: integer, format: int64}, runtime_dataset_id: {type: string}, original_table_name: {type: string}, imported_table_name: {type: string}, has_geometry: {type: boolean}, runtime_only: {type: boolean}, wms_publishable: {type: boolean}, primary_key: {type: string}, geometry_column: {type: string}, geometry_type: {type: string}, srid: {type: integer}, schema: {type: string}, source_provider: {type: string}}}
    RuntimeRelation: {type: object, required: [id, runtime_dataset_id, parent_table, child_table, fk_field], properties: {id: {type: integer, format: int64}, runtime_dataset_id: {type: string}, parent_table: {type: string}, child_table: {type: string}, fk_field: {type: string}, relation_id: {type: string}, relation_name: {type: string}, relation_type: {type: string}}}
    RuntimeDetails: {type: object, required: [dataset, tables, relations], properties: {dataset: {$ref: "#/components/schemas/RuntimeDataset"}, tables: {type: array, items: {$ref: "#/components/schemas/RuntimeTable"}}, relations: {type: array, items: {$ref: "#/components/schemas/RuntimeRelation"}}}}
