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
This commit is contained in:
CaliBrain
2026-08-20 14:42:45 -04:00
committed by GitHub
parent eafb965662
commit 646b531669
3 changed files with 65 additions and 7 deletions
+2 -2
View File
@@ -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_
+10 -5
View File
@@ -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(
+53
View File
@@ -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}"