From 646b5316697defeef1eb392f706c49e0df687725 Mon Sep 17 00:00:00 2001 From: CaliBrain Date: Thu, 20 Aug 2026 14:42:45 -0400 Subject: [PATCH] fix(hardcover): accept the short hc_pat_ keys Hardcover issues now (#1241) Hardcover replaced its ~500 char JWTs with short opaque personal access tokens ("hc_pat_..."), and the connection test rejected anything under 100 chars before a request ever left Shelfmark, so every newly created key failed with "API key seems too short". The length floor now applies only to keys without the hc_pat_ prefix; a prefixed key goes straight to Hardcover, which is the authority on whether it is valid. Also strip a pasted "bearer " prefix regardless of casing -- Hardcover's docs tell users to paste the token into an "authorization" header, so the prefix rides along on the copy, and the old case-sensitive removeprefix() sent it through as part of the token. The API key field now names the expected shape. Note that Hardcover's PAT path currently answers every hc_pat_ token with a 500, a fabricated one included, while non-PAT tokens still get a clean 401. So a new key cannot connect yet regardless of this change -- that failure is server-side and not something this code can reach. Refs #1240 --- docs/environment-variables.md | 4 +- shelfmark/metadata_providers/hardcover.py | 15 ++++--- tests/metadata/test_hardcover_api_key.py | 53 +++++++++++++++++++++++ 3 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 tests/metadata/test_hardcover_api_key.py diff --git a/docs/environment-variables.md b/docs/environment-variables.md index db5923a4..92fa6dd8 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -1977,7 +1977,7 @@ Move deletes the job from your usenet client after import; Copy keeps it in the | Variable | Description | Type | Default | |----------|-------------|------|---------| | `HARDCOVER_ENABLED` | Enable Hardcover as a metadata provider for book searches | boolean | `false` | -| `HARDCOVER_API_KEY` | Get your API key from hardcover.app/account/api | string (secret) | _none_ | +| `HARDCOVER_API_KEY` | Get your API key from hardcover.app/account/api (starts with hc_pat_) | string (secret) | _none_ | | `HARDCOVER_DEFAULT_SORT` | Default sort order for Hardcover search results. | string (choice) | `relevance` | | `HARDCOVER_EXCLUDE_COMPILATIONS` | Filter out compilations, anthologies, and omnibus editions from search results | boolean | `false` | | `HARDCOVER_EXCLUDE_UNRELEASED` | Filter out books with a release year in the future | boolean | `false` | @@ -1999,7 +1999,7 @@ Enable Hardcover as a metadata provider for book searches **API Key** -Get your API key from hardcover.app/account/api +Get your API key from hardcover.app/account/api (starts with hc_pat_) - **Type:** string (secret) - **Default:** _none_ diff --git a/shelfmark/metadata_providers/hardcover.py b/shelfmark/metadata_providers/hardcover.py index 71404ed6..28df49a2 100644 --- a/shelfmark/metadata_providers/hardcover.py +++ b/shelfmark/metadata_providers/hardcover.py @@ -48,6 +48,10 @@ HARDCOVER_PAGE_SIZE = 25 # Hardcover API returns max 25 results per page HARDCOVER_MIN_AUTHOR_PARTS = 2 HARDCOVER_MIN_TYPEAHEAD_QUERY_LENGTH = 2 HARDCOVER_MAX_SERIES_OPTIONS = 7 +# Hardcover hands out short opaque tokens now ("hc_pat_...") instead of the ~500 char +# JWTs it used to, so the length floor only applies to keys without that prefix. +HARDCOVER_API_KEY_PREFIX = "hc_pat_" +HARDCOVER_BEARER_PREFIX_PATTERN = re.compile(r"^bearer\s+", re.IGNORECASE) HARDCOVER_API_KEY_MIN_LENGTH = 100 HARDCOVER_LIST_URL_PATTERN = re.compile( r"^/(?:@([\w.-]+)/)?lists?/([\w-]+)/?$", @@ -670,7 +674,7 @@ def _normalize_series_position(value: Any) -> float | None: def _normalize_hardcover_api_key(value: object) -> str: """Normalize Hardcover API keys, stripping copied auth-header prefixes.""" normalized_value = normalize_optional_text(value) or "" - return normalized_value.removeprefix("Bearer ").strip() + return HARDCOVER_BEARER_PREFIX_PATTERN.sub("", normalized_value.strip()).strip() def _normalize_search_text(value: str) -> str: @@ -3019,12 +3023,13 @@ def _test_hardcover_connection(current_values: dict[str, Any] | None = None) -> _save_connected_user(None, None) return {"success": False, "message": "API key is required"} - if key_len < HARDCOVER_API_KEY_MIN_LENGTH: + is_prefixed_key = api_key.startswith(HARDCOVER_API_KEY_PREFIX) + if not is_prefixed_key and key_len < HARDCOVER_API_KEY_MIN_LENGTH: return { "success": False, "message": ( - f"API key seems too short ({key_len} chars). " - f"Expected {HARDCOVER_API_KEY_MIN_LENGTH}+ chars." + f"API key seems too short ({key_len} chars). Expected a key starting " + f"with {HARDCOVER_API_KEY_PREFIX} or {HARDCOVER_API_KEY_MIN_LENGTH}+ chars." ), } @@ -3131,7 +3136,7 @@ def hardcover_settings() -> list[SettingsField]: PasswordField( key="HARDCOVER_API_KEY", label="API Key", - description="Get your API key from hardcover.app/account/api", + description="Get your API key from hardcover.app/account/api (starts with hc_pat_)", required=True, ), ActionButton( diff --git a/tests/metadata/test_hardcover_api_key.py b/tests/metadata/test_hardcover_api_key.py new file mode 100644 index 00000000..7f3e1315 --- /dev/null +++ b/tests/metadata/test_hardcover_api_key.py @@ -0,0 +1,53 @@ +import pytest + +from shelfmark.metadata_providers import hardcover +from shelfmark.metadata_providers.hardcover import ( + HardcoverProvider, + _test_hardcover_connection, +) + +# Hardcover replaced its ~500 char JWTs with short opaque tokens. +PAT = "hc_pat_" + "a" * 32 + + +@pytest.fixture(autouse=True) +def _no_config_writes(monkeypatch): + """Keep the connection test from touching the on-disk provider config.""" + monkeypatch.setattr(hardcover, "_save_connected_user", lambda user_id, username: None) + + +class TestHardcoverApiKey: + def test_personal_access_token_is_accepted(self, monkeypatch): + monkeypatch.setattr( + HardcoverProvider, + "_execute_query", + lambda self, query, variables: {"me": [{"id": 1, "username": "alex"}]}, + ) + + result = _test_hardcover_connection({"HARDCOVER_API_KEY": PAT}) + + assert result == {"success": True, "message": "Connected as: alex"} + + def test_short_key_without_the_prefix_is_rejected(self): + result = _test_hardcover_connection({"HARDCOVER_API_KEY": "eyJhbGciOiJIUzI1NiJ9.short"}) + + assert result["success"] is False + assert "too short" in result["message"] + + def test_short_prefixed_key_still_reaches_the_api(self, monkeypatch): + """A key wearing the hc_pat_ prefix is Hardcover's to accept or reject.""" + monkeypatch.setattr( + HardcoverProvider, + "_execute_query", + lambda self, query, variables: None, + ) + + result = _test_hardcover_connection({"HARDCOVER_API_KEY": "hc_pat_ab"}) + + assert result == {"success": False, "message": "API request failed - check your API key"} + + @pytest.mark.parametrize("pasted", [f"Bearer {PAT}", f"bearer {PAT}", f" {PAT} "]) + def test_pasted_auth_header_noise_is_stripped(self, pasted): + provider = HardcoverProvider(api_key=pasted) + + assert provider.session.headers["Authorization"] == f"Bearer {PAT}"