{
  "openapi": "3.1.0",
  "info": {
    "title": "OxiCloud API",
    "description": "REST API for OxiCloud — self-hosted cloud storage, calendar & contacts",
    "license": {
      "name": "MIT"
    },
    "version": "0.9.2"
  },
  "paths": {
    "/api/address-books": {
      "get": {
        "tags": [
          "contacts"
        ],
        "summary": "List all address books accessible to the current user (owned + shared),\nplus the virtual read-only system book listing all OxiCloud users.",
        "operationId": "list_address_books",
        "responses": {
          "200": {
            "description": "List of address books",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/AddressBookResponse"
                  }
                }
              }
            }
          },
          "500": {
            "description": "Internal server error"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "post": {
        "tags": [
          "contacts"
        ],
        "summary": "Create a new personal address book.",
        "operationId": "create_address_book",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateAddressBookRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "Address book created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AddressBookResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid input"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/address-books/{book_id}": {
      "put": {
        "tags": [
          "contacts"
        ],
        "summary": "Update an address book's name, description, or color.",
        "operationId": "update_address_book",
        "parameters": [
          {
            "name": "book_id",
            "in": "path",
            "description": "Address book UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateAddressBookRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Address book updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AddressBookResponse"
                }
              }
            }
          },
          "403": {
            "description": "Only the owner can update"
          },
          "404": {
            "description": "Address book not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "delete": {
        "tags": [
          "contacts"
        ],
        "summary": "Delete an address book and all its contacts. Only the owner can do this.",
        "operationId": "delete_address_book",
        "parameters": [
          {
            "name": "book_id",
            "in": "path",
            "description": "Address book UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Address book deleted"
          },
          "403": {
            "description": "Only the owner can delete"
          },
          "404": {
            "description": "Address book not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/address-books/{book_id}/contacts": {
      "get": {
        "tags": [
          "contacts"
        ],
        "summary": "List contacts in an address book.\n`book_id = \"system\"` returns all OxiCloud users (excluding the caller).",
        "operationId": "list_contacts",
        "parameters": [
          {
            "name": "book_id",
            "in": "path",
            "description": "Address book UUID or \"system\"",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Max results (omit for the full book; system book defaults to 100)",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          },
          {
            "name": "offset",
            "in": "query",
            "description": "Pagination offset (omit for none; system book defaults to 0)",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List of contacts",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ContactDto"
                  }
                }
              }
            }
          },
          "403": {
            "description": "Access denied"
          },
          "404": {
            "description": "Address book not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "post": {
        "tags": [
          "contacts"
        ],
        "summary": "Create a new contact in an address book.",
        "operationId": "create_contact",
        "parameters": [
          {
            "name": "book_id",
            "in": "path",
            "description": "Address book UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateContactRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "Contact created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ContactDto"
                }
              }
            }
          },
          "400": {
            "description": "Invalid input"
          },
          "403": {
            "description": "Access denied or read-only book"
          },
          "404": {
            "description": "Address book not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/address-books/{book_id}/contacts/{contact_id}": {
      "get": {
        "tags": [
          "contacts"
        ],
        "summary": "Get a single contact. Returns an `ETag` header for optimistic concurrency.\n`book_id = \"system\"` looks up an OxiCloud user by UUID.",
        "operationId": "get_contact",
        "parameters": [
          {
            "name": "book_id",
            "in": "path",
            "description": "Address book UUID or \"system\"",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "contact_id",
            "in": "path",
            "description": "Contact UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Contact details",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ContactDto"
                }
              }
            }
          },
          "403": {
            "description": "Access denied"
          },
          "404": {
            "description": "Contact not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "put": {
        "tags": [
          "contacts"
        ],
        "summary": "Update a contact. Honours `If-Match` for optimistic concurrency — returns\n`412 Precondition Failed` when the stored ETag does not match.",
        "operationId": "update_contact",
        "parameters": [
          {
            "name": "book_id",
            "in": "path",
            "description": "Address book UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "contact_id",
            "in": "path",
            "description": "Contact UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateContactRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Contact updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ContactDto"
                }
              }
            }
          },
          "400": {
            "description": "Invalid input"
          },
          "403": {
            "description": "Access denied or read-only book"
          },
          "404": {
            "description": "Contact not found"
          },
          "412": {
            "description": "ETag mismatch — contact was modified"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "delete": {
        "tags": [
          "contacts"
        ],
        "summary": "Delete a contact. Optionally honours `If-Match`.",
        "operationId": "delete_contact",
        "parameters": [
          {
            "name": "book_id",
            "in": "path",
            "description": "Address book UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "contact_id",
            "in": "path",
            "description": "Contact UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Contact deleted"
          },
          "403": {
            "description": "Access denied or read-only book"
          },
          "404": {
            "description": "Contact not found"
          },
          "412": {
            "description": "ETag mismatch"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/address-books/{book_id}/groups": {
      "get": {
        "tags": [
          "contacts"
        ],
        "summary": "List contact groups in an address book. The system book has no groups.",
        "operationId": "list_groups",
        "parameters": [
          {
            "name": "book_id",
            "in": "path",
            "description": "Address book UUID or \"system\"",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List of contact groups",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ContactGroupDto"
                  }
                }
              }
            }
          },
          "403": {
            "description": "Access denied"
          },
          "404": {
            "description": "Address book not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "post": {
        "tags": [
          "contacts"
        ],
        "summary": "Create a contact group in an address book.",
        "operationId": "create_group",
        "parameters": [
          {
            "name": "book_id",
            "in": "path",
            "description": "Address book UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GroupNameRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "Group created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ContactGroupDto"
                }
              }
            }
          },
          "403": {
            "description": "Access denied or read-only book"
          },
          "404": {
            "description": "Address book not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/address-books/{book_id}/groups/{group_id}": {
      "get": {
        "tags": [
          "contacts"
        ],
        "summary": "Get a single contact group by ID.",
        "operationId": "get_group",
        "parameters": [
          {
            "name": "book_id",
            "in": "path",
            "description": "Address book UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "group_id",
            "in": "path",
            "description": "Group UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Group details",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ContactGroupDto"
                }
              }
            }
          },
          "403": {
            "description": "Access denied"
          },
          "404": {
            "description": "Group not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "put": {
        "tags": [
          "contacts"
        ],
        "summary": "Rename a contact group.",
        "operationId": "update_group",
        "parameters": [
          {
            "name": "book_id",
            "in": "path",
            "description": "Address book UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "group_id",
            "in": "path",
            "description": "Group UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/GroupNameRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Group updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ContactGroupDto"
                }
              }
            }
          },
          "403": {
            "description": "Access denied or read-only book"
          },
          "404": {
            "description": "Group not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "delete": {
        "tags": [
          "contacts"
        ],
        "summary": "Delete a contact group.",
        "operationId": "delete_group",
        "parameters": [
          {
            "name": "book_id",
            "in": "path",
            "description": "Address book UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "group_id",
            "in": "path",
            "description": "Group UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Group deleted"
          },
          "403": {
            "description": "Access denied or read-only book"
          },
          "404": {
            "description": "Group not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/address-books/{book_id}/groups/{group_id}/contacts": {
      "get": {
        "tags": [
          "contacts"
        ],
        "summary": "List contacts that belong to a group.",
        "operationId": "list_contacts_in_group",
        "parameters": [
          {
            "name": "book_id",
            "in": "path",
            "description": "Address book UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "group_id",
            "in": "path",
            "description": "Group UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Contacts in the group",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ContactDto"
                  }
                }
              }
            }
          },
          "403": {
            "description": "Access denied"
          },
          "404": {
            "description": "Group not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "post": {
        "tags": [
          "contacts"
        ],
        "summary": "Add a contact to a group.",
        "operationId": "add_contact_to_group",
        "parameters": [
          {
            "name": "book_id",
            "in": "path",
            "description": "Address book UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "group_id",
            "in": "path",
            "description": "Group UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AddMemberRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "204": {
            "description": "Contact added to group"
          },
          "400": {
            "description": "Invalid contact ID"
          },
          "403": {
            "description": "Access denied or read-only book"
          },
          "404": {
            "description": "Group or contact not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/address-books/{book_id}/groups/{group_id}/contacts/{contact_id}": {
      "delete": {
        "tags": [
          "contacts"
        ],
        "summary": "Remove a contact from a group.",
        "operationId": "remove_contact_from_group",
        "parameters": [
          {
            "name": "book_id",
            "in": "path",
            "description": "Address book UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "group_id",
            "in": "path",
            "description": "Group UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "contact_id",
            "in": "path",
            "description": "Contact UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Contact removed from group"
          },
          "403": {
            "description": "Access denied or read-only book"
          },
          "404": {
            "description": "Group or contact not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/admin/dashboard": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "GET /api/admin/dashboard — full dashboard statistics",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "get_dashboard_stats",
        "responses": {
          "200": {
            "description": "Dashboard statistics",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DashboardStatsDto"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/dedup/recalculate": {
      "post": {
        "tags": [
          "admin"
        ],
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "recalculate_stats",
        "responses": {
          "200": {
            "description": "Statistics after integrity verification",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StatsResponse"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid token"
          },
          "403": {
            "description": "Caller is not an admin"
          },
          "500": {
            "description": "Integrity verification failed"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/dedup/stats": {
      "get": {
        "tags": [
          "admin"
        ],
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "get_stats",
        "responses": {
          "200": {
            "description": "Deduplication statistics",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StatsResponse"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid token"
          },
          "403": {
            "description": "Caller is not an admin"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/drive-policies/defaults/{kind}": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "GET /api/admin/drive-policies/defaults/{kind}",
        "description": "The policy every new drive of this kind inherits, and that existing\ndrives resolve against for every knob they have not overridden.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "get_drive_policy_defaults",
        "parameters": [
          {
            "name": "kind",
            "in": "path",
            "description": "personal | shared",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Current defaults for this drive kind",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DrivePolicies"
                }
              }
            }
          },
          "400": {
            "description": "Unknown drive kind"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      },
      "put": {
        "tags": [
          "admin"
        ],
        "summary": "PUT /api/admin/drive-policies/defaults/{kind}",
        "description": "**Replaces** the bag rather than merging it. The per-drive PATCH merges\nso an admin can nudge one knob without restating the rest, but a default\nis a complete statement of posture for the kind — merging would leave no\nway to express \"unset this knob\", since a key could never be taken back\nout.\n\nWith `?dry_run=true` nothing is written and the response is the impact\nthe change *would* have: how many drives follow it, how many override it\nand stay put, and how many end up laxer than the new default. That turns\nboth compliance reports into a pre-commit check rather than an\nafter-the-fact audit.\n\nRefuses (400) rather than silently dropping a knob that cannot take\neffect — `read_only`, which is per-drive only, and knobs that do not\napply to the kind. A setting stored where it does nothing is one the\nadmin believes they made.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "set_drive_policy_defaults",
        "parameters": [
          {
            "name": "kind",
            "in": "path",
            "description": "personal | shared",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "dry_run",
            "in": "query",
            "description": "When true, compute and return the impact WITHOUT saving.\n\nSend the literal `true` / `false`: `serde_urlencoded` refuses `1`,\n`yes` and `on` outright and fails the whole query struct, so a\ntruthy-looking `?dry_run=1` is a 400 rather than a silent false.",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {}
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Stored defaults, or the impact preview when dry_run=true",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Unknown kind, unknown knob, or a knob that cannot be defaulted"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/drive-policies/drift": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "GET /api/admin/drive-policies/drift",
        "description": "Drives that are currently laxer than their kind's default, across both\nkinds, computed live.\n\nDeliberately NOT a consistency-job finding. It reads two small tables, so\na page load can afford it — and being live is what makes it correct: a\nscan reports a completed run, so a drive the admin has just fixed stays\non the list until someone re-scans. Here the row disappears as soon as\nthe override is corrected.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "get_drive_policy_drift",
        "responses": {
          "200": {
            "description": "Drives laxer than their kind's default",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/WeakerDriveDto"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/drives": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "GET /api/admin/drives — list every drive on the system, admin-only.",
        "description": "Distinct from `GET /api/drives`, which is the caller's own listing\nfiltered through `role_grants`. An admin who creates a shared drive\nfor someone else has no grant on it — but the admin panel still\nneeds to see the drive (to audit, to manage, to delete). The admin\nguard at the handler edge is the access control; no role filtering\nhappens in the repo (see `drive_repository::list_all`).\n\nReturns rows ordered by display name. `caller_role` is omitted —\nthe admin is not necessarily a drive member, so the field would be\nmisleading here.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "list_all_drives",
        "responses": {
          "200": {
            "description": "Every drive on the system",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/DriveDto"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/drives/{id}": {
      "delete": {
        "tags": [
          "admin"
        ],
        "summary": "`DELETE /api/admin/drives/{id}` — admin-only drive delete (D3b).",
        "description": "Same shape as the user-facing `DELETE /api/drives/{id}`, but\nbypasses the per-drive `Manage` check (the admin guard at the\nroute edge is the access control). The remaining invariants —\ndefault Personal drive is undeletable, drive must be empty — still\napply: an admin can't accidentally wipe a populated drive or the\ndefault home folder of any user. Audit emits\n`drive.deleted_via_admin` on success.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "delete_drive_admin",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Drive UUID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Drive deleted"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          },
          "405": {
            "description": "Default Personal drive — undeletable"
          },
          "409": {
            "description": "Drive is not empty"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/drives/{id}/members": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "GET /api/admin/drives/{id}/members — list every role grant on a drive,\nadmin-only.",
        "description": "Distinct from `GET /api/drives/{id}/members` which goes through\n`DriveManagementService::list_members` and requires `Permission::Read`\non the drive. The admin who created the drive for someone else has\nno role on it, so the user-facing endpoint would 404 for them.\n\nThis endpoint reuses the engine's `list_grants_on_resource` directly\n— same query, same shape, just gated by the admin middleware instead\nof by `authz.require`. Returns the same `Vec<GrantDto>` so the\nfrontend renders it through the existing grant types.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "list_drive_members_admin",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Drive UUID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Role grants on the drive",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/GrantDto"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      },
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "POST /api/admin/drives/{id}/members — add or refresh a member's role\nwithout holding `Manage` on the drive. Admin-only; bypasses the\nper-drive authz check via the `caller_is_admin = true` argument on\n`DriveManagementService::set_member_role`. Personal-drive guard and\nlast-owner protection still apply.",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "add_drive_member_admin",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Drive UUID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AdminAddDriveMemberDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "Member added",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GrantDto"
                }
              }
            }
          },
          "400": {
            "description": "Validation error (e.g. last-owner constraint)"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          },
          "405": {
            "description": "Personal drive — membership is immutable"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/drives/{id}/members/{kind}/{sid}": {
      "delete": {
        "tags": [
          "admin"
        ],
        "summary": "DELETE /api/admin/drives/{id}/members/{kind}/{sid} — remove a\nmember as an admin. Bypasses `Manage`; keeps last-owner protection.",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "remove_drive_member_admin",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Drive UUID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "kind",
            "in": "path",
            "description": "Subject kind: user|group|token",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sid",
            "in": "path",
            "description": "Subject UUID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Member removed (or wasn't a member — idempotent)"
          },
          "400": {
            "description": "Last-owner protection — promote another member first"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          },
          "405": {
            "description": "Personal drive — membership is immutable"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      },
      "patch": {
        "tags": [
          "admin"
        ],
        "summary": "PATCH /api/admin/drives/{id}/members/{kind}/{sid} — change a member's\nrole / expiry as an admin. Same admin-bypass shape as\n`add_drive_member_admin`.",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "update_drive_member_admin",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Drive UUID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "kind",
            "in": "path",
            "description": "Subject kind: user|group|token",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sid",
            "in": "path",
            "description": "Subject UUID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AdminUpdateDriveMemberDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Member role updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GrantDto"
                }
              }
            }
          },
          "400": {
            "description": "Validation error (e.g. last-owner demotion)"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          },
          "405": {
            "description": "Personal drive — membership is immutable"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/jobs": {
      "get": {
        "tags": [
          "admin"
        ],
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "list_jobs",
        "responses": {
          "200": {
            "description": "Jobs listed",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/jobs/{name}/cancel": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "`POST /api/admin/jobs/{name}/cancel` — cooperative cancel of the\ncurrently-running recoverable run for `{name}`.",
        "description": "Flips the row's `status` from `Running` → `CancelRequested`. The\nhandler is responsible for polling `store.status()` between batches\nand returning `RunOutcome::Paused` at the next safe boundary; if it\ndoesn't, the cancel is a no-op until the run completes naturally.\n\nReturns 200 with the run_id when a Running row was flipped, 200 with\n`cancelled: false` when nothing was running (either no runs exist,\nor the latest is Paused / Completed / Failed / already CancelRequested).\nNever 404 on \"no active run\" — the job name is registered and the\nendpoint just reports the truth.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "cancel_job",
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "description": "Registered job name",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Cancel signalled (or no-op if nothing was running)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          },
          "500": {
            "description": "DB error"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/jobs/{name}/pause": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "`POST /api/admin/jobs/{name}/pause` — cooperative PAUSE of the\ncurrently-running recoverable run for `{name}`.",
        "description": "Same DB mechanism as the old cancel (Running → CancelRequested,\nhandler yields to Paused), but no `cancel_intent` stamp so the\nengine writes `Paused`. Use this to interrupt a long-running\njob and resume it later; use `/cancel` to abandon it terminally.\n\nIdempotent: if the row is already Paused, returns 200 with\n`paused: false, reason: \"already_paused\"`.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "pause_job",
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "description": "Registered job name",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Pause signalled (or no-op if nothing was running)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          },
          "500": {
            "description": "DB error"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/jobs/{name}/runs": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "`GET /api/admin/jobs/{name}/runs?limit=N` — history of recoverable\nruns for a registered job, newest first. Includes terminal +\nnon-terminal rows.",
        "description": "Read-only, no audit line — standard admin-middleware auth is enough.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "list_job_runs",
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "description": "Registered job name",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Max rows to return (default 20, capped at 100)",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int32",
              "minimum": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Runs listed (may be empty)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          },
          "500": {
            "description": "DB error"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/jobs/{name}/runs/{id}": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "`GET /api/admin/jobs/{name}/runs/{id}` — single-run detail.",
        "description": "Returns 404 when the id doesn't exist. `{name}` is not validated\nagainst the run's `job_name` — the id is globally unique — but\nkeeping the name in the URL path lets operators build stable\nper-job history links without knowing individual run ids upfront.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "get_job_run",
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "description": "Registered job name",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "id",
            "in": "path",
            "description": "Run UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Run detail",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          },
          "404": {
            "description": "Run not found"
          },
          "500": {
            "description": "DB error"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/jobs/{name}/runs/{id}/findings": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "`GET /api/admin/jobs/{name}/runs/{id}/findings?limit=N&offset=M` —\npaginated findings emitted by a specific run of a recoverable job.",
        "description": "404 when the run id doesn't exist (anti-enum: caller knew the id\nsomehow; we don't leak whether it was pruned vs never-existed).\nRead-only, no audit — standard admin-middleware auth is enough.\n\n`{name}` is not validated against the run's `job_name` (the id is\nglobally unique) but keeps the URL path consistent with the other\nper-run endpoints for stable per-job history links.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "list_job_run_findings",
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "description": "Registered job name",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "id",
            "in": "path",
            "description": "Run UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Max rows (default 100, capped at 500)",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int32",
              "minimum": 0
            }
          },
          {
            "name": "offset",
            "in": "query",
            "description": "Rows to skip (default 0)",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int32",
              "minimum": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Findings listed (may be empty)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          },
          "404": {
            "description": "Run not found"
          },
          "500": {
            "description": "DB error"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/jobs/{name}/trigger": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "`POST /api/admin/jobs/{name}/trigger` — dispatch one run off-schedule.",
        "description": "Returns the job's `JobOutcome` inline. Idempotent under exclusivity:\nif the previous run is still in flight, the handler returns\n`Ok { count: 0, extra: { \"skipped\": \"already_running\" } }` rather\nthan spawning a parallel dispatch.\n\nEmits an audit line before dispatch — bulk-mutation side effects on\noperator command belong on the audit stream.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "trigger_job",
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "description": "Registered job name",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Dispatched; outcome inline",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          },
          "404": {
            "description": "Job not registered"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/notify/info": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "`GET /api/admin/notify/info` — the alerting policy, as the running\nprocess sees it.",
        "description": "Separate from `/smtp/info` and `/webhook/info`, which describe\n*transports*. This describes what gets sent: the severity floor, and\nwhich channels are actually wired. Worth its own endpoint because the\nfloor was invisible in the panel until now — an operator could see a\nconfigured webhook, a healthy SMTP server, and still be told nothing,\nbecause the default threshold only admits `data_loss`.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "get_notify_info",
        "responses": {
          "200": {
            "description": "Current alerting policy",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotifyInfoDto"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/search/cache": {
      "delete": {
        "tags": [
          "admin"
        ],
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "clear_search_cache",
        "responses": {
          "200": {
            "description": "Cache cleared",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid token"
          },
          "403": {
            "description": "Caller is not an admin"
          },
          "503": {
            "description": "Search service unavailable"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/sessions": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "GET /api/admin/sessions?user_id=&include_revoked=&limit=&offset= — list sessions",
        "description": "Global cross-user listing by default. `user_id` narrows to one\nuser; omit for cross-user. `include_revoked=true` opts into\nshowing revoked / expired rows for forensics (default hides).\nResponse is `{sessions, limit, offset}` — no total count (would\nrequire a second scan; the panel paginates on presence of\nexactly `limit` rows returned).\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "list_sessions",
        "parameters": [
          {
            "name": "user_id",
            "in": "query",
            "description": "Narrow to one user (UUID); omit for cross-user",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "include_revoked",
            "in": "query",
            "description": "Include revoked + expired rows (default false — active only)",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Max rows to return (default 100, max 500)",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          },
          {
            "name": "offset",
            "in": "query",
            "description": "Pagination offset",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List of sessions",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/sessions/{id}": {
      "delete": {
        "tags": [
          "admin"
        ],
        "summary": "DELETE /api/admin/sessions/:id — revoke a session",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "revoke_session",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Session UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Session revoked",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Invalid UUID"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          },
          "404": {
            "description": "Session not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/settings/oidc": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "GET /api/admin/settings/oidc — get OIDC settings for the admin panel",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "get_oidc_settings",
        "responses": {
          "200": {
            "description": "OIDC settings",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OidcSettingsDto"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      },
      "put": {
        "tags": [
          "admin"
        ],
        "summary": "PUT /api/admin/settings/oidc — save OIDC settings + hot-reload",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "save_oidc_settings",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SaveOidcSettingsDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "OIDC settings saved",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/settings/registration": {
      "put": {
        "tags": [
          "admin"
        ],
        "summary": "PUT /api/admin/settings/registration — enable/disable public registration",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "set_registration_setting",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {}
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Registration setting updated",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Missing field"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/settings/storage": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "GET /api/admin/settings/storage — get storage backend settings",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "get_storage_settings",
        "responses": {
          "200": {
            "description": "Storage settings",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/StorageSettingsDto"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      },
      "put": {
        "tags": [
          "admin"
        ],
        "summary": "PUT /api/admin/settings/storage — save storage backend settings",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "save_storage_settings",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SaveStorageSettingsDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Storage settings saved",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/settings/storage/generate-key": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "POST /api/admin/settings/storage/generate-key — generate a random AES-256 key.",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "generate_encryption_key",
        "responses": {
          "200": {
            "description": "Generated AES-256 key",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/smtp/info": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "GET /api/admin/smtp/info — read-only view of the running SMTP config.",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "get_smtp_info",
        "responses": {
          "200": {
            "description": "Current SMTP settings",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SmtpInfoDto"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/smtp/test": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "POST /api/admin/smtp/test — send a diagnostic email to `dto.to`.",
        "description": "Returns 200 regardless of SMTP outcome; the body's `success` flag\n+ `code`/`message` (or `error`) tell the frontend what to render.\nThis keeps SMTP-level failures (4xx/5xx replies, connection\ntimeouts) as ordinary diagnostic data rather than HTTP errors.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "send_smtp_test",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SendSmtpTestDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Send attempt completed",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SmtpTestResultDto"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          },
          "503": {
            "description": "SMTP not configured"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/storage/entries/{name}/rotate": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "POST /api/admin/storage/entries/{name}/rotate — trigger the\n`backend_rotate` recoverable job on a specific entry.",
        "description": "Normalises every blob on `<name>` to the entry's head-pair\nformat: legacy → v1, plaintext ↔ encrypted, old-key → new-key.\nSee `docs/plan/storage-key-rotation.md` §\"The rotation job\".\n\nUnlike migration, rotation does NOT engage read-only mode —\nrewrites happen in place under normal traffic. Concurrent user\nwrites coexist safely.\n\nThe handler validates the entry name synchronously (400 on\nunknown entry); the actual walk detaches into a\n`tokio::spawn` so the HTTP call returns immediately.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "trigger_backend_rotate",
        "parameters": [
          {
            "name": "name",
            "in": "path",
            "description": "Storage entry name to rotate",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Rotation dispatched"
          },
          "400": {
            "description": "Unknown entry"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/storage/migration": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "GET /api/admin/storage/migration — current migration progress.",
        "description": "Shim over the recoverable-run engine: reads the latest\n`backend_migration` run from `jobs.recoverable_runs` (via the\n`JobStoreProvider`) and projects it into the legacy\n`MigrationStateDto` shape the admin storage tab expects. When no\nrun has ever been triggered the response is an empty \"idle\" DTO —\nsame behaviour the old in-memory `MigrationState::default()`\nproduced.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "get_migration_status",
        "responses": {
          "200": {
            "description": "Current migration status",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MigrationStateDto"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/storage/migration/pause": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "POST /api/admin/storage/migration/pause — pause a running migration.",
        "description": "Shim over cooperative cancel: flips the run row's status to\n`CancelRequested`; the recoverable handler polls between batches\nand returns `Paused` at the next boundary. If nothing is running,\nreturns 200 with `paused: false` — matches the \"no-op is fine\"\ncontract of `/api/admin/jobs/{name}/cancel`.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "pause_migration",
        "responses": {
          "200": {
            "description": "Pause signalled (or no-op)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/storage/migration/resume": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "POST /api/admin/storage/migration/resume — resume a paused migration.",
        "description": "Same underlying trigger as `/start`: `run_or_resume` inspects the\nlatest row and picks Fresh / Resume / AlreadyActive at dispatch\ntime. Kept as a distinct endpoint for wire-compat.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "resume_migration",
        "responses": {
          "200": {
            "description": "Migration resumed (or already running)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/storage/migration/start": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "POST /api/admin/storage/migration/start — begin background migration.",
        "description": "Shim that forwards to `JobRegistry::trigger(\"backend_migration\",\n...)`. `run_or_resume` (the RecoverableAdapter's inner dispatch)\nresumes a Paused run or starts a fresh one — one endpoint covers\nboth. Exclusivity is enforced at the DB layer (the partial unique\nindex on `jobs.recoverable_runs`), so a second concurrent trigger\nis a no-op that returns the existing run.\n\n`StartMigrationDto.concurrency` is currently ignored — the\nrecoverable copy loop runs sequentially. Kept in the DTO for\nwire-compat with the admin UI; will be honoured if a concurrency\nknob is added later.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "start_migration",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/StartMigrationDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Migration started",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/transfer-ownership": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "POST /api/admin/transfer-ownership — hand the instance to another user.",
        "description": "Owner-only, and the only way the owner's own role ever changes: the\ngeneric role endpoint refuses `\"owner\"` in both directions. The swap is\none transaction, so the instance is never briefly ownerless or briefly\ndouble-owned.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "transfer_ownership",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/TransferOwnershipDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Ownership transferred",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Target cannot hold ownership, or is already the owner"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Only the current owner may transfer ownership"
          },
          "404": {
            "description": "Target user not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/users": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "GET /api/admin/users?limit=50&offset=0 — list all users",
        "description": "Always returns `Vec<FullUserDto>` — the shape one row of the\n`/me` response's embedded `full` block carries. The former\n`?summary` toggle (flat `PublicUserDto` vs nested `FullUserDto`)\nhas been retired: admin listing is low-volume and the FE always\nasked for the nested shape anyway, so the two-shape split served\nno caller and only invited jq-path bugs. See\n`docs/plan/userdto-refactor.md`.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "list_users",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "description": "Max users to return (default 100, max 500)",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          },
          {
            "name": "offset",
            "in": "query",
            "description": "Pagination offset",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List of users",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AdminUsersPageResponse"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      },
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "POST /api/admin/users — create a new user (admin only)",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "create_user",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AdminCreateUserDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "User created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FullUserDto"
                }
              }
            }
          },
          "400": {
            "description": "Invalid user data"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required, and cannot create a role the caller does not outrank"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/users/{id}": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "GET /api/admin/users/:id — get single user",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "get_user",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "User UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "User details",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FullUserDto"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          },
          "404": {
            "description": "User not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      },
      "delete": {
        "tags": [
          "admin"
        ],
        "summary": "DELETE /api/admin/users/:id — delete a user",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "delete_user",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "User UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "User deleted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Cannot delete own account"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/users/{id}/active": {
      "put": {
        "tags": [
          "admin"
        ],
        "summary": "PUT /api/admin/users/:id/active — activate/deactivate user",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "update_user_active",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "User UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateUserActiveDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "User active status updated",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Cannot deactivate own account"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/users/{id}/password": {
      "put": {
        "tags": [
          "admin"
        ],
        "summary": "PUT /api/admin/users/:id/password — reset a user's password (admin only)",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "reset_user_password",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "User UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AdminResetPasswordDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Password reset",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Invalid password"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required, and must outrank the target"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/users/{id}/promote-to-internal": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "POST /api/admin/users/{id}/promote-to-internal — flip an external\n(grant-only) account into a normal internal account, provisioning\nits personal drive on the way. The deployment MUST have magic-link\nlogin enabled (the admin doesn't set the user's password on their\nbehalf, so the promoted user needs some way to log in). Refuses\nOIDC-linked users and users who are already internal.",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "admin_promote_external_to_internal",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Target user id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "User promoted",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicUserDto"
                }
              }
            }
          },
          "400": {
            "description": "Magic-link login is disabled on this deployment"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required (or target is OIDC-linked)"
          },
          "404": {
            "description": "User not found"
          },
          "409": {
            "description": "User is already internal"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/users/{id}/quota": {
      "put": {
        "tags": [
          "admin"
        ],
        "summary": "PUT /api/admin/users/:id/quota — update user storage quota",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "update_user_quota",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "User UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateUserQuotaDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Quota updated",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required, and must outrank the target"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/users/{id}/role": {
      "put": {
        "tags": [
          "admin"
        ],
        "summary": "PUT /api/admin/users/:id/role — change user role",
        "description": "Requires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "update_user_role",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "User UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateUserRoleDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Role updated",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Cannot change own role, or the target is the server owner"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/webhook/info": {
      "get": {
        "tags": [
          "admin"
        ],
        "summary": "GET /api/admin/webhook/info — read-only view of the running webhook\nconfig, beside SMTP on the admin Notifications page.",
        "description": "Reports the host but **never the full URL**: a Telegram endpoint carries\nthe bot token in its path, and a Slack or Discord webhook URL *is* the\ncredential. The host answers \"does this point where I think it does\"\nwithout putting a secret in a browser's network log.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "get_webhook_info",
        "responses": {
          "200": {
            "description": "Current webhook settings",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookInfoDto"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/admin/webhook/test": {
      "post": {
        "tags": [
          "admin"
        ],
        "summary": "`POST /api/admin/webhook/test` — POST a synthetic alert to the\nconfigured webhook.",
        "description": "Under `/webhook` rather than `/jobs`: the webhook is a transport, and\njob findings are its first consumer rather than its definition. What\nthis verifies is the URL, the format and the credentials — not anything\nabout jobs.\n\nSame shape as `POST /api/admin/smtp/test`, and for the same reason: 200\nregardless of the transport's verdict, with the detail in the body. A\nwebhook returning 404 is diagnostic data an operator needs to read, not\nan HTTP error for the panel to swallow.\n\nThe synthetic alert deliberately bypasses the severity threshold that\ngates real findings — testing with the default `data_loss` threshold\nwould otherwise deliver nothing and read as a broken webhook.\n\nRequires the deployment administrator role (`role:admin`); enforced by the `require_admin` layer on the `/api/admin` nest.",
        "operationId": "send_webhook_test",
        "responses": {
          "200": {
            "description": "Delivery attempted",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookTestResultDto"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          },
          "503": {
            "description": "No webhook configured"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "role:admin"
      }
    },
    "/api/auth/change-password": {
      "put": {
        "tags": [
          "auth"
        ],
        "summary": "Change the current user's password.",
        "operationId": "change_password",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ChangePasswordDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Password changed successfully",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "New password does not meet requirements"
          },
          "401": {
            "description": "Not authenticated or current password incorrect"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/auth/dpop/bind": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "One-shot bind a DPoP JWK thumbprint to the caller's current session.",
        "description": "Purpose: post-redirect flows (OIDC callback, magic-link redemption)\ncreate the session before the SPA has a chance to send its DPoP\nkeypair thumbprint. The SPA calls this endpoint immediately after\nthe redirect lands, so the session graduates from unbound to bound\nbefore the first authenticated `/api/*` request.\n\nContract:\n  * 200 on success — session now carries the thumbprint.\n  * 400 if the thumbprint is malformed (wrong length / non-base64url).\n  * 409 if the session already carries a thumbprint (anti-downgrade\n        invariant per `docs/plan/dpop.md` — a bound session cannot be\n        re-bound to a different key).\n  * 401 if no session (auth middleware layer emits this).",
        "operationId": "dpop_bind",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DpopBindDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Thumbprint bound",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Malformed thumbprint"
          },
          "401": {
            "description": "Not authenticated"
          },
          "409": {
            "description": "Session already bound"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/auth/login": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Authenticate with username and password.",
        "description": "On success, sets `oxicloud_access`, `oxicloud_refresh`, and `oxicloud_csrf`\nHttpOnly cookies in addition to returning the tokens in the JSON body.",
        "operationId": "login",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/LoginDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Login successful — tokens in body and cookies",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthResponseDto"
                }
              }
            }
          },
          "401": {
            "description": "Invalid credentials or password login disabled"
          },
          "403": {
            "description": "Account disabled"
          },
          "429": {
            "description": "Account temporarily locked (too many failed attempts)"
          }
        }
      }
    },
    "/api/auth/logout": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Revoke the current session and clear auth cookies.",
        "description": "Accepts the refresh token from **either** a JSON body\n`{ \"refresh_token\": \"...\" }` (API clients) or the `oxicloud_refresh`\nHttpOnly cookie (browsers).",
        "operationId": "logout",
        "requestBody": {
          "description": "Optional — omit when using the HttpOnly cookie",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "refresh_token"
                ],
                "properties": {
                  "refresh_token": {
                    "type": "string"
                  }
                }
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Logged out, auth cookies cleared",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Not authenticated or refresh token missing"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/auth/magic-link/send": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "POST /api/auth/magic-link/send — request a sign-in link by email.",
        "description": "Always returns 200 with a uniform message regardless of outcome, so\nthe response shape doesn't leak account existence. The real outcome\n(sent / no-account / has-credential / account-deactivated /\nmalformed-email) is recorded in the `audit` channel via\n`MagicLinkInviteService::send_login_link`.\n\n503 only when the magic-link feature isn't configured at all\n(SMTP env missing) — operators need to know about misconfiguration;\nit's not a state an anonymous caller can probe via timing because\nthe absence of the entire feature is visible from any other\nendpoint touching `/api/auth/magic-link/*`.\n\nPR 12 rate limits:\n- **Per-source-IP**, 200/hour — bounds the cost of one attacker\n  spreading low per-email volumes over many target addresses.\n- **Per-target-email**, 5/hour, keyed on the normalised email —\n  stops the endpoint from being an email-bombing primitive against\n  a single known recipient.\nBoth caps return the uniform 200 (never 429 to anonymous callers,\notherwise the status itself becomes an enumeration oracle); the\nreal reason is recorded in the audit channel.\nAuthenticated callers (Authorization header or access cookie\npresent) bypass both limits.",
        "operationId": "send_magic_link",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SendMagicLinkDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Uniform 'if an account exists, a link will be sent' response",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "503": {
            "description": "Magic-link / SMTP is not configured on this server"
          }
        }
      }
    },
    "/api/auth/me": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "Return the authenticated user's profile, including cached storage usage.",
        "operationId": "get_current_user",
        "responses": {
          "200": {
            "description": "Current user profile",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SelfUserDto"
                }
              }
            }
          },
          "401": {
            "description": "Not authenticated"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/auth/me/profile": {
      "patch": {
        "tags": [
          "auth"
        ],
        "summary": "Update the caller's profile (PR 24).",
        "description": "Fields are individually optional — absent = no change. Username is\n**claim-once, immutable**: passing `username` when the caller\nalready has one is rejected with 409 (the DAV / NextCloud path\nsurface bakes username in as a stable identifier; renaming would\nbreak clients). Given / family name are freely settable.\n\nOIDC-linked users are rejected wholesale with 403 — their profile\nis owned by the IdP.",
        "operationId": "update_profile",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateProfileDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Updated profile (SelfUserDto) — same shape as GET /me so the SPA sees the just-written state without a follow-up fetch",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SelfUserDto"
                }
              }
            }
          },
          "400": {
            "description": "Validation error (e.g. invalid handle format, empty given_name)"
          },
          "401": {
            "description": "Not authenticated"
          },
          "403": {
            "description": "OIDC-managed profile — edit at the IdP"
          },
          "409": {
            "description": "Username already claimed (immutable) or taken by another user"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/auth/oidc/authorize": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "Initiate OIDC authorization — redirects to the configured identity provider.",
        "description": "Generates PKCE, CSRF state, and nonce then issues a 302 redirect to the\nprovider's authorization endpoint.",
        "operationId": "oidc_authorize",
        "responses": {
          "302": {
            "description": "Redirect to OIDC provider authorization URL"
          },
          "404": {
            "description": "OIDC not enabled"
          },
          "503": {
            "description": "Auth service not configured"
          }
        }
      }
    },
    "/api/auth/oidc/backchannel-logout": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "OIDC Back-Channel Logout 1.0 receiver.",
        "description": "The IdP POSTs a signed `logout_token` JWT here when a user's SSO\nsession ends (they logged out elsewhere, admin revoked the session,\naccount was disabled). Body is `application/x-www-form-urlencoded`\nper spec §2.5 with a single `logout_token` field.\n\nResponse codes are constrained by the spec (§2.8):\n- 200 on successful processing (including a validated token that\n  matched no OxiCloud sessions — the notification is still \"handled\").\n- 400 on any validation failure (bad signature, expired, missing\n  required claims, replay). We do NOT return 200 with an error body.\n\nThis endpoint is public: no auth middleware, no CSRF, no cookie.\nThe `logout_token` signature IS the authentication — an unsigned or\nwrongly-signed token gets rejected by the OIDC service validator.",
        "operationId": "oidc_backchannel_logout",
        "requestBody": {
          "description": "Form-encoded body with a single `logout_token` field (the signed JWT from the IdP)",
          "content": {
            "application/x-www-form-urlencoded": {}
          }
        },
        "responses": {
          "200": {
            "description": "Logout notification accepted (0 or more sessions revoked)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "logout_token missing, malformed, or failed validation"
          },
          "503": {
            "description": "OIDC not configured on this deployment"
          }
        }
      }
    },
    "/api/auth/oidc/callback": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "Handle the OIDC provider callback.",
        "description": "Validates the `state` / PKCE / nonce, exchanges the code for tokens, then\nredirects the browser to the frontend login route with a short-lived\nexchange code (`/login?oidc_code=…`), which the SPA swaps for a session.",
        "operationId": "oidc_callback",
        "parameters": [
          {
            "name": "code",
            "in": "query",
            "description": "Authorization code from the OIDC provider",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "state",
            "in": "query",
            "description": "CSRF state value echoed by the provider",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "302": {
            "description": "Redirect to frontend with one-time exchange code"
          },
          "401": {
            "description": "OIDC validation failed (bad state, nonce, or code)"
          },
          "404": {
            "description": "OIDC not enabled"
          }
        }
      }
    },
    "/api/auth/oidc/exchange": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Exchange a one-time OIDC code for access + refresh tokens.",
        "description": "The frontend calls this after being redirected back with `?oidc_code=…`.\nThe exchange code is valid for a single use and expires in 60 s.",
        "operationId": "oidc_exchange",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OidcExchangeDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Tokens issued, auth cookies set",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthResponseDto"
                }
              }
            }
          },
          "401": {
            "description": "Exchange code invalid or expired"
          }
        }
      }
    },
    "/api/auth/oidc/link/start": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Start a self-service OIDC linking flow for the currently-authenticated\nuser. Returns the authorize URL for the SPA to `window.location`\nnavigate to. Callback lands on the standard `/api/auth/oidc/callback`\nwhich dispatches to the link branch based on the state cache's\n`intent` field. See docs/plan/oidc-account-linking.md.",
        "operationId": "oidc_link_start",
        "responses": {
          "200": {
            "description": "Authorize URL to navigate the user to",
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          },
          "401": {
            "description": "Not authenticated"
          },
          "404": {
            "description": "OIDC not enabled"
          },
          "409": {
            "description": "User is already linked — unlink first"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/auth/oidc/providers": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "Return OIDC provider information for the login UI.",
        "description": "Returns `enabled: false` when OIDC is not configured.",
        "operationId": "oidc_providers",
        "responses": {
          "200": {
            "description": "OIDC provider info (enabled=false when OIDC not configured)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OidcProviderInfoDto"
                }
              }
            }
          },
          "503": {
            "description": "Auth service not configured"
          }
        }
      }
    },
    "/api/auth/oidc/unlink": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Unlink the current user's OIDC identity. Refuses when the user has\nno other credential (password / OPAQUE) — see plan doc for the\nno-alternative-auth guard rationale.",
        "operationId": "oidc_unlink",
        "responses": {
          "200": {
            "description": "OIDC identity unlinked (or was already unlinked)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "401": {
            "description": "Not authenticated"
          },
          "403": {
            "description": "Refused — user has no other credential and would be locked out"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/auth/opaque/login/ke1": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "KE1: user lookup → envelope fetch → `ServerLogin::start` → stash\nstate under a fresh exchange_id → return handle + response bytes.",
        "description": "## Anti-enumeration\n\nA KE1 that CAN'T be honestly answered (unknown user, or user\nexists but has no OPAQUE envelope yet) MUST look identical to a\nKE1 that CAN. opaque-ke's `ServerLogin::start` supports a \"dummy\"\nbranch (`password_file = None`) that generates a well-formed\nKE2 response indistinguishable from the real branch. The\ndummy-branch KE3 will fail at the client-side `ClientLogin::finish`\n(wrong MAC), never reaching the server — so KE3 for the\nnon-existent user is symmetric with KE3 for a wrong passphrase.\n\nUser is NOT looked up via `_with_perms` — this is the auth\nbootstrap; there's no caller identity yet. We use the same\ndispatch as legacy `/api/auth/login`.",
        "operationId": "login_ke1",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OpaqueLoginKe1Dto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Login response + single-use exchange handle",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OpaqueLoginKe1Response"
                }
              }
            }
          },
          "400": {
            "description": "Malformed request payload"
          },
          "503": {
            "description": "OPAQUE service not configured"
          }
        }
      }
    },
    "/api/auth/opaque/login/ke3": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "KE3: consume exchange state → `ServerLogin::finish` → mint session.",
        "description": "On success:\n\n  * Stamp `opaque_migrated_at` (Phase 3 signal that this user\n    has completed at least one OPAQUE login — future legacy\n    login attempts will be refused once Phase 4 flips to\n    `opaque_only`).\n  * Mint access + refresh tokens under a fresh session family,\n    via `AuthApplicationService::mint_session_for_authenticated_user`.\n  * Return the shared `AuthResponseDto` shape identical to\n    `POST /api/auth/login` — the SPA consumes both paths through\n    one downstream handler.\n\nOn failure (bad passphrase, replay, expired exchange, unknown\nexchange_id): 401 `InvalidCredentials` — the SAME shape for every\nfailure branch so attackers can't distinguish \"expired\" from\n\"wrong password\" from \"id already consumed\".",
        "operationId": "login_ke3",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OpaqueLoginKe3Dto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Session issued",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthResponseDto"
                }
              }
            }
          },
          "400": {
            "description": "Malformed request payload"
          },
          "401": {
            "description": "Invalid credentials"
          },
          "503": {
            "description": "OPAQUE service not configured"
          }
        }
      }
    },
    "/api/auth/opaque/login/lookup": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Resolve `userIdentifier` → envelope-existence check. Used by the\nSPA login form to branch between OPAQUE and legacy on submit.",
        "description": "Rate-limited via the shared `login_limiter` (mount layer in\n`main.rs`), so lookup can't be used as a cheap enumeration probe.",
        "operationId": "login_lookup",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OpaqueLookupDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Whether the identifier resolves to a user with an OPAQUE envelope",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OpaqueLookupResponse"
                }
              }
            }
          },
          "400": {
            "description": "Malformed request body"
          },
          "503": {
            "description": "OPAQUE service not configured"
          }
        }
      }
    },
    "/api/auth/opaque/params": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "Publish the OPAQUE client config. Safe to call unauthenticated\n(nothing about individual users is returned) and cache-friendly\n(the response only changes when the operator rotates env vars).",
        "description": "Note: `Cache-Control` is deliberately unset — the SPA fetches\nthis once at page load, and if the operator rotates the KSF\nparams mid-flight, we want the change to be picked up on the\nnext SPA reload rather than lingering behind an intermediary\ncache.",
        "operationId": "opaque_params",
        "responses": {
          "200": {
            "description": "OPAQUE client config",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OpaqueParamsResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/auth/opaque/register/finish": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "KE2 (register/finish): parse client `RegistrationUpload`, finalise\nvia `ServerRegistration::finish`, persist the resulting envelope\nbytes. Idempotent-per-user: re-running finish overwrites the\nexisting envelope (COALESCEing `opaque_registered_at`).",
        "operationId": "register_finish",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OpaqueRegisterFinishDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "204": {
            "description": "Registration persisted"
          },
          "400": {
            "description": "Malformed record or ciphersuite mismatch"
          },
          "401": {
            "description": "Not authenticated"
          },
          "503": {
            "description": "OPAQUE service not configured"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/auth/opaque/register/start": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "KE1 (register/start): parse client `RegistrationRequest`, run\n`ServerRegistration::start`, return the server response.",
        "description": "No state is persisted server-side by this call — registration is\nstateless on the server between KE1 and KE2 (the `RegistrationRequest`\ncarries the client's OPRF blinding; the response includes the\nserver's static pubkey; the client alone knows the seed).",
        "operationId": "register_start",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OpaqueRegisterStartDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Server registration response",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/OpaqueRegisterStartResponse"
                }
              }
            }
          },
          "400": {
            "description": "Malformed registration request"
          },
          "401": {
            "description": "Not authenticated"
          },
          "503": {
            "description": "OPAQUE service not configured"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/auth/refresh": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Refresh an access token.",
        "description": "Accepts the refresh token from **either**:\n1. JSON body `{ \"refresh_token\": \"...\" }` (API clients / backward compat)\n2. HttpOnly `oxicloud_refresh` cookie (browsers)\n\nIssues new access + refresh tokens and rotates all three auth cookies.",
        "operationId": "refresh_token",
        "requestBody": {
          "description": "Optional — omit when using the HttpOnly cookie",
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "refresh_token"
                ],
                "properties": {
                  "refresh_token": {
                    "type": "string"
                  }
                }
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "New tokens issued",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthResponseDto"
                }
              }
            }
          },
          "401": {
            "description": "Refresh token missing, expired, or revoked"
          }
        }
      }
    },
    "/api/auth/register": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Register a new user account.",
        "description": "**Response shape depends on SMTP availability**:\n\n- **SMTP configured** (`magic_link_invite_service` is wired): the\n  endpoint returns a **uniform 200** for both success and collision\n  (anti-enumeration). The \"Registration request received\" message\n  covers both branches honestly because successful email-only\n  signups receive a welcome magic-link. Real outcome recorded in\n  the `audit` channel as `auth.register` with `reason` one of\n  `created`, `email_taken`, `username_taken`.\n- **SMTP not configured**: there is no welcome-mail cover story, so\n  the classic `201 + PublicUserDto` on success and `409` on collision\n  apply. Anti-enumeration would just be misleading UX (telling the\n  user to check an email that will never arrive). Email-only\n  signup is **503** in this mode because the user would otherwise\n  be stranded with an account they can't log into.\n\n**Instance-wide policy stays visible** in both modes: when\nregistration is disabled by the admin or password registration is\ndisabled in OIDC-only mode, the endpoint returns **403** with a\nclear message. These are instance-wide settings, not per-user\noracles — legitimate users deserve an actionable error.",
        "operationId": "register",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RegisterDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Uniform registration response (SMTP configured, anti-enumeration mode)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "201": {
            "description": "User registered successfully (SMTP not configured)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicUserDto"
                }
              }
            }
          },
          "400": {
            "description": "Validation error (malformed request body)"
          },
          "403": {
            "description": "Registration disabled (admin setting or OIDC-only mode)"
          },
          "409": {
            "description": "Username or email already taken (SMTP not configured)"
          },
          "503": {
            "description": "Email-only signup requires SMTP to be configured"
          }
        }
      }
    },
    "/api/auth/status": {
      "get": {
        "tags": [
          "auth"
        ],
        "summary": "Return the system initialisation state (used by the UI before setup).",
        "operationId": "get_system_status",
        "responses": {
          "200": {
            "description": "System status",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SystemStatus"
                }
              }
            }
          },
          "503": {
            "description": "Auth service not configured"
          }
        }
      }
    },
    "/api/auth/upgrade-to-internal": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "Convert the authenticated external user into a full internal\naccount. The caller must currently be `is_external = true`; on\nsuccess, `is_external` is flipped to `false`, a personal drive is\nprovisioned (atomic CTE via `PersonalDriveLifecycleHook`), and the\nuser's flags cache is invalidated so subsequent per-request guards\nsee the new state within cache-round-trip time.",
        "description": "Password policy:\n  * If the deployment offers magic-link login\n    (`OXICLOUD_AUTH_METHODS` includes `magic_link` AND OIDC is not\n    enabled AND SMTP is wired), the body's `password` field is\n    optional — an upgraded user without a password stays magic-\n    link-only for login.\n  * Otherwise, `password` is required — refused with 400\n    `error_type = \"PasswordRequired\"`.\n\nDomain gate: the caller's email domain MUST be in\n`OXICLOUD_REGISTRATION_ALLOWED_EMAIL_DOMAINS` (when non-empty).\nOtherwise invitations would become a bypass of the operator's\nself-registration policy. Refused with 403\n`error_type = \"RegistrationDomainNotAllowed\"`.\n\nResponse: the updated `SelfUserDto` (same shape as `GET /me`) so the SPA\nabsorbs the post-upgrade state — new `storage_quota_bytes`,\n`is_external = false`, updated OPAQUE / auth capability flags — in one\nround trip without a follow-up `/me` fetch.",
        "operationId": "upgrade_to_internal",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpgradeToInternalDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Upgrade succeeded — returns SelfUserDto (same shape as GET /me)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SelfUserDto"
                }
              }
            }
          },
          "400": {
            "description": "Password missing / too short"
          },
          "401": {
            "description": "Not authenticated"
          },
          "403": {
            "description": "OIDC user, or domain not in allowlist"
          },
          "409": {
            "description": "Already internal"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/batch/download": {
      "get": {
        "tags": [
          "batch"
        ],
        "operationId": "download_batch_querystring",
        "parameters": [
          {
            "name": "file_ids",
            "in": "query",
            "description": "Comma-separated file IDs",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "folder_ids",
            "in": "query",
            "description": "Comma-separated folder IDs",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "ZIP archive stream",
            "content": {
              "application/zip": {}
            }
          },
          "400": {
            "description": "Malformed request, or every item failed for DIFFERENT reasons"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Every item was denied and the caller can see the resources"
          },
          "404": {
            "description": "Every item was denied and the caller cannot see the resources"
          },
          "500": {
            "description": "ZIP creation failed"
          },
          "507": {
            "description": "Every item exceeded the destination drive's quota"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "post": {
        "tags": [
          "batch"
        ],
        "summary": "Handler for downloading multiple files and folders as a single ZIP.",
        "description": "The ZIP is written to a temporary file and streamed to the client,\nso RAM usage is O(buffer_size) regardless of archive size.",
        "operationId": "download_batch_post",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchDownloadRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "ZIP archive stream",
            "content": {
              "application/zip": {}
            }
          },
          "400": {
            "description": "Malformed request, or every item failed for DIFFERENT reasons"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Every item was denied and the caller can see the resources"
          },
          "404": {
            "description": "Every item was denied and the caller cannot see the resources"
          },
          "500": {
            "description": "ZIP creation failed"
          },
          "507": {
            "description": "Every item exceeded the destination drive's quota"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/batch/files/copy": {
      "post": {
        "tags": [
          "batch"
        ],
        "summary": "Handler for copying multiple files in batch",
        "operationId": "copy_files_batch",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchFileOperationRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "All files copied",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "206": {
            "description": "Partial success — same `BatchOperationResponse` shape as the 200 (successful[], failed[], stats). Caller still inspects `failed[]` for the per-item cause.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Malformed request, or every item failed for DIFFERENT reasons"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Every item was denied and the caller can see the resources"
          },
          "404": {
            "description": "Every item was denied and the caller cannot see the resources"
          },
          "507": {
            "description": "Every item exceeded the destination drive's quota"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/batch/files/delete": {
      "post": {
        "tags": [
          "batch"
        ],
        "summary": "Handler for deleting multiple files in batch",
        "operationId": "delete_files_batch",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchFileOperationRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "All files deleted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "206": {
            "description": "Partial success — same `BatchOperationResponse` shape as the 200 (successful[], failed[], stats). Caller still inspects `failed[]` for the per-item cause.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Malformed request, or every item failed for DIFFERENT reasons"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Every item was denied and the caller can see the resources"
          },
          "404": {
            "description": "Every item was denied and the caller cannot see the resources"
          },
          "507": {
            "description": "Every item exceeded the destination drive's quota"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/batch/files/get": {
      "post": {
        "tags": [
          "batch"
        ],
        "summary": "Handler for getting multiple files in batch",
        "operationId": "get_files_batch",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchFileOperationRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Batch file details",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "206": {
            "description": "Partial success — same `BatchOperationResponse` shape as the 200 (successful[], failed[], stats). Caller still inspects `failed[]` for the per-item cause.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Malformed request, or every item failed for DIFFERENT reasons"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Every item was denied and the caller can see the resources"
          },
          "404": {
            "description": "Every item was denied and the caller cannot see the resources"
          },
          "507": {
            "description": "Every item exceeded the destination drive's quota"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/batch/files/move": {
      "post": {
        "tags": [
          "batch"
        ],
        "summary": "Handler for moving multiple files in batch",
        "operationId": "move_files_batch",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchFileOperationRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "All files moved",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "206": {
            "description": "Partial success — same `BatchOperationResponse` shape as the 200 (successful[], failed[], stats). Caller still inspects `failed[]` for the per-item cause.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Malformed request, or every item failed for DIFFERENT reasons"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Every item was denied and the caller can see the resources"
          },
          "404": {
            "description": "Every item was denied and the caller cannot see the resources"
          },
          "507": {
            "description": "Every item exceeded the destination drive's quota"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/batch/folders/copy": {
      "post": {
        "tags": [
          "batch"
        ],
        "summary": "Handler for copying multiple folder trees in batch",
        "operationId": "copy_folders_batch",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchFolderOperationRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "All folders copied",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "206": {
            "description": "Partial success — same `BatchOperationResponse` shape as the 200 (successful[], failed[], stats). Caller still inspects `failed[]` for the per-item cause.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Malformed request, or every item failed for DIFFERENT reasons"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Every item was denied and the caller can see the resources"
          },
          "404": {
            "description": "Every item was denied and the caller cannot see the resources"
          },
          "507": {
            "description": "Every item exceeded the destination drive's quota"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/batch/folders/create": {
      "post": {
        "tags": [
          "batch"
        ],
        "summary": "Handler for creating multiple folders in batch",
        "operationId": "create_folders_batch",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchCreateFoldersRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "All folders created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "206": {
            "description": "Partial success — same `BatchOperationResponse` shape as the 200 (successful[], failed[], stats). Caller still inspects `failed[]` for the per-item cause.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Malformed request, or every item failed for DIFFERENT reasons"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Every item was denied and the caller can see the resources"
          },
          "404": {
            "description": "Every item was denied and the caller cannot see the resources"
          },
          "507": {
            "description": "Every item exceeded the destination drive's quota"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/batch/folders/delete": {
      "post": {
        "tags": [
          "batch"
        ],
        "summary": "Handler for deleting multiple folders in batch",
        "operationId": "delete_folders_batch",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchFolderOperationRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "All folders deleted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "206": {
            "description": "Partial success — same `BatchOperationResponse` shape as the 200 (successful[], failed[], stats). Caller still inspects `failed[]` for the per-item cause.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Malformed request, or every item failed for DIFFERENT reasons"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Every item was denied and the caller can see the resources"
          },
          "404": {
            "description": "Every item was denied and the caller cannot see the resources"
          },
          "507": {
            "description": "Every item exceeded the destination drive's quota"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/batch/folders/get": {
      "post": {
        "tags": [
          "batch"
        ],
        "summary": "Handler for getting multiple folders in batch",
        "operationId": "get_folders_batch",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchFolderOperationRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Batch folder details",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "206": {
            "description": "Partial success — same `BatchOperationResponse` shape as the 200 (successful[], failed[], stats). Caller still inspects `failed[]` for the per-item cause.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Malformed request, or every item failed for DIFFERENT reasons"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Every item was denied and the caller can see the resources"
          },
          "404": {
            "description": "Every item was denied and the caller cannot see the resources"
          },
          "507": {
            "description": "Every item exceeded the destination drive's quota"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/batch/folders/move": {
      "post": {
        "tags": [
          "batch"
        ],
        "summary": "Handler for moving multiple folders in batch",
        "operationId": "move_folders_batch",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchFolderOperationRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "All folders moved",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "206": {
            "description": "Partial success — same `BatchOperationResponse` shape as the 200 (successful[], failed[], stats). Caller still inspects `failed[]` for the per-item cause.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Malformed request, or every item failed for DIFFERENT reasons"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Every item was denied and the caller can see the resources"
          },
          "404": {
            "description": "Every item was denied and the caller cannot see the resources"
          },
          "507": {
            "description": "Every item exceeded the destination drive's quota"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/batch/trash": {
      "post": {
        "tags": [
          "batch"
        ],
        "summary": "Handler for moving multiple files and folders to trash in batch",
        "operationId": "trash_batch",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchTrashRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "All items trashed",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "206": {
            "description": "Partial success — same `BatchOperationResponse` shape as the 200 (successful[], failed[], stats). Caller still inspects `failed[]` for the per-item cause.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Malformed request, or every item failed for DIFFERENT reasons"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Every item was denied and the caller can see the resources"
          },
          "404": {
            "description": "Every item was denied and the caller cannot see the resources"
          },
          "507": {
            "description": "Every item exceeded the destination drive's quota"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/config": {
      "get": {
        "tags": [
          "config"
        ],
        "summary": "`GET /api/config` — return the public server-configuration\nsnapshot. Unauthenticated. No cache header — values change on\nserver-restart / feature-toggle / status flip, and the endpoint\nis called at most once per SPA boot per client. Adding a short\n`Cache-Control` TTL later is safe if load ever becomes a concern.",
        "operationId": "get_config",
        "responses": {
          "200": {
            "description": "Public server configuration",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ServerConfigDto"
                }
              }
            }
          }
        }
      }
    },
    "/api/dedup/blob/{hash}": {
      "get": {
        "tags": [
          "dedup"
        ],
        "operationId": "get_blob",
        "parameters": [
          {
            "name": "hash",
            "in": "path",
            "description": "BLAKE3 hash of the blob (64 hex characters)",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Raw blob content (user-scoped)",
            "content": {
              "application/octet-stream": {}
            }
          },
          "400": {
            "description": "Invalid hash format"
          },
          "404": {
            "description": "Blob not found or not owned by this user"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/dedup/check-batch": {
      "post": {
        "tags": [
          "dedup"
        ],
        "operationId": "check_hashes_batch",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/HashBatchRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "The subset of the submitted hashes the caller already owns",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HashBatchResponse"
                }
              }
            }
          },
          "400": {
            "description": "Too many hashes in one batch"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/dedup/check/{hash}": {
      "get": {
        "tags": [
          "dedup"
        ],
        "operationId": "check_hash",
        "parameters": [
          {
            "name": "hash",
            "in": "path",
            "description": "BLAKE3 hash (64 hex characters)",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Hash check result. `ref_count` is only present for admin users.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HashCheckResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid hash format"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/drives": {
      "get": {
        "tags": [
          "drives"
        ],
        "operationId": "list_drives",
        "responses": {
          "200": {
            "description": "Drives the caller can read",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/DriveDto"
                  }
                }
              }
            }
          },
          "500": {
            "description": "Internal server error"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/drives/{id}": {
      "delete": {
        "tags": [
          "drives"
        ],
        "summary": "`DELETE /api/drives/{id}` — Owner-only deletion (D3b).",
        "description": "Refuses (per `DriveManagementService::delete_drive`):\n- `404` when the caller lacks Manage on the drive (anti-enum).\n- `405` when the drive is the user's default Personal drive.\n- `409` when the drive still holds live folders/files; the caller\n  must trash or move them first.\n\nOn success the drive row, its root folder, and every role grant\nscoped to the drive are removed in one transaction; cached drive\nroles are invalidated.",
        "operationId": "delete_drive",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Drive UUID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Drive deleted"
          },
          "404": {
            "description": "Drive not found or caller lacks Manage"
          },
          "405": {
            "description": "Default Personal drive — undeletable"
          },
          "409": {
            "description": "Drive is not empty — move/trash contents first"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/drives/{id}/members": {
      "get": {
        "tags": [
          "drives"
        ],
        "operationId": "list_drive_members",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Drive UUID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Role grants on this drive",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/GrantDto"
                  }
                }
              }
            }
          },
          "404": {
            "description": "Drive not found or caller lacks Read"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "post": {
        "tags": [
          "drives"
        ],
        "operationId": "add_drive_member",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Drive UUID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AddDriveMemberDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "Member added",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GrantDto"
                }
              }
            }
          },
          "400": {
            "description": "Validation error (e.g. last-owner constraint)"
          },
          "404": {
            "description": "Drive not found or caller lacks Manage"
          },
          "405": {
            "description": "Personal drive — membership is immutable"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/drives/{id}/members/{kind}/{sid}": {
      "delete": {
        "tags": [
          "drives"
        ],
        "operationId": "remove_drive_member",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Drive UUID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "kind",
            "in": "path",
            "description": "Subject kind: user|group|token",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sid",
            "in": "path",
            "description": "Subject UUID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Member removed (or was never a member — idempotent)"
          },
          "400": {
            "description": "Last-owner protection — promote another member first"
          },
          "404": {
            "description": "Drive not found or caller lacks Manage"
          },
          "405": {
            "description": "Personal drive — membership is immutable"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "patch": {
        "tags": [
          "drives"
        ],
        "operationId": "update_drive_member",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Drive UUID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "kind",
            "in": "path",
            "description": "Subject kind: user|group|token",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "sid",
            "in": "path",
            "description": "Subject UUID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateDriveMemberDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Member role updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GrantDto"
                }
              }
            }
          },
          "400": {
            "description": "Validation error (e.g. last-owner demotion)"
          },
          "404": {
            "description": "Drive not found or caller lacks Manage"
          },
          "405": {
            "description": "Personal drive — membership is immutable"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/drives/{id}/policies": {
      "patch": {
        "tags": [
          "drives"
        ],
        "summary": "`PATCH /api/drives/{id}/policies` — **OxiCloud-admin only** policy\nupdate (D5).",
        "description": "Policies were originally owner-mutable, but that made them\nself-policing soft caps — an owner could disable\n`forbid_external_sharing`, create the grant, and re-enable. For\ncompliance-grade enforcement, mutation is restricted to the\ntenant operator (admin role), mirroring the same carve-out that\nguards `drives.quota_bytes` and `users.storage_quota_bytes` (§7).\n\nNon-admin callers receive `404` (anti-enumeration — same response\nas \"drive does not exist\", so a probe can't tell apart \"no such\ndrive\" from \"policies are admin-managed\").\n\nPartial merge into the JSONB `policies` column; the post-merge\ntyped view is returned.\n\nAudit: emits `drive.policy_changed` with `by = <admin_user_id>`\nand every key's post-merge value (steady-state observability).",
        "operationId": "update_drive_policies",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Drive UUID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateDrivePoliciesDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Policies merged",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "404": {
            "description": "Drive not found OR caller is not OxiCloud admin"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/drives/{id}/quota": {
      "patch": {
        "tags": [
          "drives"
        ],
        "summary": "`PATCH /api/drives/{id}/quota` — **OxiCloud-admin only** storage-cap\nmutation for **shared** drives (D4).",
        "description": "Personal drives are refused with `400 InvalidInput` — their\neffective cap comes from the owner user's\n`users.storage_quota_bytes` envelope (memory\n`project_user_envelope_quota_model`); use\n`PUT /api/admin/users/{id}/quota` instead. Allowing a per-personal-\ndrive quota here would fork the model into two competing paths.\n\nNon-admin callers receive `404` (anti-enumeration — same shape as\n\"no such drive\", so a probe can't distinguish \"drive doesn't\nexist\" from \"quota edit is admin-only\"). Matches the pattern\nestablished by `update_drive_policies` above.\n\n**Soft-quota semantic on reduction.** A newly-lowered quota may\nland BELOW the drive's current `used_bytes`. The write succeeds;\n`storage_usage_service` then blocks new writes on\n`used + delta > quota`, so owners of a shared drive that's now\nover its freshly-reduced cap can only shrink (delete) until they\ncome back under. Existing content is never retroactively touched\n— matches xfs `xfs_quota` / ext4 `edquota` behaviour on quota\nshrink.\n\nCache invalidation: the repo drops `readable_cache` +\n`default_drive_cache` (both embed the whole drive row incl.\n`quota_bytes`), matching the `update_policies` pattern.\n\nAudit: emits `drive.quota_changed` with `new_quota_bytes`,\n`used_bytes`, and `over_quota` — so an operator grepping\n`audit drive.quota_changed` can spot a shrink that landed the\ndrive in the over-quota delete-only state.",
        "operationId": "update_drive_quota",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Drive UUID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateDriveQuotaDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Quota updated",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Personal drive — quota is envelope-managed via the owner user"
          },
          "404": {
            "description": "Drive not found OR caller is not OxiCloud admin"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/favorites/batch": {
      "post": {
        "tags": [
          "favorites"
        ],
        "summary": "Add multiple items to favourites in a single transaction.\nPOST /api/favorites/batch",
        "operationId": "batch_add_favorites",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/BatchFavoritesRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Batch add result",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BatchFavoritesResult"
                }
              }
            }
          },
          "400": {
            "description": "Invalid request"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/favorites/resources": {
      "get": {
        "tags": [
          "favorites"
        ],
        "summary": "Cursor-paginated list of a user's favorited resources.",
        "description": "Supports sorting by `name`, `type`, `favorited_at`, `modified_at`, `size`, or `owner`.\nItems that have been deleted/trashed are silently excluded.\n`path` is cleared when the resource is not owned by the requesting user.",
        "operationId": "list_favorites_resources",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "description": "Maximum items per page (1–200, default 50).",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int32",
              "minimum": 0
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "description": "Opaque cursor from a previous response. Omit to start from the first page.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "order_by",
            "in": "query",
            "description": "Sort / group-by dimension. Supported: `\"name\"` (default), `\"type\"`,\n`\"favorited_at\"`, `\"modified_at\"`, `\"size\"`, `\"owner\"`.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "resource_types",
            "in": "query",
            "description": "Comma-separated resource types to include, e.g. `\"file,folder\"`.\nOmit to include both.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "reverse",
            "in": "query",
            "description": "Reverse the sort order. Default `false`.",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of favorited resources",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CursorListResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid cursor or query parameters"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/favorites/{item_type}/{item_id}": {
      "post": {
        "tags": [
          "favorites"
        ],
        "summary": "Add an item to user's favorites",
        "operationId": "add_favorite",
        "parameters": [
          {
            "name": "item_type",
            "in": "path",
            "description": "Item type (file or folder)",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "item_id",
            "in": "path",
            "description": "Item ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "201": {
            "description": "Item added to favorites",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Invalid item type"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "delete": {
        "tags": [
          "favorites"
        ],
        "summary": "Remove an item from user's favorites",
        "operationId": "remove_favorite",
        "parameters": [
          {
            "name": "item_type",
            "in": "path",
            "description": "Item type (file or folder)",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "item_id",
            "in": "path",
            "description": "Item ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Item removed from favorites",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "404": {
            "description": "Item not in favorites"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/files": {
      "get": {
        "tags": [
          "files"
        ],
        "operationId": "list_files_query",
        "parameters": [
          {
            "name": "folder_id",
            "in": "query",
            "description": "Filter by folder ID",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List of files",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/FileDto"
                  }
                }
              }
            }
          },
          "304": {
            "description": "Not modified"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/files/by-hash": {
      "post": {
        "tags": [
          "files"
        ],
        "operationId": "create_file_by_hash",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateFileByHashRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "File created from an already-owned blob — zero bytes transferred",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FileDto"
                }
              }
            }
          },
          "400": {
            "description": "Invalid hash format or empty name"
          },
          "404": {
            "description": "No owned blob with this hash (anti-enumeration: same shape as unknown hash)"
          },
          "409": {
            "description": "A file with this name already exists in the folder"
          },
          "507": {
            "description": "Storage quota exceeded"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/files/delta/chunks": {
      "put": {
        "tags": [
          "delta-upload"
        ],
        "operationId": "delta_upload_chunks",
        "requestBody": {
          "description": "Sequence of [u32 BE length][bytes] frames, one per chunk (each ≤ 1 MiB)",
          "content": {
            "application/octet-stream": {}
          }
        },
        "responses": {
          "200": {
            "description": "Server-computed identity of every received frame, in order",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeltaChunksResponse"
                }
              }
            }
          },
          "400": {
            "description": "Malformed framing, oversized frame, or oversized request"
          },
          "429": {
            "description": "Rate limited"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/files/delta/commit": {
      "post": {
        "tags": [
          "delta-upload"
        ],
        "operationId": "delta_commit",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DeltaCommitRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Existing file's content replaced",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FileDto"
                }
              }
            }
          },
          "201": {
            "description": "File created from the committed chunk sequence",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FileDto"
                }
              }
            }
          },
          "400": {
            "description": "Malformed request, or the declared file_hash does not match the chunk sequence"
          },
          "404": {
            "description": "Target folder/file not found or not accessible"
          },
          "409": {
            "description": "Chunks not claimable — upload them and retry",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeltaStillMissingResponse"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited"
          },
          "507": {
            "description": "Storage quota exceeded"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/files/delta/download": {
      "post": {
        "tags": [
          "delta-upload"
        ],
        "operationId": "delta_download_chunks",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DeltaDownloadRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Requested chunks as [u32 BE length][bytes] frames, in request order",
            "content": {
              "application/octet-stream": {}
            }
          },
          "400": {
            "description": "Malformed hashes, duplicates, or batch above the per-request ceiling"
          },
          "404": {
            "description": "Some chunks are not available to this caller",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeltaNotAvailableResponse"
                }
              }
            }
          },
          "429": {
            "description": "Rate limited"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/files/delta/negotiate": {
      "post": {
        "tags": [
          "delta-upload"
        ],
        "operationId": "delta_negotiate",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/DeltaNegotiateRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Chunks the caller must upload (the rest are claimable without bytes)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeltaNegotiateResponse"
                }
              }
            }
          },
          "400": {
            "description": "Malformed chunk list (hash format, size bounds, count ceiling)"
          },
          "429": {
            "description": "Rate limited"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/files/upload": {
      "post": {
        "tags": [
          "files"
        ],
        "operationId": "upload_file_with_thumbnails",
        "requestBody": {
          "description": "File data + folder_id (required: it determines the file's owner and drive)",
          "content": {
            "multipart/form-data": {}
          }
        },
        "responses": {
          "201": {
            "description": "File uploaded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FileDto"
                }
              }
            }
          },
          "400": {
            "description": "Invalid request"
          },
          "507": {
            "description": "Storage quota exceeded"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/files/{id}": {
      "get": {
        "tags": [
          "files"
        ],
        "description": "Reachable with a public-share token (`share:read`): a visitor holding an unlocked share link may call this, scoped to what that share grants.",
        "operationId": "download_file",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "File ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "metadata",
            "in": "query",
            "description": "Return metadata JSON instead of file content",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "original",
            "in": "query",
            "description": "Skip WebP transcoding",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "inline",
            "in": "query",
            "description": "Content-Disposition: inline",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "File content (Content-Type on the actual response reflects the file's own mime type)",
            "content": {
              "application/octet-stream": {}
            }
          },
          "206": {
            "description": "Partial content (Range request) — same byte stream as the 200, scoped to the requested byte range. `Content-Type` on the actual response reflects the file's own mime type.",
            "content": {
              "application/octet-stream": {}
            }
          },
          "304": {
            "description": "Not modified"
          },
          "404": {
            "description": "File not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "share:read"
      },
      "delete": {
        "tags": [
          "files"
        ],
        "operationId": "delete_file",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "File ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "File deleted (moved to trash if enabled)"
          },
          "404": {
            "description": "File not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/files/{id}/manifest": {
      "get": {
        "tags": [
          "delta-upload"
        ],
        "operationId": "delta_file_manifest",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "File ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Chunk recipe of the file (immutable per file_hash; served with ETag = file_hash)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/DeltaManifestResponse"
                }
              }
            }
          },
          "304": {
            "description": "Not modified (If-None-Match matched the current file_hash)"
          },
          "404": {
            "description": "File not found, not accessible, or not owned by the caller"
          },
          "429": {
            "description": "Rate limited"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/files/{id}/metadata": {
      "get": {
        "tags": [
          "files"
        ],
        "description": "Reachable with a public-share token (`share:read`): a visitor holding an unlocked share link may call this, scoped to what that share grants.",
        "operationId": "get_file_metadata",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "File ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "File metadata (EXIF, dimensions, duration, etc.)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "404": {
            "description": "File not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "share:read"
      }
    },
    "/api/files/{id}/move": {
      "put": {
        "tags": [
          "files"
        ],
        "operationId": "move_file_simple",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "File ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "description": "MoveFilePayload",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MoveFilePayload"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Moved file",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FileDto"
                }
              }
            }
          },
          "404": {
            "description": "File or destination not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/files/{id}/rename": {
      "put": {
        "tags": [
          "files"
        ],
        "operationId": "rename_file",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "File ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "description": "{\"name\": \"new-name.txt\"}",
          "content": {
            "application/json": {}
          }
        },
        "responses": {
          "200": {
            "description": "Renamed file",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FileDto"
                }
              }
            }
          },
          "404": {
            "description": "File not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/files/{id}/thumbnail/{size}": {
      "get": {
        "tags": [
          "files"
        ],
        "description": "Reachable with a public-share token (`share:read`): a visitor holding an unlocked share link may call this, scoped to what that share grants.",
        "operationId": "get_thumbnail",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "File ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "size",
            "in": "path",
            "description": "Thumbnail size: icon | preview | large",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Thumbnail image (image/jpeg or image/webp)",
            "content": {
              "image/webp": {}
            }
          },
          "204": {
            "description": "No thumbnail available for this file type"
          },
          "304": {
            "description": "Not modified"
          },
          "404": {
            "description": "File not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "share:read"
      },
      "put": {
        "tags": [
          "files"
        ],
        "operationId": "upload_thumbnail",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "File ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "size",
            "in": "path",
            "description": "Thumbnail size: icon | preview | large",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "description": "Raw image bytes (max 512 KB)",
          "content": {
            "application/octet-stream": {}
          }
        },
        "responses": {
          "201": {
            "description": "Thumbnail stored",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Invalid image or size too large"
          },
          "404": {
            "description": "File not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/folders": {
      "get": {
        "tags": [
          "folders"
        ],
        "operationId": "list_root_folders",
        "responses": {
          "200": {
            "description": "List of root folders",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/FolderDto"
                  }
                }
              }
            }
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "post": {
        "tags": [
          "folders"
        ],
        "operationId": "create_folder",
        "requestBody": {
          "description": "Folder creation payload",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateFolderDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "Folder created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FolderDto"
                }
              }
            }
          },
          "400": {
            "description": "Invalid request"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/folders/{id}": {
      "get": {
        "tags": [
          "folders"
        ],
        "description": "Reachable with a public-share token (`share:read`): a visitor holding an unlocked share link may call this, scoped to what that share grants.",
        "operationId": "get_folder",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Folder ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Folder",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FolderDto"
                }
              }
            }
          },
          "404": {
            "description": "Folder not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "share:read"
      },
      "delete": {
        "tags": [
          "folders"
        ],
        "operationId": "delete_folder_with_trash",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Folder ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Folder deleted"
          },
          "404": {
            "description": "Folder not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/folders/{id}/ancestors": {
      "get": {
        "tags": [
          "folders"
        ],
        "description": "Reachable with a public-share token (`share:read`): a visitor holding an unlocked share link may call this, scoped to what that share grants.",
        "operationId": "get_folder_ancestors",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Leaf folder ID — the walk starts here and climbs the parent chain up to the drive root or the caller's share/drive-membership boundary.",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "include_grants",
            "in": "query",
            "description": "Include every grant that reaches this folder — its own, each\nvisible ancestor's, and the drive's — in `effective_grants`.\nDefault `false`.\n\n**Raises the permission the endpoint requires** from `Read` to\n`Share`: the breadcrumb only needs the chain, but listing who\nelse holds access is what `Permission::Share` gates everywhere\nelse. Enforced in `get_ancestors_with_perms`, not here.\n\nSend the literal `true` / `false`. `serde_urlencoded` rejects\n`1`, `yes` and `on` outright rather than guessing, and the\nwhole query struct fails to deserialize when it does — so a\ntruthy-looking `?include_grants=1` is a 400, not a silent false.",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Ancestor chain + access-source. `ancestors` is root-first, leaf-last (length ≥ 1). With `include_grants=true`, also every grant reaching the leaf in `effective_grants`. See `FolderAncestorsDto`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FolderAncestorsDto"
                }
              }
            }
          },
          "403": {
            "description": "`include_grants=true` and the caller lacks Share on the leaf"
          },
          "404": {
            "description": "Folder not found or caller lacks Read (anti-enum)"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "share:read"
      }
    },
    "/api/folders/{id}/download": {
      "get": {
        "tags": [
          "folders"
        ],
        "description": "Reachable with a public-share token (`share:read`): a visitor holding an unlocked share link may call this, scoped to what that share grants.",
        "operationId": "download_folder_zip",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Folder ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "ZIP archive stream (application/zip)",
            "content": {
              "application/zip": {}
            }
          },
          "404": {
            "description": "Folder not found"
          },
          "501": {
            "description": "ZIP service not available"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "share:read"
      }
    },
    "/api/folders/{id}/move": {
      "put": {
        "tags": [
          "folders"
        ],
        "operationId": "move_folder",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Folder ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "description": "Move payload",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MoveFolderDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Moved folder",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FolderDto"
                }
              }
            }
          },
          "404": {
            "description": "Folder or destination not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/folders/{id}/rename": {
      "put": {
        "tags": [
          "folders"
        ],
        "operationId": "rename_folder",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Folder ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "description": "Rename payload",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RenameFolderDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Renamed folder",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/FolderDto"
                }
              }
            }
          },
          "404": {
            "description": "Folder not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/folders/{id}/resources": {
      "get": {
        "tags": [
          "folders"
        ],
        "description": "Reachable with a public-share token (`share:read`): a visitor holding an unlocked share link may call this, scoped to what that share grants.",
        "operationId": "list_folder_resources",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Folder ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Maximum items per page (1–200, default 50).",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int32",
              "minimum": 0
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "description": "Opaque cursor from a previous response. Omit to start from the top.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "order_by",
            "in": "query",
            "description": "Sort / group-by dimension. Supported: `\"name\"` (default), `\"type\"`,\n`\"modified_at\"`, `\"created_at\"`, `\"size\"`.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "resource_types",
            "in": "query",
            "description": "Comma-separated resource types to include, e.g. `\"file,folder\"`.\nOmit to include both.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "reverse",
            "in": "query",
            "description": "Reverse the sort order. Default `false` (normal order).\nMust be the same on all pages of the same result set — the cursor\ncarries this flag so the server can validate consistency.",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Cursor-paginated files and folders inside the requested folder. Items arrive in `order_by` order (folders first when order_by=name). `next_cursor` is absent on the last page.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CursorListResponse"
                }
              }
            }
          },
          "404": {
            "description": "Folder not found or access denied"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "x-oxicloud-scope": "share:read"
      }
    },
    "/api/grants": {
      "get": {
        "tags": [
          "grants"
        ],
        "operationId": "list_on_resource",
        "parameters": [
          {
            "name": "resource_type",
            "in": "query",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/ResourceTypeDto"
            }
          },
          {
            "name": "resource_id",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Grants on the specified resource",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/GrantDto"
                  }
                }
              }
            }
          },
          "404": {
            "description": "Resource not found or caller lacks Share"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "post": {
        "tags": [
          "grants"
        ],
        "summary": "Share a resource with someone — kicks off the **social flow** (invite +\nnotification + lazy external-user provisioning if subject is an email).",
        "description": "**Use this when:** the recipient may not exist yet, hasn't been told,\nor this is the initial grant. The handler:\n- resolves `subject` (User / Group / Token / Email — email lazily creates\n  an external user via `MagicLinkInviteService::resolve_or_create_recipient`),\n- writes one role-grant row via `authz.set_role` (`ON CONFLICT UPDATE`),\n- sends a share-notification email (magic-link arm for externals, plain\n  notification for internal users — both honour `notify_on_share` opt-out\n  and per-recipient rate limits).\n\n**Compare with `PUT /api/grants/role`:** that endpoint is the *silent*\nadmin-style role change for an already-known subject; it skips the\ninvitation and notification side-effects entirely. Both write through\nthe same idempotent UPSERT, so the only operational difference is the\nsocial flow attached here.\n\n**Drive resources** (`resource.type == \"drive\"`) are routed through\n`DriveManagementService.set_member_role` internally — the\npersonal-drive guard and shared-drive last-owner protection apply\nno matter which endpoint creates the grant.",
        "operationId": "create_grant",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateGrantDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "Grant(s) created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreateGrantResponseDto"
                }
              }
            }
          },
          "400": {
            "description": "Invalid input (both/neither of permissions+role provided)"
          },
          "404": {
            "description": "Resource not found OR caller lacks Share permission"
          },
          "405": {
            "description": "Drive resource: personal drives have immutable membership"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/grants/incoming": {
      "get": {
        "tags": [
          "grants"
        ],
        "operationId": "list_incoming",
        "responses": {
          "200": {
            "description": "Direct role grants targeting the caller",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/GrantDto"
                  }
                }
              }
            }
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/grants/incoming/resources": {
      "get": {
        "tags": [
          "grants"
        ],
        "operationId": "list_shared_with_me",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "description": "Maximum number of items to return (1–200, default 50).",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int32",
              "minimum": 0
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "description": "Opaque cursor from a previous response. Omit to start from the\nmost-recently-granted item.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "sort_by",
            "in": "query",
            "description": "Sort dimension. Supported values: `\"granted_at\"` (default),\n`\"granted_by\"` (for swimlane grouping).",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "resource_types",
            "in": "query",
            "description": "Comma-separated resource types to include, e.g. `file,folder`.\nOmit to return all known types.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "reverse",
            "in": "query",
            "description": "Reverse the sort order. Default `false` (normal order).\nMust be the same on all pages of the same result set — the cursor\ncarries this flag so the server can validate consistency.",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Cursor-paginated resources shared with the caller. Each item carries the full file or folder details plus aggregated permissions. `next_cursor` is absent on the last page.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CursorListResponse"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/grants/outgoing": {
      "get": {
        "tags": [
          "grants"
        ],
        "operationId": "list_outgoing",
        "responses": {
          "200": {
            "description": "Grants the caller has created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/GrantDto"
                  }
                }
              }
            }
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/grants/outgoing/resources": {
      "get": {
        "tags": [
          "grants"
        ],
        "operationId": "list_my_shares",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "description": "Maximum number of items to return (1–200, default 50).",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int32",
              "minimum": 0
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "description": "Opaque cursor from a previous response. Omit to start from the\nmost-recently-granted item.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "sort_by",
            "in": "query",
            "description": "Sort dimension. Supported values: `\"granted_at\"` (default),\n`\"granted_by\"` (for swimlane grouping).",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "resource_types",
            "in": "query",
            "description": "Comma-separated resource types to include, e.g. `file,folder`.\nOmit to return all known types.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "reverse",
            "in": "query",
            "description": "Reverse the sort order. Default `false` (normal order).\nMust be the same on all pages of the same result set — the cursor\ncarries this flag so the server can validate consistency.",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Cursor-paginated resources the caller has shared with others. Each item carries the full resource details plus all subjects (users and tokens) the resource was shared with. `next_cursor` is absent on the last page.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CursorListResponse"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/grants/role": {
      "put": {
        "tags": [
          "grants"
        ],
        "summary": "Change an existing member's role on a resource — **silent**, no\ninvitation, no notification.",
        "description": "**Use this when:** the subject is already known (user/group/token has\nan existing grant or you've already informed them out-of-band) and you\njust want to bump their role. Typical use is the management UI's\n\"Viewer → Editor\" dropdown — you don't want a fresh share email\nfiring on every dropdown change.\n\n**Compare with `POST /api/grants`:** that endpoint kicks off the\ninvitation + share-notification social flow and accepts an email-shaped\nsubject for lazy external-user provisioning. This endpoint is the\nadmin-style update — concrete subjects only, no side effects beyond\nthe role row itself.\n\nBoth endpoints write through the same idempotent UPSERT\n(`authz.set_role`, unique on `(subject, resource)`), so they are\nindistinguishable in their effect on the role-grants table — the\ndifference is purely the social side-effects attached to `POST`.\n\n**Drive resources** (`resource.type == \"drive\"`) are routed through\n`DriveManagementService.set_member_role` internally — the\npersonal-drive guard and shared-drive last-owner protection apply.",
        "operationId": "set_role",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateRoleDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Role applied; returns the new full grant set",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/GrantDto"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Drive resource: shared-drive last-owner demotion refused"
          },
          "404": {
            "description": "Resource not found or caller lacks Share"
          },
          "405": {
            "description": "Drive resource: personal drives have immutable membership"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/grants/{id}": {
      "delete": {
        "tags": [
          "grants"
        ],
        "operationId": "revoke_grant",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Grant UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Grant revoked (or did not exist)"
          },
          "404": {
            "description": "Caller lacks Share permission on the underlying resource"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/grants/{id}/notify": {
      "post": {
        "tags": [
          "grants"
        ],
        "operationId": "notify_grant_recipient",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Grant UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Mixed outcome (some recipients coalesced / not-applicable); body carries the full NotifyOutcomeSet",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NotifyOutcomeSetDto"
                }
              }
            }
          },
          "204": {
            "description": "Notification(s) dispatched"
          },
          "404": {
            "description": "Grant not found OR caller is not the granter"
          },
          "409": {
            "description": "Token subject (use the existing /magic/v1/{token}/resend channel)"
          },
          "429": {
            "description": "Per-recipient hard rate limit exceeded"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/groups": {
      "get": {
        "tags": [
          "groups"
        ],
        "summary": "List subject groups (paginated). Admin-only.",
        "operationId": "subject_group_list",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int32",
              "minimum": 0
            }
          },
          {
            "name": "offset",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int32",
              "minimum": 0
            }
          },
          {
            "name": "q",
            "in": "query",
            "description": "Optional case-insensitive substring filter on group name.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of groups",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GroupListDto"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "post": {
        "tags": [
          "groups"
        ],
        "summary": "Create a new ReBAC subject group. Admin-only. The name must match the\nRFC 5321 local-part shape and be globally unique (case-insensitive).",
        "operationId": "subject_group_create",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateGroupRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "Group created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GroupDto"
                }
              }
            }
          },
          "400": {
            "description": "Invalid name (RFC 5321 violation)"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          },
          "409": {
            "description": "Group with this name already exists"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/groups/search": {
      "get": {
        "tags": [
          "groups"
        ],
        "summary": "Search non-virtual groups by name substring. Authenticated only (no\nadmin role required) — backs the share-dialog recipient autocomplete.",
        "operationId": "subject_group_search",
        "parameters": [
          {
            "name": "q",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int32",
              "minimum": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Matching non-virtual groups",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/GroupDto"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/groups/{id}": {
      "get": {
        "tags": [
          "groups"
        ],
        "summary": "Fetch a single group's details. Admin-only.",
        "operationId": "subject_group_get",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Group ID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Group details",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GroupDto"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          },
          "404": {
            "description": "Group not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "delete": {
        "tags": [
          "groups"
        ],
        "summary": "Delete a group. Cascades to `subject_group_members` (FK) and to\n`access_grants` rows referencing this group as a subject. Admin-only.",
        "operationId": "subject_group_delete",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Group ID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Group deleted"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required, or group is virtual"
          },
          "404": {
            "description": "Group not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "patch": {
        "tags": [
          "groups"
        ],
        "summary": "Update a group's metadata. Admin-only. v1 only persists name renames.",
        "operationId": "subject_group_update",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Group ID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateGroupRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Updated group",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GroupDto"
                }
              }
            }
          },
          "400": {
            "description": "Invalid name"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required, or group is virtual"
          },
          "404": {
            "description": "Group not found"
          },
          "409": {
            "description": "Name already taken"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/groups/{id}/effective-members": {
      "get": {
        "tags": [
          "groups"
        ],
        "summary": "List every user transitively reached through this group (members of\nmembers of members, etc.). Used by admin / audit tooling. Admin-only.",
        "operationId": "subject_group_effective_members",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Group ID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Flat list of transitively-reached user IDs",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "string",
                    "format": "uuid"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          },
          "404": {
            "description": "Group not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/groups/{id}/members": {
      "get": {
        "tags": [
          "groups"
        ],
        "summary": "List the *direct* members of a group (one level only). Admin-only.",
        "operationId": "subject_group_list_members",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Group ID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Direct members",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/GroupMemberDto"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required"
          },
          "404": {
            "description": "Group not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "post": {
        "tags": [
          "groups"
        ],
        "summary": "Add a member to a group. Exactly one of `user_id` / `group_id` must be\nprovided. Adding a group-member runs a write-time cycle check and a\nnesting-depth check (max 8). Admin-only.",
        "operationId": "subject_group_add_member",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Group ID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AddSubjectGroupMemberRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "Member added",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Invalid request, cycle would be created, or depth limit exceeded"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required, or virtual group"
          },
          "404": {
            "description": "Group not found"
          },
          "409": {
            "description": "Member already in group"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/groups/{id}/members/group/{gid}": {
      "delete": {
        "tags": [
          "groups"
        ],
        "summary": "Remove a nested group-member from a group. Admin-only.",
        "operationId": "subject_group_remove_group_member",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Parent group ID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "gid",
            "in": "path",
            "description": "Child group ID to remove",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Member removed"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required, or virtual group"
          },
          "404": {
            "description": "Group or member not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/groups/{id}/members/user/{uid}": {
      "delete": {
        "tags": [
          "groups"
        ],
        "summary": "Remove a user-member from a group. Admin-only.",
        "operationId": "subject_group_remove_user_member",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Group ID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "uid",
            "in": "path",
            "description": "User ID to remove",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Member removed"
          },
          "401": {
            "description": "Unauthorized"
          },
          "403": {
            "description": "Admin required, or virtual group"
          },
          "404": {
            "description": "Group or member not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/i18n/locales": {
      "get": {
        "tags": [
          "i18n"
        ],
        "operationId": "get_locales",
        "responses": {
          "200": {
            "description": "Available locales",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/LocaleDto"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/i18n/locales/{locale_code}": {
      "get": {
        "tags": [
          "i18n"
        ],
        "operationId": "get_translations_by_locale",
        "parameters": [
          {
            "name": "locale_code",
            "in": "path",
            "description": "Locale code, e.g. en, fr, de",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "All translations for this locale",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Unsupported locale"
          }
        }
      }
    },
    "/api/i18n/translate": {
      "get": {
        "tags": [
          "i18n"
        ],
        "operationId": "translate",
        "parameters": [
          {
            "name": "key",
            "in": "query",
            "description": "Translation key",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "locale",
            "in": "query",
            "description": "Target locale code (defaults to en)",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Translation",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TranslationResponseDto"
                }
              }
            }
          },
          "400": {
            "description": "Unsupported locale",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/TranslationErrorDto"
                }
              }
            }
          },
          "404": {
            "description": "Key not found"
          }
        }
      }
    },
    "/api/notifications": {
      "get": {
        "tags": [
          "notifications"
        ],
        "summary": "GET /api/notifications",
        "operationId": "list_notifications",
        "parameters": [
          {
            "name": "unread",
            "in": "query",
            "description": "Only return unread rows",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "before",
            "in": "query",
            "description": "Cursor — rows strictly before this created_at (load-older pagination)",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "after",
            "in": "query",
            "description": "Cursor — rows strictly after this created_at (delta catch-up on WS reconnect / tab reactivation)",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Max rows (server-side clamp at 500)",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int32",
              "minimum": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List of notifications",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ListResponseDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/notifications/read-all": {
      "post": {
        "tags": [
          "notifications"
        ],
        "summary": "POST /api/notifications/read-all — bulk mark-all-read.",
        "operationId": "mark_all_read",
        "responses": {
          "200": {
            "description": "Rows marked",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MarkAllReadResponseDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/notifications/unread": {
      "get": {
        "tags": [
          "notifications"
        ],
        "summary": "GET /api/notifications/unread — badge-only fast path.",
        "operationId": "unread_count",
        "responses": {
          "200": {
            "description": "Unread count",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UnreadCountDto"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/notifications/{id}": {
      "delete": {
        "tags": [
          "notifications"
        ],
        "summary": "DELETE /api/notifications/{id} — hard-delete one row.",
        "description": "Same anti-enum semantics as `mark_read` — always 204.",
        "operationId": "delete_notification",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Notification id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Deleted (idempotent, anti-enum)"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/notifications/{id}/read": {
      "post": {
        "tags": [
          "notifications"
        ],
        "summary": "POST /api/notifications/{id}/read — mark one as read.",
        "description": "Always responds 204 regardless of whether the row existed and\nbelonged to the caller — the service's `bool` return is logged\n(audit reason `notification.marked_read` on success), never\nsurfaced to the wire.",
        "operationId": "mark_read",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Notification id",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Marked read (idempotent, anti-enum)"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/people": {
      "get": {
        "tags": [
          "people"
        ],
        "summary": "GET /api/people — identity clusters for the caller.",
        "operationId": "list_people",
        "responses": {
          "200": {
            "description": "Identity clusters",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PersonDto"
                  }
                }
              }
            }
          },
          "404": {
            "description": "People feature disabled"
          }
        }
      }
    },
    "/api/people/data": {
      "delete": {
        "tags": [
          "people"
        ],
        "summary": "DELETE /api/people/data — erase all of the caller's face data.",
        "operationId": "delete_all",
        "responses": {
          "204": {
            "description": "All face data erased"
          },
          "404": {
            "description": "People feature disabled"
          }
        }
      }
    },
    "/api/people/faces/{file_id}": {
      "get": {
        "tags": [
          "people"
        ],
        "summary": "GET /api/people/faces/{file_id} — face boxes within a photo (lightbox tags).",
        "operationId": "faces_for_file",
        "parameters": [
          {
            "name": "file_id",
            "in": "path",
            "description": "Photo file id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Face bounding boxes in the photo",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/FaceBoxDto"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Malformed file id"
          },
          "404": {
            "description": "People feature disabled"
          }
        }
      }
    },
    "/api/people/merge": {
      "post": {
        "tags": [
          "people"
        ],
        "summary": "POST /api/people/merge — merge `from` into `into`.",
        "operationId": "merge_people",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MergeBody"
              }
            }
          },
          "required": true
        },
        "responses": {
          "204": {
            "description": "Merged"
          },
          "400": {
            "description": "Malformed cluster id(s)"
          },
          "404": {
            "description": "People feature disabled"
          }
        }
      }
    },
    "/api/people/recluster": {
      "post": {
        "tags": [
          "people"
        ],
        "summary": "POST /api/people/recluster — re-run identity clustering for the caller.",
        "operationId": "recluster",
        "responses": {
          "200": {
            "description": "Recluster complete",
            "content": {
              "application/json": {
                "schema": {}
              }
            }
          },
          "404": {
            "description": "People feature disabled"
          }
        }
      }
    },
    "/api/people/{id}": {
      "patch": {
        "tags": [
          "people"
        ],
        "summary": "PATCH /api/people/{id} — name (or clear the name of) a person.",
        "operationId": "rename_person",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Person cluster id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/RenameBody"
              }
            }
          },
          "required": true
        },
        "responses": {
          "204": {
            "description": "Renamed"
          },
          "400": {
            "description": "Malformed person id"
          },
          "404": {
            "description": "People feature disabled"
          }
        }
      }
    },
    "/api/people/{id}/photos": {
      "get": {
        "tags": [
          "people"
        ],
        "summary": "GET /api/people/{id}/photos — file ids of a person's photos.",
        "operationId": "person_photos",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Person cluster id",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "File ids where this person appears",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Malformed person id"
          },
          "404": {
            "description": "People feature disabled"
          }
        }
      }
    },
    "/api/photos": {
      "get": {
        "tags": [
          "photos"
        ],
        "summary": "Lists all image/video files for the authenticated user, sorted by\ncapture date (EXIF DateTimeOriginal) falling back to upload date.",
        "description": "Supports cursor-based pagination via the `before` parameter.\nThe `X-Next-Cursor` response header contains the cursor for the next page.",
        "operationId": "list_photos",
        "parameters": [
          {
            "name": "before",
            "in": "query",
            "description": "Cursor: only return items with sort_date before this epoch value",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Max items to return (default 200, max 500)",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int64"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List of media files sorted by capture date",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PhotoDto"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "500": {
            "description": "Internal server error"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/photos/geo": {
      "get": {
        "tags": [
          "photos"
        ],
        "summary": "Lists the caller's geotagged photos aggregated into map clusters within a\nbounding box. Gated on `OXICLOUD_ENABLE_PLACES` (the route is only mounted\nwhen the Places service is present).",
        "operationId": "list_photos_geo",
        "parameters": [
          {
            "name": "bbox",
            "in": "query",
            "description": "Bounding box 'west,south,east,north' (decimal degrees)",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "zoom",
            "in": "query",
            "description": "Map zoom level (0-20), controls cluster size",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int32",
              "minimum": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Geotagged photos aggregated into map clusters. Flat array; each entry is one aggregation cell carrying its centroid (lng/lat), the number of photos in the cell, and one representative sample_file_id usable as the cluster thumbnail.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/GeoCluster"
                  }
                }
              }
            }
          },
          "400": {
            "description": "Invalid bounding box"
          },
          "401": {
            "description": "Unauthorized"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/playlists": {
      "get": {
        "tags": [
          "playlists"
        ],
        "operationId": "list_playlists",
        "responses": {
          "200": {
            "description": "List of playlists",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlaylistDto"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "post": {
        "tags": [
          "playlists"
        ],
        "operationId": "create_playlist",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreatePlaylistDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "Playlist created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlaylistDto"
                }
              }
            }
          },
          "400": {
            "description": "Bad request"
          },
          "401": {
            "description": "Unauthorized"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/playlists/audio-metadata/{file_id}": {
      "get": {
        "tags": [
          "playlists"
        ],
        "operationId": "get_audio_metadata",
        "parameters": [
          {
            "name": "file_id",
            "in": "path",
            "description": "Audio file ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Audio metadata",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AudioMetadataDto"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "404": {
            "description": "File not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/playlists/{playlist_id}": {
      "get": {
        "tags": [
          "playlists"
        ],
        "operationId": "get_playlist",
        "parameters": [
          {
            "name": "playlist_id",
            "in": "path",
            "description": "Playlist ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Playlist details",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlaylistDto"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "404": {
            "description": "Playlist not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "put": {
        "tags": [
          "playlists"
        ],
        "operationId": "update_playlist",
        "parameters": [
          {
            "name": "playlist_id",
            "in": "path",
            "description": "Playlist ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdatePlaylistDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Playlist updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PlaylistDto"
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "404": {
            "description": "Playlist not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "delete": {
        "tags": [
          "playlists"
        ],
        "operationId": "delete_playlist",
        "parameters": [
          {
            "name": "playlist_id",
            "in": "path",
            "description": "Playlist ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Playlist deleted"
          },
          "401": {
            "description": "Unauthorized"
          },
          "404": {
            "description": "Playlist not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/playlists/{playlist_id}/reorder": {
      "put": {
        "tags": [
          "playlists"
        ],
        "operationId": "reorder_tracks",
        "parameters": [
          {
            "name": "playlist_id",
            "in": "path",
            "description": "Playlist ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ReorderTracksDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "204": {
            "description": "Tracks reordered"
          },
          "401": {
            "description": "Unauthorized"
          },
          "404": {
            "description": "Playlist not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/playlists/{playlist_id}/share": {
      "post": {
        "tags": [
          "playlists"
        ],
        "operationId": "share_playlist",
        "parameters": [
          {
            "name": "playlist_id",
            "in": "path",
            "description": "Playlist ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SharePlaylistDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "204": {
            "description": "Playlist shared"
          },
          "401": {
            "description": "Unauthorized"
          },
          "404": {
            "description": "Playlist not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/playlists/{playlist_id}/share/{user_id}": {
      "delete": {
        "tags": [
          "playlists"
        ],
        "operationId": "remove_share",
        "parameters": [
          {
            "name": "playlist_id",
            "in": "path",
            "description": "Playlist ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "user_id",
            "in": "path",
            "description": "User ID to remove share",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Share removed"
          },
          "401": {
            "description": "Unauthorized"
          },
          "404": {
            "description": "Playlist or share not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/playlists/{playlist_id}/shares": {
      "get": {
        "tags": [
          "playlists"
        ],
        "operationId": "get_playlist_shares",
        "parameters": [
          {
            "name": "playlist_id",
            "in": "path",
            "description": "Playlist ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List of playlist shares",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlaylistShareInfoDto"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "404": {
            "description": "Playlist not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/playlists/{playlist_id}/tracks": {
      "get": {
        "tags": [
          "playlists"
        ],
        "operationId": "list_playlist_tracks",
        "parameters": [
          {
            "name": "playlist_id",
            "in": "path",
            "description": "Playlist ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "List of playlist tracks",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlaylistItemDto"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "404": {
            "description": "Playlist not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "post": {
        "tags": [
          "playlists"
        ],
        "operationId": "add_tracks",
        "parameters": [
          {
            "name": "playlist_id",
            "in": "path",
            "description": "Playlist ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/AddTracksDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "Tracks added",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PlaylistItemDto"
                  }
                }
              }
            }
          },
          "401": {
            "description": "Unauthorized"
          },
          "404": {
            "description": "Playlist not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/playlists/{playlist_id}/tracks/{file_id}": {
      "delete": {
        "tags": [
          "playlists"
        ],
        "operationId": "remove_track",
        "parameters": [
          {
            "name": "playlist_id",
            "in": "path",
            "description": "Playlist ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "file_id",
            "in": "path",
            "description": "File ID to remove",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Track removed"
          },
          "401": {
            "description": "Unauthorized"
          },
          "404": {
            "description": "Playlist or track not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/recent/clear": {
      "delete": {
        "tags": [
          "recent"
        ],
        "summary": "Clear all recent items",
        "operationId": "clear_recent_items",
        "responses": {
          "200": {
            "description": "Recent items cleared",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/recent/resources": {
      "get": {
        "tags": [
          "recent"
        ],
        "summary": "List recently accessed resources with cursor pagination.",
        "description": "Sorted by `accessed_at` DESC by default (most recently accessed first).\n`path` is cleared when the resource is not owned by the requesting user.",
        "operationId": "list_recent_resources",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "description": "Maximum items per page (1–200, default 50).",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int32",
              "minimum": 0
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "description": "Opaque cursor from a previous response. Omit to start from the first page.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "order_by",
            "in": "query",
            "description": "Sort / group-by dimension. Supported: `\"accessed_at\"` (default), `\"name\"`,\n`\"type\"`, `\"modified_at\"`, `\"size\"`, `\"owner\"`.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "resource_types",
            "in": "query",
            "description": "Comma-separated resource types to include, e.g. `\"file,folder\"`.\nOmit to include both.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "reverse",
            "in": "query",
            "description": "Reverse the sort order. Default `false`.",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of recently accessed resources",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CursorListResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid cursor or query parameters"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/recent/{item_type}/{item_id}": {
      "post": {
        "tags": [
          "recent"
        ],
        "summary": "Record access to an item",
        "operationId": "record_item_access",
        "parameters": [
          {
            "name": "item_type",
            "in": "path",
            "description": "Item type (file or folder)",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "item_id",
            "in": "path",
            "description": "Item ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Access recorded",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "400": {
            "description": "Invalid item type"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "delete": {
        "tags": [
          "recent"
        ],
        "summary": "Remove an item from recents",
        "operationId": "remove_from_recent",
        "parameters": [
          {
            "name": "item_type",
            "in": "path",
            "description": "Item type (file or folder)",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "item_id",
            "in": "path",
            "description": "Item ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Item removed from recents",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "404": {
            "description": "Item not in recents"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/s/{token}": {
      "get": {
        "tags": [
          "shares"
        ],
        "summary": "Access a shared item via its token",
        "operationId": "access_shared_item",
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "description": "Share token",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Shared item details",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ShareDto"
                }
              }
            }
          },
          "401": {
            "description": "Password required"
          },
          "410": {
            "description": "Share expired"
          }
        }
      }
    },
    "/api/s/{token}/verify": {
      "post": {
        "tags": [
          "shares"
        ],
        "summary": "Verify password for a password-protected shared item",
        "operationId": "verify_shared_item_password",
        "parameters": [
          {
            "name": "token",
            "in": "path",
            "description": "Share token",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/VerifyPasswordRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Password verified, item details returned",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ShareDto"
                }
              }
            }
          },
          "401": {
            "description": "Invalid password"
          },
          "410": {
            "description": "Share expired"
          }
        }
      }
    },
    "/api/search": {
      "get": {
        "tags": [
          "search"
        ],
        "operationId": "search_resources",
        "parameters": [
          {
            "name": "query",
            "in": "query",
            "description": "Text to search in names / content",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Max items per page (1–200, default 50)",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int32",
              "minimum": 0
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "description": "Opaque cursor from a previous response",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "order_by",
            "in": "query",
            "description": "Sort dimension: relevance (default) | name | size | updated_at | created_at",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "resource_types",
            "in": "query",
            "description": "Comma-separated: file, folder (both by default)",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "reverse",
            "in": "query",
            "description": "Reverse the sort order",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "type",
            "in": "query",
            "description": "Filter by file extensions (comma-separated)",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "folder_id",
            "in": "query",
            "description": "Restrict search to this folder",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "recursive",
            "in": "query",
            "description": "Recurse into subfolders (default true)",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "created_after",
            "in": "query",
            "description": "Minimum creation timestamp (unix seconds)",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int64",
              "minimum": 0
            }
          },
          {
            "name": "created_before",
            "in": "query",
            "description": "Maximum creation timestamp",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int64",
              "minimum": 0
            }
          },
          {
            "name": "modified_after",
            "in": "query",
            "description": "Minimum modification timestamp",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int64",
              "minimum": 0
            }
          },
          {
            "name": "modified_before",
            "in": "query",
            "description": "Maximum modification timestamp",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int64",
              "minimum": 0
            }
          },
          {
            "name": "min_size",
            "in": "query",
            "description": "Minimum file size (bytes)",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int64",
              "minimum": 0
            }
          },
          {
            "name": "max_size",
            "in": "query",
            "description": "Maximum file size (bytes)",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int64",
              "minimum": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Search results (cursor-paginated envelope shared with /*/resources)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SearchResourcesDto"
                }
              }
            }
          },
          "503": {
            "description": "Search service unavailable"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/search/suggest": {
      "get": {
        "tags": [
          "search"
        ],
        "operationId": "suggest_files",
        "parameters": [
          {
            "name": "query",
            "in": "query",
            "description": "Partial name to complete",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "folder_id",
            "in": "query",
            "description": "Restrict to this folder",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Max suggestions (default 10, max 20)",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int32",
              "minimum": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Suggestions",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SearchSuggestionsDto"
                }
              }
            }
          },
          "503": {
            "description": "Search service unavailable"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/setup": {
      "post": {
        "tags": [
          "auth"
        ],
        "summary": "One-time endpoint to create the first admin user.",
        "description": "Available only when the system is not yet initialized (no admin exists).\nOnce the admin is created the endpoint permanently returns 403.\nUses an atomic \"claim\" operation so concurrent requests cannot both succeed.",
        "operationId": "setup_admin",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SetupAdminDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "First admin created and system initialized",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicUserDto"
                }
              }
            }
          },
          "403": {
            "description": "System already initialized"
          },
          "503": {
            "description": "Auth service not configured"
          }
        }
      }
    },
    "/api/shares": {
      "get": {
        "tags": [
          "shares"
        ],
        "summary": "Get all shared links created by the current user.\nSupports optional filtering by item_id + item_type query params.",
        "operationId": "get_user_shares",
        "responses": {
          "200": {
            "description": "List of shares",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ShareDto"
                  }
                }
              }
            }
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "post": {
        "tags": [
          "shares"
        ],
        "summary": "Create a new shared link",
        "operationId": "create_shared_link",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateShareDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "Share created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ShareDto"
                }
              }
            }
          },
          "400": {
            "description": "Bad request"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/shares/{id}": {
      "get": {
        "tags": [
          "shares"
        ],
        "summary": "Get information about a specific shared link by ID",
        "operationId": "get_shared_link",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Share ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Share details",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ShareDto"
                }
              }
            }
          },
          "404": {
            "description": "Share not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "put": {
        "tags": [
          "shares"
        ],
        "summary": "Update a shared link's properties",
        "operationId": "update_shared_link",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Share ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/UpdateShareDto"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Share updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ShareDto"
                }
              }
            }
          },
          "404": {
            "description": "Share not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "delete": {
        "tags": [
          "shares"
        ],
        "summary": "Delete a shared link",
        "operationId": "delete_shared_link",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Share ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Share deleted"
          },
          "404": {
            "description": "Share not found"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/trash/drive/{drive_id}": {
      "delete": {
        "tags": [
          "trash"
        ],
        "summary": "`DELETE /api/trash/drive/{drive_id}` — per-drive empty trash.",
        "description": "Same destructive shape as the all-drives `DELETE /api/trash`, but\nscoped to a single drive the caller can Delete in. Used by the\n`/trash` page's Drive group-by, which exposes a per-row \"Empty\"\naffordance so multi-drive owners don't have to wipe everything at\nonce.\n\nRefused with `404` (anti-enum) when the caller has no Delete-bearing\nrole on the named drive — the user-facing drive listing would emit\nthe same shape for an unknown id.",
        "operationId": "empty_trash_for_drive",
        "parameters": [
          {
            "name": "drive_id",
            "in": "path",
            "description": "Drive UUID",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Drive trash emptied successfully",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "404": {
            "description": "Caller lacks Delete on this drive"
          },
          "501": {
            "description": "Trash feature not enabled"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/trash/empty": {
      "delete": {
        "tags": [
          "trash"
        ],
        "summary": "Empties the trash completely for the current user",
        "operationId": "empty_trash",
        "responses": {
          "200": {
            "description": "Trash emptied successfully",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "501": {
            "description": "Trash feature not enabled"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/trash/files/{id}": {
      "delete": {
        "tags": [
          "trash"
        ],
        "summary": "Moves a file to the trash",
        "operationId": "move_file_to_trash",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "File ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "File moved to trash",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "501": {
            "description": "Trash feature not enabled"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/trash/folders/{id}": {
      "delete": {
        "tags": [
          "trash"
        ],
        "summary": "Moves a folder to the trash",
        "operationId": "move_folder_to_trash",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Folder ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Folder moved to trash",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "501": {
            "description": "Trash feature not enabled"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/trash/resources": {
      "get": {
        "tags": [
          "trash"
        ],
        "summary": "Cursor-paginated list of a user's trashed resources.",
        "description": "Sorts by `deletion_date` (default — soonest expiry first), `trashed_at`\n(most recently trashed first), `name`, `type`, or `size`. Filter on\n`resource_types=file` or `resource_types=folder` to narrow to one kind.\nItems implicitly trashed as descendants of a trashed parent are excluded\n(only top-level trashed items appear).",
        "operationId": "get_trash_resources",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "description": "Maximum items per page (1–200, default 50).",
            "required": false,
            "schema": {
              "type": "integer",
              "format": "int32",
              "minimum": 0
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "description": "Opaque cursor from a previous response. Omit to start from the first page.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "order_by",
            "in": "query",
            "description": "Sort / group-by dimension. Supported: `\"deletion_date\"` (default — soonest\nexpiry first), `\"trashed_at\"` (most recently trashed first), `\"name\"`,\n`\"type\"`, `\"size\"`.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "resource_types",
            "in": "query",
            "description": "Comma-separated resource types to include, e.g. `\"file,folder\"`.\nOmit to include both.",
            "required": false,
            "schema": {
              "type": [
                "string",
                "null"
              ]
            }
          },
          {
            "name": "reverse",
            "in": "query",
            "description": "Reverse the sort order. Default `false`.",
            "required": false,
            "schema": {
              "type": "boolean"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Paginated list of trashed resources",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CursorListResponse"
                }
              }
            }
          },
          "400": {
            "description": "Invalid cursor or query parameters"
          },
          "501": {
            "description": "Trash feature not enabled"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/trash/{id}": {
      "delete": {
        "tags": [
          "trash"
        ],
        "summary": "Permanently deletes an item from the trash",
        "operationId": "delete_permanently",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Trash item ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Item permanently deleted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "501": {
            "description": "Trash feature not enabled"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/trash/{id}/restore": {
      "post": {
        "tags": [
          "trash"
        ],
        "summary": "Restores an item from the trash to its original location",
        "operationId": "restore_from_trash",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "Trash item ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Item restored from trash",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object"
                }
              }
            }
          },
          "501": {
            "description": "Trash feature not enabled"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/uploads": {
      "post": {
        "tags": [
          "uploads"
        ],
        "summary": "**Deprecated.** Prefer `/api/files/delta/*` — hash-first negotiation,\nresumable, chunked. The `/api/uploads/*` family stays for backward\ncompatibility with existing clients but receives no new features.",
        "description": "**Deprecated.** Prefer the delta-upload surface at `/api/files/delta/*` (hash-first negotiation, resumable, chunked). The `/api/uploads/*` family is kept for backward compatibility with existing clients but is no longer receiving new features.",
        "operationId": "create_upload",
        "requestBody": {
          "description": "Upload session parameters",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CreateUploadRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "Upload session created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreateUploadResponseDto"
                }
              }
            }
          },
          "400": {
            "description": "Invalid request (empty filename, zero size, chunk too small)"
          },
          "507": {
            "description": "Storage quota exceeded"
          }
        },
        "deprecated": true,
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/uploads/{upload_id}": {
      "delete": {
        "tags": [
          "uploads"
        ],
        "summary": "**Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.",
        "description": "**Deprecated.** See `POST /api/uploads` for the migration note.",
        "operationId": "cancel_upload",
        "parameters": [
          {
            "name": "upload_id",
            "in": "path",
            "description": "Upload session ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Upload cancelled and temp files cleaned up"
          },
          "500": {
            "description": "Cancel failed"
          }
        },
        "deprecated": true,
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "head": {
        "tags": [
          "uploads"
        ],
        "summary": "**Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.",
        "description": "**Deprecated.** See `POST /api/uploads` for the migration note.",
        "operationId": "get_upload_status",
        "parameters": [
          {
            "name": "upload_id",
            "in": "path",
            "description": "Upload session ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Upload status in response headers and body",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/UploadStatusResponseDto"
                }
              }
            }
          },
          "404": {
            "description": "Upload session not found"
          }
        },
        "deprecated": true,
        "security": [
          {
            "bearerAuth": []
          }
        ]
      },
      "patch": {
        "tags": [
          "uploads"
        ],
        "summary": "**Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.",
        "description": "**Deprecated.** See `POST /api/uploads` for the migration note.",
        "operationId": "upload_chunk",
        "parameters": [
          {
            "name": "upload_id",
            "in": "path",
            "description": "Upload session ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "chunk_index",
            "in": "query",
            "description": "Zero-based chunk index",
            "required": true,
            "schema": {
              "type": "integer",
              "minimum": 0
            }
          },
          {
            "name": "checksum",
            "in": "query",
            "description": "Optional hex-encoded checksum for integrity verification. Computed incrementally during the streaming write. Algorithm is selected by `checksumalg` (default `md5`). Also accepted via the legacy `Content-MD5` request header.",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "checksumalg",
            "in": "query",
            "description": "Algorithm used by `checksum`. One of: `md5` (default, legacy), `sha256` / `sha-256`, `blake3`. Unknown values return 400.",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "description": "Raw chunk bytes",
          "content": {
            "application/octet-stream": {}
          }
        },
        "responses": {
          "200": {
            "description": "Chunk received",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ChunkUploadResponseDto"
                }
              }
            }
          },
          "400": {
            "description": "Invalid chunk, size mismatch, checksum mismatch, or unknown `checksumalg`"
          },
          "404": {
            "description": "Upload session not found"
          },
          "413": {
            "description": "Chunk exceeds `storage.chunk_max_bytes` cap"
          }
        },
        "deprecated": true,
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/uploads/{upload_id}/complete": {
      "post": {
        "tags": [
          "uploads"
        ],
        "summary": "**Deprecated.** Prefer `/api/files/delta/*` — see `create_upload`.",
        "description": "**Deprecated.** See `POST /api/uploads` for the migration note.",
        "operationId": "complete_upload",
        "parameters": [
          {
            "name": "upload_id",
            "in": "path",
            "description": "Upload session ID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "description": "Optional. End-to-end integrity verification of the assembled file. **`blake3` is highly recommended** as the `checksumalg` value — the server already computes BLAKE3 over the assembled file during hash-on-write assembly, so verification is a string comparison with zero extra CPU/IO. Picking `md5` or `sha256` is supported for legacy client tooling but triggers a second full hash pass over the assembled file. Clients that POST with no body (or with an empty JSON object) get today's behavior: no verification, server returns the BLAKE3 it computed.",
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/CompleteUploadRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "description": "File assembled and created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CompleteUploadResponse"
                }
              }
            }
          },
          "400": {
            "description": "Unknown `checksumalg` or final-checksum mismatch"
          },
          "404": {
            "description": "Upload session not found"
          },
          "500": {
            "description": "Assembly, hashing, or file creation failed"
          }
        },
        "deprecated": true,
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    },
    "/api/users/{id}": {
      "get": {
        "tags": [
          "users"
        ],
        "operationId": "get_user_profile",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "description": "User UUID",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Profile of a user the caller can see",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicUserDto"
                }
              }
            }
          },
          "404": {
            "description": "User does not exist OR caller has no visibility (anti-enumeration: indistinguishable)"
          },
          "429": {
            "description": "Per-caller rate limit exceeded"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ]
      }
    }
  },
  "components": {
    "schemas": {
      "AccessSourceDriveDto": {
        "type": "object",
        "description": "Drive info for `AccessSourceKind::Drive`. Split out so serde can drop\nit (`skip_serializing_if = \"Option::is_none\"`) when the kind isn't drive.",
        "required": [
          "id",
          "name",
          "kind"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "kind": {
            "$ref": "#/components/schemas/DriveKindDto"
          },
          "name": {
            "type": "string"
          }
        }
      },
      "AccessSourceDto": {
        "type": "object",
        "description": "Access-source detail returned alongside the ancestors chain.",
        "required": [
          "kind"
        ],
        "properties": {
          "caller_role": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/RoleDto",
                "description": "Caller's own role via the boundary grant (`role_grants.role` on\nthe same row that carries `granted_by`). Lets the FE render\npermission-aware affordances — \"you can Edit / Comment /\nView this share\" — without a second lookup. Reflects the boundary\ngrant only: aggregate effective role via other channels may be\nstronger. Null on `token` access."
              }
            ]
          },
          "drive": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/AccessSourceDriveDto",
                "description": "Populated when `kind == Drive`. Null otherwise."
              }
            ]
          },
          "kind": {
            "$ref": "#/components/schemas/AccessSourceKind"
          },
          "subject": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/AccessSourceSubjectDto",
                "description": "SHARER — the user who created the grant that gave the caller\naccess at the boundary (`storage.role_grants.granted_by`). Kind\nis always `User` today: `granted_by` references `auth.users` and\na group can't perform an action. Null when the boundary can't be\nresolved to a single grant (e.g. `token` access)."
              }
            ]
          }
        }
      },
      "AccessSourceKind": {
        "type": "string",
        "description": "How the caller reached the topmost accessible ancestor. Drives the\nbreadcrumb's root icon + tooltip.",
        "enum": [
          "drive",
          "direct_share",
          "token"
        ]
      },
      "AccessSourceSubjectDto": {
        "type": "object",
        "required": [
          "kind",
          "id"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "kind": {
            "$ref": "#/components/schemas/AccessSourceSubjectKind"
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Display name (username / group name). MVP leaves this out — the\nendpoint returns `subject: None` entirely rather than emitting a\nhalf-populated `{id, name: null}`."
          }
        }
      },
      "AccessSourceSubjectKind": {
        "type": "string",
        "enum": [
          "user",
          "group"
        ]
      },
      "AddDriveMemberDto": {
        "type": "object",
        "description": "Body for `POST /api/drives/{id}/members`.",
        "required": [
          "subject",
          "role"
        ],
        "properties": {
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "role": {
            "$ref": "#/components/schemas/RoleDto"
          },
          "subject": {
            "$ref": "#/components/schemas/SubjectDto"
          }
        }
      },
      "AddMemberRequest": {
        "type": "object",
        "description": "Request body for adding a contact to a group.",
        "required": [
          "contact_id"
        ],
        "properties": {
          "contact_id": {
            "type": "string"
          }
        }
      },
      "AddSubjectGroupMemberRequest": {
        "type": "object",
        "properties": {
          "group_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "user_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "Set exactly one of `user_id` or `group_id` — the other field must be\nabsent or null. Adding a `group_id` triggers a write-time cycle and\ndepth check; max nesting depth is 8."
          }
        }
      },
      "AddTracksDto": {
        "type": "object",
        "required": [
          "file_ids"
        ],
        "properties": {
          "file_ids": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "AddressBookResponse": {
        "type": "object",
        "description": "Address book entry with `is_readonly` and `is_system` flags.",
        "required": [
          "id",
          "name",
          "owner_id",
          "is_public",
          "is_readonly",
          "is_system",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "color": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "id": {
            "type": "string"
          },
          "is_public": {
            "type": "boolean"
          },
          "is_readonly": {
            "type": "boolean"
          },
          "is_system": {
            "type": "boolean"
          },
          "name": {
            "type": "string"
          },
          "owner_id": {
            "type": "string"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "AddressDto": {
        "type": "object",
        "required": [
          "type",
          "is_primary"
        ],
        "properties": {
          "city": {
            "type": [
              "string",
              "null"
            ]
          },
          "country": {
            "type": [
              "string",
              "null"
            ]
          },
          "is_primary": {
            "type": "boolean"
          },
          "postal_code": {
            "type": [
              "string",
              "null"
            ]
          },
          "state": {
            "type": [
              "string",
              "null"
            ]
          },
          "street": {
            "type": [
              "string",
              "null"
            ]
          },
          "type": {
            "type": "string"
          }
        }
      },
      "AdminAddDriveMemberDto": {
        "type": "object",
        "description": "Body for `POST /api/admin/drives/{id}/members` and\n`PATCH /api/admin/drives/{id}/members/{kind}/{sid}` — same wire shape\nas the user-facing endpoints, kept here so this handler doesn't pull\nin the regular drive-handler module's DTOs (which would create a\ncircular feel between admin and user-facing surfaces).",
        "required": [
          "subject",
          "role"
        ],
        "properties": {
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "role": {
            "$ref": "#/components/schemas/RoleDto"
          },
          "subject": {
            "$ref": "#/components/schemas/SubjectDto"
          }
        }
      },
      "AdminCreateUserDto": {
        "type": "object",
        "description": "Request body for admin-created users",
        "required": [
          "username",
          "password"
        ],
        "properties": {
          "active": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Whether the account is active; defaults to true"
          },
          "email": {
            "type": [
              "string",
              "null"
            ],
            "description": "Optional — if omitted, a placeholder email is generated"
          },
          "is_external": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "`true` to create a grant-only external user (no home folder, no\nstorage quota). Defaults to `false` (internal user). External\nusers authenticate via magic-link / OIDC / OCM federation —\npassword is set but never used."
          },
          "password": {
            "type": "string"
          },
          "quota_bytes": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "Storage quota in bytes; 0 = unlimited. If omitted, uses role default.\nIgnored when `is_external = true` (external users have no storage)."
          },
          "role": {
            "type": [
              "string",
              "null"
            ],
            "description": "\"admin\" or \"user\"; defaults to \"user\""
          },
          "username": {
            "type": "string"
          }
        }
      },
      "AdminResetPasswordDto": {
        "type": "object",
        "description": "Request body for admin password reset",
        "required": [
          "new_password"
        ],
        "properties": {
          "new_password": {
            "type": "string"
          }
        }
      },
      "AdminUpdateDriveMemberDto": {
        "type": "object",
        "required": [
          "role"
        ],
        "properties": {
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "role": {
            "$ref": "#/components/schemas/RoleDto"
          }
        }
      },
      "AdminUsersPageResponse": {
        "type": "object",
        "description": "Response envelope for `GET /api/admin/users`. `users` is always\n`Vec<FullUserDto>` — same shape one row of `/me`'s embedded\n`full` block carries; the FE seeds `resolveUser` cache from\n`row.user` (kills the per-row `/api/users/{id}` fetch). See\n`docs/plan/userdto-refactor.md`.",
        "required": [
          "users",
          "total",
          "limit",
          "offset"
        ],
        "properties": {
          "limit": {
            "type": "integer",
            "format": "int64"
          },
          "offset": {
            "type": "integer",
            "format": "int64"
          },
          "total": {
            "type": "integer",
            "format": "int64"
          },
          "users": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FullUserDto"
            }
          }
        }
      },
      "AudioMetadataDto": {
        "type": "object",
        "required": [
          "file_id",
          "duration_secs",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "album": {
            "type": [
              "string",
              "null"
            ]
          },
          "album_artist": {
            "type": [
              "string",
              "null"
            ]
          },
          "artist": {
            "type": [
              "string",
              "null"
            ]
          },
          "bitrate": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32"
          },
          "channels": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "disc_number": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32"
          },
          "duration_secs": {
            "type": "integer",
            "format": "int32"
          },
          "file_id": {
            "type": "string"
          },
          "format": {
            "type": [
              "string",
              "null"
            ]
          },
          "genre": {
            "type": [
              "string",
              "null"
            ]
          },
          "sample_rate": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32"
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "track_number": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "year": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32"
          }
        }
      },
      "AuthDto": {
        "type": "object",
        "description": "Auth block within [`ServerConfigDto`]. Non-privacy-implicating\ntunables the SPA needs at boot so form validation matches server\nenforcement.",
        "required": [
          "min_password_length",
          "opaque_mode"
        ],
        "properties": {
          "min_password_length": {
            "type": "integer",
            "format": "int32",
            "description": "Minimum password length (UTF-8 bytes). Mirrors\n`AuthConfig::min_password_length`, which every server-side\npassword-bearing endpoint uses. The SPA gates submit on the\nsame value so users see the failure instantly instead of\nafter a round-trip. See AtalayaLabs/OxiCloud#677.",
            "minimum": 0
          },
          "opaque_mode": {
            "type": "string",
            "description": "OPAQUE deployment mode — one of `\"off\"`, `\"migrate\"`,\n`\"opaque_only\"`. Mirrors `AuthConfig::opaque.mode` (env\n`OXICLOUD_AUTH_OPAQUE_MODE`). Login already discovers this\nper-user via `POST /api/auth/opaque/login/lookup`; exposing\nthe server-wide mode here is for future consumers (admin\npanel status indicators, OPAQUE-native setup / register\nflows). Login callers should NOT branch on this value —\nkeep using the per-user lookup so mixed populations during\nmigration stay honest."
          }
        }
      },
      "AuthResponseDto": {
        "type": "object",
        "required": [
          "user",
          "access_token",
          "refresh_token",
          "token_type",
          "expires_in"
        ],
        "properties": {
          "access_token": {
            "type": "string"
          },
          "expires_in": {
            "type": "integer",
            "format": "int64"
          },
          "force_password_change": {
            "type": "boolean",
            "description": "When `true`, the caller must be routed to the change-password\nflow before any other action. Set on the login response for\nusers whose `auth.users.force_password_change_at_next_login`\ncolumn is TRUE — the admin password-reset flow flips that\ncolumn atomically alongside `clear_registration` so admin-set\npasswords remain temporary until the user picks their own.\nCleared by a successful `POST /api/auth/change-password`.\n\nSPA policy: if this is `true`, redirect to `/settings/password`\n(or the equivalent) immediately after the login handler settles.\nBackend does not gate any endpoints on this flag — it's a\nsoft-enforcement signal; a client that ignores it keeps its\nsession, but the responsibility falls on the SPA to route\ncorrectly. Backend enforcement (session scope claim) is a\npossible follow-up if the soft path proves insufficient."
          },
          "refresh_token": {
            "type": "string"
          },
          "token_type": {
            "type": "string"
          },
          "user": {
            "$ref": "#/components/schemas/SelfUserDto",
            "description": "Full self view — identical shape to `/api/auth/me`. Every\nlogin / refresh / OIDC-callback / magic-link redemption ships\nthis so the SPA's post-auth state matches its post-`/me` state\n(no UI race between `AuthResponseDto` and the first `/me`\nfetch). See `docs/plan/userdto-refactor.md` § Endpoint mapping."
          }
        }
      },
      "BackendCacheInfoDto": {
        "type": "object",
        "description": "`.blob-cache` tier occupancy snapshot (see [`DashboardStatsDto::backend_cache`]).\nSeparate struct from the moka one so adding tier-specific fields\nlater (eviction count, LRU age) doesn't force a shared schema.",
        "required": [
          "size_bytes",
          "max_bytes",
          "chunks",
          "cache_dir"
        ],
        "properties": {
          "cache_dir": {
            "type": "string",
            "description": "On-disk location where the cache files live. Shown on the\nadmin UI so operators know where to point `du`, backups, or\nan SSD mount."
          },
          "chunks": {
            "type": "integer",
            "format": "int64",
            "description": "Number of cached chunks (one entry per content-addressable\nblob). Unlike moka's assembled-file entries, these are the\nchunk-granular units of the dedup registry. The disk tier\ncovers both source-file chunks AND satellite derived blobs\n(thumbnails, transcodes) — one unified disk cache serving\nevery blob read.",
            "minimum": 0
          },
          "max_bytes": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "size_bytes": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          }
        }
      },
      "BatchCreateFoldersRequest": {
        "type": "object",
        "description": "DTO for batch folder creation requests",
        "required": [
          "folders"
        ],
        "properties": {
          "folders": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CreateFolderDetail"
            },
            "description": "Details of the folders to create"
          }
        }
      },
      "BatchDownloadRequest": {
        "type": "object",
        "description": "DTO for batch download requests",
        "properties": {
          "file_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "IDs of the files to include in the ZIP"
          },
          "folder_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "IDs of the folders to include in the ZIP"
          }
        }
      },
      "BatchFavoriteItem": {
        "type": "object",
        "description": "Single item in a batch-add-favorites request.",
        "required": [
          "item_id",
          "item_type"
        ],
        "properties": {
          "item_id": {
            "type": "string"
          },
          "item_type": {
            "type": "string"
          }
        }
      },
      "BatchFavoritesRequest": {
        "type": "object",
        "description": "Request body for POST /api/favorites/batch",
        "required": [
          "items"
        ],
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/BatchFavoriteItem"
            }
          }
        }
      },
      "BatchFavoritesResult": {
        "type": "object",
        "description": "Result DTO for batch add-to-favorites.",
        "required": [
          "stats",
          "favorites"
        ],
        "properties": {
          "favorites": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FavoriteItemDto"
            },
            "description": "Full list of the user's favourites (enriched), so the client can\nreplace its local cache in a single round-trip."
          },
          "stats": {
            "$ref": "#/components/schemas/BatchFavoritesStats",
            "description": "Statistics about the batch operation"
          }
        }
      },
      "BatchFavoritesStats": {
        "type": "object",
        "required": [
          "requested",
          "inserted",
          "already_existed"
        ],
        "properties": {
          "already_existed": {
            "type": "integer",
            "format": "int64",
            "description": "How many were already favourites (skipped)",
            "minimum": 0
          },
          "inserted": {
            "type": "integer",
            "format": "int64",
            "description": "How many were actually inserted (new)",
            "minimum": 0
          },
          "requested": {
            "type": "integer",
            "description": "How many items were requested",
            "minimum": 0
          }
        }
      },
      "BatchFileOperationRequest": {
        "type": "object",
        "description": "DTO for batch file operation requests",
        "required": [
          "file_ids"
        ],
        "properties": {
          "file_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "IDs of the files to process"
          },
          "target_folder_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Target folder ID (optional)"
          }
        }
      },
      "BatchFolderOperationRequest": {
        "type": "object",
        "description": "DTO for batch folder operation requests",
        "required": [
          "folder_ids"
        ],
        "properties": {
          "folder_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "IDs of the folders to process"
          },
          "recursive": {
            "type": "boolean",
            "description": "Whether the operation should be recursive"
          },
          "target_folder_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Target folder ID (optional)"
          }
        }
      },
      "BatchTrashRequest": {
        "type": "object",
        "description": "DTO for batch trash operation requests",
        "properties": {
          "file_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "IDs of the files to move to trash"
          },
          "folder_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "IDs of the folders to move to trash"
          }
        }
      },
      "ChangePasswordDto": {
        "type": "object",
        "required": [
          "current_password",
          "new_password"
        ],
        "properties": {
          "current_password": {
            "type": "string"
          },
          "new_password": {
            "type": "string"
          }
        }
      },
      "ChunkRef": {
        "type": "object",
        "description": "One chunk reference: `h` = BLAKE3 hex (64 chars), `s` = size in bytes.\nField names are deliberately terse — a 10 GB file is ~40 000 of these.",
        "required": [
          "h",
          "s"
        ],
        "properties": {
          "h": {
            "type": "string",
            "description": "BLAKE3 hash of the chunk (64 hex chars)."
          },
          "s": {
            "type": "integer",
            "format": "int64",
            "description": "Chunk size in bytes (1 ..= 1 MiB).",
            "minimum": 0
          }
        }
      },
      "ChunkUploadResponseDto": {
        "type": "object",
        "description": "Response returned after a single chunk is uploaded.",
        "required": [
          "chunk_index",
          "bytes_received",
          "progress",
          "is_complete"
        ],
        "properties": {
          "bytes_received": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "chunk_index": {
            "type": "integer",
            "minimum": 0
          },
          "is_complete": {
            "type": "boolean"
          },
          "progress": {
            "type": "number",
            "format": "double"
          }
        }
      },
      "CompleteUploadRequest": {
        "type": "object",
        "description": "Optional body for `POST /api/uploads/{id}/complete`.\n\nWhen the client supplies `checksum`, the server compares it against\nthe streamed content's hash BEFORE the file row is created — failure\nreleases the blob reference and returns 400, with the chunk parts\nkept on disk for a retry. This is the end-to-end integrity check:\nper-chunk MD5 proves each chunk arrived intact, but only the final\nhash catches mis-ordered or corrupted assemblies.\n\n**`blake3` is highly recommended** — it's the content-addressing\nalgorithm of the blob store itself, so verification is a string\ncomparison against the hash the store already computed, and the\nvalue the client sends equals the `content_hash` they'd later read\nback from `GET /api/files/{id}`. `md5` and `sha256` are accepted\nfor legacy client tooling; they are computed by an in-flight tee\nduring the same streaming pass — no extra disk read either way.\n\n`Default` keeps the existing wire shape: clients that POST with no\nbody get today's behavior (no verification, server just returns\nwhat it computed).",
        "properties": {
          "checksum": {
            "type": [
              "string",
              "null"
            ],
            "description": "Lowercase hex digest the client expects the streamed content to\nhash to. Compared case-insensitively. Omit to skip verification."
          },
          "checksumalg": {
            "type": [
              "string",
              "null"
            ],
            "description": "Algorithm name. `blake3` is the recommended choice (default —\nmatches the blob store's content-addressing algorithm). `md5`,\n`sha256` / `sha-256` are accepted and computed in-flight.\nUnknown values return 400."
          }
        }
      },
      "CompleteUploadResponse": {
        "type": "object",
        "description": "Final response after completing upload",
        "required": [
          "file_id",
          "filename",
          "size",
          "path"
        ],
        "properties": {
          "file_id": {
            "type": "string"
          },
          "filename": {
            "type": "string"
          },
          "path": {
            "type": "string"
          },
          "size": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          }
        }
      },
      "ContactDto": {
        "type": "object",
        "required": [
          "id",
          "address_book_id",
          "uid",
          "email",
          "phone",
          "address",
          "created_at",
          "updated_at",
          "etag"
        ],
        "properties": {
          "address": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AddressDto"
            }
          },
          "address_book_id": {
            "type": "string"
          },
          "anniversary": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "birthday": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "email": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/EmailDto"
            }
          },
          "etag": {
            "type": "string"
          },
          "first_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "full_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "id": {
            "type": "string"
          },
          "last_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "nickname": {
            "type": [
              "string",
              "null"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "organization": {
            "type": [
              "string",
              "null"
            ]
          },
          "phone": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PhoneDto"
            }
          },
          "photo_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "uid": {
            "type": "string"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ContactGroupDto": {
        "type": "object",
        "required": [
          "id",
          "address_book_id",
          "name",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "address_book_id": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "id": {
            "type": "string"
          },
          "members_count": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32"
          },
          "name": {
            "type": "string"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "ContentCacheInfoDto": {
        "type": "object",
        "description": "Moka content-cache occupancy snapshot (see [`DashboardStatsDto::content_cache`]).\n\nEach moka entry holds the ASSEMBLED bytes of one small file\n(<10 MB) keyed by the file's content hash — not individual\nchunks. So `files` is the honest name for the entry count here;\nuse it against the sibling `BackendCacheInfoDto`'s `chunks` to\navoid conflating the two tiers' granularities in dashboards or\nalerts.",
        "required": [
          "size_bytes",
          "max_bytes",
          "files"
        ],
        "properties": {
          "files": {
            "type": "integer",
            "format": "int64",
            "description": "Number of cached files. For a file physically split into N\nchunks on-backend, moka still holds exactly ONE assembled\nentry here.",
            "minimum": 0
          },
          "max_bytes": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "size_bytes": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          }
        }
      },
      "CreateAddressBookRequest": {
        "type": "object",
        "description": "Request body for creating an address book.",
        "required": [
          "name"
        ],
        "properties": {
          "color": {
            "type": [
              "string",
              "null"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "is_public": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "name": {
            "type": "string"
          }
        }
      },
      "CreateContactRequest": {
        "type": "object",
        "description": "Request body for creating a contact.",
        "properties": {
          "address": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/AddressDto"
            }
          },
          "anniversary": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "birthday": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "email": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/EmailDto"
            }
          },
          "first_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "full_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "last_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "nickname": {
            "type": [
              "string",
              "null"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "organization": {
            "type": [
              "string",
              "null"
            ]
          },
          "phone": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PhoneDto"
            }
          },
          "photo_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "CreateFileByHashRequest": {
        "type": "object",
        "description": "Request body for the instant-upload endpoint.",
        "required": [
          "name",
          "folder_id",
          "hash"
        ],
        "properties": {
          "folder_id": {
            "type": "string",
            "description": "Target folder ID (the caller needs Create permission on it)."
          },
          "hash": {
            "type": "string",
            "description": "BLAKE3 hash (64 hex chars) of content the caller already owns."
          },
          "name": {
            "type": "string",
            "description": "File name to create (path components are stripped)."
          }
        }
      },
      "CreateFolderDetail": {
        "type": "object",
        "description": "Detail for folder creation",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Folder name"
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Parent folder ID (optional)"
          }
        }
      },
      "CreateFolderDto": {
        "type": "object",
        "description": "DTO for folder creation requests",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "Name of the folder to create"
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Parent folder ID (None for root level)"
          }
        }
      },
      "CreateGrantDto": {
        "type": "object",
        "description": "`POST /api/grants` — create or refresh a role assignment.\n\nStrictly role-keyed since the cleanup PR: callers send exactly one\nrole; the engine writes a single row in `storage.role_grants`. The\nhistorical per-permission shape (`permissions: [...]`) was dropped —\nthe OxiCloud UI is the only known caller and it already sends `role`.",
        "required": [
          "subject",
          "resource",
          "role"
        ],
        "properties": {
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Optional expiry for the grant. RFC 3339 / ISO 8601."
          },
          "resource": {
            "$ref": "#/components/schemas/ResourceDto"
          },
          "role": {
            "$ref": "#/components/schemas/RoleDto"
          },
          "subject": {
            "$ref": "#/components/schemas/SubjectInputDto"
          }
        }
      },
      "CreateGrantResponseDto": {
        "type": "object",
        "description": "Response body for `POST /api/grants`. Wraps the array of created\ngrants (one per `permission` in the request) together with the\naggregated notification result. Replaces the previous bare\n`Vec<GrantDto>` shape; the frontend share modal is updated in\nlockstep.",
        "required": [
          "grants",
          "notification"
        ],
        "properties": {
          "grants": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/GrantDto"
            }
          },
          "notification": {
            "$ref": "#/components/schemas/NotifyOutcomeSetDto"
          }
        }
      },
      "CreateGroupRequest": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "name": {
            "type": "string",
            "description": "RFC 5321 local-part shape (starts alnum, then alnum/dot/dash/underscore; 1–64 chars)."
          }
        }
      },
      "CreatePlaylistDto": {
        "type": "object",
        "required": [
          "name"
        ],
        "properties": {
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "is_public": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "name": {
            "type": "string"
          }
        }
      },
      "CreateShareDto": {
        "type": "object",
        "required": [
          "item_id",
          "item_type"
        ],
        "properties": {
          "expires_at": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "minimum": 0
          },
          "item_id": {
            "type": "string"
          },
          "item_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "item_type": {
            "type": "string"
          },
          "password": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "CreateUploadRequest": {
        "type": "object",
        "description": "Request body for creating an upload session",
        "required": [
          "filename",
          "total_size"
        ],
        "properties": {
          "chunk_size": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0
          },
          "content_type": {
            "type": [
              "string",
              "null"
            ]
          },
          "filename": {
            "type": "string"
          },
          "folder_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "total_size": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          }
        }
      },
      "CreateUploadResponseDto": {
        "type": "object",
        "description": "Response returned when a new upload session is created.",
        "required": [
          "upload_id",
          "chunk_size",
          "total_chunks",
          "expires_at"
        ],
        "properties": {
          "chunk_size": {
            "type": "integer",
            "minimum": 0
          },
          "expires_at": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "total_chunks": {
            "type": "integer",
            "minimum": 0
          },
          "upload_id": {
            "type": "string"
          }
        }
      },
      "CursorListResponse": {
        "type": "object",
        "description": "Standard response envelope for cursor-paginated listing endpoints.\n\n`next_cursor` is omitted from the JSON when `None` (i.e. last page).\nCallers must treat a missing `next_cursor` as end-of-results — never\ninclude a `total` count (that would require an expensive `COUNT(*)`).",
        "required": [
          "items"
        ],
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OutgoingResourceItemDto"
            }
          },
          "next_cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Opaque cursor for the next page.  Absent when this is the last page."
          }
        }
      },
      "DashboardStatsDto": {
        "type": "object",
        "description": "Dashboard statistics",
        "required": [
          "server_version",
          "oidc_configured",
          "active_ws_sessions",
          "total_users",
          "active_users",
          "admin_users",
          "external_users",
          "online_users",
          "online_sessions",
          "drive_usage",
          "users_over_80_percent",
          "users_over_quota",
          "registration_enabled",
          "storage_cache_recommended",
          "content_cache",
          "thumbnail_cache"
        ],
        "properties": {
          "active_users": {
            "type": "integer",
            "format": "int64"
          },
          "active_ws_sessions": {
            "type": "integer",
            "format": "int64",
            "description": "Currently-connected message-bus WebSocket sessions. One per\nbrowser tab that reached a folder view and hasn't closed the\ntab yet. Zero when `OXICLOUD_MESSAGEBUS_ENABLE=false`.\nSnapshot value — a subsequent request can see a different\nnumber if a connection opened/closed in between. Renders on\nthe admin dashboard's \"Live activity\" section next to\n`online_sessions` (HTTP-driven distinct-user count).",
            "minimum": 0
          },
          "admin_users": {
            "type": "integer",
            "format": "int64"
          },
          "backend_cache": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/BackendCacheInfoDto",
                "description": "Current occupancy of the on-disk `.blob-cache` tier when\nenabled, `None` otherwise. `None` is the \"local-only\ndeployment or operator hasn't opted in\" case, in which the\nadmin UI hides the row entirely rather than drawing a disabled\nbar."
              }
            ]
          },
          "content_cache": {
            "$ref": "#/components/schemas/ContentCacheInfoDto",
            "description": "Current occupancy of the in-memory file-content cache (moka).\nAdmin dashboard renders `size_bytes / max_bytes` as a capacity\nbar; combined with `oxicloud_content_cache_hits_total /\n_misses_total` on `/metrics` the operator can see both\n\"how full\" and \"how useful\". Always present — the content\ncache runs unconditionally."
          },
          "dedup_ratio": {
            "type": [
              "number",
              "null"
            ],
            "format": "double"
          },
          "drive_usage": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/DriveKindUsageDto"
            }
          },
          "external_users": {
            "type": "integer",
            "format": "int64",
            "description": "Grant-only accounts (magic-link / OIDC-only / OCM recipients).\nFiltered out of `total_users` / `active_users` since those\ncolumns count operational seats (see the SELECT comment). Here\nas its own metric because operators of external-heavy\ndeployments (public shares, invited-collab shops) need to see\nthe invited population at a glance."
          },
          "oidc_configured": {
            "type": "boolean"
          },
          "online_sessions": {
            "type": "integer",
            "format": "int64",
            "description": "Non-revoked sessions active in the last 5 min. Answers \"how\nmany concurrent connections must I serve?\". Ratio\n`online_sessions / online_users` is the multi-device factor."
          },
          "online_users": {
            "type": "integer",
            "format": "int64",
            "description": "Distinct users behind non-revoked sessions active in the last\n5 min. Answers \"how many humans are here right now?\"."
          },
          "registration_enabled": {
            "type": "boolean"
          },
          "server_version": {
            "type": "string"
          },
          "storage_cache_recommended": {
            "type": "boolean",
            "description": "`true` when the active storage backend is remote (S3 / Azure)\nand the local-disk blob cache is NOT enabled — i.e., every\nblob read pays a round-trip to the remote. Signals the admin\nUI to show the \"enable the backend cache\" banner. `false` on\nlocal-filesystem deployments (nothing to cache) and when the\ncache is already on."
          },
          "thumbnail_cache": {
            "$ref": "#/components/schemas/ThumbnailCacheInfoDto",
            "description": "Current occupancy of the in-memory thumbnail cache (moka\ninstance distinct from the file-content cache above —\ndifferent key space, different budget, different eviction).\nAlways present. See `docs/architecture/caching.md` for the\ntwo-tier memory topology and why they aren't merged."
          },
          "total_bytes_stored": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64"
          },
          "total_users": {
            "type": "integer",
            "format": "int64"
          },
          "users_over_80_percent": {
            "type": "integer",
            "format": "int64"
          },
          "users_over_quota": {
            "type": "integer",
            "format": "int64"
          }
        }
      },
      "DeletePermanentlyRequest": {
        "type": "object",
        "description": "Request to permanently delete an item from trash",
        "required": [
          "trash_id"
        ],
        "properties": {
          "trash_id": {
            "type": "string"
          }
        }
      },
      "DeltaChunksResponse": {
        "type": "object",
        "description": "Response of `PUT /api/files/delta/chunks` — the server-computed identity\nof every received frame, in wire order. Clients compare against their\nown hashes to detect corruption before committing.",
        "required": [
          "received"
        ],
        "properties": {
          "received": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ChunkRef"
            }
          }
        }
      },
      "DeltaCommitRequest": {
        "type": "object",
        "description": "Request body of `POST /api/files/delta/commit`.\n\nExactly one of (`name` + `folder_id`) or `file_id` selects the mode:\ncreate a new file, or replace an existing file's content.",
        "required": [
          "file_hash",
          "chunks"
        ],
        "properties": {
          "chunks": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ChunkRef"
            },
            "description": "Full chunk sequence, in file order (per occurrence)."
          },
          "file_hash": {
            "type": "string",
            "description": "BLAKE3 of the complete file (verified server-side, never trusted)."
          },
          "file_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Update mode: file whose content is replaced (caller needs Write)."
          },
          "folder_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Create mode: target folder (caller needs Create permission)."
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Create mode: file name (basename)."
          }
        }
      },
      "DeltaDownloadRequest": {
        "type": "object",
        "description": "Request body of `POST /api/files/delta/download` — distinct chunk\nhashes to fetch, served back as `[u32 BE length][bytes]` frames in\nrequest order (the same wire format the upload direction uses).",
        "required": [
          "hashes"
        ],
        "properties": {
          "hashes": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "DeltaManifestResponse": {
        "type": "object",
        "description": "Response of `GET /api/files/{id}/manifest` — the recipe to rebuild the\nfile from chunks. Immutable for a given `file_hash`, so clients cache\nit keyed by hash (the endpoint also serves it with `ETag: file_hash`).",
        "required": [
          "file_hash",
          "total_size",
          "chunks"
        ],
        "properties": {
          "chunks": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ChunkRef"
            },
            "description": "Full chunk sequence, in file order (per occurrence)."
          },
          "file_hash": {
            "type": "string",
            "description": "BLAKE3 of the whole file (verify the local reassembly against it)."
          },
          "total_size": {
            "type": "integer",
            "format": "int64",
            "description": "Total size in bytes.",
            "minimum": 0
          }
        }
      },
      "DeltaNegotiateRequest": {
        "type": "object",
        "description": "Request body of `POST /api/files/delta/negotiate`.",
        "required": [
          "chunks"
        ],
        "properties": {
          "chunks": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/ChunkRef"
            },
            "description": "The file's chunks, in order (duplicates allowed — repeated content)."
          }
        }
      },
      "DeltaNegotiateResponse": {
        "type": "object",
        "description": "Response of `POST /api/files/delta/negotiate`.",
        "required": [
          "missing"
        ],
        "properties": {
          "missing": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Distinct chunk hashes the caller must upload (first-occurrence order)."
          }
        }
      },
      "DeltaNotAvailableResponse": {
        "type": "object",
        "description": "404 body of `POST /api/files/delta/download` when some requested chunks\nare not reachable through the caller's files (or don't exist — the two\nare deliberately indistinguishable).",
        "required": [
          "not_available"
        ],
        "properties": {
          "not_available": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "DeltaStillMissingResponse": {
        "type": "object",
        "description": "409 body of `POST /api/files/delta/commit` when chunks vanished between\nnegotiate and commit (GC race) or were never claimable: the client\nuploads exactly these hashes and retries the same commit.",
        "required": [
          "still_missing"
        ],
        "properties": {
          "still_missing": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "DpopBindDto": {
        "type": "object",
        "description": "Post-redirect DPoP bind DTO — only field is the JWK thumbprint.",
        "required": [
          "dpop_jkt"
        ],
        "properties": {
          "dpop_jkt": {
            "type": "string",
            "description": "Base64url SHA-256 of the canonical public-key JWK (RFC 7638) —\nexactly 43 characters, `[A-Za-z0-9_-]`."
          }
        }
      },
      "DriveDto": {
        "type": "object",
        "description": "One row in `GET /api/drives` — a drive the caller can read.\n\n`default_for_user` is `Some(<caller_id>)` for the caller's default\nPersonal drive and `None` otherwise. The picker UI uses this to put\nthe default at the top of the list and mark it as \"your home\".",
        "required": [
          "id",
          "name",
          "kind",
          "root_folder_id",
          "used_bytes",
          "policies",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "caller_role": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/RoleDto",
                "description": "Highest role the **calling** user holds on this drive (direct OR\ngroup-mediated). Populated by `GET /api/drives` and the drive\ngrants in `/api/grants/incoming/resources`. Omitted (`None`) in\ncontexts where the caller isn't naturally a member — e.g. the\noutgoing-grants listing where the caller is the granter and may\nnot have a current role on the drive themselves.\n\n**UI gating**:\n- `Some(Owner)` → mutation controls (add/edit/remove members, change policies, delete drive)\n- `Some(Editor/Contributor/Commenter)` → mutate drive contents, no membership UI\n- `Some(Viewer)` → read-only\n- `None` → show neither member list nor mutation controls\n\nSee `project_caller_role_on_file_folder_dto` memory for the\npattern extension to FileDto / FolderDto (deferred, perf-sensitive)."
              }
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "default_for_user": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid"
          },
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "kind": {
            "$ref": "#/components/schemas/DriveKindDto"
          },
          "name": {
            "type": "string",
            "description": "Display name. Sourced from `storage.folders.name` of the row\npointed at by `root_folder_id` (drives have no `name` column —\nsee docs/plan/drive.md §3). The wire shape is unchanged from\nthe client's perspective."
          },
          "policies": {
            "description": "Capability-flag bag — clients render UI affordances based on\nknown keys (`forbid_public_links`, `include_in_photo_index`,\n`forbid_music_index`, …). Unknown keys preserved verbatim."
          },
          "quota_bytes": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "Storage cap in bytes. `None` means \"no quota\" (admin override /\nfuture system drives). Mutation is OxiCloud-admin only — drive\nowners cannot self-grant capacity."
          },
          "root_folder_id": {
            "type": "string",
            "format": "uuid",
            "description": "The drive's mount-point folder. Folder API calls\n(`POST /api/folders { parent_id: <root_folder_id> }`,\n`PATCH /api/folders/<root_folder_id>` to rename) use this id —\nno polymorphic \"create at drive root\" surface needed."
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "used_bytes": {
            "type": "integer",
            "format": "int64",
            "description": "Running total of bytes consumed. Maintained incrementally in D4;\non D0 this reflects the backfilled baseline."
          }
        }
      },
      "DriveKindDto": {
        "type": "string",
        "enum": [
          "personal",
          "shared"
        ]
      },
      "DriveKindUsageDto": {
        "type": "object",
        "description": "One row of the dashboard's quota panel — usage aggregate for a\nsingle drive kind. Unlimited caps are excluded from `capped_quota_bytes`\nand counted in `unlimited_count` so the panel can render the ratio\nhonestly (\"X / Y over N capped drives · M unlimited\").",
        "required": [
          "kind",
          "used_bytes",
          "unlimited_count",
          "capped_count"
        ],
        "properties": {
          "capped_count": {
            "type": "integer",
            "format": "int64",
            "description": "Count of drives with a numeric cap. Used to hide rows with\nzero drives and denominate the ratio."
          },
          "capped_quota_bytes": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "Sum of caps over capped drives only. `None` when there are no\ncapped drives of this kind (would otherwise report `0 / 0`\nmeaninglessly)."
          },
          "kind": {
            "type": "string",
            "description": "`\"personal\"` or `\"shared\"`."
          },
          "unlimited_count": {
            "type": "integer",
            "format": "int64",
            "description": "Count of drives (personal: users) with no cap. Personal-kind\nunlimited = `auth.users.storage_quota_bytes = 0`; shared-kind\nunlimited = `storage.drives.quota_bytes IS NULL`."
          },
          "used_bytes": {
            "type": "integer",
            "format": "int64",
            "description": "Total bytes stored across drives of this kind. Excludes trashed\nfiles (see `bug_trash_excluded_from_quota` for the known gap)."
          }
        }
      },
      "DrivePolicies": {
        "type": "object",
        "description": "Typed mirror of the `policies` JSONB. Five known keys; the JSONB column\nremains the source of truth and may carry unknown keys verbatim — this\nstruct is a read view for enforcement and a write view for the policy\nPATCH endpoint. Every field defaults to `false` (everything allowed)\nso a freshly-created drive doesn't need a populated policy bag.\n\nSee `docs/plan/drive.md` §8 for the enforcement matrix\n(which callsite each key is checked at).",
        "properties": {
          "forbid_cross_drive_move": {
            "type": "boolean",
            "description": "Blocks MOVE when `src.drive_id != dst.drive_id`. Enforced at the\nmove endpoints. Lands paired with D6's cross-drive move work.",
            "default": false
          },
          "forbid_external_sharing": {
            "type": "boolean",
            "description": "Blocks grants whose subject has `users.is_external = true`. Enforced\nat `magic_link_invite_service::resolve_or_create_recipient` and\n`grant_handler::create_grant`.",
            "default": false
          },
          "forbid_owner_role_change": {
            "type": "boolean",
            "description": "Locks the Owner-role membership set: no owner can be added,\nremoved, or demoted by another owner — only OxiCloud admin can\nchange the Owner roster. Editor / Viewer mutations by remaining\nowners are unaffected. Personal drives are already\nsingle-owner-immutable via `refuse_if_personal`, so this policy\nonly adds value on shared drives. Enforced at\n`DriveManagementService::set_member_role` (refuses Owner role\nwrites) and `::remove_member` (refuses Owner removals) when the\ncaller is non-admin.",
            "default": false
          },
          "forbid_public_links": {
            "type": "boolean",
            "description": "Blocks anonymous-link (token-share) creation on resources in this\ndrive. Enforced at `share_service::create_shared_link`.",
            "default": false
          },
          "forbid_sharing": {
            "type": "boolean",
            "description": "Disables per-resource grants on resources in this drive. Drive-level\nmembership (Owner/Editor/Viewer) still works.\n\nThe BROADER rule: it covers public links too, so it is enforced at\n`grant_handler::create_grant` (via [`DrivePolicies::refuse_sharing`])\n**and** on the public-link path (via\n[`DrivePolicies::refuse_public_links`]). Enforcing only the first left\na drive that forbade sharing outright still minting anonymous links,\nwhile the admin editor greyed `forbid_public_links` out as \"already\nenforced by\" this one.",
            "default": false
          },
          "include_in_music_index": {
            "type": "boolean",
            "description": "Same shape as `include_in_photo_index`, applied to the Music\nlibrary surface (playlists today; a `/api/music/tracks` library\nview later). Symmetric opt-in — Music was originally cross-drive\nvia a `forbid_music_index` opt-out, but that mixed-form naming\ncreated \"one include-in, one forbid\" confusion and the\n\"shared audio is always intentional\" claim didn't hold under\nscrutiny (voicemail MP3s in a work drive shouldn't bleed into\nthe personal library). See §15.",
            "default": false
          },
          "include_in_photo_index": {
            "type": "boolean",
            "description": "Opts this drive into the `/api/photos` timeline (§15). Non-default\ndrives are omitted by default so a random shared folder full of\nscreenshots doesn't bleed into the personal timeline; owners flip\nthis on when the drive genuinely is a photo library (e.g. \"Family\nPhotos\"). Default personal drives get `true` on creation via the\n`PersonalDriveLifecycleHook` + a one-shot backfill for existing\nrows, so the SQL predicate is a single positive rule with no\nper-kind carve-out. Read at `file_blob_read_repository::\nlist_media_files` + `list_geo_clusters`. See §15 for the query\nshape and rationale.",
            "default": false
          },
          "max_public_link_days": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32",
            "description": "Cap, in days, on how long an anonymous link in this drive may live.\n`None` = no cap, which is the LAXEST value — a link that never\nexpires is permitted. Enforced at `share_service::create_shared_link`.\n\nThe first non-boolean knob. Note the expiry it constrains lives on\n`storage.role_grants.expires_at` for the token grant, NOT on\n`storage.shares` — that column was dropped in\n`20260601000000_rebac_expiry_and_perms_cleanup.sql`.",
            "default": null,
            "minimum": 0
          },
          "read_only": {
            "type": "boolean",
            "description": "**Full freeze / legal-hold.** When `true`, every mutation on\nresources in this drive is refused — user-initiated and\nbackground alike. Compliance-grade guarantee:\n\n- User-initiated: enforced at `PgAclEngine::check_inner`, which\n  short-circuits `Create` / `Update` / `Delete` / `Share`\n  permissions on any resource in a read-only drive. Read still\n  passes. Manage-on-Drive still passes so admins can un-freeze.\n- Background jobs: the periodic trash-retention purge and\n  orphan-upload sweep filter out read-only drives at SELECT\n  time (SQL-side `JOIN storage.drives … WHERE (policies->>\n  'read_only')::boolean IS NOT TRUE`). Retention clock keeps\n  ticking; on unfreeze, the next sweep tick catches up.\n\nApplies to both personal and shared drives — a user winding\ndown their account, freezing a secondary personal archive, or\nputting a shared drive on legal hold all use the same knob.\nMutation is admin-only via `PATCH /api/drives/{id}/policies`\n(per §8 — same carve-out as every other policy).",
            "default": false
          },
          "require_public_link_password": {
            "type": "boolean",
            "description": "Requires every anonymous link in this drive to carry a password.\nEnforced at `share_service::create_shared_link`; the corresponding\nstate is `storage.shares.password_hash IS NOT NULL`.",
            "default": false
          }
        }
      },
      "EffectiveLinkDto": {
        "type": "object",
        "description": "A public link on an ancestor folder (or the drive) that also reaches\nthe requested folder.\n\nCarries the share's own metadata — a token grant alone would give a\nclient nothing to show but an opaque id.\n\n**Not [`ShareDto`](crate::application::dtos::share_dto::ShareDto),\ndeliberately.** That type carries `token` and `url` — the live\nsecret — which is right when handing someone a link they own, and\nwrong here. The caller holds `Share` on a DESCENDANT; the ancestor's\nlink may publish the whole ancestor tree, so \"something above you is\npublished, here is where\" is the honest payload and a working URL is\nan escalation they never visited that folder to earn. Both fields\nare non-`Option` there, so reuse would mean `\"\"` — a value a client\ncould build a broken link from.\n\n`expires_at` is the GRANT's, not the share row's. The two can\ndiverge, and the grant is what actually gates access.",
        "required": [
          "grant_id",
          "share_id",
          "has_password",
          "resource"
        ],
        "properties": {
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "grant_id": {
            "type": "string",
            "format": "uuid",
            "description": "`storage.role_grants.id` of the token grant. Stable list key."
          },
          "has_password": {
            "type": "boolean",
            "description": "Whether opening the link requires a password."
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Operator-given link name. `None` when the creator never set one."
          },
          "resource": {
            "$ref": "#/components/schemas/ResourceDto",
            "description": "Where the link lives — the folder or drive carrying the grant.\nClients resolve a folder's display name from `ancestors`."
          },
          "share_id": {
            "type": "string",
            "format": "uuid",
            "description": "The share this link belongs to (`storage.shares.id`, which is the\ntoken grant's subject)."
          }
        }
      },
      "EmailDto": {
        "type": "object",
        "required": [
          "email",
          "type",
          "is_primary"
        ],
        "properties": {
          "email": {
            "type": "string"
          },
          "is_primary": {
            "type": "boolean"
          },
          "type": {
            "type": "string"
          }
        }
      },
      "FaceBoxDto": {
        "type": "object",
        "description": "One face box within a photo (for tagging overlays in the lightbox).",
        "required": [
          "id",
          "x",
          "y",
          "w",
          "h"
        ],
        "properties": {
          "h": {
            "type": "number",
            "format": "float"
          },
          "id": {
            "type": "string"
          },
          "person_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "w": {
            "type": "number",
            "format": "float"
          },
          "x": {
            "type": "number",
            "format": "float"
          },
          "y": {
            "type": "number",
            "format": "float"
          }
        }
      },
      "FavoriteItemDto": {
        "type": "object",
        "description": "DTO for favorites item, enriched with item metadata via SQL JOIN\nso the frontend does not need N+1 requests to resolve names/sizes.",
        "required": [
          "id",
          "user_id",
          "item_id",
          "item_type",
          "created_at",
          "icon_class",
          "icon_special_class",
          "category",
          "size_formatted"
        ],
        "properties": {
          "category": {
            "type": "string",
            "description": "Human-readable category (e.g. \"Image\", \"Folder\")"
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the item was added to favorites"
          },
          "icon_class": {
            "type": "string",
            "description": "FontAwesome icon CSS class (e.g. \"fas fa-file-image\", \"fas fa-folder\")"
          },
          "icon_special_class": {
            "type": "string",
            "description": "Extra CSS class for icon styling (e.g. \"image-icon\", \"folder-icon\")"
          },
          "id": {
            "type": "string",
            "description": "Unique identifier for the favorite entry"
          },
          "item_id": {
            "type": "string",
            "description": "ID of the favorited item (file or folder)"
          },
          "item_mime_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "MIME type (files only)"
          },
          "item_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Display name of the file or folder"
          },
          "item_path": {
            "type": [
              "string",
              "null"
            ],
            "description": "Full human-readable path (e.g. \"Documents/Work\" for a folder,\n\"Documents/Work/report.pdf\" for a file)"
          },
          "item_size": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "Size in bytes (files only; folders → None)"
          },
          "item_type": {
            "type": "string",
            "description": "Type of the item ('file' or 'folder')"
          },
          "modified_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Last modification timestamp of the item"
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Parent folder ID (folder_id for files, parent_id for folders)"
          },
          "size_formatted": {
            "type": "string",
            "description": "Formatted file size (e.g. \"3.27 MB\"); \"--\" for folders"
          },
          "user_id": {
            "type": "string",
            "description": "User ID who owns this favorite"
          }
        }
      },
      "FavoritesResourceItemDto": {
        "type": "object",
        "description": "One item in a `GET /api/favorites/resources` page.",
        "required": [
          "resource_type",
          "favorited_at",
          "resource"
        ],
        "properties": {
          "favorited_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the resource was added to the user's favorites."
          },
          "resource": {
            "$ref": "#/components/schemas/ResourceContentDto",
            "description": "Full resource details — shape determined by `resource_type`."
          },
          "resource_type": {
            "$ref": "#/components/schemas/ResourceTypeDto"
          }
        }
      },
      "FeaturesDto": {
        "type": "object",
        "description": "Feature-flag block within [`ServerConfigDto`]. One boolean per\noptional subsystem. Adding a new feature: append a field with a\ndefault that matches the server-side default; NEVER remove a field\n(client code may depend on the absence of a `false` value to mean\n\"unknown\").",
        "required": [
          "message_bus",
          "trash",
          "search",
          "sharing",
          "music",
          "places",
          "faces",
          "video_thumbnails",
          "external_mounts",
          "markdown_collab"
        ],
        "properties": {
          "external_mounts": {
            "type": "boolean",
            "description": "Admin-configured external filesystem mounts. See\n`FeaturesConfig::enable_external_mounts`."
          },
          "faces": {
            "type": "boolean",
            "description": "Face detection + identity clustering (\"People\"). Biometric —\nOFF by default. See `FeaturesConfig::enable_faces`."
          },
          "markdown_collab": {
            "type": "boolean",
            "description": "Collaborative `.md` editing over the message-bus WebSocket\n(Yjs CRDT). When `false`, the FE hides the \"New markdown\"\nmenu entry and falls back to the plain `.md` viewer when\nopening one. See `FeaturesConfig::enable_markdown_collab`."
          },
          "message_bus": {
            "type": "boolean",
            "description": "Message bus over WebSocket. When `false`, `/api/rt/ws` and\n`/api/rt/ticket` are not registered — clients skip WS setup\nentirely. See `FeaturesConfig::enable_message_bus`."
          },
          "music": {
            "type": "boolean",
            "description": "Music player + playlists. See `FeaturesConfig::enable_music`."
          },
          "places": {
            "type": "boolean",
            "description": "Photo-map (\"Places\") tab. See `FeaturesConfig::enable_places`."
          },
          "search": {
            "type": "boolean",
            "description": "Full-text and metadata search (`/api/search/*`). See\n`FeaturesConfig::enable_search`."
          },
          "sharing": {
            "type": "boolean",
            "description": "File sharing (public share links + user-to-user grants). See\n`FeaturesConfig::enable_file_sharing`."
          },
          "trash": {
            "type": "boolean",
            "description": "Recycle bin / soft-delete flow. When `false`, deletes are\npermanent — no `/api/trash` endpoint. See\n`FeaturesConfig::enable_trash`."
          },
          "video_thumbnails": {
            "type": "boolean",
            "description": "Server-side video-thumbnail generation via ffmpeg. See\n`FeaturesConfig::enable_video_thumbnails`."
          }
        }
      },
      "FileDto": {
        "type": "object",
        "description": "DTO for file responses",
        "required": [
          "id",
          "name",
          "path",
          "size",
          "mime_type",
          "created_at",
          "modified_at",
          "icon_class",
          "icon_special_class",
          "category",
          "size_formatted",
          "content_hash",
          "etag",
          "is_favorite",
          "is_shared"
        ],
        "properties": {
          "category": {
            "type": "string",
            "description": "Human-readable file category (e.g. \"Image\", \"Document\")"
          },
          "content_hash": {
            "type": "string",
            "description": "Raw BLAKE3 content hash. Populated from `File::content_hash()`.\nExposed in REST JSON so API consumers can use it for\ncontent-addressable URLs, dedup verification, and integrity\naudits. Distinct from `etag` (which is an HTTP-only cache\ntoken whose formula may grow to include `modified_at` etc.)."
          },
          "created_at": {
            "type": "integer",
            "format": "int64",
            "description": "Creation timestamp",
            "minimum": 0
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "§14 provenance: user that originally created this file.\n`None` when the referenced user has been deleted (FK is\n`ON DELETE SET NULL`) or for stub/legacy files."
          },
          "etag": {
            "type": "string",
            "description": "Opaque HTTP ETag. Populated from `File::etag()`. Used by\nWebDAV/NextCloud handlers when emitting `ETag` headers and\nalso exposed in REST JSON so frontends can pass it back\nthrough `If-Match` / `If-None-Match` on download / mutation\nendpoints without a separate HEAD round-trip."
          },
          "folder_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Parent folder ID"
          },
          "icon_class": {
            "type": "string",
            "description": "FontAwesome icon CSS class (e.g. \"fas fa-file-image\")"
          },
          "icon_special_class": {
            "type": "string",
            "description": "Extra CSS class for icon styling (e.g. \"image-icon\", \"\" when default)"
          },
          "id": {
            "type": "string",
            "description": "File ID"
          },
          "is_favorite": {
            "type": "boolean",
            "description": "Caller-scoped: `true` when the requesting user has favorited\nthis file. **Wire contract: always present**, never null and\nnever absent — the SPA reads it as a required `boolean` with no\nnullish branch. Every emission path (listing endpoints inline\nvia a per-row `EXISTS` in the listing SQL; single-item endpoints\nvia the shared `caller_flags` helper on the favorites port) is\nresponsible for populating this before the DTO reaches the\nwire. WebDAV/CalDAV/CardDAV DTOs default to `false` — the XML\nproperty serializer drops the field entirely, so a stale\ndefault is never observable on those surfaces."
          },
          "is_shared": {
            "type": "boolean",
            "description": "Resource-scoped: `true` when the file has ANY explicit\nrole-grant on it (link share via `subject_type = 'token'`,\nuser/group grant, any role). \"Someone was given access to\nthis beyond drive membership.\" Same wire contract as\n`is_favorite` — always present."
          },
          "mime_type": {
            "type": "string",
            "description": "MIME type — `Arc<str>` because MIME values repeat across files\nand DTOs are cloned on every request (clone is O(1) atomic increment)."
          },
          "modified_at": {
            "type": "integer",
            "format": "int64",
            "description": "Last modification timestamp",
            "minimum": 0
          },
          "name": {
            "type": "string",
            "description": "File name"
          },
          "path": {
            "type": "string",
            "description": "Path to the file (relative)"
          },
          "size": {
            "type": "integer",
            "format": "int64",
            "description": "Size in bytes",
            "minimum": 0
          },
          "size_formatted": {
            "type": "string",
            "description": "Human-readable formatted size (e.g. \"3.27 MB\")"
          },
          "sort_date": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "Sort date for Photos timeline — COALESCE(EXIF captured_at, created_at).\nOnly populated by the /api/photos endpoint.",
            "minimum": 0
          },
          "updated_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "§14 provenance: user that performed the most recent mutation\nthat bumped `updated_at`. Authorship signal — distinct from\n`owner_id`. `None` when the referenced user is deleted or for\nstub/legacy files."
          }
        }
      },
      "FolderAncestorDto": {
        "type": "object",
        "description": "Single crumb in the walk from the drive root (or share-boundary) down\nto the leaf. Present only for ancestors the caller has Read on; the\nwalk stops at the first inaccessible parent.",
        "required": [
          "id",
          "name",
          "drive_id"
        ],
        "properties": {
          "drive_id": {
            "type": "string",
            "format": "uuid",
            "description": "Drive the folder belongs to. Always populated (every folder has\na drive_id in the D0+ schema). Lets clients derive the current\ndrive from `ancestors.at(-1).drive_id` without a second\n`GET /api/folders/{id}` round-trip — the ancestors response is\nthe authoritative \"everything I need for the folder-context\nheader\" call. See 2026-07-26 UX pass on /files load traffic."
          },
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "name": {
            "type": "string"
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "`None` on the drive-root folder. On boundary crumbs it's the id\nof the (invisible-to-caller) parent — clients don't render it\nbut the field is preserved for debugging."
          }
        }
      },
      "FolderAncestorsDto": {
        "type": "object",
        "description": "Response envelope for `GET /api/folders/{id}/ancestors`.\n\n`ancestors` is root-first (drive root or share boundary as element\n0), leaf-last. Length ≥ 1 (the leaf itself is always included).\n`access_source` describes the boundary at element 0.",
        "required": [
          "ancestors",
          "access_source"
        ],
        "properties": {
          "access_source": {
            "$ref": "#/components/schemas/AccessSourceDto"
          },
          "ancestors": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FolderAncestorDto"
            }
          },
          "effective_grants": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "$ref": "#/components/schemas/GrantDto"
            },
            "description": "Every grant that reaches the leaf: its own, each visible\nancestor's, and the drive's. Answers \"who can get at this\nfolder, and through what?\" — a question the per-resource grant\nlisting cannot, because it only ever reports direct rows.\n\n**Present only for `?include_grants=true`.** Omitted otherwise\nbecause the breadcrumb calls this endpoint on every folder\nnavigation and must not start paying for a grants query it never\nreads.\n\nNo separate \"source\" field: each [`GrantDto`] already names the\nresource it sits on, so a client tells direct from inherited by\ncomparing `resource.id` against the leaf, and resolves an\ninherited grant's display name from `ancestors` — which it\nalready has in the same response.\n\nSafe by construction: the ancestor walk drops folders the caller\ncannot Read (see `get_ancestors_with_perms`), and this only\ncollects grants for ancestors that survived. A caller can never\nlearn of a folder — or of who holds access to one — that they\ncould not already see."
          },
          "effective_links": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "$ref": "#/components/schemas/EffectiveLinkDto"
            },
            "description": "Public links that reach the leaf, from the same walk.\n\nSplit out of `effective_grants` rather than mixed in: a link is\nnot a subject. It has no name to resolve, no role to change and\nnobody to notify, and clients render it in a different place\nentirely. Keeping both in one list made every consumer\nre-partition by `subject.type` before it could draw anything.\n\nSame presence rule as `effective_grants` — only for\n`?include_grants=true`."
          }
        }
      },
      "FolderDto": {
        "type": "object",
        "description": "DTO for folder responses",
        "required": [
          "id",
          "name",
          "path",
          "drive_id",
          "created_at",
          "modified_at",
          "is_root",
          "icon_class",
          "icon_special_class",
          "category",
          "etag",
          "is_favorite",
          "is_shared"
        ],
        "properties": {
          "category": {
            "type": "string",
            "description": "Human-readable category (always \"Folder\")"
          },
          "created_at": {
            "type": "integer",
            "format": "int64",
            "description": "Creation timestamp",
            "minimum": 0
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "§14 provenance: user that originally created this folder.\n`None` when the referenced user has been deleted (FK is\n`ON DELETE SET NULL`) or for stub/legacy folders."
          },
          "drive_id": {
            "type": "string",
            "format": "uuid",
            "description": "Drive that owns this folder. The scope axis for path-based\nlookups across REST / WebDAV / NextCloud / CalDAV / CardDAV.\nPost-D0 `storage.folders.drive_id` is `NOT NULL`; stub /\nDTO-reconstructed folders carry `Uuid::nil()`."
          },
          "etag": {
            "type": "string",
            "description": "Opaque ETag for HTTP responses. Populated from `Folder::etag()`\nat conversion time so every WebDAV / NextCloud handler emits\nthe same value, and exposed in REST JSON so the frontend can\npass it back through `If-Match` on rename / move endpoints\nwithout a separate HEAD round-trip."
          },
          "icon_class": {
            "type": "string",
            "description": "FontAwesome icon CSS class (always \"fas fa-folder\")"
          },
          "icon_special_class": {
            "type": "string",
            "description": "Extra CSS class for icon styling (always \"folder-icon\")"
          },
          "id": {
            "type": "string",
            "description": "Folder ID"
          },
          "is_favorite": {
            "type": "boolean",
            "description": "Caller-scoped: `true` when the requesting user has favorited\nthis folder. See `FileDto::is_favorite` for the full wire\ncontract note (always present, never null; enrichment path\ncovers listing rows via inline `EXISTS` and single-item\nendpoints via the `caller_flags` helper)."
          },
          "is_root": {
            "type": "boolean",
            "description": "Whether this is a root folder"
          },
          "is_shared": {
            "type": "boolean",
            "description": "Resource-scoped: `true` when the folder has ANY explicit\nrole-grant on it. Same wire contract as `is_favorite`."
          },
          "modified_at": {
            "type": "integer",
            "format": "int64",
            "description": "Last modification timestamp",
            "minimum": 0
          },
          "name": {
            "type": "string",
            "description": "Folder name"
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Parent folder ID"
          },
          "path": {
            "type": "string",
            "description": "Path to the folder (relative)"
          },
          "updated_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "§14 provenance: user that performed the most recent mutation\nthat bumped `updated_at`. Authorship signal — distinct from\n`owner_id`. `None` when the referenced user is deleted or for\nstub/legacy folders."
          }
        }
      },
      "FolderListingDto": {
        "type": "object",
        "description": "Combined DTO that returns both sub-folders and files for a given folder\nin a single response, eliminating the double-fetch on every navigation.",
        "required": [
          "folders",
          "files",
          "favorite_ids",
          "shared_ids"
        ],
        "properties": {
          "favorite_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Ids (folders + files in this listing) the caller has favorited. Lets the\nclient render star badges without a separate per-navigation favorites\nfetch. Sorted for a stable response / ETag."
          },
          "files": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FileDto"
            },
            "description": "Files inside the requested folder"
          },
          "folders": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/FolderDto"
            },
            "description": "Sub-folders inside the requested folder"
          },
          "shared_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Ids in this listing the caller has an outgoing share/grant on (incl.\npublic links). Sorted for a stable response / ETag."
          }
        }
      },
      "FolderResourceItemDto": {
        "type": "object",
        "description": "One item in a `/resources` page — a file or folder with a `resource_type` tag.\nRe-uses [`ResourceContentDto`] so the shape is identical to `SharedWithMeItemDto.resource`.",
        "required": [
          "resource_type",
          "resource"
        ],
        "properties": {
          "resource": {
            "$ref": "#/components/schemas/ResourceContentDto",
            "description": "Full resource details. Shape is determined by `resource_type`."
          },
          "resource_type": {
            "$ref": "#/components/schemas/ResourceTypeDto"
          }
        }
      },
      "FullUserDto": {
        "type": "object",
        "description": "Full user record — public identity + all fields BOTH an admin\n(viewing another user) AND the subject themselves may see. Returned\nas `Vec<FullUserDto>` by `/api/admin/users`; embedded in\n[`SelfUserDto`] for `/api/auth/me`.\n\nThis is the DTO closest to the underlying `auth.users` row. Adding a\nfield here means an admin looking at any user can see it, and the\nsubject themselves can see it in their `/me` response — but the field\nstays off the public [`PublicUserDto`] surface.",
        "required": [
          "user",
          "created_at",
          "updated_at",
          "active",
          "storage_quota_bytes",
          "storage_used_bytes",
          "has_password",
          "opaque_registered",
          "opaque_migrated"
        ],
        "properties": {
          "active": {
            "type": "boolean",
            "description": "Account-active flag — a deactivated user couldn't reach `/me`\nanyway, but admin needs to see it."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "Row bookkeeping."
          },
          "email_verified_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the user first demonstrated control of their email. Trust\nsignal — meaningful to admin (auditing verification status) and\nto self (own record), but not to a share picker rendering a\nvignette."
          },
          "federation_issuer": {
            "type": [
              "string",
              "null"
            ],
            "description": "The authority that minted this user's `federation_subject` —\nissuer URL for OIDC (id_token `iss` claim), peer domain for OCM,\n`None` for local users. Same rationale as `federation_kind`."
          },
          "federation_kind": {
            "type": [
              "string",
              "null"
            ],
            "description": "Which trust chain minted this user's federation identity —\n`\"oidc\" | \"ocm\" | \"magic_link\"` — or `None` for pure local users.\nKept off `PublicUserDto` because a peer's federation kind is a\nsoft org-affiliation leak; only self + admin need it."
          },
          "has_password": {
            "type": "boolean",
            "description": "TRUE when the account has a server-verifiable password\n(`password_hash IS NOT NULL`). Kept off `PublicUserDto` because\nper-user auth adoption leaks through directory endpoints."
          },
          "last_login_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Activity signal — private to the subject; admin sees it too."
          },
          "opaque_migrated": {
            "type": "boolean",
            "description": "TRUE when the user has completed ≥1 successful OPAQUE login.\nDistinct from `opaque_registered`: an admin can invalidate the\nenvelope leaving the user registered=false but with historical\nmigrated=true."
          },
          "opaque_registered": {
            "type": "boolean",
            "description": "TRUE when the user has an OPAQUE envelope on file."
          },
          "preferred_locale": {
            "type": [
              "string",
              "null"
            ],
            "description": "Subject's own locale preference. Only THEY or an admin managing\nthem needs this — other callers use their own locale."
          },
          "storage_quota_bytes": {
            "type": "integer",
            "format": "int64",
            "description": "Storage quotas — personal financials. Admin manages others';\nself sees own."
          },
          "storage_used_bytes": {
            "type": "integer",
            "format": "int64"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          },
          "user": {
            "$ref": "#/components/schemas/PublicUserDto",
            "description": "Public identity — same set every authenticated caller sees."
          }
        }
      },
      "GeoCluster": {
        "type": "object",
        "description": "A clustered group of geotagged photos within one aggregation cell.",
        "required": [
          "lng",
          "lat",
          "count",
          "sample_file_id"
        ],
        "properties": {
          "count": {
            "type": "integer",
            "format": "int64",
            "description": "Number of photos in the cluster."
          },
          "lat": {
            "type": "number",
            "format": "double",
            "description": "Cluster centroid latitude."
          },
          "lng": {
            "type": "number",
            "format": "double",
            "description": "Cluster centroid longitude."
          },
          "sample_file_id": {
            "type": "string",
            "description": "A representative photo id, for the cluster thumbnail."
          }
        }
      },
      "GrantDto": {
        "type": "object",
        "required": [
          "id",
          "subject",
          "resource",
          "role",
          "granted_by",
          "granted_at"
        ],
        "properties": {
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "granted_at": {
            "type": "string",
            "format": "date-time"
          },
          "granted_by": {
            "type": "string",
            "format": "uuid"
          },
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "resource": {
            "$ref": "#/components/schemas/ResourceDto"
          },
          "role": {
            "$ref": "#/components/schemas/RoleDto",
            "description": "Role-keyed since D-Prep cleanup — one row in `storage.role_grants`\nis one `GrantDto`. The bundle of underlying permissions is implied\nby the role and recomputed client-side from the same lookup table."
          },
          "subject": {
            "$ref": "#/components/schemas/SubjectDto"
          }
        }
      },
      "GroupDto": {
        "type": "object",
        "required": [
          "id",
          "name",
          "is_virtual",
          "created_at",
          "updated_at",
          "can_manage",
          "member_count"
        ],
        "properties": {
          "can_manage": {
            "type": "boolean",
            "description": "True when the caller may rename, delete, or change the membership\nof this group. v1 computes this as `caller.role == \"admin\"`. v2\nwill compute it from per-group `Manage` grants on\n`Resource::SubjectGroup(id)` once that resource type lands in\n`access_grants`. Frontend reads this unconditionally so the v2\nmigration is backend-only."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "is_virtual": {
            "type": "boolean",
            "description": "True for system-managed groups (e.g. `Internal`). Membership and\nmetadata on virtual groups are immutable."
          },
          "member_count": {
            "type": "integer",
            "format": "int64",
            "description": "Direct-member count (users + nested groups, one level only). The\nmanagement UI shows this as a chip on each list row; for transitive\nexpansion size, see the `/effective-members` endpoint."
          },
          "name": {
            "type": "string"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "GroupListDto": {
        "type": "object",
        "required": [
          "items",
          "total"
        ],
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/GroupDto"
            }
          },
          "total": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          }
        }
      },
      "GroupMemberDto": {
        "oneOf": [
          {
            "type": "object",
            "required": [
              "id",
              "kind"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "kind": {
                "type": "string",
                "enum": [
                  "user"
                ]
              }
            }
          },
          {
            "type": "object",
            "required": [
              "id",
              "kind"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "kind": {
                "type": "string",
                "enum": [
                  "group"
                ]
              }
            }
          }
        ]
      },
      "GroupNameRequest": {
        "type": "object",
        "description": "Request body for creating or renaming a group.",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string"
          }
        }
      },
      "HashBatchRequest": {
        "type": "object",
        "description": "Request body for the batch hash-ownership check (`POST /api/dedup/check-batch`).",
        "required": [
          "hashes"
        ],
        "properties": {
          "hashes": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Candidate BLAKE3 hashes (64 hex chars each) to test for ownership."
          }
        }
      },
      "HashBatchResponse": {
        "type": "object",
        "description": "Response for the batch hash-ownership check.",
        "required": [
          "owned"
        ],
        "properties": {
          "owned": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The subset of the submitted `hashes` the authenticated user already owns."
          }
        }
      },
      "HashCheckResponse": {
        "type": "object",
        "description": "Response for hash check endpoint",
        "required": [
          "exists",
          "hash"
        ],
        "properties": {
          "existing_size": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "If exists, the size of the existing blob",
            "minimum": 0
          },
          "exists": {
            "type": "boolean",
            "description": "Whether a blob with this hash already exists"
          },
          "hash": {
            "type": "string",
            "description": "The BLAKE3 hash that was checked"
          },
          "ref_count": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32",
            "description": "Global reference count for this blob across all users.\nOnly populated when the authenticated user has the `admin` role;\nomitted for regular users to prevent cross-user content inference.",
            "minimum": 0
          }
        }
      },
      "HeaderPayload": {
        "type": "object",
        "description": "Compact JSON shape written into the header. Fields are documented\nin `common::migration_progress::MigrationProgress`.\n\nPublic because `GET /api/config` returns the same shape as the\ninitial hydration snapshot for FE stores — the endpoint mirrors\nwhatever the header carries so the client has a single wire\nvocabulary to render. Frontend treats the value as opaque JSON\nand pattern-matches on the fields it currently understands;\nadding a field is additive.",
        "required": [
          "readonly"
        ],
        "properties": {
          "migration": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/ProgressHeader"
              }
            ]
          },
          "readonly": {
            "type": "boolean"
          },
          "rotation": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/ProgressHeader",
                "description": "K3: independent of `readonly` — rotation does NOT engage the\napp-wide read-only flag, so the frontend needs a distinct\nsignal to know \"rotation is running, show the rotation\nbanner instead of migration banner\"."
              }
            ]
          }
        }
      },
      "ListResponseDto": {
        "type": "object",
        "required": [
          "items",
          "unread_count"
        ],
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/NotificationDto"
            }
          },
          "unread_count": {
            "type": "integer",
            "format": "int64",
            "description": "Unread rows for this user across the whole table — the bell\nbadge reads this. Kept on the list response so a bell open\ndoesn't need a second round-trip for the badge."
          }
        }
      },
      "LocaleDto": {
        "type": "object",
        "description": "DTO for locale information",
        "required": [
          "code",
          "name"
        ],
        "properties": {
          "code": {
            "type": "string",
            "description": "Locale code (e.g., \"en\", \"es\")"
          },
          "name": {
            "type": "string",
            "description": "Locale name in its own language (e.g., \"English\", \"Español\")"
          }
        }
      },
      "LoginDto": {
        "type": "object",
        "required": [
          "username",
          "password"
        ],
        "properties": {
          "dpop_jkt": {
            "type": [
              "string",
              "null"
            ],
            "description": "DPoP JWK thumbprint the client generated at page load. When\npresent, binds the new session to a browser-held keypair so\nstealing the cookie without the private key is useless (RFC\n9449). Absent → session is created unbound (fail-open per the\n`docs/plan/dpop.md` threat model). Malformed → 400."
          },
          "password": {
            "type": "string"
          },
          "username": {
            "type": "string",
            "description": "Identifier the user typed. Accepts BOTH a username (no `@`) and\nan email address (`@` present). The server dispatches on\n`@`-in-input: with `@` it looks up by email; without, by\nusername. The two namespaces are provably disjoint (PR 16\nforbids `@` in usernames), so a single field handles both\nwithout ambiguity. The frontend submits whatever the user\ntyped in the \"Username or email\" field as-is."
          }
        }
      },
      "MarkAllReadResponseDto": {
        "type": "object",
        "required": [
          "marked"
        ],
        "properties": {
          "marked": {
            "type": "integer",
            "format": "int64",
            "description": "Number of rows that transitioned unread → read.",
            "minimum": 0
          }
        }
      },
      "MergeBody": {
        "type": "object",
        "required": [
          "into",
          "from"
        ],
        "properties": {
          "from": {
            "type": "string",
            "description": "Cluster id being merged into `into`. Deleted after the merge."
          },
          "into": {
            "type": "string",
            "description": "Cluster id that will absorb the other one (`from`'s photos are\nreattributed to `into`; `from` is deleted). Typically the\nlarger / named cluster wins."
          }
        }
      },
      "MigrationStateDto": {
        "type": "object",
        "description": "Migration progress returned by `GET /api/admin/storage/migration`.\nRe-exports the `MigrationState` shape for the admin UI.",
        "required": [
          "status",
          "total_blobs",
          "migrated_blobs",
          "migrated_bytes",
          "failed_blobs"
        ],
        "properties": {
          "completed_at": {
            "type": [
              "string",
              "null"
            ]
          },
          "failed_blobs": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "migrated_blobs": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "migrated_bytes": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "started_at": {
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "type": "string"
          },
          "throughput_bytes_per_sec": {
            "type": [
              "number",
              "null"
            ],
            "format": "double",
            "description": "Estimated throughput in bytes/sec (for UI ETA calculation)."
          },
          "total_blobs": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          }
        }
      },
      "MoveFilePayload": {
        "type": "object",
        "description": "Payload for moving a file",
        "properties": {
          "folder_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Target folder ID (None means root)"
          }
        }
      },
      "MoveFolderDto": {
        "type": "object",
        "description": "DTO for folder move requests",
        "properties": {
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "New parent folder ID (None for root level)"
          }
        }
      },
      "MoveToTrashRequest": {
        "type": "object",
        "description": "Request to move an item to trash",
        "required": [
          "item_id",
          "item_type"
        ],
        "properties": {
          "item_id": {
            "type": "string"
          },
          "item_type": {
            "type": "string"
          }
        }
      },
      "NotificationDto": {
        "type": "object",
        "description": "Wire shape for one notification row. `payload` stays a raw JSON\nvalue — per-kind decoding happens on the FE using the `kind`\ndiscriminant.",
        "required": [
          "id",
          "kind",
          "payload",
          "created_at"
        ],
        "properties": {
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "kind": {
            "type": "string"
          },
          "payload": {
            "type": "object"
          },
          "read_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "`null` = unread."
          }
        }
      },
      "NotifyInfoDto": {
        "type": "object",
        "description": "Read-only alerting policy, from `GET /api/admin/notify/info`.\n\nThe *what gets sent* half, where `SmtpInfoDto` and [`WebhookInfoDto`]\nare the *how it travels* half. Configured through environment\nvariables only, shown for confirmation rather than editing.",
        "required": [
          "min_severity",
          "sinks",
          "email_recipients"
        ],
        "properties": {
          "email_recipients": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "`OXICLOUD_JOBS_NOTIFY_EMAIL_TO`, as parsed. Empty when mail\nalerting is off."
          },
          "min_severity": {
            "type": "string",
            "description": "Severity floor: `data_loss`, `inconsistent`, `anomaly`, or `none`.\n\nThe field the panel was missing. An operator with a configured\nwebhook and a healthy relay can still be told nothing, because the\ndefault floor admits only `data_loss` — and until this was\nsurfaced, the only way to discover that was to read the server's\nenvironment."
          },
          "sinks": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Channels actually built and wired, by name — `webhook`, `email`.\nEmpty means findings are recorded but nobody is told."
          }
        }
      },
      "NotifyOutcomeDto": {
        "oneOf": [
          {
            "type": "object",
            "description": "An email actually went out for this recipient. `detail` is\n`\"magic_link\"` (external invitation with a fresh token) or\n`\"plain_notification\"` (internal \"you got a new grant\" mail).",
            "required": [
              "detail",
              "kind"
            ],
            "properties": {
              "detail": {
                "type": "string"
              },
              "kind": {
                "type": "string",
                "enum": [
                  "sent"
                ]
              }
            }
          },
          {
            "type": "object",
            "description": "Skipped silently because this (granter, recipient) pair was\nnotified less than the coalesce-window ago. The grant is still\nrecorded; the recipient sees it next time they log in.",
            "required": [
              "last_sent_at",
              "kind"
            ],
            "properties": {
              "kind": {
                "type": "string",
                "enum": [
                  "coalesced"
                ]
              },
              "last_sent_at": {
                "type": "string",
                "format": "date-time"
              }
            }
          },
          {
            "type": "object",
            "description": "Per-recipient hard cap (5/h) reached. The caller may retry after\n`retry_after_secs`.",
            "required": [
              "retry_after_secs",
              "kind"
            ],
            "properties": {
              "kind": {
                "type": "string",
                "enum": [
                  "rate_limited"
                ]
              },
              "retry_after_secs": {
                "type": "integer",
                "format": "int32",
                "minimum": 0
              }
            }
          },
          {
            "type": "object",
            "description": "No mail was dispatched for this recipient. `reason` is one of:\n- `\"recipient_opted_out\"`  — user toggled `notify_on_share = false`\n- `\"operator_disabled\"`    — `OXICLOUD_NOTIFY_INTERNAL_USERS_ON_SHARE=false`\n- `\"no_email\"`             — user row has no email on file\n- `\"oidc_only_no_email\"`   — OIDC-only user with no email claim\n- `\"subject_is_token\"`     — anonymous link share (the surface\n  that creates the grant or the `/notify` endpoint maps this to 409)",
            "required": [
              "reason",
              "kind"
            ],
            "properties": {
              "kind": {
                "type": "string",
                "enum": [
                  "not_applicable"
                ]
              },
              "reason": {
                "type": "string"
              }
            }
          }
        ],
        "description": "One per resolved recipient. `kind` discriminates; sibling fields are\nonly meaningful for the matching variant. Tagged JSON shape:\n\n```json\n{ \"kind\": \"sent\",           \"detail\": \"magic_link\" }\n{ \"kind\": \"sent\",           \"detail\": \"plain_notification\" }\n{ \"kind\": \"coalesced\",      \"last_sent_at\": \"2026-06-04T12:00:00Z\" }\n{ \"kind\": \"rate_limited\",   \"retry_after_secs\": 1800 }\n{ \"kind\": \"not_applicable\", \"reason\": \"recipient_opted_out\" }\n```"
      },
      "NotifyOutcomeSetDto": {
        "type": "object",
        "description": "The aggregated result of dispatching share notifications for ONE grant\naction (one `create_grant` request OR one `/notify` call). Carries\nper-recipient outcomes so the frontend can render a single\nsummary-style toast:\n\n- `total_recipients = 1`, `outcomes[0] = Sent` → \"Notified Carol\"\n- `total_recipients = 1`, `outcomes[0] = Coalesced` → \"Carol already\n  notified recently\"\n- `total_recipients = N`, all `Sent` → \"Notified all N group members\"\n- `total_recipients = N`, mix → \"Notified 8 of 10 — 2 opted out\"\n\n`total_recipients` equals `outcomes.len()` after resolution. For\ntoken-subject grants it is `0` (no human recipient — no toast).",
        "required": [
          "total_recipients",
          "outcomes"
        ],
        "properties": {
          "outcomes": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/NotifyOutcomeDto"
            }
          },
          "total_recipients": {
            "type": "integer",
            "minimum": 0
          }
        }
      },
      "OidcExchangeDto": {
        "type": "object",
        "description": "Request body for the OIDC one-time code exchange endpoint",
        "required": [
          "code"
        ],
        "properties": {
          "code": {
            "type": "string"
          }
        }
      },
      "OidcProviderInfoDto": {
        "type": "object",
        "description": "Information about available OIDC providers + self-service auth\nmethods enabled on the deployment. Consumed by the login page to\ndecide which forms/buttons to render.",
        "required": [
          "enabled",
          "provider_name",
          "authorize_endpoint",
          "password_login_enabled"
        ],
        "properties": {
          "authorize_endpoint": {
            "type": "string"
          },
          "auto_redirect_to_oidc": {
            "type": "boolean",
            "description": "True iff the effective allowlist is `[Oidc]` AND the\n`auto_redirect_if_standalone_oidc` policy is set. Frontend\nuses this to decide whether to auto-redirect to the authorize\nendpoint on login-page mount (true) or show a click-to-continue\nbutton (false). Default false — the safe posture that avoids\nredirect loops when the IdP is degraded."
          },
          "enabled": {
            "type": "boolean"
          },
          "issuer": {
            "type": "string",
            "description": "The authoritative issuer URL for THIS deployment's OIDC config —\nsame value that lands on `auth.users.federation_issuer` for\nusers JIT-provisioned via this IdP.\n\nPopulated so the frontend can resolve display: when\n`PublicUserDto.federation_issuer` equals this `issuer`, render\n`provider_name` as the human-friendly label (avoids showing raw\nissuer URLs like `https://sso.example.com/realms/main` in the\nadmin badge / profile view). Falls back to the raw issuer when\nthere's no match — happens for legacy rows not yet lazy-rebound,\nor (future) users linked to a different IdP than the currently\nconfigured one.\n\nEmpty string when OIDC is disabled on this deployment."
          },
          "magic_link_login_enabled": {
            "type": "boolean",
            "description": "True iff the server accepts magic-link login requests\n(`OXICLOUD_AUTH_METHODS` includes `magic_link` AND SMTP is\nconfigured). Frontend renders the magic-link form when true."
          },
          "password_login_enabled": {
            "type": "boolean"
          },
          "provider_name": {
            "type": "string"
          },
          "require_verified_email": {
            "type": "boolean",
            "description": "True iff `OXICLOUD_REQUIRE_VERIFIED_EMAIL` is set. Frontend uses\nthis hint to explain the `EmailNotVerified` login response and\nto nudge new users toward the magic-link verification path\nstraight after signup."
          }
        }
      },
      "OidcSettingsDto": {
        "type": "object",
        "description": "Current OIDC settings returned to admin UI (secrets masked)",
        "required": [
          "enabled",
          "issuer_url",
          "client_id",
          "client_secret_set",
          "scopes",
          "auto_provision",
          "admin_groups",
          "disable_password_login",
          "provider_name",
          "callback_url",
          "env_overrides"
        ],
        "properties": {
          "admin_groups": {
            "type": "string"
          },
          "auto_provision": {
            "type": "boolean"
          },
          "callback_url": {
            "type": "string",
            "description": "Auto-generated callback URL the admin must register in their IdP"
          },
          "client_id": {
            "type": "string"
          },
          "client_secret_set": {
            "type": "boolean",
            "description": "True if a client secret is configured (never reveals the actual value)"
          },
          "disable_password_login": {
            "type": "boolean"
          },
          "enabled": {
            "type": "boolean"
          },
          "env_overrides": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Field names overridden by environment variables (read-only in UI)"
          },
          "issuer_url": {
            "type": "string"
          },
          "provider_name": {
            "type": "string"
          },
          "scopes": {
            "type": "string"
          }
        }
      },
      "OpaqueKsfParams": {
        "type": "object",
        "description": "Client-side Argon2id parameters the SPA must feed to\n`@serenity-kit/opaque` on `finishRegistration` / `finishLogin`.\nValues MUST match the server's `OpaqueConfig::ksf_*` — the\nhandshake fails to derive matching keys otherwise, so publishing\nthese is what keeps client and server in lock-step across param\nbumps.",
        "required": [
          "memoryKib",
          "iterations",
          "parallelism"
        ],
        "properties": {
          "iterations": {
            "type": "integer",
            "format": "int32",
            "minimum": 0
          },
          "memoryKib": {
            "type": "integer",
            "format": "int32",
            "minimum": 0
          },
          "parallelism": {
            "type": "integer",
            "format": "int32",
            "minimum": 0
          }
        }
      },
      "OpaqueLoginKe1Dto": {
        "type": "object",
        "description": "Client → server on KE1. `userIdentifier` is the same string the\nSPA sends to legacy `/api/auth/login` — email if it contains `@`,\nusername otherwise (server dispatches on `@`, same as legacy).\n`startLoginRequest` is the base64-encoded output of\n`ClientLogin::start(...).message`.",
        "required": [
          "userIdentifier",
          "startLoginRequest"
        ],
        "properties": {
          "startLoginRequest": {
            "type": "string"
          },
          "userIdentifier": {
            "type": "string"
          }
        }
      },
      "OpaqueLoginKe1Response": {
        "type": "object",
        "description": "Server → client on KE1. `exchangeId` is an opaque handle the\nclient MUST echo back on KE3 (single-use, 60 s TTL); the client\ncan't derive server state from it. `loginResponse` is the\nbase64-encoded output of `ServerLogin::start(...).message`.",
        "required": [
          "exchangeId",
          "loginResponse"
        ],
        "properties": {
          "exchangeId": {
            "type": "string",
            "format": "uuid"
          },
          "loginResponse": {
            "type": "string"
          }
        }
      },
      "OpaqueLoginKe3Dto": {
        "type": "object",
        "description": "Client → server on KE3. `exchangeId` matches what KE1 handed\nback; `finishLoginRequest` is the base64-encoded output of\n`ClientLogin::finish(...).message`.",
        "required": [
          "exchangeId",
          "finishLoginRequest"
        ],
        "properties": {
          "dpopJkt": {
            "type": [
              "string",
              "null"
            ],
            "description": "DPoP JWK thumbprint the client generated at page load. When\npresent, binds the new session to a browser-held keypair (RFC\n9449). Absent → session created unbound. See `docs/plan/dpop.md`."
          },
          "exchangeId": {
            "type": "string",
            "format": "uuid"
          },
          "finishLoginRequest": {
            "type": "string"
          }
        }
      },
      "OpaqueLookupDto": {
        "type": "object",
        "description": "Client → server on lookup. Same identifier shape as\n[`OpaqueLoginKe1Dto::user_identifier`] and legacy\n`/api/auth/login`'s `username` field: `@` in the input dispatches\nto email lookup, absence → username lookup.",
        "required": [
          "userIdentifier"
        ],
        "properties": {
          "userIdentifier": {
            "type": "string"
          }
        }
      },
      "OpaqueLookupResponse": {
        "type": "object",
        "description": "Server → client on lookup. The SPA reads `hasOpaque` to decide\nwhether to run OPAQUE login (KE1/KE3) or fall back to legacy\n`/api/auth/login` (which then silently registers via the Phase 2\nhook).\n\n**Anti-enum invariant** — this shape MUST be identical whether\nthe user exists or not:\n\n  * unknown user           → `hasOpaque: false`\n  * user, no envelope      → `hasOpaque: false`\n  * user with envelope     → `hasOpaque: true`\n\nA probing attacker cannot distinguish \"unknown\" from \"known but\nunregistered\" from the response body. The only signal an\nattacker gets is \"known with envelope\" vs \"everything else,\"\nwhich reveals adoption progress but NOT identity existence.",
        "required": [
          "hasOpaque"
        ],
        "properties": {
          "hasOpaque": {
            "type": "boolean"
          },
          "ksf": {
            "oneOf": [
              {
                "type": "null"
              },
              {
                "$ref": "#/components/schemas/OpaqueKsfParams",
                "description": "KSF parameters this user's envelope was minted under. Present\nonly when `has_opaque = true`. The client MUST use these values\n(not the ones from `GET /params`) on the login handshake — the\nenvelope's OPRF derivation was bound to them at register time\nand a mismatch will fail the AKE with `InvalidCredentials`.\n\n`None` when: (a) `has_opaque = false` (nothing to publish),\nor (b) the envelope predates per-envelope-KSF storage\n(migration `20261005000000`) — in which case the client falls\nback to `/params` values, which is the same behaviour as\nbefore per-envelope storage existed.\n\nAnti-enum note: the presence of this field ONLY signals what\n`has_opaque` already signals (positive existence). Value\ndifferences across users could reveal timing of registration\nbut not identity — same low-severity leak as the existing\nper-identifier probe, no additional exposure."
              }
            ]
          }
        }
      },
      "OpaqueParamsResponse": {
        "type": "object",
        "description": "Payload of `GET /api/auth/opaque/params`. `enabled = false` when\nthe OPAQUE substrate is not wired for this deployment (mode=off,\nor password auth disabled — the same cross-check\n`OpaqueConfig::effective_mode` runs). SPA gates the OPAQUE code\npath on this flag; when false, it falls back to legacy password\nauth as if OPAQUE didn't exist.\n\n`ciphersuiteVersion` + `ksf` are ALWAYS populated (safe defaults\neven when `enabled = false`) so a client that ignored `enabled`\nwouldn't nil-deref.",
        "required": [
          "enabled",
          "ciphersuiteVersion",
          "ksf"
        ],
        "properties": {
          "ciphersuiteVersion": {
            "type": "integer",
            "format": "int32"
          },
          "enabled": {
            "type": "boolean"
          },
          "ksf": {
            "$ref": "#/components/schemas/OpaqueKsfParams"
          }
        }
      },
      "OpaqueRegisterFinishDto": {
        "type": "object",
        "description": "Client → server on register KE2. `registrationRecord` is the\nbase64-encoded output of `ClientRegistration::finish(...).message`;\n`ciphersuiteVersion` is what the client believed it was minting\nunder (compared to the current server value — mismatch → 400).\n\n`ksf*` fields carry the Argon2id parameters the client actually\nused at `startRegistration`/`finishRegistration` time. Server\npersists them per-envelope so future changes to the server's\n`OpaqueConfig::ksf_*` don't invalidate this envelope. Optional\non the wire (older clients that predate per-envelope storage\nomit them; server falls back to its current config in that case).",
        "required": [
          "registrationRecord",
          "ciphersuiteVersion"
        ],
        "properties": {
          "ciphersuiteVersion": {
            "type": "integer",
            "format": "int32"
          },
          "ksfIterations": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32",
            "minimum": 0
          },
          "ksfMemoryKib": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32",
            "minimum": 0
          },
          "ksfParallelism": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32",
            "minimum": 0
          },
          "registrationRecord": {
            "type": "string"
          }
        }
      },
      "OpaqueRegisterStartDto": {
        "type": "object",
        "description": "Client → server on register KE1. `registrationRequest` is the\nbase64-encoded output of the client's\n`ClientRegistration::start(...).message`.",
        "required": [
          "registrationRequest"
        ],
        "properties": {
          "registrationRequest": {
            "type": "string"
          }
        }
      },
      "OpaqueRegisterStartResponse": {
        "type": "object",
        "description": "Server → client on register KE1. `registrationResponse` is the\nbase64-encoded output of `ServerRegistration::start(...).message`.\nAlso echoes `ciphersuiteVersion` so the client can reject a\nmid-flight suite change and abort before uploading.",
        "required": [
          "registrationResponse",
          "ciphersuiteVersion"
        ],
        "properties": {
          "ciphersuiteVersion": {
            "type": "integer",
            "format": "int32"
          },
          "registrationResponse": {
            "type": "string"
          }
        }
      },
      "OutgoingResourceGrantDto": {
        "type": "object",
        "description": "One (subject, permissions) entry within an outgoing resource item.",
        "required": [
          "grant_id",
          "subject_type",
          "subject_id",
          "subject_display",
          "role",
          "granted_at",
          "has_password"
        ],
        "properties": {
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "grant_id": {
            "type": "string",
            "format": "uuid"
          },
          "granted_at": {
            "type": "string",
            "format": "date-time"
          },
          "has_password": {
            "type": "boolean",
            "description": "Whether the token has a password set. Always `false` for user subjects."
          },
          "is_external": {
            "type": "boolean",
            "description": "True when the subject is a magic-link-only external user\n(PR N2). Always `false` for token and group subjects, and for\ninternal users. Used by the My Shares per-row menu to choose\nbetween \"Resend invitation email\" (external) and \"Notify by\nemail\" (internal)."
          },
          "role": {
            "type": "string",
            "description": "Role label: `\"viewer\"` | `\"commenter\"` | `\"contributor\"` | `\"editor\"`\n| `\"owner\"`. Emitted by `role_from_permissions()` during the dual-write\nwindow; once D-Prep cleanup lands this is read directly from\n`storage.role_grants.role`. The legacy `\"admin\"` spelling is no longer\nemitted — clients that cached it must accept `\"owner\"` too (the API\n`Role::parse` still accepts `\"admin\"` on input for one release)."
          },
          "subject_display": {
            "type": "string",
            "description": "Human-readable label (username for users, share name for tokens)."
          },
          "subject_id": {
            "type": "string",
            "format": "uuid"
          },
          "subject_type": {
            "type": "string",
            "description": "`\"user\"` | `\"token\"`"
          }
        }
      },
      "OutgoingResourceItemDto": {
        "type": "object",
        "description": "One item in the my-shares list.",
        "required": [
          "resource_type",
          "first_shared_at",
          "resource",
          "grants"
        ],
        "properties": {
          "first_shared_at": {
            "type": "string",
            "format": "date-time",
            "description": "Earliest grant date across all subjects on this resource."
          },
          "grants": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OutgoingResourceGrantDto"
            },
            "description": "One entry per (subject, permissions) pair."
          },
          "resource": {
            "$ref": "#/components/schemas/ResourceContentDto",
            "description": "Full resource details. Shape is determined by `resource_type`."
          },
          "resource_type": {
            "$ref": "#/components/schemas/ResourceTypeDto"
          }
        }
      },
      "PaginationDto": {
        "type": "object",
        "description": "A DTO to represent pagination information",
        "required": [
          "page",
          "page_size",
          "total_items",
          "total_pages",
          "has_next",
          "has_prev"
        ],
        "properties": {
          "has_next": {
            "type": "boolean",
            "description": "Indicates if there is a next page"
          },
          "has_prev": {
            "type": "boolean",
            "description": "Indicates if there is a previous page"
          },
          "page": {
            "type": "integer",
            "description": "Current page (starts at 0)",
            "minimum": 0
          },
          "page_size": {
            "type": "integer",
            "description": "Page size",
            "minimum": 0
          },
          "total_items": {
            "type": "integer",
            "description": "Total number of items",
            "minimum": 0
          },
          "total_pages": {
            "type": "integer",
            "description": "Total number of pages",
            "minimum": 0
          }
        }
      },
      "PaginationRequestDto": {
        "type": "object",
        "description": "A DTO to represent a pagination request",
        "properties": {
          "page": {
            "type": "integer",
            "description": "Requested page (starts at 0)",
            "minimum": 0
          },
          "page_size": {
            "type": "integer",
            "description": "Requested page size",
            "minimum": 0
          }
        }
      },
      "PermissionDto": {
        "type": "string",
        "enum": [
          "read",
          "create",
          "share",
          "comment",
          "delete",
          "update",
          "manage"
        ]
      },
      "PersonDto": {
        "type": "object",
        "description": "A named (or unnamed) identity cluster, with a cover photo for its tile.",
        "required": [
          "id",
          "face_count",
          "is_hidden"
        ],
        "properties": {
          "cover_file_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "File id of the cover face's photo, for the tile thumbnail."
          },
          "face_count": {
            "type": "integer",
            "format": "int64"
          },
          "id": {
            "type": "string"
          },
          "is_hidden": {
            "type": "boolean"
          },
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "`None` until the user names the person."
          }
        }
      },
      "PhoneDto": {
        "type": "object",
        "required": [
          "number",
          "type",
          "is_primary"
        ],
        "properties": {
          "is_primary": {
            "type": "boolean"
          },
          "number": {
            "type": "string"
          },
          "type": {
            "type": "string"
          }
        }
      },
      "PhotoDto": {
        "allOf": [
          {
            "$ref": "#/components/schemas/FileDto"
          },
          {
            "type": "object",
            "properties": {
              "height": {
                "type": [
                  "integer",
                  "null"
                ],
                "format": "int32",
                "minimum": 0
              },
              "width": {
                "type": [
                  "integer",
                  "null"
                ],
                "format": "int32",
                "minimum": 0
              }
            }
          }
        ],
        "description": "Photos-timeline item: a `FileDto` plus the image's original pixel\ndimensions (from EXIF/metadata), flattened into the same JSON shape so\nthe gallery can lay tiles out at their true aspect ratio without a\nsecond per-file metadata round-trip."
      },
      "PlaylistDto": {
        "type": "object",
        "required": [
          "id",
          "name",
          "owner_id",
          "is_public",
          "created_at",
          "updated_at"
        ],
        "properties": {
          "cover_file_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "id": {
            "type": "string"
          },
          "is_public": {
            "type": "boolean"
          },
          "name": {
            "type": "string"
          },
          "owner_id": {
            "type": "string"
          },
          "total_duration_secs": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32"
          },
          "track_count": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64"
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "PlaylistItemDto": {
        "type": "object",
        "required": [
          "id",
          "playlist_id",
          "file_id",
          "position",
          "added_at"
        ],
        "properties": {
          "added_at": {
            "type": "string",
            "format": "date-time"
          },
          "album": {
            "type": [
              "string",
              "null"
            ]
          },
          "artist": {
            "type": [
              "string",
              "null"
            ]
          },
          "duration_secs": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32"
          },
          "file_id": {
            "type": "string"
          },
          "file_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "file_size": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64"
          },
          "id": {
            "type": "string"
          },
          "mime_type": {
            "type": [
              "string",
              "null"
            ]
          },
          "playlist_id": {
            "type": "string"
          },
          "position": {
            "type": "integer",
            "format": "int32"
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "PlaylistShareInfoDto": {
        "type": "object",
        "required": [
          "user_id",
          "can_write"
        ],
        "properties": {
          "can_write": {
            "type": "boolean"
          },
          "user_id": {
            "type": "string"
          }
        }
      },
      "PolicyDefaultsImpactDto": {
        "type": "object",
        "description": "What changing a default would do, computed WITHOUT applying it.\n\nThe point of the whole feature: an admin sees the blast radius before\ncommitting, rather than discovering it in a compliance report afterwards.",
        "required": [
          "drives_weaker_than_default",
          "drives_affected",
          "by_knob",
          "weaker_drives"
        ],
        "properties": {
          "by_knob": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PolicyKnobImpactDto"
            },
            "description": "Per-knob breakdown, so the UI can say WHICH setting has the reach\nrather than only how many drives are involved."
          },
          "drives_affected": {
            "type": "integer",
            "description": "Drives whose effective policy the change actually moves — i.e. those\ninheriting the knob rather than overriding it.",
            "minimum": 0
          },
          "drives_weaker_than_default": {
            "type": "integer",
            "description": "Drives of this kind that would end up LESS restrictive than the new\ndefault, because they explicitly override a knob it tightens. They\nkeep their override — this is the count that will appear in the drift\nreport.",
            "minimum": 0
          },
          "weaker_drives": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/WeakerDriveDto"
            },
            "description": "The drives behind `drives_weaker_than_default`, named.\n\nThe scan reports the same verdict and names the drive; this preview\ncomputes it over the same rows and had the ids in hand already. Giving\nback only a count made the two surfaces disagree about how much they\nwere willing to say — an admin told \"3 drives will stay weaker\" right\nbefore saving cannot act on it, and has to save, run the scan, and\ncome back to learn which 3. Naming them here is what makes this a\npre-commit check rather than a warning."
          }
        }
      },
      "PolicyKnobImpactDto": {
        "type": "object",
        "required": [
          "knob",
          "follows",
          "overrides"
        ],
        "properties": {
          "follows": {
            "type": "integer",
            "description": "Drives that inherit this knob and would follow the new value.",
            "minimum": 0
          },
          "knob": {
            "type": "string"
          },
          "overrides": {
            "type": "integer",
            "description": "Drives that override this knob and would stay put — reported as drift\nwhen their value is the laxer one.",
            "minimum": 0
          }
        }
      },
      "ProgressHeader": {
        "type": "object",
        "description": "Shared progress shape used by both `migration` and `rotation`\nheader fields — same struct name, same JSON field names. Frontend\ntreats them identically at the render layer.",
        "required": [
          "target",
          "migrated",
          "total",
          "percent"
        ],
        "properties": {
          "migrated": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "percent": {
            "type": "integer",
            "format": "int32",
            "minimum": 0
          },
          "target": {
            "type": "string"
          },
          "total": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          }
        }
      },
      "PublicUserDto": {
        "type": "object",
        "description": "Public identity — what any authenticated caller may see about ANOTHER\nuser. Returned by `/api/users/{id}` and everywhere a user is\nreferenced by another surface (share responses, group members,\nmagic-link invitees, recipient enrichment).\n\nThis is the audience-narrowest DTO: adding a field here means every\nauthenticated caller can see it about every visible user. Fields that\nare meaningful only to the subject themselves (preferences, session\nstate) or only to an admin (auth adoption signals) belong on\n[`SelfUserDto`] or [`FullUserDto`] respectively.",
        "required": [
          "id",
          "email",
          "role",
          "is_external"
        ],
        "properties": {
          "email": {
            "type": "string"
          },
          "family_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Optional last/family name. Social identity."
          },
          "given_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Optional first/given name. Social identity."
          },
          "id": {
            "type": "string"
          },
          "image": {
            "type": [
              "string",
              "null"
            ],
            "description": "Avatar payload (base64 data-URI up to 512 KiB). Public so a share\npicker can render the recipient's face directly. Will move to a\ndedicated avatar endpoint in a future refactor — this shape is\ntransitional."
          },
          "is_external": {
            "type": "boolean",
            "description": "`true` for grant-only external recipients (magic-link, OIDC-only,\nfuture OCM federated). Renders the \"external\" badge on the vignette."
          },
          "is_online": {
            "type": "boolean",
            "description": "Presence signal — TRUE when the server observed a request on any\nof this user's non-revoked sessions within the last\n[`ONLINE_WINDOW`](crate::application::dtos::session_dto::ONLINE_WINDOW)\n(5 min). Sourced from an EXISTS subquery when the DTO is built\nfrom a list-projection path; single-user endpoints that don't\nenrich presence ship `false`."
          },
          "role": {
            "type": "string",
            "description": "Role string (\"admin\" | \"user\"). Kept public because the sharee /\ngroup-member vignette renders an admin badge."
          },
          "username": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "RecentItemDto": {
        "type": "object",
        "description": "DTO for recent items, enriched with item metadata via SQL JOIN\nso the frontend does not need N+1 requests to resolve names/sizes.",
        "required": [
          "id",
          "user_id",
          "item_id",
          "item_type",
          "accessed_at",
          "icon_class",
          "icon_special_class",
          "category",
          "size_formatted"
        ],
        "properties": {
          "accessed_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the item was accessed"
          },
          "category": {
            "type": "string",
            "description": "Human-readable category (e.g. \"Image\", \"Folder\")"
          },
          "icon_class": {
            "type": "string",
            "description": "FontAwesome icon CSS class (e.g. \"fas fa-file-image\", \"fas fa-folder\")"
          },
          "icon_special_class": {
            "type": "string",
            "description": "Extra CSS class for icon styling (e.g. \"image-icon\", \"folder-icon\")"
          },
          "id": {
            "type": "string",
            "description": "Unique identifier for the recent item"
          },
          "item_id": {
            "type": "string",
            "description": "Item ID (file or folder)"
          },
          "item_mime_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "MIME type (files only)"
          },
          "item_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Display name of the file or folder"
          },
          "item_path": {
            "type": [
              "string",
              "null"
            ],
            "description": "Full human-readable path (e.g. \"Documents/Work\" for a folder,\n\"Documents/Work/report.pdf\" for a file)"
          },
          "item_size": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "Size in bytes (files only; folders → None)"
          },
          "item_type": {
            "type": "string",
            "description": "Item type ('file' or 'folder')"
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Parent folder ID (folder_id for files, parent_id for folders)"
          },
          "size_formatted": {
            "type": "string",
            "description": "Formatted file size (e.g. \"3.27 MB\"); \"--\" for folders"
          },
          "user_id": {
            "type": "string",
            "description": "Owner user ID"
          }
        }
      },
      "RecentResourceItemDto": {
        "type": "object",
        "description": "One item in a `GET /api/recent/resources` page.",
        "required": [
          "resource_type",
          "accessed_at",
          "resource"
        ],
        "properties": {
          "accessed_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the resource was last accessed."
          },
          "resource": {
            "$ref": "#/components/schemas/ResourceContentDto",
            "description": "Full resource details — shape determined by `resource_type`."
          },
          "resource_type": {
            "$ref": "#/components/schemas/ResourceTypeDto"
          }
        }
      },
      "RefreshTokenDto": {
        "type": "object",
        "required": [
          "refresh_token"
        ],
        "properties": {
          "refresh_token": {
            "type": "string"
          }
        }
      },
      "RegisterDto": {
        "type": "object",
        "required": [
          "email"
        ],
        "properties": {
          "email": {
            "type": "string"
          },
          "password": {
            "type": [
              "string",
              "null"
            ],
            "description": "Optional password (≥8 chars when present). When omitted, a\nwelcome magic-link is mailed to `email` for first-session\nbootstrap. The user can later set a password via the\nchange-password endpoint to switch to classic username/email +\npassword login."
          },
          "username": {
            "type": [
              "string",
              "null"
            ],
            "description": "Optional handle (2-64 chars, no `@`). When omitted, the user can\nclaim one later via the profile-edit endpoint. Users without a\nusername cannot use NextCloud clients or create app passwords\n(Basic-Auth resolves users by username); web UI / native API\nworks fine without one."
          }
        }
      },
      "RenameBody": {
        "type": "object",
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ],
            "description": "New display name for the cluster. `null` or omitted clears\nthe name, reverting the cluster to its \"Unnamed\" state."
          }
        }
      },
      "RenameFolderDto": {
        "type": "object",
        "description": "DTO for folder rename requests",
        "required": [
          "name"
        ],
        "properties": {
          "name": {
            "type": "string",
            "description": "New name for the folder"
          }
        }
      },
      "ReorderTracksDto": {
        "type": "object",
        "required": [
          "item_ids"
        ],
        "properties": {
          "item_ids": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      },
      "ResourceContentDto": {
        "oneOf": [
          {
            "$ref": "#/components/schemas/FileDto"
          },
          {
            "$ref": "#/components/schemas/FolderDto"
          },
          {
            "$ref": "#/components/schemas/DriveDto"
          }
        ],
        "description": "The resource payload for one item in the shared-with-me list.\n\nThe variant is discriminated by `resource_type` on the parent\n[`SharedWithMeItemDto`].  Serialised as the inner object (no wrapper key)\nvia `#[serde(untagged)]`, so consumers see the file/folder fields directly\nunder the `resource` key."
      },
      "ResourceDto": {
        "type": "object",
        "required": [
          "type",
          "id"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "type": {
            "$ref": "#/components/schemas/ResourceTypeDto"
          }
        }
      },
      "ResourceTypeDto": {
        "type": "string",
        "enum": [
          "folder",
          "file",
          "drive",
          "calendar",
          "address_book",
          "playlist"
        ]
      },
      "RestoreFromTrashRequest": {
        "type": "object",
        "description": "Request to restore an item from trash",
        "required": [
          "trash_id"
        ],
        "properties": {
          "trash_id": {
            "type": "string"
          }
        }
      },
      "RoleDto": {
        "type": "string",
        "description": "Wire-format wrapper around the domain `Role` enum. Carries the\nserde/utoipa derives. Maps 1:1 to/from `Role` via `From`.\n\nThe historical `\"admin\"` alias for `Owner` (used during the D-Prep\ndual-write window for cached clients) has been retired in the cleanup\nPR — the OxiCloud UI emits `\"owner\"` exclusively. Stragglers receive\na 422 on POST/PUT, which surfaces the upgrade cleanly.",
        "enum": [
          "viewer",
          "commenter",
          "contributor",
          "editor",
          "owner"
        ]
      },
      "SaveOidcSettingsDto": {
        "type": "object",
        "description": "Request body for saving OIDC settings from the admin panel",
        "required": [
          "enabled",
          "issuer_url",
          "client_id"
        ],
        "properties": {
          "admin_groups": {
            "type": [
              "string",
              "null"
            ]
          },
          "auto_provision": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "client_id": {
            "type": "string"
          },
          "client_secret": {
            "type": [
              "string",
              "null"
            ],
            "description": "Only update if provided and non-empty (None = keep existing)"
          },
          "disable_password_login": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "enabled": {
            "type": "boolean"
          },
          "issuer_url": {
            "type": "string"
          },
          "provider_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "scopes": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "SaveStorageSettingsDto": {
        "type": "object",
        "description": "Request body for saving storage settings from the admin panel",
        "required": [
          "backend"
        ],
        "properties": {
          "backend": {
            "type": "string"
          },
          "s3_access_key": {
            "type": [
              "string",
              "null"
            ],
            "description": "Only update if provided and non-empty (None = keep existing)"
          },
          "s3_bucket": {
            "type": [
              "string",
              "null"
            ]
          },
          "s3_endpoint_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "s3_force_path_style": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "s3_region": {
            "type": [
              "string",
              "null"
            ]
          },
          "s3_secret_key": {
            "type": [
              "string",
              "null"
            ],
            "description": "Only update if provided and non-empty (None = keep existing)"
          }
        }
      },
      "SearchCriteriaDto": {
        "type": "object",
        "description": "\n * Data Transfer Object for file search criteria.\n *\n * This structure represents all possible search parameters that can be used\n * to filter files and folders in the system. It supports various filter types\n * including name matching, file types, date ranges, and size constraints.",
        "properties": {
          "created_after": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "Optional minimum creation date (seconds since epoch)",
            "minimum": 0
          },
          "created_before": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "Optional maximum creation date (seconds since epoch)",
            "minimum": 0
          },
          "file_types": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "type": "string"
            },
            "description": "Optional list of file extensions to include (e.g., \"pdf\", \"jpg\")"
          },
          "folder_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Optional folder ID to limit search scope"
          },
          "limit": {
            "type": "integer",
            "description": "Maximum number of results to return",
            "minimum": 0
          },
          "max_size": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "Optional maximum file size in bytes",
            "minimum": 0
          },
          "min_size": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "Optional minimum file size in bytes",
            "minimum": 0
          },
          "modified_after": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "Optional minimum modification date (seconds since epoch)",
            "minimum": 0
          },
          "modified_before": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "Optional maximum modification date (seconds since epoch)",
            "minimum": 0
          },
          "name_contains": {
            "type": [
              "string",
              "null"
            ],
            "description": "Optional text to search in file/folder names"
          },
          "offset": {
            "type": "integer",
            "description": "Offset for pagination",
            "minimum": 0
          },
          "recursive": {
            "type": "boolean",
            "description": "Whether to search recursively within subfolders (default: true)"
          },
          "reverse": {
            "type": "boolean",
            "description": "Reverse the sort direction — descending for name/size/date,\nno-op for `relevance` (a descending relevance sort is meaningless)."
          },
          "sort_by": {
            "type": "string",
            "description": "Sort dimension. Canonical set: `\"relevance\"` | `\"name\"` | `\"size\"`\n| `\"updated_at\"` | `\"created_at\"`. Direction is `reverse` below —\nthe `_desc` suffix pattern was retired 2026-07-26 in favour of\na single boolean, so every consumer treats \"which column\" and\n\"which direction\" as orthogonal concerns."
          }
        }
      },
      "SearchFileResultDto": {
        "type": "object",
        "description": "A file search result enriched with server-computed metadata.\n\nPhase 1-plus extension (AuthZ-adjacent audit follow-up, 2026-07-26):\ncarries `etag`, `created_by`, `updated_by`, `is_favorite`, `is_shared`\nthrough from `FileDto`. Pre-fix these fields were dropped at `enrich_file`\ntime, so the wire-normalised `SearchResourcesDto` handler couldn't\nreconstruct a full `FileDto` for its `resource` slot — every result\nlooked unfavorited / unshared, and provenance was blank. The extra\ncolumns come from `file_blob_read_repository.rs::search_files_paginated`\n(SELECT'd inline, EXISTS subqueries for the caller-scoped booleans).",
        "required": [
          "id",
          "name",
          "path",
          "size",
          "mime_type",
          "created_at",
          "modified_at",
          "relevance_score",
          "size_formatted",
          "icon_class",
          "icon_special_class",
          "category"
        ],
        "properties": {
          "blob_hash": {
            "type": "string",
            "description": "Raw BLAKE3 content hash. Feeds `FileDto::content_hash` and\n`File::compute_etag` when search results are converted to\n`FileDto` (NC REPORT/SEARCH response). Defaults to `String::new()`\nfor backward-compatible deserialisation of cached results\nthat pre-date the column."
          },
          "category": {
            "type": "string",
            "description": "Content category: \"document\", \"image\", \"video\", \"audio\", \"archive\", \"code\", \"other\""
          },
          "created_at": {
            "type": "integer",
            "format": "int64",
            "description": "Creation timestamp",
            "minimum": 0
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "§14 provenance — user that originally created this file. `None`\nwhen the referenced user has been deleted or for legacy rows."
          },
          "etag": {
            "type": "string",
            "description": "HTTP ETag — derived from `blob_hash + modified_at`. Duplicates\n`FileDto::etag` so the wire handler can hand a client the same\ntoken for `If-Match` / `If-None-Match` conditional requests on\nsearch results as it would on a folder listing."
          },
          "folder_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Parent folder ID"
          },
          "icon_class": {
            "type": "string",
            "description": "CSS icon class for the file type (e.g., \"fas fa-file-pdf\")"
          },
          "icon_special_class": {
            "type": "string",
            "description": "Extra CSS class for icon styling (e.g., \"pdf-icon\", \"code-icon js-icon\")"
          },
          "id": {
            "type": "string",
            "description": "File ID"
          },
          "is_favorite": {
            "type": "boolean",
            "description": "Caller-scoped: `true` when the requesting user has favorited\nthis file. Populated by an EXISTS subquery in the search SQL —\nthe search repo carries it back as a per-row bool that the\nservice plumbs into this DTO."
          },
          "is_shared": {
            "type": "boolean",
            "description": "Resource-scoped: `true` when the file has ANY explicit role-grant.\nPopulated by the sibling EXISTS on `storage.role_grants`."
          },
          "match_source": {
            "type": [
              "string",
              "null"
            ],
            "description": "Where the match came from: \"name\" (filename matched the query) or\n\"content\" (discovered via the full-text content index)."
          },
          "mime_type": {
            "type": "string",
            "description": "MIME type — `Arc<str>` so enrichment reuses `FileDto`'s interned\nvalue (an atomic increment) instead of allocating per result row."
          },
          "modified_at": {
            "type": "integer",
            "format": "int64",
            "description": "Last modification timestamp",
            "minimum": 0
          },
          "name": {
            "type": "string",
            "description": "File name"
          },
          "path": {
            "type": "string",
            "description": "Path to the file (relative)"
          },
          "relevance_score": {
            "type": "integer",
            "format": "int32",
            "description": "Relevance score (0-100) computed server-side",
            "minimum": 0
          },
          "size": {
            "type": "integer",
            "format": "int64",
            "description": "Size in bytes",
            "minimum": 0
          },
          "size_formatted": {
            "type": "string",
            "description": "Human-readable file size (e.g., \"2.5 MB\")"
          },
          "snippet": {
            "type": [
              "string",
              "null"
            ],
            "description": "Plain-text fragment around the first content match, present only for\nhits discovered through the full-text content index."
          },
          "updated_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "§14 provenance — user that performed the most recent mutation."
          }
        }
      },
      "SearchFolderResultDto": {
        "type": "object",
        "description": "A folder search result enriched with server-computed metadata.\n\nSee `SearchFileResultDto` for the Phase 1-plus rationale — same\nstory: the six caller/provenance fields are carried through so the\nwire-normalised handler can hand the frontend a complete `FolderDto`.",
        "required": [
          "id",
          "name",
          "path",
          "drive_id",
          "created_at",
          "modified_at",
          "is_root",
          "relevance_score"
        ],
        "properties": {
          "created_at": {
            "type": "integer",
            "format": "int64",
            "description": "Creation timestamp",
            "minimum": 0
          },
          "created_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "§14 provenance — creator user id. `None` for legacy folders."
          },
          "drive_id": {
            "type": "string",
            "format": "uuid",
            "description": "Drive that owns this folder. Same column as `storage.folders.drive_id`,\ncarried through so downstream callers (e.g. the NC search REPORT\nhandler) can populate `FolderDto::drive_id` without a fallback sentinel."
          },
          "etag": {
            "type": "string",
            "description": "HTTP ETag — folders derive theirs from the tree-etag propagator.\nDuplicating it here keeps the wire handler's `FolderDto`\nreconstruction complete."
          },
          "id": {
            "type": "string",
            "description": "Folder ID"
          },
          "is_favorite": {
            "type": "boolean",
            "description": "Caller-scoped: `true` when the requesting user has favorited\nthis folder. EXISTS on `auth.user_favorites`."
          },
          "is_root": {
            "type": "boolean",
            "description": "Whether it is a root folder"
          },
          "is_shared": {
            "type": "boolean",
            "description": "Resource-scoped: `true` when the folder has any explicit\nrole-grant. EXISTS on `storage.role_grants`."
          },
          "modified_at": {
            "type": "integer",
            "format": "int64",
            "description": "Last modification timestamp",
            "minimum": 0
          },
          "name": {
            "type": "string",
            "description": "Folder name"
          },
          "parent_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Parent folder ID"
          },
          "path": {
            "type": "string",
            "description": "Path to the folder (relative)"
          },
          "relevance_score": {
            "type": "integer",
            "format": "int32",
            "description": "Relevance score (0-100) computed server-side",
            "minimum": 0
          },
          "updated_by": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "§14 provenance — last-mutator user id."
          }
        }
      },
      "SearchMeta": {
        "type": "object",
        "description": "Search-specific per-item metadata (relevance score, snippet, hit\nsource). Sits inline on each `SearchResourceItem` so consumers get\ndata locality — no keyed lookup. `ResourceList` ignores the field.\n\nWire keys are shortened (`meta.score`, `meta.via`) vs the internal\n`SearchFileResultDto` field names (`relevance_score`, `match_source`)\nto keep the envelope compact on large result pages.",
        "required": [
          "score"
        ],
        "properties": {
          "score": {
            "type": "integer",
            "format": "int32",
            "description": "Relevance 0-100. Higher = better match.",
            "minimum": 0
          },
          "snippet": {
            "type": [
              "string",
              "null"
            ],
            "description": "Plain-text fragment around the first content-index hit. Absent\nfor name-only matches and for folder results."
          },
          "via": {
            "type": [
              "string",
              "null"
            ],
            "description": "Where the hit came from: `\"name\"` or `\"content\"`. Absent when the\norigin is ambiguous (empty query → everything matches)."
          }
        }
      },
      "SearchResourceItem": {
        "type": "object",
        "description": "One search result — same `resource_type + resource` shape as the\n`/*/resources` envelopes so `ResourceList` consumes it as-is, plus\nthe inline `meta` for search-specific enrichment.",
        "required": [
          "resource_type",
          "resource",
          "meta"
        ],
        "properties": {
          "meta": {
            "$ref": "#/components/schemas/SearchMeta",
            "description": "Search-specific metadata for this row."
          },
          "resource": {
            "$ref": "#/components/schemas/ResourceContentDto",
            "description": "Full resource details (untagged: `FileDto | FolderDto | DriveDto`).\nShape determined by `resource_type`."
          },
          "resource_type": {
            "$ref": "#/components/schemas/ResourceTypeDto"
          }
        }
      },
      "SearchResourcesDto": {
        "type": "object",
        "description": "Response envelope for `GET /api/search` — cursor-paginated + search\nmetadata. `total` is an approximate caller-visible count (permission-\nfiltered) when the service can compute it cheaply, absent otherwise —\nmatches sibling envelope endpoints, which all serialise counts as\nintegers (see `app_password_dto`, `plugin_dto`, `pagination`).",
        "required": [
          "items",
          "query_time_ms"
        ],
        "properties": {
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SearchResourceItem"
            }
          },
          "next_cursor": {
            "type": [
              "string",
              "null"
            ],
            "description": "Opaque cursor for the next page. Absent on the last page."
          },
          "query_time_ms": {
            "type": "integer",
            "format": "int64",
            "description": "Server-side query time. UI shows \"Found N in Xms\" and admins use\nit as a health signal.",
            "minimum": 0
          },
          "total": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Approximate caller-visible total match count. Never leaks a count\nfor rows the caller cannot see. Omitted when unknown.",
            "minimum": 0
          }
        }
      },
      "SearchResultsDto": {
        "type": "object",
        "description": "\n * Data Transfer Object for search results.\n *\n * This structure encapsulates the results of a search operation, including\n * both files and folders that match the search criteria, along with pagination\n * information and server-computed metadata.",
        "required": [
          "files",
          "folders",
          "limit",
          "offset",
          "has_more",
          "query_time_ms",
          "sort_by"
        ],
        "properties": {
          "files": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SearchFileResultDto"
            },
            "description": "Files matching the search criteria (enriched with metadata)"
          },
          "folders": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SearchFolderResultDto"
            },
            "description": "Folders matching the search criteria (enriched with metadata)"
          },
          "has_more": {
            "type": "boolean",
            "description": "Whether there are more results available"
          },
          "limit": {
            "type": "integer",
            "description": "Limit used in the search",
            "minimum": 0
          },
          "offset": {
            "type": "integer",
            "description": "Offset used in the search",
            "minimum": 0
          },
          "query_time_ms": {
            "type": "integer",
            "format": "int64",
            "description": "Query execution time in milliseconds (server-side)",
            "minimum": 0
          },
          "sort_by": {
            "type": "string",
            "description": "Sort order used"
          },
          "total_count": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Total count of matching items (for pagination)",
            "minimum": 0
          }
        }
      },
      "SearchSuggestionItem": {
        "type": "object",
        "description": "Individual search suggestion item",
        "required": [
          "name",
          "item_type",
          "id",
          "path",
          "icon_class",
          "icon_special_class",
          "relevance_score"
        ],
        "properties": {
          "icon_class": {
            "type": "string",
            "description": "CSS icon class"
          },
          "icon_special_class": {
            "type": "string",
            "description": "Extra CSS class for icon styling"
          },
          "id": {
            "type": "string",
            "description": "Item ID for navigation"
          },
          "item_type": {
            "type": "string",
            "description": "Type: \"file\" or \"folder\""
          },
          "name": {
            "type": "string",
            "description": "The suggested name"
          },
          "path": {
            "type": "string",
            "description": "Path for context"
          },
          "relevance_score": {
            "type": "integer",
            "format": "int32",
            "description": "Relevance score",
            "minimum": 0
          }
        }
      },
      "SearchSuggestionsDto": {
        "type": "object",
        "description": "DTO for search suggestion results (quick prefix search)",
        "required": [
          "suggestions",
          "query_time_ms"
        ],
        "properties": {
          "query_time_ms": {
            "type": "integer",
            "format": "int64",
            "description": "Query execution time in milliseconds",
            "minimum": 0
          },
          "suggestions": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SearchSuggestionItem"
            },
            "description": "Suggested file/folder names matching the query prefix"
          }
        }
      },
      "SelfUserDto": {
        "type": "object",
        "description": "Self view — everything the caller may see about themselves.\nReturned by `/api/auth/me` and by every `AuthResponseDto` path\n(login / refresh / OIDC callback / magic-link redemption).\n\nComposed on top of [`FullUserDto`] so `/me` and `/admin/users` share\nthe SAME \"full profile\" contract for the fields both need — new\nself+admin-visible fields go on `FullUserDto` and both endpoints get\nthem together. Fields here are pure self-scoped state: preferences,\nsession-scoped flags, and caller-scoped permissions.",
        "required": [
          "full",
          "ui_preferences",
          "notify_on_share",
          "is_dpop_bound",
          "force_password_change",
          "can_edit_image"
        ],
        "properties": {
          "can_edit_image": {
            "type": "boolean",
            "description": "Caller-scoped permission: can I edit my own avatar? `false` for\nOIDC users whose avatar comes from the IdP. Only meaningful when\ncaller == subject; nonsense on any other DTO."
          },
          "force_password_change": {
            "type": "boolean",
            "description": "Admin-set temp-password gate — SPA nav guard blocks everything\nbut `/change-password` until this flips back. Cleared by a\nsuccessful `POST /api/auth/change-password`."
          },
          "full": {
            "$ref": "#/components/schemas/FullUserDto",
            "description": "Full profile — same shape as one row of `/api/admin/users`."
          },
          "is_dpop_bound": {
            "type": "boolean",
            "description": "Session-scoped: my current session carries a DPoP thumbprint.\nSPA reads this on `session.load()` to skip a redundant\n`POST /api/auth/dpop/bind` when the session is already bound."
          },
          "notify_on_share": {
            "type": "boolean",
            "description": "Whether I want share-notification emails."
          },
          "ui_preferences": {
            "description": "Opaque UI preferences bag — my own UI state. Cross-device store\nfor pure UI toggles (view mode, sidebar collapse, hide dotfiles,\n…). The server never inspects the contents. Always present on\nthe wire; empty bag is `{}`, never `null`."
          }
        }
      },
      "SendMagicLinkDto": {
        "type": "object",
        "description": "Request body for `POST /api/auth/magic-link/send`.",
        "required": [
          "email"
        ],
        "properties": {
          "email": {
            "type": "string"
          }
        }
      },
      "SendSmtpTestDto": {
        "type": "object",
        "description": "Request body for `POST /api/admin/smtp/test`: send a hardcoded\ndiagnostic email to the given recipient.",
        "required": [
          "to"
        ],
        "properties": {
          "to": {
            "type": "string"
          }
        }
      },
      "ServerConfigDto": {
        "type": "object",
        "description": "Server-configuration DTO. Additive over time — clients ignore\nunknown fields, and no field is ever repurposed (same discipline\nas JSON-RPC error codes on the message bus).",
        "required": [
          "version",
          "features",
          "auth",
          "server_status"
        ],
        "properties": {
          "auth": {
            "$ref": "#/components/schemas/AuthDto",
            "description": "Auth-related tunables the SPA needs to gate submit locally.\nComposes with `/api/auth/oidc/providers` — that endpoint lists\nfederation options; this one carries the numeric thresholds\n(password length today, more later) that the form has to check\nbefore firing the request."
          },
          "features": {
            "$ref": "#/components/schemas/FeaturesDto",
            "description": "Feature flags — which subsystems the server has enabled.\nClients gate optional UI on these (e.g. hide the notification\nbell if `features.message_bus` is false, since the bell would\nhave no delivery channel)."
          },
          "server_status": {
            "$ref": "#/components/schemas/HeaderPayload",
            "description": "Live server-status snapshot — exact same shape and field\nnames as the `X-Server-Status` response header. Clients use\nthis to hydrate their reactive store at boot; subsequent live\nchanges propagate through the header on every other request\n(the middleware and this endpoint share `build_header_payload`\nso drift is impossible). Non-optional so the client always\nhas a definite value; `readonly: false` with no `migration`\nor `rotation` is the \"everything nominal\" case."
          },
          "version": {
            "type": "string",
            "description": "Server version — `OXICLOUD_VERSION` derived by `build.rs` from\nthe git tag / `GITHUB_REF_NAME` / `git describe`. Matches what\n`GET /api/version` returns. Cargo.toml stays pinned at `0.0.0`;\nthis string is the canonical build identity."
          }
        }
      },
      "SessionOrigin": {
        "type": "string",
        "description": "How a session was originally minted. Set at INSERT by the login\nhandler; carried over on refresh (a rotation doesn't change how the\nuser first authenticated). Stored as `text` server-side with a CHECK\nconstraint — see `migrations/20261013000000_sessions_origin.sql`.\n\n`serde(rename_all = \"snake_case\")` so the wire values match the\ncolumn values one-to-one: `password | opaque | magic_link | oidc |\nunknown`. `Unknown` is the fallback for pre-migration rows and any\nfuture login path that hasn't been taught to stamp an origin yet.",
        "enum": [
          "password",
          "opaque",
          "magic_link",
          "oidc",
          "device",
          "unknown"
        ]
      },
      "SessionSummaryDto": {
        "type": "object",
        "description": "Wire shape for `GET /api/admin/sessions`. Contains everything the\nadmin table renders and **nothing the raw session entity would\nleak** (refresh token, OIDC ID-token, full DPoP thumbprint).",
        "required": [
          "id",
          "user_id",
          "created_at",
          "expires_at",
          "last_seen_at",
          "is_bound",
          "is_revoked",
          "is_active",
          "is_online",
          "origin",
          "is_current"
        ],
        "properties": {
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "dpop_jkt_prefix": {
            "type": [
              "string",
              "null"
            ],
            "description": "First 8 chars of the DPoP thumbprint when bound, `None` otherwise.\nEnough to distinguish two bindings of the same user across\ndevices at a glance; not enough to leak the full jkt."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time"
          },
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "ip_address": {
            "type": [
              "string",
              "null"
            ]
          },
          "is_active": {
            "type": "boolean",
            "description": "Whether this row is currently usable — `!revoked && expires_at > now()`.\nKept server-side so the SPA doesn't drift if the browser clock is off.\n**Distinct from [`is_online`](Self::is_online)** — this is a\n*lifecycle* signal (row still has authority), that one is a\n*presence* signal (a request landed on it lately)."
          },
          "is_bound": {
            "type": "boolean",
            "description": "`true` iff the session is DPoP-bound. Rendered as a lock icon\nin the admin table. Complements the auth-badges surface."
          },
          "is_current": {
            "type": "boolean",
            "description": "`true` when this row IS the caller's currently-active session —\nset by the service layer by comparing the row's `dpop_jkt` with\nthe caller's own bound thumbprint. Lets the admin panel flag\n\"revoking this cuts your own branch\" so an admin doesn't\naccidentally log themselves out. `false` when either side is\nunbound (can't correlate) or when the jkts don't match."
          },
          "is_online": {
            "type": "boolean",
            "description": "Whether the session was actually observed serving a request in the\nlast [`ONLINE_WINDOW`] (5 min). Presence signal, orthogonal to\n[`is_active`](Self::is_active): a session may be active-and-online\n(green dot in the admin table), active-and-idle (no dot, \"last\nseen 12 min ago\"), or non-active-and-offline (expired / revoked\nrows are never online). Derived server-side against\n[`ONLINE_WINDOW`] so the row-level badge stays consistent with\nthe `oxicloud_sessions_online[_users]` Prometheus aggregates.\n\nGuaranteed `false` for revoked / expired rows — those short-\ncircuit before the recency check so a revoked row that happened\nto receive a request in its final second before revocation\ndoesn't confusingly render \"Online\" post-revocation."
          },
          "is_revoked": {
            "type": "boolean",
            "description": "`true` when the row is revoked. Present because the panel has an\nopt-in \"include revoked\" checkbox — active-only listings will\nalways show `false` here, but forensics listings need the flag."
          },
          "last_seen_at": {
            "type": "string",
            "format": "date-time",
            "description": "Wall-clock time this session was last observed serving an\nauthenticated request. Moved forward per request by the\nin-process [`LastSeenTracker`](crate::infrastructure::services::last_seen_tracker)\nvia a batched UPDATE every 30 s — so this value trails the\ntrue \"last seen\" by at most one flush interval on a running\nserver. On DB read it always converges after a graceful\nshutdown flush. Distinct from `created_at`: that only moves\non session rotation (silent refresh), so its resolution is\ncapped at the access-token TTL. The admin table renders a\n\"last seen X ago\" column off this field."
          },
          "origin": {
            "$ref": "#/components/schemas/SessionOrigin",
            "description": "How the session was minted — see [`SessionOrigin`]. Set at\nINSERT time by the login handler and carried over on refresh\n(rotation doesn't change how the user first authenticated).\nDrives the admin panel's origin column + filter."
          },
          "user_agent": {
            "type": [
              "string",
              "null"
            ]
          },
          "user_id": {
            "type": "string",
            "format": "uuid"
          }
        }
      },
      "SetupAdminDto": {
        "type": "object",
        "description": "DTO for the one-time initial admin setup endpoint (`/api/setup`).\nAvailable only when the system is not yet initialized (no admin exists).",
        "required": [
          "username",
          "email",
          "password"
        ],
        "properties": {
          "email": {
            "type": "string"
          },
          "password": {
            "type": "string"
          },
          "username": {
            "type": "string"
          }
        }
      },
      "ShareDto": {
        "type": "object",
        "required": [
          "id",
          "item_id",
          "item_type",
          "token",
          "url",
          "has_password",
          "created_at",
          "created_by",
          "access_count"
        ],
        "properties": {
          "access_count": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "created_at": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "created_by": {
            "type": "string"
          },
          "expires_at": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "minimum": 0
          },
          "has_password": {
            "type": "boolean"
          },
          "id": {
            "type": "string"
          },
          "item_id": {
            "type": "string"
          },
          "item_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "item_type": {
            "type": "string"
          },
          "token": {
            "type": "string"
          },
          "url": {
            "type": "string"
          }
        }
      },
      "SharePlaylistDto": {
        "type": "object",
        "required": [
          "user_id"
        ],
        "properties": {
          "can_write": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "user_id": {
            "type": "string"
          }
        }
      },
      "SharedWithMeItemDto": {
        "type": "object",
        "description": "One item in the shared-with-me list.\n\n`resource_type` indicates whether `resource` contains a file or a folder.\nUsing a single `resource` field (instead of nullable `file`/`folder` pairs)\nmakes adding new resource types backward-compatible — only `resource_type`\ngains a new variant; the wrapper shape stays the same.",
        "required": [
          "resource_type",
          "permissions",
          "granted_at",
          "granted_by",
          "resource"
        ],
        "properties": {
          "granted_at": {
            "type": "string",
            "format": "date-time",
            "description": "Earliest grant date for this resource."
          },
          "granted_by": {
            "type": "string",
            "format": "uuid",
            "description": "UUID of the user who created the (earliest) grant."
          },
          "permissions": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PermissionDto"
            },
            "description": "All permissions the caller holds on this resource (aggregated)."
          },
          "resource": {
            "$ref": "#/components/schemas/ResourceContentDto",
            "description": "Full resource details. Shape is determined by `resource_type`."
          },
          "resource_type": {
            "$ref": "#/components/schemas/ResourceTypeDto"
          }
        }
      },
      "SharegrantedPayload": {
        "type": "object",
        "description": "Payload written on the DB row when a `share_granted` notification\nis created. Kind = [`kind::SHARE_GRANTED`].\n\nThe `resource_name` and `resource_path` fields are snapshotted at\ngrant time — even if the resource is later renamed or moved, the\nnotification still reflects what it was called when the share\nhappened. `resource_path` is populated for kinds addressable via\n`/files/[...path]` (folders + files); `None` for calendars,\naddress books, playlists, drives.\n\nWire form matches the `payload` JSONB column exactly — Rust is\nthe source of truth, OpenAPI schema auto-derives via\n`#[derive(ToSchema)]`. Adding a new field is additive on the\nJSONB column; no migration needed.",
        "required": [
          "granter_id",
          "resource_type",
          "resource_id",
          "role"
        ],
        "properties": {
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Grant expiry, if bounded. `None` = never expires."
          },
          "granter_id": {
            "type": "string",
            "format": "uuid",
            "description": "The user who created the grant."
          },
          "navigate_folder_id": {
            "type": [
              "string",
              "null"
            ],
            "format": "uuid",
            "description": "FE-navigation hint — the folder id the notification link\nshould route to. Populated when `resource_type` isn't itself\na folder-shaped resource but the FE still wants to land in\n`/files/{id}` (concretely: **drives** — the recipient lands\non the drive's root folder). For `resource_type == 'folder'`\nthe FE uses `resource_id` directly and this field stays\n`None`; same for `file` (routes to `/shared-with-me?file=`\nvia a different path). `None` for calendar / address_book /\nplaylist — those aren't reachable via `/files/*` at all."
          },
          "resource_id": {
            "type": "string",
            "format": "uuid",
            "description": "Resource UUID."
          },
          "resource_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Display name at grant time. `None` if the lookup failed at\ningest (bell renders a generic fallback in that case)."
          },
          "resource_path": {
            "type": [
              "string",
              "null"
            ],
            "description": "Storage path at grant time. Populated for\n`folder` / `file` kinds; `None` for other kinds."
          },
          "resource_type": {
            "type": "string",
            "description": "Resource kind slug: `folder`, `file`, `drive`, `calendar`,\n`address_book`, `playlist`. Same string form as\n[`crate::domain::services::authorization::Resource::type_str`]."
          },
          "role": {
            "type": "string",
            "description": "Role granted (`viewer`, `editor`, `owner`, …). Same string\nform as [`crate::domain::services::authorization::Role::as_str`]."
          }
        }
      },
      "SmtpInfoDto": {
        "type": "object",
        "description": "Read-only SMTP info shown on the admin SMTP page. SMTP configuration\nis sourced exclusively from environment variables — these fields are\nfor display only and any change has to happen by updating the env\nand restarting the server.",
        "required": [
          "enabled",
          "host",
          "port",
          "tls",
          "from",
          "user_state"
        ],
        "properties": {
          "enabled": {
            "type": "boolean",
            "description": "Whether `OXICLOUD_SMTP_HOST` is set and SMTP construction succeeded."
          },
          "from": {
            "type": "string",
            "description": "`OXICLOUD_SMTP_FROM` mailbox. Empty when unset."
          },
          "host": {
            "type": "string",
            "description": "`OXICLOUD_SMTP_HOST`. Empty string when unset."
          },
          "port": {
            "type": "integer",
            "format": "int32",
            "description": "`OXICLOUD_SMTP_PORT`. Default 587.",
            "minimum": 0
          },
          "tls": {
            "type": "string",
            "description": "Transport encryption mode: `\"starttls\"`, `\"tls\"`, or `\"none\"`."
          },
          "user_state": {
            "type": "string",
            "description": "`<set>` if a SASL user is configured, `<anon>` otherwise.\nNever echoes the username — admins compare against the\nruntime config without having to look in `.env`."
          }
        }
      },
      "SmtpTestResultDto": {
        "type": "object",
        "description": "Result of a `POST /api/admin/smtp/test` invocation. `success=true`\ncarries the SMTP server's response code + first reply line; on\nfailure the relevant error message goes in `error`. Always 200 OK\nso the frontend can render both outcomes in one place — the SMTP\nfailure is a normal operational state, not an HTTP error.",
        "required": [
          "success"
        ],
        "properties": {
          "code": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32",
            "description": "SMTP status code (e.g. 250). Only set on success.",
            "minimum": 0
          },
          "error": {
            "type": [
              "string",
              "null"
            ],
            "description": "Human-readable error message. Only set on failure."
          },
          "message": {
            "type": [
              "string",
              "null"
            ],
            "description": "First line of the SMTP server's reply. Only set on success."
          },
          "success": {
            "type": "boolean"
          }
        }
      },
      "StartMigrationDto": {
        "type": "object",
        "description": "Request body for `POST /api/admin/storage/migration/start`.\n\n**Multi-entry contract** (see `docs/plan/storage-multi-entry.md`):\n`target_name` is REQUIRED — it names the storage entry the copy\njob will move blobs INTO. The admin picks it from the entries\ndeclared in `OXICLOUD_STORAGE_ENTRIES`. The trigger endpoint\nrejects the request when the name doesn't exist or equals the\ncurrently-active entry (no-op guard).",
        "required": [
          "target_name"
        ],
        "properties": {
          "concurrency": {
            "type": [
              "integer",
              "null"
            ],
            "description": "How many blobs to copy in parallel (default: 4).\n\n**Currently ignored** — the recoverable copy loop is\nsequential (one blob at a time within the batch). Kept in\nthe DTO for wire-compat with the admin UI form; will be\nhonoured once per-batch fan-out lands (dual-write /\nconcurrent-copy future slice).",
            "minimum": 0
          },
          "target_name": {
            "type": "string",
            "description": "Name of the storage entry to migrate blobs INTO. Must be\npresent in `OXICLOUD_STORAGE_ENTRIES` and must differ from\nthe currently-active entry."
          }
        }
      },
      "StatsResponse": {
        "type": "object",
        "description": "Response for dedup stats endpoint",
        "required": [
          "unique_blobs",
          "total_references",
          "bytes_saved",
          "total_logical_bytes",
          "total_physical_bytes",
          "dedup_ratio",
          "savings_percentage"
        ],
        "properties": {
          "bytes_saved": {
            "type": "integer",
            "format": "int64",
            "description": "Total bytes saved by deduplication",
            "minimum": 0
          },
          "dedup_ratio": {
            "type": "number",
            "format": "double",
            "description": "Deduplication ratio (logical / physical)"
          },
          "savings_percentage": {
            "type": "number",
            "format": "double",
            "description": "Percentage of storage saved"
          },
          "total_logical_bytes": {
            "type": "integer",
            "format": "int64",
            "description": "Total logical bytes (what users think they have)",
            "minimum": 0
          },
          "total_physical_bytes": {
            "type": "integer",
            "format": "int64",
            "description": "Total physical bytes (actual disk usage)",
            "minimum": 0
          },
          "total_references": {
            "type": "integer",
            "format": "int64",
            "description": "Total number of references (files pointing to blobs)",
            "minimum": 0
          },
          "unique_blobs": {
            "type": "integer",
            "format": "int64",
            "description": "Total number of unique blobs stored",
            "minimum": 0
          }
        }
      },
      "StorageEncryptionPairDto": {
        "type": "object",
        "description": "One `<cipher>:<key>` pair rendered for the admin UI. Never\ncarries key material — only cipher name + a truncated fingerprint\nsafe to show operators.",
        "required": [
          "cipher",
          "is_head"
        ],
        "properties": {
          "cipher": {
            "type": "string",
            "description": "`\"aes-256-gcm\"` for a real-cipher pair, `\"none\"` for a\n`none:` sentinel (writes as plaintext-v1)."
          },
          "fingerprint": {
            "type": [
              "string",
              "null"
            ],
            "description": "SSH-style colon-hex 8-byte truncation of `sha256(key)`.\nMatches the v1 header's `<key_fp>` field and the CLI's\n`oxicloud --fingerprint <key>` output. `None` for `none:`\npairs (no key material to fingerprint)."
          },
          "is_head": {
            "type": "boolean",
            "description": "True for the LAST pair in the list — the write pair. UI\nbadges it distinctly (\"← head\" or an arrow). Exactly one\npair has `is_head = true` when the list is non-empty."
          }
        }
      },
      "StorageEntrySummaryDto": {
        "type": "object",
        "description": "Per-entry summary emitted in `StorageSettingsDto.entries`. Never\ncarries credentials — those live in env vars only. `is_active`\nmarks which entry the LIVE backend uses right now (matches\n`active_entry_name` on the parent DTO).",
        "required": [
          "name",
          "backend",
          "is_active",
          "encryption_enabled"
        ],
        "properties": {
          "backend": {
            "type": "string",
            "description": "Backend type — \"local\" / \"s3\" / \"azure\"."
          },
          "encryption_enabled": {
            "type": "boolean",
            "description": "True when the entry has a per-entry encryption key. UI shows\na lock icon. Presence-only — the key bytes never leave the\nserver."
          },
          "encryption_pairs": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/StorageEncryptionPairDto"
            },
            "description": "Ordered pair-list summary — one entry per configured pair in\n`OXICLOUD_STORAGE_<NAME>_ENCRYPTION_KEY`, oldest first, head\nlast. Empty vec means the entry has no `_ENCRYPTION_KEY`\ndeclared at all (pure plaintext-v1 writes today, no crypto).\n\nFrontend renders this on the entry card so operators can:\n  - See which pairs are configured + their SSH-style\n    fingerprints without inspecting `.env`.\n  - Cross-reference the head pair against the `head_key_fp`\n    from the last `backend_rotate` completion — if they\n    match AND `failed = 0`, every on-disk blob is under the\n    head, and non-head pairs are safe to remove."
          },
          "is_active": {
            "type": "boolean",
            "description": "True for exactly one entry (the entry the LIVE backend is on).\nFrontend uses this to badge the active row and to exclude it\nfrom the migration-target dropdown."
          },
          "location_hint": {
            "type": [
              "string",
              "null"
            ],
            "description": "Human-readable physical location hint, if the backend surfaces\none (`root_dir` for Local, `bucket` for S3, `container` for\nAzure). Cosmetic — helps the admin distinguish two Local\nentries pointing at different disks."
          },
          "name": {
            "type": "string"
          }
        }
      },
      "StorageSettingsDto": {
        "type": "object",
        "description": "Current storage settings returned to admin UI.\n\nPost-multi-entry (`docs/plan/storage-multi-entry.md`) this exposes:\n- the entries declared in `.env` (safe: `location_hint` shows\n  provider+bucket, credential-related fields never appear),\n- the name of the active entry (backend selection is admin-observable),\n- the read-only flag (drives the UI banner during migration),\n- the currently-live backend type + dedup stats (informational).\n\nThe pre-multi-entry `s3_*` / `backend` / `env_overrides` fields\nused to also appear here — they duplicated `entries[]` and leaked\nstale legacy admin_settings rows, so slice-6 dropped them. Consumers\nwanting per-provider details read them off `entries[i].backend` and\n`entries[i].location_hint` instead.",
        "required": [
          "current_backend",
          "total_blobs",
          "total_bytes_stored",
          "dedup_ratio",
          "entries",
          "active_entry_name",
          "migration_readonly"
        ],
        "properties": {
          "active_entry_name": {
            "type": "string",
            "description": "Name of the entry the LIVE backend is currently bound to.\nPopulated as the boot-selected name (per\n`CoreServices.active_backend_name`). Empty string for the\nzero-entries legacy path (`\"legacy\"` sentinel)."
          },
          "current_backend": {
            "type": "string",
            "description": "Backend type currently in use (`\"local\"` / `\"s3\"` / `\"azure\"`) —\nwhat the LIVE `blob_backend` is bound to, from\n`blob_handler.backend().backend_type()`. Redundant with\n`entries[i where is_active].backend` in multi-entry mode; kept\nbecause pre-boot / mid-migration inspection may still find it\nuseful."
          },
          "dedup_ratio": {
            "type": "number",
            "format": "double"
          },
          "entries": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/StorageEntrySummaryDto"
            },
            "description": "All named storage entries declared in env. Empty when running\nin legacy single-backend mode (`OXICLOUD_STORAGE_ENTRIES`\nunset AND no legacy synthesis happened). Order matches\n`_ENTRIES`."
          },
          "migration_readonly": {
            "type": "boolean",
            "description": "Global read-only flag — when true, all write-adjacent\nAuthZ checks refuse. Set by the migration handler at run\nstart; cleared by the boot-clear rule after operator\nrestart. Frontend renders a banner on the storage tab when\ntrue."
          },
          "total_blobs": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "total_bytes_stored": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          }
        }
      },
      "SubjectDto": {
        "type": "object",
        "required": [
          "type",
          "id"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "type": {
            "$ref": "#/components/schemas/SubjectTypeDto"
          }
        }
      },
      "SubjectInputDto": {
        "oneOf": [
          {
            "type": "object",
            "required": [
              "id",
              "type"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "type": {
                "type": "string",
                "enum": [
                  "user"
                ]
              }
            }
          },
          {
            "type": "object",
            "required": [
              "id",
              "type"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "type": {
                "type": "string",
                "enum": [
                  "group"
                ]
              }
            }
          },
          {
            "type": "object",
            "required": [
              "id",
              "type"
            ],
            "properties": {
              "id": {
                "type": "string",
                "format": "uuid"
              },
              "type": {
                "type": "string",
                "enum": [
                  "token"
                ]
              }
            }
          },
          {
            "type": "object",
            "description": "Invite-by-email. Lazily provisions an external user with the\nnormalised address as both username and email when no match\nexists; otherwise reuses the existing user. Triggers a magic-link\ninvitation email when the resolved user has no other login\ncredential.",
            "required": [
              "email",
              "type"
            ],
            "properties": {
              "email": {
                "type": "string"
              },
              "type": {
                "type": "string",
                "enum": [
                  "email"
                ]
              }
            }
          }
        ],
        "description": "Subject shape accepted by `POST /api/grants`. Internally-tagged enum\nso the existing `{type:\"user\", id:\"...\"}` payload keeps working\nalongside the new `{type:\"email\", email:\"...\"}` variant that feeds\nthe invite-by-email flow. The response-side [`SubjectDto`] stays\nunchanged — externals resolve to `Subject::User(uuid)` with\n`is_external = TRUE` on the user row, never a distinct subject type."
      },
      "SubjectTypeDto": {
        "type": "string",
        "enum": [
          "user",
          "group",
          "token"
        ]
      },
      "SystemStatus": {
        "type": "object",
        "description": "System initialisation state, returned by `GET /api/auth/status`.",
        "required": [
          "initialized",
          "admin_count",
          "registration_allowed"
        ],
        "properties": {
          "admin_count": {
            "type": "integer",
            "format": "int64",
            "description": "Number of admin users in the system."
          },
          "initialized": {
            "type": "boolean",
            "description": "Whether the system has been set up with an admin."
          },
          "registration_allowed": {
            "type": "boolean",
            "description": "Whether self-registration is allowed."
          }
        }
      },
      "ThumbnailCacheInfoDto": {
        "type": "object",
        "description": "Thumbnail-moka occupancy snapshot. Separate struct from the\nfile-content moka one because the granularity differs: each\nthumbnail entry is one encoded WebP/AVIF payload keyed by\n`(file_id, size)`, not an assembled file.",
        "required": [
          "size_bytes",
          "max_bytes",
          "thumbnails"
        ],
        "properties": {
          "max_bytes": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "size_bytes": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "thumbnails": {
            "type": "integer",
            "format": "int64",
            "description": "Number of cached thumbnail payloads. Each entry = one\n`(file_id, size, format)` tuple of encoded bytes.",
            "minimum": 0
          }
        }
      },
      "TransferOwnershipDto": {
        "type": "object",
        "description": "Request body for transferring server ownership.\n\nSeparate from [`UpdateUserRoleDto`] on purpose: ownership is not an\nassignable role. The generic role endpoint refuses `\"owner\"`, because\nconferring it there would create a second owner rather than move the\none that exists.",
        "required": [
          "new_owner_id"
        ],
        "properties": {
          "new_owner_id": {
            "type": "string",
            "description": "The user who becomes the new owner. The caller — who must be the\ncurrent owner — is demoted to admin in the same transaction."
          }
        }
      },
      "TranslationErrorDto": {
        "type": "object",
        "description": "DTO for translation error",
        "required": [
          "key",
          "locale",
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "The error message"
          },
          "key": {
            "type": "string",
            "description": "The translation key that was not found"
          },
          "locale": {
            "type": "string",
            "description": "The locale code used for translation"
          }
        }
      },
      "TranslationRequestDto": {
        "type": "object",
        "description": "DTO for translation request",
        "required": [
          "key"
        ],
        "properties": {
          "key": {
            "type": "string",
            "description": "The translation key"
          },
          "locale": {
            "type": [
              "string",
              "null"
            ],
            "description": "The locale code (optional, defaults to \"en\")"
          }
        }
      },
      "TranslationResponseDto": {
        "type": "object",
        "description": "DTO for translation response",
        "required": [
          "key",
          "locale",
          "text"
        ],
        "properties": {
          "key": {
            "type": "string",
            "description": "The translation key"
          },
          "locale": {
            "type": "string",
            "description": "The locale code used for translation"
          },
          "text": {
            "type": "string",
            "description": "The translated text"
          }
        }
      },
      "TrashResourceItemDto": {
        "type": "object",
        "description": "One item in a `GET /api/trash/resources` page.\n\n`deletion_date` is the real timestamp at which the item will be permanently\ndeleted (= `trashed_at + retention_days`). The client derives \"days until\ndeletion\" itself from this + the current clock — the wire format does not\nduplicate that derivation. `resource.path` carries the original location\n(soft-delete preserves the row's `path` column).",
        "required": [
          "resource_type",
          "trashed_at",
          "deletion_date",
          "drive_id",
          "resource"
        ],
        "properties": {
          "deletion_date": {
            "type": "string",
            "format": "date-time",
            "description": "When the item will be permanently deleted by the retention sweeper."
          },
          "drive_id": {
            "type": "string",
            "format": "uuid",
            "description": "The drive the trashed item belongs to. Enables client-side\ngroup-by-drive in the `/trash` UI (D2b spec — see\n`project_trash_groupbys_d2b` memory). The drive's display name\nresolves through the `/api/drives` listing the client already\nholds in `drives.svelte` — no extra round-trip needed."
          },
          "resource": {
            "$ref": "#/components/schemas/ResourceContentDto",
            "description": "Full resource details — shape determined by `resource_type`."
          },
          "resource_type": {
            "$ref": "#/components/schemas/ResourceTypeDto"
          },
          "trashed_at": {
            "type": "string",
            "format": "date-time",
            "description": "When the user moved the item to trash."
          }
        }
      },
      "TrashedItemDto": {
        "type": "object",
        "description": "DTO representing an item in the trash",
        "required": [
          "id",
          "original_id",
          "item_type",
          "name",
          "original_path",
          "trashed_at",
          "days_until_deletion",
          "category",
          "icon_class",
          "icon_special_class"
        ],
        "properties": {
          "category": {
            "type": "string",
            "description": "Human-readable category (e.g., \"Image\", \"Folder\", \"Document\")"
          },
          "days_until_deletion": {
            "type": "integer",
            "format": "int64"
          },
          "icon_class": {
            "type": "string",
            "description": "FontAwesome icon class for the file type"
          },
          "icon_special_class": {
            "type": "string",
            "description": "Special CSS class for icon styling (e.g., \"image-icon\", \"pdf-icon\")"
          },
          "id": {
            "type": "string"
          },
          "item_type": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "original_id": {
            "type": "string"
          },
          "original_path": {
            "type": "string"
          },
          "trashed_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "UnreadCountDto": {
        "type": "object",
        "required": [
          "unread_count"
        ],
        "properties": {
          "unread_count": {
            "type": "integer",
            "format": "int64"
          }
        }
      },
      "UpdateAddressBookRequest": {
        "type": "object",
        "description": "Request body for updating an address book.",
        "properties": {
          "color": {
            "type": [
              "string",
              "null"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "is_public": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "name": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "UpdateContactRequest": {
        "type": "object",
        "description": "Request body for updating a contact (all fields optional).",
        "properties": {
          "address": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "$ref": "#/components/schemas/AddressDto"
            }
          },
          "anniversary": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "birthday": {
            "type": [
              "string",
              "null"
            ],
            "format": "date"
          },
          "email": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "$ref": "#/components/schemas/EmailDto"
            }
          },
          "first_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "full_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "last_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "nickname": {
            "type": [
              "string",
              "null"
            ]
          },
          "notes": {
            "type": [
              "string",
              "null"
            ]
          },
          "organization": {
            "type": [
              "string",
              "null"
            ]
          },
          "phone": {
            "type": [
              "array",
              "null"
            ],
            "items": {
              "$ref": "#/components/schemas/PhoneDto"
            }
          },
          "photo_url": {
            "type": [
              "string",
              "null"
            ]
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "UpdateDriveMemberDto": {
        "type": "object",
        "description": "Body for `PATCH /api/drives/{id}/members/{kind}/{sid}`.",
        "required": [
          "role"
        ],
        "properties": {
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Optional. Pass `null` (or omit) to clear an existing expiry."
          },
          "role": {
            "$ref": "#/components/schemas/RoleDto"
          }
        }
      },
      "UpdateDrivePoliciesDto": {
        "type": "object",
        "description": "Body for `PATCH /api/drives/{id}/policies` (D5).\n\nPartial merge: any field left out of the JSON keeps its current\nJSONB value (the repo uses `policies || $partial`). Each field\ndefaults to `false` in `DrivePolicies`, but the merge is keyed on\npresence — so omitting a field means \"leave it alone\", not \"set\nit to false\". Clients flip a single key at a time without\nround-tripping the whole bag.",
        "properties": {
          "forbid_cross_drive_move": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "forbid_external_sharing": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "forbid_owner_role_change": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "forbid_public_links": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "forbid_sharing": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "include_in_music_index": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "include_in_photo_index": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "max_public_link_days": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32",
            "description": "Day cap on public links. Double `Option` so three states are\ndistinguishable on the wire, which a single `Option` collapses:\n\n  * absent     → `None`       → leave whatever this drive has\n  * `null`     → `Some(None)` → REMOVE the override, inherit again\n  * a number   → `Some(Some)` → set the cap here\n\nWithout the middle case there would be no way to stop overriding a\nknob once you started — the drive would be pinned to its current\nvalue forever, silently ignoring every future default change.\n\n`deserialize_with` is REQUIRED, not decoration. Plain\n`#[serde(default)] Option<Option<u32>>` collapses the first two\nstates: serde maps a JSON `null` onto the OUTER `Option`, so both\nabsent and null arrive as `None` and the clear silently becomes a\nno-op. Forcing the field through a deserializer that wraps whatever\nit receives in `Some` is what keeps `null` distinguishable — the\ndeserializer only runs when the key is present.",
            "minimum": 0
          },
          "read_only": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "require_public_link_password": {
            "type": [
              "boolean",
              "null"
            ]
          }
        }
      },
      "UpdateDriveQuotaDto": {
        "type": "object",
        "description": "Body for `PATCH /api/drives/{id}/quota` (D4).\n\n`quota_bytes = null` (or ≤ 0) means unlimited — matches the DB\nconvention where NULL on the row is treated as \"no cap\" by\n`storage_usage_service::check_drive_quota`. The service\nnormalises 0/negative to None before writing.",
        "properties": {
          "quota_bytes": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "description": "New quota in bytes. `null` (or omitted) or ≤ 0 → unlimited.\nA value below the drive's current `used_bytes` is accepted\nintentionally (soft-quota semantic — new writes gated,\nexisting content untouched; owners recover by deleting\nuntil the drive comes back under the cap)."
          }
        }
      },
      "UpdateGroupRequest": {
        "type": "object",
        "properties": {
          "description": {
            "type": [
              "string",
              "null"
            ],
            "description": "Reserved for future use; v1 only persists the name in `rename`."
          },
          "name": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "UpdatePlaylistDto": {
        "type": "object",
        "properties": {
          "cover_file_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "is_public": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "name": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "UpdateProfileDto": {
        "type": "object",
        "description": "Partial-update body for `PATCH /api/auth/me/profile` (PR 24).\n\nEach field is **optional**:\n- **absent** → no change to that field.\n- **present** → set / claim.\n\n**Username is claim-once, immutable.** This endpoint accepts\n`username` only when the caller currently has none — passing it\nwhen one is already claimed is rejected with `409 UsernameImmutable`.\nThe immutability avoids the NextCloud / DAV client breakage that\nwould otherwise come from renaming (paths under\n`/remote.php/dav/files/{user}/…` and the `verify_url_user` check\nboth bake the username in as a stable identifier). If a user really\ntypoed their handle and needs to fix it, an admin override is the\nescape hatch.\n\n**Given / family name** are freely settable. Any non-empty value\nreplaces the current one. Clearing back to `None` is out of scope\nfor v1.\n\n**OIDC-linked users are rejected wholesale with 403** — their\nprofile fields are managed at the IdP. The IdP is the source of\ntruth; mirroring writes here would just create a divergence.",
        "properties": {
          "family_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "New last/family name. Same semantics as `given_name`."
          },
          "given_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "New first/given name. Any non-empty value sets/replaces the\ncurrent value. Absent → no change."
          },
          "notify_on_share": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "Whether to receive an email when someone shares a resource with\nthe user. Absent → no change (existing setting preserved). Pass\n`true` to opt in, `false` to opt out. Honored only on the\nplain-notification path; magic-link first-invitations to externals\nalways send."
          },
          "preferred_locale": {
            "type": [
              "string",
              "null"
            ],
            "description": "New preferred locale (BCP-47 shape, e.g. `\"fr\"`, `\"zh-TW\"`).\nMust resolve against the server's `LocaleRegistry` — unknown\ncodes are rejected with 400. Pass an empty string to clear the\npreference back to the server default (the application layer\nnormalises `\"\"` → `None`)."
          },
          "ui_preferences": {
            "description": "Partial patch into the opaque UI preferences bag. **Must be a\nJSON object.** Applied via a SHALLOW merge on the server:\nkeys present here overwrite existing top-level keys; keys not\npresent survive. A key value of `null` REMOVES that key from\nthe bag (implemented via `jsonb_strip_nulls` after the merge).\n\nExample: current bag `{\"a\":1,\"b\":2}`, patch `{\"b\":3,\"c\":4}`\n→ merged `{\"a\":1,\"b\":3,\"c\":4}`. Patch `{\"a\":null}` → `{\"b\":2}`.\n\nAbsent → no change to the bag. This is a UI-only surface;\nserver never inspects the keys."
          },
          "username": {
            "type": [
              "string",
              "null"
            ],
            "description": "Handle to claim (2-64 chars, `[A-Za-z0-9._-]+`, no `@`).\nAccepted only when the caller currently has no username. Once\nclaimed the handle is permanent for the lifetime of the\naccount; subsequent attempts to set or change it via this\nendpoint are rejected with 409. Admin override (via the\nadmin-create-user / admin-update-user surface, future PR) is\nthe escape hatch for genuine typos."
          }
        }
      },
      "UpdateRoleDto": {
        "type": "object",
        "description": "`PUT /api/grants/role` — reconcile a subject's role on a resource.",
        "required": [
          "subject",
          "resource",
          "role"
        ],
        "properties": {
          "expires_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "Optional expiry applied to every grant written or updated by this call."
          },
          "resource": {
            "$ref": "#/components/schemas/ResourceDto"
          },
          "role": {
            "$ref": "#/components/schemas/RoleDto"
          },
          "subject": {
            "$ref": "#/components/schemas/SubjectDto"
          }
        }
      },
      "UpdateShareDto": {
        "type": "object",
        "properties": {
          "expires_at": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int64",
            "minimum": 0
          },
          "password": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "UpdateUserActiveDto": {
        "type": "object",
        "description": "Request body for updating a user's active status",
        "required": [
          "active"
        ],
        "properties": {
          "active": {
            "type": "boolean"
          }
        }
      },
      "UpdateUserQuotaDto": {
        "type": "object",
        "description": "Request body for updating a user's storage quota",
        "required": [
          "quota_bytes"
        ],
        "properties": {
          "quota_bytes": {
            "type": "integer",
            "format": "int64",
            "description": "Quota in bytes. Use 0 for unlimited."
          }
        }
      },
      "UpdateUserRoleDto": {
        "type": "object",
        "description": "Request body for updating a user's role",
        "required": [
          "role"
        ],
        "properties": {
          "role": {
            "type": "string"
          }
        }
      },
      "UpgradeToInternalDto": {
        "type": "object",
        "description": "Body for `POST /api/auth/upgrade-to-internal`. Converts an\nauthenticated external user into an internal user with their own\npersonal drive.\n\n`password` is optional — semantics decided per deployment:\n  * If `magic_link` is in `OXICLOUD_AUTH_METHODS` (and OIDC isn't\n    enabled) → password can be omitted; user remains magic-link-only\n    for login after upgrade.\n  * Otherwise → password is required; refusal returns 400\n    `error_type = \"PasswordRequired\"`. Without it the upgraded user\n    would have no login path.",
        "properties": {
          "password": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "UploadStatusResponseDto": {
        "type": "object",
        "description": "Response for querying upload session status.",
        "required": [
          "upload_id",
          "filename",
          "total_size",
          "bytes_received",
          "progress",
          "total_chunks",
          "completed_chunks",
          "pending_chunks",
          "is_complete"
        ],
        "properties": {
          "bytes_received": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "completed_chunks": {
            "type": "integer",
            "minimum": 0
          },
          "filename": {
            "type": "string"
          },
          "is_complete": {
            "type": "boolean"
          },
          "pending_chunks": {
            "type": "array",
            "items": {
              "type": "integer",
              "minimum": 0
            }
          },
          "progress": {
            "type": "number",
            "format": "double"
          },
          "total_chunks": {
            "type": "integer",
            "minimum": 0
          },
          "total_size": {
            "type": "integer",
            "format": "int64",
            "minimum": 0
          },
          "upload_id": {
            "type": "string"
          }
        }
      },
      "VerifyPasswordRequest": {
        "type": "object",
        "required": [
          "password"
        ],
        "properties": {
          "password": {
            "type": "string"
          }
        }
      },
      "WeakerDriveDto": {
        "type": "object",
        "description": "One drive that would remain laxer than the candidate default.",
        "required": [
          "id",
          "kind",
          "knobs"
        ],
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "kind": {
            "type": "string",
            "description": "Which kind's default it is being judged against — the live report\nlists both kinds together, and \"Personal\" is every personal drive's\nname, so the row would otherwise be ambiguous."
          },
          "knobs": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "The knobs on which it is laxer — the same per-knob verdict the scan\nemits one finding per, so the two lists read identically."
          },
          "name": {
            "type": [
              "string",
              "null"
            ]
          }
        }
      },
      "WebhookInfoDto": {
        "type": "object",
        "description": "Read-only webhook info, shown beside SMTP on the admin Notifications\npage. Like SMTP, configured only through environment variables.",
        "required": [
          "enabled",
          "format",
          "host",
          "target"
        ],
        "properties": {
          "enabled": {
            "type": "boolean",
            "description": "Whether `OXICLOUD_WEBHOOK_URL` is set and the sink was built."
          },
          "format": {
            "type": "string",
            "description": "`generic`, `slack`, `discord`, `teams`, `telegram` or `ntfy`."
          },
          "host": {
            "type": "string",
            "description": "Scheme and host of the endpoint — **never the full URL**.\n\nA Telegram endpoint embeds the bot token in its path\n(`/bot<TOKEN>/sendMessage`), and a Slack or Discord webhook URL is\nitself the credential: echoing either into an API response would\nput a secret in a browser's network log and anywhere that response\ngets pasted. The host is enough to confirm \"it points where I\nthink\"."
          },
          "target": {
            "type": "string",
            "description": "Recipient for the formats that carry one — a Telegram chat id, an\nntfy topic. Empty when unset or not applicable. Not a credential,\nand an operator checking their configuration needs to see it."
          }
        }
      },
      "WebhookTestResultDto": {
        "type": "object",
        "description": "Outcome of `POST /api/admin/webhook/test`.\n\n`code` + `message` mirror `SmtpTestResultDto` so the admin panel can\nrender both transports' diagnostics the same way.",
        "required": [
          "success",
          "sink"
        ],
        "properties": {
          "code": {
            "type": [
              "integer",
              "null"
            ],
            "format": "int32",
            "description": "HTTP status the receiver returned. Absent when nothing answered —\nDNS failure, refused connection, timeout — which is itself the\ndiagnosis.",
            "minimum": 0
          },
          "message": {
            "type": [
              "string",
              "null"
            ],
            "description": "The receiver's own response body, truncated — or the transport\nerror when there was no response. Usually the useful part: a bare\n403 does not distinguish a revoked token from a disabled channel,\nand the body says which."
          },
          "sink": {
            "type": "string",
            "description": "Transport name, so a future second sink is distinguishable."
          },
          "success": {
            "type": "boolean"
          }
        }
      }
    },
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer"
      }
    }
  },
  "tags": [
    {
      "name": "auth",
      "description": "Authentication and session management endpoints"
    },
    {
      "name": "files",
      "description": "File management endpoints"
    },
    {
      "name": "folders",
      "description": "Folder management endpoints"
    },
    {
      "name": "trash",
      "description": "Trash / recycle bin endpoints"
    },
    {
      "name": "search",
      "description": "Search endpoints"
    },
    {
      "name": "shares",
      "description": "Shared links endpoints"
    },
    {
      "name": "favorites",
      "description": "Favorites management endpoints"
    },
    {
      "name": "recent",
      "description": "Recent items endpoints"
    },
    {
      "name": "photos",
      "description": "Photos timeline endpoints"
    },
    {
      "name": "i18n",
      "description": "Internationalisation endpoints"
    },
    {
      "name": "uploads",
      "description": "Chunked / resumable upload endpoints"
    },
    {
      "name": "dedup",
      "description": "Content deduplication endpoints"
    },
    {
      "name": "batch",
      "description": "Batch operation endpoints"
    },
    {
      "name": "playlists",
      "description": "Music playlist endpoints"
    },
    {
      "name": "contacts",
      "description": "Address books, contacts, and groups endpoints"
    },
    {
      "name": "admin",
      "description": "Admin management endpoints"
    },
    {
      "name": "grants",
      "description": "ReBAC grant management endpoints"
    },
    {
      "name": "groups",
      "description": "ReBAC subject-group management endpoints (named, nestable, root-owned)"
    }
  ]
}