diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc2ff95..c73e2a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v4 with: repository: PapyrusReader/server - ref: cb00001f8bca3d204de403941fd113d7ede234a5 + ref: 56b9d14e84fbc8805bca0998bcde825aa74e7e17 path: .server - uses: astral-sh/setup-uv@v6 with: diff --git a/_static/openapi.json b/_static/openapi.json index 9f4062d..6f115a0 100644 --- a/_static/openapi.json +++ b/_static/openapi.json @@ -1,6 +1,188 @@ { "components": { "schemas": { + "AccountActionRequest": { + "properties": { + "authorization_token": { + "maxLength": 512, + "minLength": 1, + "title": "Authorization Token", + "type": "string" + } + }, + "required": [ + "authorization_token" + ], + "title": "AccountActionRequest", + "type": "object" + }, + "AccountDeletionAuthorization": { + "properties": { + "authorization_token": { + "title": "Authorization Token", + "type": "string" + }, + "deletion_due_at": { + "format": "date-time", + "title": "Deletion Due At", + "type": "string" + }, + "deletion_pending": { + "title": "Deletion Pending", + "type": "boolean" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Email" + }, + "expires_at": { + "format": "date-time", + "title": "Expires At", + "type": "string" + }, + "user_id": { + "format": "uuid", + "title": "User Id", + "type": "string" + } + }, + "required": [ + "authorization_token", + "user_id", + "email", + "deletion_pending", + "deletion_due_at", + "expires_at" + ], + "title": "AccountDeletionAuthorization", + "type": "object" + }, + "AccountDeletionStatus": { + "properties": { + "deletion_due_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Deletion Due At" + }, + "message": { + "title": "Message", + "type": "string" + } + }, + "required": [ + "deletion_due_at", + "message" + ], + "title": "AccountDeletionStatus", + "type": "object" + }, + "AccountGoogleExchangeRequest": { + "properties": { + "code": { + "maxLength": 512, + "minLength": 1, + "title": "Code", + "type": "string" + }, + "verifier": { + "maxLength": 128, + "minLength": 43, + "title": "Verifier", + "type": "string" + } + }, + "required": [ + "code", + "verifier" + ], + "title": "AccountGoogleExchangeRequest", + "type": "object" + }, + "AccountPasswordRequest": { + "properties": { + "email": { + "format": "email", + "title": "Email", + "type": "string" + }, + "expected_user_id": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expected User Id" + }, + "password": { + "maxLength": 128, + "minLength": 1, + "title": "Password", + "type": "string" + } + }, + "required": [ + "email", + "password" + ], + "title": "AccountPasswordRequest", + "type": "object" + }, + "AccountRecoveryRequest": { + "properties": { + "authorization_token": { + "maxLength": 512, + "minLength": 1, + "title": "Authorization Token", + "type": "string" + }, + "client_type": { + "default": "unknown", + "enum": [ + "mobile", + "desktop", + "web", + "unknown" + ], + "title": "Client Type", + "type": "string" + }, + "device_label": { + "anyOf": [ + { + "maxLength": 255, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Device Label" + } + }, + "required": [ + "authorization_token" + ], + "title": "AccountRecoveryRequest", + "type": "object" + }, "AcquisitionCapabilities": { "properties": { "arr_commands": { @@ -3587,21 +3769,6 @@ "title": "DataSyncSettingsResponse", "type": "object" }, - "DeleteAccountRequest": { - "description": "Account deletion request.", - "properties": { - "password": { - "description": "Current password for confirmation", - "title": "Password", - "type": "string" - } - }, - "required": [ - "password" - ], - "title": "DeleteAccountRequest", - "type": "object" - }, "EndpointKind": { "enum": [ "qbittorrent", @@ -7495,6 +7662,13 @@ ], "title": "Avatar Url" }, + "connected_providers": { + "items": { + "type": "string" + }, + "title": "Connected Providers", + "type": "array" + }, "created_at": { "format": "date-time", "title": "Created At", @@ -7679,13 +7853,13 @@ "name": "Papyrus Support", "url": "https://github.com/PapyrusReader/papyrus" }, - "description": "\nREST API for Papyrus - a cross-platform book management application.\n\n## Overview\n\nThe Papyrus Server handles metadata storage and synchronization for the Papyrus\ne-book reader application. It provides endpoints for:\n\n- **Authentication**: User registration, login, OAuth, and session management\n- **Books**: CRUD operations for book metadata and file references\n- **Organization**: Shelves, tags, and series management\n- **Annotations**: Highlights, notes, and bookmarks\n- **Progress**: Reading sessions and statistics\n- **Goals**: Reading goal tracking\n- **Sync**: Cross-device synchronization\n- **Storage**: File storage backend configuration\n- **Files**: File upload/download (when server is file backend)\n\n## Authentication\n\nMost endpoints require authentication via JWT Bearer token. Obtain tokens\nthrough the `/v1/auth/register`, `/v1/auth/login`, or the\nbrowser-based `/v1/auth/oauth/google/start` flow.\n\n```\nAuthorization: Bearer \n```\n\nAccess tokens expire after `60` minutes.\nUse the refresh token to obtain new access tokens via `/v1/auth/refresh`.\n\n## Rate Limiting\n\nRate limits are enforced per user:\n\n| Endpoint Category | Limit |\n|-------------------|-------|\n| Authentication | 5 requests/minute |\n| General API | 100 requests/minute |\n| File uploads | 10 requests/minute |\n| Batch operations | 20 requests/minute |\n", + "description": "\nREST API for Papyrus - a cross-platform book management application.\n\n## Overview\n\nThe Papyrus Server handles metadata storage and synchronization for the Papyrus\ne-book reader application. It provides endpoints for:\n\n- **Authentication**: User registration, login, OAuth, and session management\n- **Books**: CRUD operations for book metadata and file references\n- **Organization**: Shelves, tags, and series management\n- **Annotations**: Highlights, notes, and bookmarks\n- **Progress**: Reading sessions and statistics\n- **Goals**: Reading goal tracking\n- **Sync**: Cross-device synchronization\n- **Storage**: File storage backend configuration\n- **Files**: File upload/download (when server is file backend)\n\n## Authentication\n\nMost endpoints require authentication via JWT Bearer token. Obtain tokens\nthrough the `/v1/auth/register`, `/v1/auth/login`, or the\nbrowser-based `/v1/auth/oauth/google/start` flow.\n\n```\nAuthorization: Bearer \n```\n\nAccess tokens expire after `60` minutes.\nUse the refresh token to obtain new access tokens via `/v1/auth/refresh`.\n\n## Rate Limiting\n\nSync and file-upload limits are enforced per authenticated user, across sessions.\nAuthentication and OPDS relay limits are enforced per client IP. Limits are configurable:\n\n| Endpoint Category | Limit |\n|-------------------|-------|\n| Authentication | 5 requests/minute |\n| OPDS relay | 100 requests/minute |\n| File uploads | 60 requests/minute |\n| Sync transactions | 300 requests/minute |\n", "license": { "name": "AGPL-3.0", "url": "https://www.gnu.org/licenses/agpl-3.0.html" }, "title": "Papyrus Server API", - "version": "0.0.2" + "version": "0.0.4" }, "openapi": "3.1.0", "paths": { @@ -7739,6 +7913,288 @@ ] } }, + "/v1/account-deletion/cancel": { + "post": { + "operationId": "cancel_v1_account_deletion_cancel_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountRecoveryRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthTokens" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Cancel", + "tags": [ + "Account deletion" + ] + } + }, + "/v1/account-deletion/current": { + "post": { + "operationId": "schedule_current_v1_account_deletion_current_post", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountDeletionStatus" + } + } + }, + "description": "Successful Response" + } + }, + "security": [ + { + "HTTPBearer": [] + } + ], + "summary": "Schedule Current", + "tags": [ + "Account deletion" + ] + } + }, + "/v1/account-deletion/exchange": { + "post": { + "operationId": "exchange_v1_account_deletion_exchange_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountGoogleExchangeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountDeletionAuthorization" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Exchange", + "tags": [ + "Account deletion" + ] + } + }, + "/v1/account-deletion/google/start": { + "get": { + "operationId": "google_start_v1_account_deletion_google_start_get", + "parameters": [ + { + "in": "query", + "name": "redirect_uri", + "required": true, + "schema": { + "title": "Redirect Uri", + "type": "string" + } + }, + { + "in": "query", + "name": "challenge", + "required": true, + "schema": { + "pattern": "^[0-9a-f]{64}$", + "title": "Challenge", + "type": "string" + } + }, + { + "in": "query", + "name": "expected_user_id", + "required": false, + "schema": { + "anyOf": [ + { + "format": "uuid", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Expected User Id" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {} + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Google Start", + "tags": [ + "Account deletion" + ] + } + }, + "/v1/account-deletion/password": { + "post": { + "operationId": "authenticate_password_v1_account_deletion_password_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountPasswordRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountDeletionAuthorization" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Authenticate Password", + "tags": [ + "Account deletion" + ] + } + }, + "/v1/account-deletion/policy": { + "get": { + "operationId": "retention_policy_v1_account_deletion_policy_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "title": "Response Retention Policy V1 Account Deletion Policy Get", + "type": "object" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Retention Policy", + "tags": [ + "Account deletion" + ] + } + }, + "/v1/account-deletion/schedule": { + "post": { + "operationId": "schedule_v1_account_deletion_schedule_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountActionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountDeletionStatus" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Schedule", + "tags": [ + "Account deletion" + ] + } + }, "/v1/acquisition/arr/{endpoint_id}/commands": { "post": { "description": "Delegate a managed search/acquisition to Readarr or another Arr app.", @@ -14381,44 +14837,6 @@ } }, "/v1/users/me": { - "delete": { - "description": "Disable the user account and revoke active sessions.", - "operationId": "delete_current_user_v1_users_me_delete", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/DeleteAccountRequest" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Successful Response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HTTPValidationError" - } - } - }, - "description": "Validation Error" - } - }, - "security": [ - { - "HTTPBearer": [] - } - ], - "summary": "Delete current user account", - "tags": [ - "Users" - ] - }, "get": { "description": "Return the authenticated user's profile information.", "operationId": "get_current_user_v1_users_me_get", diff --git a/design/server-architecture.rst b/design/server-architecture.rst index 4fe51b9..b7c5858 100644 --- a/design/server-architecture.rst +++ b/design/server-architecture.rst @@ -56,6 +56,25 @@ switch the active library without transferring guest data. Server profiles and users also have separate database and media scopes. Becoming connected does not turn a guest library into a synchronized account library. +Account deletion and recovery +----------------------------- + +In-app deletion uses the current authenticated session. Public deletion requests +and sign-in to an account awaiting deletion use a short-lived, single-use +credential. Scheduling revokes existing sessions. Explicit recovery cancels the +request and issues fresh tokens in one transaction, without another sign-in. +Email notices are queued in the account transaction and delivered asynchronously +with retries. Notices are retained and delivered in event order for each account, +then removed after delivery, seven days, or permanent account deletion. +See :need:`FR-1_6` for the policy and :doc:`/manual/index` for the user flow. + +After the 30-day recovery window, a worker deletes account-owned records and +queues physical media cleanup in the same database transaction. Cleanup runs at +startup and every 60 seconds. Durable jobs survive restarts and retry storage +failures, with row locks coordinating multiple workers. Local device libraries +and external provider files remain outside this cleanup. Previously issued +PowerSync tokens remain usable until expiry and cannot be renewed. + Planned contracts ----------------- diff --git a/design/use-cases.rst b/design/use-cases.rst index a4c58f3..8385a99 100644 --- a/design/use-cases.rst +++ b/design/use-cases.rst @@ -331,7 +331,7 @@ Use case notation 1. User opens account settings or the account-deletion web resource 2. User selects "Delete account" 3. System explains affected data and displays the permanent-deletion deadline - 4. User confirms with a password or reauthenticates the linked Google identity + 4. User confirms using the current app session, or authenticates on the public web resource 5. System schedules deletion for 30 days after confirmation 6. System revokes active sessions and disables normal account access 7. At the deadline, system permanently deletes the account and associated server data, including managed media @@ -347,7 +347,7 @@ Use case notation - Before the 30-day deadline, user authenticates into a limited recovery flow - System shows the deadline and offers explicit cancellation - - Confirming cancellation restores the account and its data + - Confirming cancellation restores the account and its data, signs the user in, and opens their library - Authentication alone does not cancel deletion *A3: Recovery window expired* diff --git a/manual/index.rst b/manual/index.rst index cb03ce7..fdb1d38 100644 --- a/manual/index.rst +++ b/manual/index.rst @@ -54,6 +54,39 @@ Local storage is not a substitute for an independent backup. Before uninstalling the app or clearing browser storage, preserve any local-only files and data you need to retain. +Account deletion and recovery +----------------------------- + +Open **Settings > Account > Delete account**. Review the warning about the +30-day recovery period and choose **Delete account**. Your current signed-in session +authorizes the request without another password or Google sign-in. The receipt shows +the deletion date. Cancelling the dialog leaves the account unchanged. + +Confirmation disables ordinary access and revokes all Papyrus sessions. Existing +short-lived PowerSync credentials expire within their configured lifetime and +cannot be renewed. Local libraries and cached files remain on your devices. +To recover, sign in normally before the deletion date. Papyrus will offer to keep +your account. Choose **Keep account** to restore access and open your library +immediately, without signing in again. Choosing **Not now** +leaves deletion scheduled. Sign-in alone never cancels deletion automatically. +When server email delivery is enabled, your verified email address receives a +deletion notice with the deadline, or a confirmation when you keep the account. + +Deletion and recovery are also available without installing the app at the server's +``/v1/account-deletion`` URL (or its configured API prefix). After the deadline, +the server automatically deletes the account and its owned records, then removes +managed media and the account's local acquisition import directory. Files held by +external providers or download clients must be managed with those providers. +Storage failures are retried without restoring account access. + +Automated database and media backups are not currently implemented. The planned +retention limit for future backups is 30 days after permanent deletion. Future +restore tooling must reapply deletions before serving restored data. Self-hosted +operators must publish any separate backup or exceptional-retention policy using +``ACCOUNT_DELETION_RETENTION_NOTICE``. This configuration changes the notice, +not backup behavior. + + Troubleshooting --------------- diff --git a/requirements/functional.rst b/requirements/functional.rst index 02531d5..951247e 100644 --- a/requirements/functional.rst +++ b/requirements/functional.rst @@ -124,11 +124,12 @@ These are product commitments, not a list of released capabilities. See **Acceptance criteria:** - - Deletion requires recent authentication: password confirmation or reauthentication with the linked Google identity + - In-app deletion uses the current authenticated session without another sign-in + - The public deletion resource requires password or linked-Google authentication - Users can request deletion from the application and from an accessible web resource - Before confirmation, show the data affected and the exact permanent-deletion deadline - Scheduling deletion revokes active sessions and starts a 30-day recovery window - - During that window, authenticated recovery allows the user to explicitly cancel deletion + - During that window, authenticated recovery allows the user to explicitly cancel deletion and immediately resume their session - Signing in for recovery does not silently cancel deletion or restore ordinary account access - After the deadline, permanently delete the account and associated server data, including managed media - Explain any exceptional retention and backup expiry in the published retention policy @@ -139,9 +140,18 @@ These are product commitments, not a list of released capabilities. See this 30-day window. See `Google Play's account deletion requirements `_. - **Implementation:** The server currently disables the account and revokes - sessions. Scheduling, recovery, permanent data cleanup, and the external - deletion resource remain outstanding. + **Implementation:** Settings and the public server deletion resource support + deletion through the current app session or password/linked-Google verification + on the public resource, session revocation, explicit recovery with immediate sign-in, + and automatic permanent cleanup with retryable media removal. The client warns + about the 30-day recovery period before confirmation and shows the exact deletion + date after scheduling. A dated preview before confirmation remains available + on the public web resource but is not implemented in the simplified client flow. + Local device data remains. Automated backups are not implemented. + The planned future backup-retention limit is 30 days after permanent deletion, + and restore tooling must reapply deletions before serving restored data. + Self-hosted deployments must configure their actual exceptional-retention notice. + Live provider and production deployment acceptance remain outstanding. ----