From 16e9b98718bd1901eaa7849de30ee74b8a43061d Mon Sep 17 00:00:00 2001 From: Tyce Herrman Date: Wed, 14 Oct 2026 16:16:05 -0400 Subject: [PATCH 1/3] feat: add optional Hardcover book metadata provider --- README.md | 16 ++ .../obsidian/src/api/apis/HardcoverAPI.ts | 159 +++++++++++++++ packages/obsidian/src/main.ts | 2 + packages/obsidian/src/settings/Settings.ts | 19 ++ tests/hardcover-api.test.ts | 186 ++++++++++++++++++ 5 files changed, 382 insertions(+) create mode 100644 packages/obsidian/src/api/apis/HardcoverAPI.ts create mode 100644 tests/hardcover-api.test.ts diff --git a/README.md b/README.md index 0f5c6dc..22c8bd7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,22 @@ Search for movies, series, anime, manga, books, comics, games, music releases, o #### Search by ID +##### Hardcover + +Hardcover is an optional book provider, disabled by default. To use it: + +1. Create a free [Hardcover account](https://hardcover.app/) and obtain an API token from [account settings](https://hardcover.app/account/api). +2. In Media DB settings, link the token using **Hardcover API token**. The token is stored in Obsidian's SecretStorage; Media DB saves only its secret identifier. +3. Enable **HardcoverAPI** under **Book settings**. Search by title or author, or select HardcoverAPI in advanced search. + +For ID searches, enter the numeric Hardcover **book ID**, not an edition ID or ISBN. + +Imports include title, authors, original publication year, description, genres, cover, average Hardcover rating, pages, and available ISBNs. ISBNs and page count use Hardcover's default physical edition, falling back to its default ebook edition when no physical edition is set. This imports catalog metadata; it does not sync your Hardcover reading history or modify your Hardcover account. + +Search returns up to 20 results. Requests are spaced at least 1.1 seconds apart to respect the 60-per-minute limit. Invalid or expired tokens, insufficient permissions, and rate limits are reported without creating an entry. See [Hardcover's API documentation](https://docs.hardcover.app/api/getting-started/) for current token permissions and quotas. + +#### Other providers: Search by ID + Allows you to search by an ID that varies from API to API. Concrete information on this feature can be found in the description of the individual APIs. #### Templates diff --git a/packages/obsidian/src/api/apis/HardcoverAPI.ts b/packages/obsidian/src/api/apis/HardcoverAPI.ts new file mode 100644 index 0000000..538d2c9 --- /dev/null +++ b/packages/obsidian/src/api/apis/HardcoverAPI.ts @@ -0,0 +1,159 @@ +import { requestUrl } from 'obsidian'; +import { APIModel } from 'packages/obsidian/src/api/APIModel'; +import type MediaDbPlugin from 'packages/obsidian/src/main'; +import { BookModel } from 'packages/obsidian/src/models/BookModel'; +import type { MediaTypeModel } from 'packages/obsidian/src/models/MediaTypeModel'; +import type { MDBError } from 'packages/obsidian/src/utils/MDBError'; +import { MDBErrorKind } from 'packages/obsidian/src/utils/MDBError'; +import { MediaType } from 'packages/obsidian/src/utils/MediaType'; +import type { Result } from 'packages/obsidian/src/utils/result'; +import { err, ok } from 'packages/obsidian/src/utils/result'; +import { contactEmail, mediaDbVersion, pluginName } from 'packages/obsidian/src/utils/Utils'; + +interface HardcoverEdition { + isbn_10?: string | null; + isbn_13?: string | null; + pages?: number | null; +} + +interface HardcoverBook { + id: number; + title: string; + slug?: string | null; + release_year?: number | null; + release_date?: string | null; + description?: string | null; + pages?: number | null; + rating?: number | null; + image?: { url?: string | null } | null; + contributions?: { contribution?: string | null; author?: { name: string } | null }[]; + cached_tags?: { Genre?: { tag: string }[] } | null; + default_physical_edition?: HardcoverEdition | null; + default_ebook_edition?: HardcoverEdition | null; +} + +const BOOK_FIELDS = ` + id title slug release_year release_date description pages rating + image { url } + contributions { contribution author { name } } + cached_tags + default_physical_edition { isbn_10 isbn_13 pages } + default_ebook_edition { isbn_10 isbn_13 pages } +`; + +export class HardcoverAPI extends APIModel { + private nextRequestAt = 0; + + constructor(plugin: MediaDbPlugin) { + super(); + this.plugin = plugin; + this.apiName = 'HardcoverAPI'; + this.apiDescription = 'Book metadata from Hardcover (requires a free account and API token).'; + this.apiUrl = 'https://api.hardcover.app/v1/graphql'; + this.types = [MediaType.Book]; + } + + private failure(message: string, kind = MDBErrorKind.Api): Result { + return err({ kind, message: `MDB | ${message}`, userMessage: message, context: { apiName: this.apiName } }); + } + + private async query(query: string, variables: Record): Promise> { + const secret = this.plugin.app.secretStorage.getSecret(this.plugin.settings.HardcoverKeyId)?.trim(); + if (!secret) return this.failure('API token for Hardcover missing.', MDBErrorKind.Validation); + const token = secret.replace(/^Bearer\s+/i, ''); + + // Reserve a slot before waiting so concurrent imports also respect the per-minute limit. + const now = Date.now(); + const requestAt = Math.max(now, this.nextRequestAt); + this.nextRequestAt = requestAt + 1100; + if (requestAt > now) await new Promise(resolve => window.setTimeout(resolve, requestAt - now)); + + try { + const response = await requestUrl({ + url: this.apiUrl, + method: 'POST', + contentType: 'application/json', + headers: { + Authorization: `Bearer ${token}`, + 'User-Agent': `${pluginName}/${mediaDbVersion} (${contactEmail})`, + }, + body: JSON.stringify({ query, variables }), + throw: false, + }); + if (response.status === 401) return this.failure('Hardcover API token is invalid or expired.'); + if (response.status === 403) return this.failure('Hardcover API token does not have permission for this query.'); + if (response.status === 429) return this.failure('Hardcover rate limit reached. Please try again later.'); + if (response.status !== 200) return this.failure(`Received status code ${response.status} from Hardcover.`); + + const payload = response.json as { data?: T | null; errors?: unknown[] } | null; + if (!payload || payload.errors?.length || !payload.data) { + return this.failure('Hardcover could not complete the query. Check your token permissions and try again.'); + } + return ok(payload.data); + } catch { + // Do not retain transport errors: they may contain the Authorization header. + return this.failure('Network error querying Hardcover.', MDBErrorKind.Network); + } + } + + async searchByTitle(title: string): Promise> { + const search = await this.query<{ search?: { ids?: unknown } | null }>( + `query SearchBooks($query: String!) { + search(query: $query, query_type: "Book", per_page: 20, page: 1) { ids } + }`, + { query: title }, + ); + if (!search.ok) return search; + const rawIds = search.value.search?.ids; + if (!Array.isArray(rawIds)) return this.failure('Invalid search response from Hardcover.'); + const ids = [...new Set(rawIds.map(Number).filter(id => Number.isInteger(id) && id > 0 && id <= 2147483647))].slice(0, 20); + if (ids.length === 0) return ok([]); + + const books = await this.query<{ books?: HardcoverBook[] }>(`query SearchBookDetails($ids: [Int!]!) { books(where: {id: {_in: $ids}}) { ${BOOK_FIELDS} } }`, { ids }); + if (!books.ok) return books; + if (!Array.isArray(books.value.books)) return this.failure('Invalid book response from Hardcover.'); + // The books query has no search ranking; restore the search endpoint's order. + const byId = new Map(books.value.books.map(book => [book.id, book])); + return ok(ids.flatMap(id => (byId.has(id) ? [this.toBook(byId.get(id)!)] : []))); + } + + async getById(id: string): Promise> { + if (!/^\d+$/.test(id) || !Number.isInteger(Number(id)) || Number(id) <= 0 || Number(id) > 2147483647) { + return this.failure('Enter a numeric Hardcover book ID.', MDBErrorKind.Validation); + } + const response = await this.query<{ books_by_pk?: HardcoverBook | null }>(`query BookDetails($id: Int!) { books_by_pk(id: $id) { ${BOOK_FIELDS} } }`, { id: Number(id) }); + if (!response.ok) return response; + if (!response.value.books_by_pk) return this.failure(`No book found for Hardcover ID ${id}.`); + return ok(this.toBook(response.value.books_by_pk)); + } + + private toBook(book: HardcoverBook): BookModel { + const edition = book.default_physical_edition ?? book.default_ebook_edition; + const today = new Date().toISOString().slice(0, 10); + const authors = book.contributions + ?.filter(contributor => !contributor.contribution || contributor.contribution.toLowerCase() === 'author') + .map(contributor => contributor.author?.name) + .filter((name): name is string => !!name); + return new BookModel({ + title: book.title, + englishTitle: book.title, + year: book.release_year?.toString() ?? book.release_date?.slice(0, 4) ?? 'unknown', + dataSource: this.apiName, + id: book.id.toString(), + url: book.slug ? `https://hardcover.app/books/${book.slug}` : undefined, + author: [...new Set(authors)].join(', '), + plot: book.description ?? undefined, + genres: book.cached_tags?.Genre?.map(genre => genre.tag), + pages: edition?.pages ?? book.pages ?? undefined, + onlineRating: book.rating ?? undefined, + isbn: edition?.isbn_10 ?? undefined, + isbn13: edition?.isbn_13 ?? undefined, + image: book.image?.url ?? undefined, + released: book.release_date ? book.release_date <= today : book.release_year != null && book.release_year <= Number(today.slice(0, 4)), + }); + } + + getDisabledMediaTypes(): MediaType[] { + return this.plugin.settings.HardcoverAPI_disabledMediaTypes; + } +} diff --git a/packages/obsidian/src/main.ts b/packages/obsidian/src/main.ts index 9fb5483..f28ef07 100644 --- a/packages/obsidian/src/main.ts +++ b/packages/obsidian/src/main.ts @@ -3,6 +3,7 @@ import { Plugin, TFolder } from 'obsidian'; import { APIManager } from 'packages/obsidian/src/api/APIManager'; import { BoardGameGeekAPI } from 'packages/obsidian/src/api/apis/BoardGameGeekAPI'; import { ComicVineAPI } from 'packages/obsidian/src/api/apis/ComicVineAPI'; +import { HardcoverAPI } from 'packages/obsidian/src/api/apis/HardcoverAPI'; import { IGDBAPI } from 'packages/obsidian/src/api/apis/IGDBAPI'; import { MALAPI } from 'packages/obsidian/src/api/apis/MALAPI'; import { MALAPIManga } from 'packages/obsidian/src/api/apis/MALAPIManga'; @@ -77,6 +78,7 @@ export default class MediaDbPlugin extends Plugin { this.apiManager.registerAPI(new TMDBMovieAPI(this)); this.apiManager.registerAPI(new BoardGameGeekAPI(this)); this.apiManager.registerAPI(new OpenLibraryAPI(this)); + this.apiManager.registerAPI(new HardcoverAPI(this)); this.apiManager.registerAPI(new ComicVineAPI(this)); this.apiManager.registerAPI(new IGDBAPI(this)); this.apiManager.registerAPI(new RAWGAPI(this)); diff --git a/packages/obsidian/src/settings/Settings.ts b/packages/obsidian/src/settings/Settings.ts index 9f54d7c..349874c 100644 --- a/packages/obsidian/src/settings/Settings.ts +++ b/packages/obsidian/src/settings/Settings.ts @@ -51,6 +51,7 @@ function createPropertyMappingsDescription(): DocumentFragment { // MARK: Settings export interface MediaDbPluginSettings { OMDbKeyId: string; + HardcoverKeyId: string; TMDBKeyId: string; //MobyGamesKeyId: string; //GiantBombKeyId: string; @@ -80,6 +81,7 @@ export interface MediaDbPluginSettings { MusicBrainzAPI_disabledMediaTypes: MediaType[]; OMDbAPI_disabledMediaTypes: MediaType[]; OpenLibraryAPI_disabledMediaTypes: MediaType[]; + HardcoverAPI_disabledMediaTypes: MediaType[]; SteamAPI_disabledMediaTypes: MediaType[]; TMDBMovieAPI_disabledMediaTypes: MediaType[]; TMDBSeasonAPI_disabledMediaTypes: MediaType[]; @@ -310,6 +312,7 @@ class MediaTypeMappedSettings { // MARK: Defaults const DEFAULT_SETTINGS: MediaDbPluginSettings = { OMDbKeyId: '', + HardcoverKeyId: '', TMDBKeyId: '', //MobyGamesKeyId: '', //GiantBombKeyId: '', @@ -339,6 +342,7 @@ const DEFAULT_SETTINGS: MediaDbPluginSettings = { MusicBrainzAPI_disabledMediaTypes: [], OMDbAPI_disabledMediaTypes: [], OpenLibraryAPI_disabledMediaTypes: [], + HardcoverAPI_disabledMediaTypes: [MediaType.Book], SteamAPI_disabledMediaTypes: [], TMDBMovieAPI_disabledMediaTypes: [], TMDBSeasonAPI_disabledMediaTypes: [], @@ -580,6 +584,21 @@ export class MediaDbSettingTab extends PluginSettingTab { const apiKeyGroup = new SettingGroup(containerEl); apiKeyGroup.setHeading('API Keys'); + apiKeyGroup.addSetting( + setting => + void setting + .setName('Hardcover API token') + .setDesc('API token from Hardcover account settings. Enable Hardcover in Book settings below.') + .addComponent(el => { + const component = new SecretComponent(this.app, el); + component.setValue(this.plugin.settings.HardcoverKeyId).onChange(data => { + this.plugin.settings.HardcoverKeyId = data; + void this.plugin.saveSettings(); + }); + return component; + }), + ); + apiKeyGroup.addSetting( setting => void setting diff --git a/tests/hardcover-api.test.ts b/tests/hardcover-api.test.ts new file mode 100644 index 0000000..f5e812c --- /dev/null +++ b/tests/hardcover-api.test.ts @@ -0,0 +1,186 @@ +import { afterEach, expect, spyOn, test } from 'bun:test'; +import { GlobalRegistrator } from '@happy-dom/global-registrator'; +import * as obsidian from 'obsidian'; +import { HardcoverAPI } from 'packages/obsidian/src/api/apis/HardcoverAPI'; +import type MediaDbPlugin from 'packages/obsidian/src/main'; +import { BookModel } from 'packages/obsidian/src/models/BookModel'; +import { MDBErrorKind } from 'packages/obsidian/src/utils/MDBError'; +import { MediaType } from 'packages/obsidian/src/utils/MediaType'; + +GlobalRegistrator.register(); +const request = spyOn(obsidian, 'requestUrl'); +afterEach(() => request.mockReset()); + +function provider(token: string | null = 'test-token'): HardcoverAPI { + return new HardcoverAPI({ + app: { secretStorage: { getSecret: () => token } }, + settings: { HardcoverKeyId: 'hardcover', HardcoverAPI_disabledMediaTypes: [MediaType.Book] }, + } as unknown as MediaDbPlugin); +} + +function respond(json: unknown, status = 200): void { + request.mockResolvedValueOnce({ status, json } as obsidian.RequestUrlResponse); +} + +const book = { + id: 123, + title: 'The Left Hand of Darkness', + slug: 'the-left-hand-of-darkness', + release_year: 1969, + release_date: '1969-03-01', + description: 'A novel.', + pages: 300, + rating: 4.2, + image: { url: 'https://images.hardcover.app/cover.jpg' }, + contributions: [ + { contribution: null, author: { name: 'Ursula K. Le Guin' } }, + { contribution: 'Author', author: { name: 'Ursula K. Le Guin' } }, + { contribution: 'Translator', author: { name: 'A Translator' } }, + ], + cached_tags: { Genre: [{ tag: 'Science fiction' }] }, + default_physical_edition: { isbn_10: '0441478123', isbn_13: '9780441478125', pages: 304 }, + default_ebook_edition: { isbn_10: '0000000000', isbn_13: '0000000000000', pages: 320 }, +}; + +test('Hardcover requires a secret and does not send requests without it', async () => { + const api = provider(null); + const result = await api.searchByTitle('Dune'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.kind).toBe(MDBErrorKind.Validation); + expect(request).not.toHaveBeenCalled(); + expect(api.hasType(MediaType.Book)).toBe(false); +}); + +test('search uses variables, caps results and restores relevance order', async () => { + const api = provider('Bearer test-token'); + respond({ data: { search: { ids: ['456', '123', '456'] } } }); + respond({ data: { books: [book, { ...book, id: 456, title: 'First result' }] } }); + const result = await api.searchByTitle('title " with quotes'); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value.map(item => item.id)).toEqual(['456', '123']); + const options = request.mock.calls[0][0] as obsidian.RequestUrlParam; + expect(options.url).toBe('https://api.hardcover.app/v1/graphql'); + expect(options.headers?.Authorization).toBe('Bearer test-token'); + expect(options.throw).toBe(false); + const body = JSON.parse(options.body as string); + expect(body.variables).toEqual({ query: 'title " with quotes' }); + expect(body.query).toContain('per_page: 20'); + expect(body.query).not.toContain('title " with quotes'); +}); + +test('empty search does not fetch details', async () => { + respond({ data: { search: { ids: [] } } }); + const result = await provider().searchByTitle('unknown'); + expect(result).toEqual({ ok: true, value: [] }); + expect(request).toHaveBeenCalledTimes(1); +}); + +test('book lookup maps metadata and keeps both ISBNs from one default edition', async () => { + respond({ data: { books_by_pk: book } }); + const result = await provider().getById('123'); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toBeInstanceOf(BookModel); + expect(result.value).toMatchObject({ + id: '123', + dataSource: 'HardcoverAPI', + year: '1969', + author: 'Ursula K. Le Guin', + plot: 'A novel.', + genres: ['Science fiction'], + pages: 304, + onlineRating: 4.2, + isbn: '0441478123', + isbn13: '9780441478125', + released: true, + url: 'https://hardcover.app/books/the-left-hand-of-darkness', + userData: { read: false, lastRead: '', personalRating: 0 }, + }); +}); + +test('ebook edition is used only when there is no default physical edition', async () => { + respond({ data: { books_by_pk: { ...book, default_physical_edition: null } } }); + const result = await provider().getById('123'); + if (!result.ok) throw new Error('lookup failed'); + expect(result.value).toMatchObject({ isbn: '0000000000', isbn13: '0000000000000', pages: 320 }); +}); + +test('sparse metadata retains model defaults and future books are unreleased', async () => { + respond({ data: { books_by_pk: { id: 1, title: 'Future', release_date: '2999-01-01' } } }); + const result = await provider().getById('1'); + if (!result.ok) throw new Error('lookup failed'); + expect(result.value).toMatchObject({ year: '2999', author: '', genres: [], pages: 0, isbn: '', released: false }); +}); + +test('invalid book IDs are rejected before making a request', async () => { + for (const id of ['abc', '0', '-1', '1.5', '2147483648', '123) { me { id } }']) { + expect((await provider().getById(id)).ok).toBe(false); + } + expect(request).not.toHaveBeenCalled(); +}); + +test('year-only publication metadata marks an older book as released', async () => { + respond({ data: { books_by_pk: { id: 1, title: 'Old book', release_year: 1969 } } }); + const result = await provider().getById('1'); + if (!result.ok) throw new Error('lookup failed'); + expect(result.value).toMatchObject({ year: '1969', released: true }); +}); + +test('oversized search responses are capped at 20 even if the server ignores the limit', async () => { + const ids = Array.from({ length: 25 }, (_, i) => i + 1); + respond({ data: { search: { ids } } }); + respond({ data: { books: ids.map(id => ({ id, title: `Book ${id}` })) } }); + const result = await provider().searchByTitle('Book'); + if (!result.ok) throw new Error('search failed'); + expect(result.value).toHaveLength(20); + const options = request.mock.calls[1][0] as obsidian.RequestUrlParam; + expect(JSON.parse(options.body as string).variables.ids).toHaveLength(20); +}); + +test('concurrent lookups are spaced to respect the provider rate limit', async () => { + const api = provider(); + const times: number[] = []; + request.mockImplementation(() => { + times.push(Date.now()); + return Promise.resolve({ status: 200, json: { data: { books_by_pk: book } } }) as obsidian.RequestUrlResponsePromise; + }); + const results = await Promise.all([api.getById('123'), api.getById('123')]); + expect(results.every(result => result.ok)).toBe(true); + expect(times[1] - times[0]).toBeGreaterThanOrEqual(1050); +}); + +test('a missing book is reported as an error', async () => { + respond({ data: { books_by_pk: null } }); + const result = await provider().getById('123'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.userMessage).toContain('No book found'); +}); + +test('HTTP auth, permission and rate errors provide actionable messages', async () => { + for (const [status, message] of [ + [401, 'invalid or expired'], + [403, 'permission'], + [429, 'rate limit'], + [500, '500'], + ] as const) { + respond({}, status); + const result = await provider().getById('123'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.userMessage).toContain(message); + } +}); + +test('GraphQL partial errors and malformed responses are not imported', async () => { + for (const json of [null, {}, { errors: [{ message: 'failure' }], data: { books_by_pk: book } }, { data: { search: {} } }]) { + respond(json); + expect((await provider().searchByTitle('Dune')).ok).toBe(false); + } +}); + +test('network failures do not retain transport errors containing tokens', async () => { + request.mockRejectedValueOnce(new Error('Authorization: Bearer test-token')); + const result = await provider().getById('123'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.kind).toBe(MDBErrorKind.Network); + expect(JSON.stringify(result)).not.toContain('test-token'); +}); From 8046b5aa49b5f1ca2492f012480d2a498e3c4722 Mon Sep 17 00:00:00 2001 From: Tyce Herrman Date: Thu, 15 Oct 2026 17:01:48 -0400 Subject: [PATCH 2/3] feat: use PKCE OAuth for Hardcover connections --- README.md | 28 +-- packages/obsidian/src/api/HardcoverOAuth.ts | 205 ++++++++++++++++++ .../obsidian/src/api/apis/HardcoverAPI.ts | 14 +- packages/obsidian/src/main.ts | 14 +- packages/obsidian/src/settings/Settings.ts | 40 +++- tests/hardcover-api.test.ts | 8 +- tests/hardcover-oauth.test.ts | 147 +++++++++++++ 7 files changed, 418 insertions(+), 38 deletions(-) create mode 100644 packages/obsidian/src/api/HardcoverOAuth.ts create mode 100644 tests/hardcover-oauth.test.ts diff --git a/README.md b/README.md index 22c8bd7..e5f5dcb 100644 --- a/README.md +++ b/README.md @@ -14,22 +14,6 @@ Search for movies, series, anime, manga, books, comics, games, music releases, o #### Search by ID -##### Hardcover - -Hardcover is an optional book provider, disabled by default. To use it: - -1. Create a free [Hardcover account](https://hardcover.app/) and obtain an API token from [account settings](https://hardcover.app/account/api). -2. In Media DB settings, link the token using **Hardcover API token**. The token is stored in Obsidian's SecretStorage; Media DB saves only its secret identifier. -3. Enable **HardcoverAPI** under **Book settings**. Search by title or author, or select HardcoverAPI in advanced search. - -For ID searches, enter the numeric Hardcover **book ID**, not an edition ID or ISBN. - -Imports include title, authors, original publication year, description, genres, cover, average Hardcover rating, pages, and available ISBNs. ISBNs and page count use Hardcover's default physical edition, falling back to its default ebook edition when no physical edition is set. This imports catalog metadata; it does not sync your Hardcover reading history or modify your Hardcover account. - -Search returns up to 20 results. Requests are spaced at least 1.1 seconds apart to respect the 60-per-minute limit. Invalid or expired tokens, insufficient permissions, and rate limits are reported without creating an entry. See [Hardcover's API documentation](https://docs.hardcover.app/api/getting-started/) for current token permissions and quotas. - -#### Other providers: Search by ID - Allows you to search by an ID that varies from API to API. Concrete information on this feature can be found in the description of the individual APIs. #### Templates @@ -145,6 +129,18 @@ Now you select the result you want, and the plugin will cast its magic, creating | [VNDB](https://vndb.org/) | The VNDB API offers metadata for visual novels | games | No | 200 requests per 5 minutes | Yes | | [Boardgame Geek](https://boardgamegeek.com) | The Boardgame Geek API offers metadata for boardgames | boardgames | Yes, by making an account [here](https://boardgamegeek.com/join/) and then [requesting an application token](https://boardgamegeek.com/applications) | Exact usage limits are still undetermined | No | +#### Hardcover setup + +Hardcover is an optional book provider, disabled by default. It uses OAuth authorization-code flow with S256 PKCE, requesting only `read:catalog` (public catalog metadata and search). No personal access token or client secret is required. + +For development, register a **Mobile, desktop, or CLI** public application at [Hardcover Developer Apps](https://hardcover.app/account/developer-apps). Register `obsidian://media-db-hardcover` as its redirect URI, allow only `read:catalog`, and disable Device Authorization Grant. Enter its public client ID in **Hardcover OAuth client ID** in Media DB settings. A production client registration should be owned by the project maintainer; this PR does not ship a contributor's test client ID. + +Click **Connect**, authorize catalog access in your browser, and return to the vault that started the connection. Enable **HardcoverAPI** under **Book settings**, then search for books normally or select HardcoverAPI in advanced search. OAuth access and rotating refresh credentials are stored only in Obsidian SecretStorage; settings contain a secret identifier. **Disconnect** revokes the refresh credentials before clearing the local connection. If revocation fails, reconnect to the network and retry Disconnect. + +For ID searches, enter the numeric Hardcover **book ID**, not an edition ID or ISBN. Imports include title, authors, original publication year, description, genres, cover, average Hardcover rating, pages, and available ISBNs. ISBNs and page count use the default physical edition, falling back to the default ebook edition. This imports catalog metadata; it does not sync reading history or modify your Hardcover library. + +Search returns up to 20 results. Requests are spaced at least 1.1 seconds apart. Authorization failures, insufficient permissions, and rate limits are reported without creating an entry. See [Hardcover's OAuth documentation](https://docs.hardcover.app/api/oauth/) for registration and authorization details. + #### Notes - [Tenrai](https://tenrai.org/) diff --git a/packages/obsidian/src/api/HardcoverOAuth.ts b/packages/obsidian/src/api/HardcoverOAuth.ts new file mode 100644 index 0000000..d05a721 --- /dev/null +++ b/packages/obsidian/src/api/HardcoverOAuth.ts @@ -0,0 +1,205 @@ +import { requestUrl } from 'obsidian'; +import type MediaDbPlugin from 'packages/obsidian/src/main'; +import type { MDBError } from 'packages/obsidian/src/utils/MDBError'; +import { MDBErrorKind } from 'packages/obsidian/src/utils/MDBError'; +import type { Result } from 'packages/obsidian/src/utils/result'; +import { err, ok } from 'packages/obsidian/src/utils/result'; + +export const HARDCOVER_REDIRECT_URI = 'obsidian://media-db-hardcover'; +export const HARDCOVER_SCOPE = 'read:catalog'; +const ISSUER = 'https://api.hardcover.app'; + +interface Tokens { + accessToken: string; + refreshToken: string; + expiresAt: number; + clientId: string; +} + +interface PendingAuthorization { + state: string; + verifier: string; + clientId: string; + expiresAt: number; +} + +function base64url(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +function randomString(): string { + return base64url(crypto.getRandomValues(new Uint8Array(32))); +} + +export class HardcoverOAuth { + private pending?: PendingAuthorization; + private refreshing?: Promise>; + private generation = 0; + + constructor(private plugin: MediaDbPlugin) {} + + private failure(message: string): Result { + return err({ kind: MDBErrorKind.Validation, message, userMessage: message }); + } + + private readTokens(requireMatchingClient = true): Tokens | undefined { + const id = this.plugin.settings.HardcoverOAuthSecretId; + if (!id) return undefined; + try { + const tokens = JSON.parse(this.plugin.app.secretStorage.getSecret(id) ?? 'null') as Tokens | null; + return tokens && + typeof tokens.accessToken === 'string' && + typeof tokens.refreshToken === 'string' && + Number.isFinite(tokens.expiresAt) && + typeof tokens.clientId === 'string' && + (!requireMatchingClient || tokens.clientId === this.plugin.settings.HardcoverClientId.trim()) + ? tokens + : undefined; + } catch { + return undefined; + } + } + + isConnected(): boolean { + return !!this.readTokens(); + } + + async beginAuthorization(): Promise> { + const clientId = this.plugin.settings.HardcoverClientId.trim(); + if (!clientId) return this.failure('Configure the public Hardcover OAuth client ID first.'); + const generation = ++this.generation; + const verifier = randomString(); + const state = randomString(); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); + if (generation !== this.generation) return this.failure('Hardcover connection was cancelled.'); + this.pending = { state, verifier, clientId, expiresAt: Date.now() + 10 * 60 * 1000 }; + const params = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: HARDCOVER_REDIRECT_URI, + state, + code_challenge: base64url(new Uint8Array(digest)), + code_challenge_method: 'S256', + scope: HARDCOVER_SCOPE, + }); + return ok(`https://hardcover.app/oauth2/authorize?${params}`); + } + + async completeAuthorization(params: Record): Promise> { + const pending = this.pending; + if ( + !pending || + pending.expiresAt <= Date.now() || + params.state !== pending.state || + params.iss !== ISSUER || + pending.clientId !== this.plugin.settings.HardcoverClientId.trim() + ) { + return this.failure('Hardcover sign-in could not be verified. Start Connect again in the originating vault.'); + } + this.pending = undefined; + if (params.error || !params.code) return this.failure('Hardcover sign-in was cancelled or denied.'); + const generation = this.generation; + const response = await this.exchange({ + grant_type: 'authorization_code', + code: params.code, + code_verifier: pending.verifier, + redirect_uri: HARDCOVER_REDIRECT_URI, + client_id: pending.clientId, + }); + if (!response.ok) return response; + if (generation !== this.generation) return this.failure('Hardcover connection was cancelled.'); + await this.storeTokens(response.value); + return ok(undefined); + } + + private async exchange(form: Record): Promise> { + try { + const response = await requestUrl({ + url: `${ISSUER}/oauth2/token`, + method: 'POST', + contentType: 'application/x-www-form-urlencoded', + body: new URLSearchParams(form).toString(), + throw: false, + }); + if (response.status !== 200) return this.failure('Hardcover authorization expired or was rejected. Connect again.'); + const data = response.json as { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown; token_type?: unknown; scope?: unknown }; + if ( + typeof data.access_token !== 'string' || + !data.access_token || + typeof data.refresh_token !== 'string' || + !data.refresh_token || + typeof data.expires_in !== 'number' || + data.expires_in <= 0 || + !Number.isFinite(data.expires_in) || + typeof data.token_type !== 'string' || + data.token_type.toLowerCase() !== 'bearer' + ) { + return this.failure('Hardcover returned an invalid OAuth response. Connect again.'); + } + return ok({ accessToken: data.access_token, refreshToken: data.refresh_token, expiresAt: Date.now() + data.expires_in * 1000, clientId: form.client_id }); + } catch { + return this.failure('Could not contact Hardcover for authorization. Please try again.'); + } + } + + private async storeTokens(tokens: Tokens): Promise { + if (!this.plugin.settings.HardcoverOAuthSecretId) this.plugin.settings.HardcoverOAuthSecretId = `media-db-hardcover-${crypto.randomUUID()}`; + this.plugin.app.secretStorage.setSecret(this.plugin.settings.HardcoverOAuthSecretId, JSON.stringify(tokens)); + await this.plugin.saveSettings(); + } + + async getAccessToken(): Promise> { + const tokens = this.readTokens(); + if (!tokens) return this.failure('Connect to Hardcover in Media DB settings first.'); + if (tokens.expiresAt > Date.now() + 60_000) return ok(tokens.accessToken); + if (!this.refreshing) { + const generation = this.generation; + this.refreshing = this.refreshTokens(tokens, generation).finally(() => { + this.refreshing = undefined; + }); + } + return this.refreshing; + } + + private async refreshTokens(tokens: Tokens, generation: number): Promise> { + const result = await this.exchange({ grant_type: 'refresh_token', refresh_token: tokens.refreshToken, client_id: tokens.clientId }); + if (!result.ok) return result; + if (generation !== this.generation || tokens.clientId !== this.plugin.settings.HardcoverClientId.trim()) + return this.failure('Hardcover connection changed. Connect again.'); + await this.storeTokens(result.value); + return ok(result.value.accessToken); + } + + async disconnect(): Promise> { + this.pending = undefined; + this.generation++; + const tokens = this.readTokens(false); + if (tokens) { + try { + const response = await requestUrl({ + url: `${ISSUER}/oauth2/revoke`, + method: 'POST', + contentType: 'application/x-www-form-urlencoded', + body: new URLSearchParams({ token: tokens.refreshToken, token_type_hint: 'refresh_token', client_id: tokens.clientId }).toString(), + throw: false, + }); + if (response.status !== 200) return this.failure('Hardcover could not revoke the connection. Please try Disconnect again.'); + } catch { + return this.failure('Hardcover could not revoke the connection. Please try Disconnect again.'); + } + } + const id = this.plugin.settings.HardcoverOAuthSecretId; + if (id) this.plugin.app.secretStorage.setSecret(id, ''); + this.plugin.settings.HardcoverOAuthSecretId = ''; + await this.plugin.saveSettings(); + return ok(undefined); + } + + cancelPendingAuthorization(): void { + this.pending = undefined; + this.generation++; + } +} diff --git a/packages/obsidian/src/api/apis/HardcoverAPI.ts b/packages/obsidian/src/api/apis/HardcoverAPI.ts index 538d2c9..c91c15f 100644 --- a/packages/obsidian/src/api/apis/HardcoverAPI.ts +++ b/packages/obsidian/src/api/apis/HardcoverAPI.ts @@ -48,7 +48,7 @@ export class HardcoverAPI extends APIModel { super(); this.plugin = plugin; this.apiName = 'HardcoverAPI'; - this.apiDescription = 'Book metadata from Hardcover (requires a free account and API token).'; + this.apiDescription = 'Book metadata from Hardcover (connect your free account using OAuth).'; this.apiUrl = 'https://api.hardcover.app/v1/graphql'; this.types = [MediaType.Book]; } @@ -58,9 +58,9 @@ export class HardcoverAPI extends APIModel { } private async query(query: string, variables: Record): Promise> { - const secret = this.plugin.app.secretStorage.getSecret(this.plugin.settings.HardcoverKeyId)?.trim(); - if (!secret) return this.failure('API token for Hardcover missing.', MDBErrorKind.Validation); - const token = secret.replace(/^Bearer\s+/i, ''); + const authorization = await this.plugin.hardcoverOAuth.getAccessToken(); + if (!authorization.ok) return authorization; + const token = authorization.value; // Reserve a slot before waiting so concurrent imports also respect the per-minute limit. const now = Date.now(); @@ -80,14 +80,14 @@ export class HardcoverAPI extends APIModel { body: JSON.stringify({ query, variables }), throw: false, }); - if (response.status === 401) return this.failure('Hardcover API token is invalid or expired.'); - if (response.status === 403) return this.failure('Hardcover API token does not have permission for this query.'); + if (response.status === 401) return this.failure('Hardcover authorization is invalid or expired. Connect again in Media DB settings.'); + if (response.status === 403) return this.failure('Hardcover authorization does not have permission for this query.'); if (response.status === 429) return this.failure('Hardcover rate limit reached. Please try again later.'); if (response.status !== 200) return this.failure(`Received status code ${response.status} from Hardcover.`); const payload = response.json as { data?: T | null; errors?: unknown[] } | null; if (!payload || payload.errors?.length || !payload.data) { - return this.failure('Hardcover could not complete the query. Check your token permissions and try again.'); + return this.failure('Hardcover could not complete the query. Check your connection permissions and try again.'); } return ok(payload.data); } catch { diff --git a/packages/obsidian/src/main.ts b/packages/obsidian/src/main.ts index f28ef07..57b5115 100644 --- a/packages/obsidian/src/main.ts +++ b/packages/obsidian/src/main.ts @@ -1,5 +1,5 @@ import 'packages/obsidian/src/styles.css'; -import { Plugin, TFolder } from 'obsidian'; +import { Notice, Plugin, TFolder } from 'obsidian'; import { APIManager } from 'packages/obsidian/src/api/APIManager'; import { BoardGameGeekAPI } from 'packages/obsidian/src/api/apis/BoardGameGeekAPI'; import { ComicVineAPI } from 'packages/obsidian/src/api/apis/ComicVineAPI'; @@ -17,6 +17,7 @@ import { TMDBSeasonAPI } from 'packages/obsidian/src/api/apis/TMDBSeasonAPI'; import { TMDBSeriesAPI } from 'packages/obsidian/src/api/apis/TMDBSeriesAPI'; import { VNDBAPI } from 'packages/obsidian/src/api/apis/VNDBAPI'; import { WikipediaAPI } from 'packages/obsidian/src/api/apis/WikipediaAPI'; +import { HardcoverOAuth } from 'packages/obsidian/src/api/HardcoverOAuth'; import type { LegacyApiKeyEntry } from 'packages/obsidian/src/modals/LegacyApiKeysModal'; import { LegacyApiKeysModal } from 'packages/obsidian/src/modals/LegacyApiKeysModal'; import { PropertyMapper } from 'packages/obsidian/src/settings/PropertyMapper'; @@ -45,6 +46,7 @@ export default class MediaDbPlugin extends Plugin { bulkImportHelper!: BulkImportHelper; dateFormatter!: DateFormatter; errorReporter!: ErrorReporter; + hardcoverOAuth!: HardcoverOAuth; async onload(): Promise { this.mediaTypeManager = new MediaTypeManager(); @@ -57,13 +59,21 @@ export default class MediaDbPlugin extends Plugin { this.dateFormatter = new DateFormatter(); await this.loadSettings(); + this.hardcoverOAuth = new HardcoverOAuth(this); + this.registerObsidianProtocolHandler('media-db-hardcover', params => { + void this.hardcoverOAuth.completeAuthorization(params).then(result => { + new Notice(result.ok ? 'Hardcover connected. You can now search for books.' : (result.error.userMessage ?? 'Hardcover connection failed.')); + }); + }); this.registerDefaultApis(); this.addSettingTab(new MediaDbSettingTab(this.app, this)); this.registerRibbonAndFileMenu(); this.registerCommands(); } - onunload(): void {} + onunload(): void { + this.hardcoverOAuth?.cancelPendingAuthorization(); + } private registerDefaultApis(): void { this.apiManager = new APIManager(); diff --git a/packages/obsidian/src/settings/Settings.ts b/packages/obsidian/src/settings/Settings.ts index 349874c..8056c26 100644 --- a/packages/obsidian/src/settings/Settings.ts +++ b/packages/obsidian/src/settings/Settings.ts @@ -51,7 +51,8 @@ function createPropertyMappingsDescription(): DocumentFragment { // MARK: Settings export interface MediaDbPluginSettings { OMDbKeyId: string; - HardcoverKeyId: string; + HardcoverClientId: string; + HardcoverOAuthSecretId: string; TMDBKeyId: string; //MobyGamesKeyId: string; //GiantBombKeyId: string; @@ -312,7 +313,8 @@ class MediaTypeMappedSettings { // MARK: Defaults const DEFAULT_SETTINGS: MediaDbPluginSettings = { OMDbKeyId: '', - HardcoverKeyId: '', + HardcoverClientId: '', + HardcoverOAuthSecretId: '', TMDBKeyId: '', //MobyGamesKeyId: '', //GiantBombKeyId: '', @@ -587,17 +589,37 @@ export class MediaDbSettingTab extends PluginSettingTab { apiKeyGroup.addSetting( setting => void setting - .setName('Hardcover API token') - .setDesc('API token from Hardcover account settings. Enable Hardcover in Book settings below.') - .addComponent(el => { - const component = new SecretComponent(this.app, el); - component.setValue(this.plugin.settings.HardcoverKeyId).onChange(data => { - this.plugin.settings.HardcoverKeyId = data; + .setName('Hardcover OAuth client ID') + .setDesc( + 'Public native-app client ID from Hardcover Developer Apps. Register obsidian://media-db-hardcover as the redirect and allow only read:catalog. No client secret is used.', + ) + .addText(component => { + component.setValue(this.plugin.settings.HardcoverClientId).onChange(data => { + this.plugin.hardcoverOAuth.cancelPendingAuthorization(); + this.plugin.settings.HardcoverClientId = data.trim(); void this.plugin.saveSettings(); }); - return component; }), ); + apiKeyGroup.addSetting( + setting => + void setting + .setName('Hardcover connection') + .setDesc('Connect in your browser to read public book metadata. Keep this vault open until you return. Credentials are stored in SecretStorage.') + .addButton(button => + button.setButtonText('Connect').onClick(async () => { + const result = await this.plugin.hardcoverOAuth.beginAuthorization(); + if (result.ok) window.open(result.value, '_blank', 'noopener'); + else new Notice(result.error.userMessage ?? 'Could not start Hardcover connection.'); + }), + ) + .addButton(button => + button.setButtonText('Disconnect').onClick(async () => { + const result = await this.plugin.hardcoverOAuth.disconnect(); + new Notice(result.ok ? 'Hardcover disconnected.' : (result.error.userMessage ?? 'Could not disconnect Hardcover.')); + }), + ), + ); apiKeyGroup.addSetting( setting => diff --git a/tests/hardcover-api.test.ts b/tests/hardcover-api.test.ts index f5e812c..2ed397e 100644 --- a/tests/hardcover-api.test.ts +++ b/tests/hardcover-api.test.ts @@ -7,14 +7,14 @@ import { BookModel } from 'packages/obsidian/src/models/BookModel'; import { MDBErrorKind } from 'packages/obsidian/src/utils/MDBError'; import { MediaType } from 'packages/obsidian/src/utils/MediaType'; -GlobalRegistrator.register(); +if (typeof window === 'undefined') GlobalRegistrator.register(); const request = spyOn(obsidian, 'requestUrl'); afterEach(() => request.mockReset()); function provider(token: string | null = 'test-token'): HardcoverAPI { return new HardcoverAPI({ - app: { secretStorage: { getSecret: () => token } }, - settings: { HardcoverKeyId: 'hardcover', HardcoverAPI_disabledMediaTypes: [MediaType.Book] }, + hardcoverOAuth: { getAccessToken: async () => (token ? { ok: true, value: token } : { ok: false, error: { kind: MDBErrorKind.Validation, message: 'Connect first' } }) }, + settings: { HardcoverAPI_disabledMediaTypes: [MediaType.Book] }, } as unknown as MediaDbPlugin); } @@ -52,7 +52,7 @@ test('Hardcover requires a secret and does not send requests without it', async }); test('search uses variables, caps results and restores relevance order', async () => { - const api = provider('Bearer test-token'); + const api = provider(); respond({ data: { search: { ids: ['456', '123', '456'] } } }); respond({ data: { books: [book, { ...book, id: 456, title: 'First result' }] } }); const result = await api.searchByTitle('title " with quotes'); diff --git a/tests/hardcover-oauth.test.ts b/tests/hardcover-oauth.test.ts new file mode 100644 index 0000000..55f68b1 --- /dev/null +++ b/tests/hardcover-oauth.test.ts @@ -0,0 +1,147 @@ +import { afterEach, expect, spyOn, test } from 'bun:test'; +import { GlobalRegistrator } from '@happy-dom/global-registrator'; +import * as obsidian from 'obsidian'; +import { HardcoverOAuth, HARDCOVER_REDIRECT_URI } from 'packages/obsidian/src/api/HardcoverOAuth'; +import type MediaDbPlugin from 'packages/obsidian/src/main'; + +if (typeof window === 'undefined') GlobalRegistrator.register(); +const request = spyOn(obsidian, 'requestUrl'); +afterEach(() => request.mockReset()); + +function setup() { + const secrets = new Map(); + const settings = { HardcoverClientId: 'test-client', HardcoverOAuthSecretId: '' }; + const plugin = { + settings, + app: { secretStorage: { getSecret: (id: string) => secrets.get(id) ?? null, setSecret: (id: string, value: string) => secrets.set(id, value) } }, + saveSettings: async () => {}, + } as unknown as MediaDbPlugin; + return { oauth: new HardcoverOAuth(plugin), settings, secrets }; +} + +function respond(json: unknown, status = 200) { + request.mockResolvedValueOnce({ status, json } as obsidian.RequestUrlResponse); +} + +const tokens = { access_token: 'access-secret', refresh_token: 'refresh-secret', expires_in: 3600, token_type: 'Bearer' }; + +async function callback(oauth: HardcoverOAuth) { + const start = await oauth.beginAuthorization(); + if (!start.ok) throw new Error('start failed'); + return { state: new URL(start.value).searchParams.get('state')!, code: 'authorization-code', iss: 'https://api.hardcover.app' }; +} + +test('OAuth uses a public client, S256 PKCE and only catalog scope', async () => { + const { oauth } = setup(); + const params = await callback(oauth); + respond(tokens); + expect((await oauth.completeAuthorization(params)).ok).toBe(true); + const body = new URLSearchParams((request.mock.calls[0][0] as obsidian.RequestUrlParam).body as string); + expect(body.get('client_secret')).toBeNull(); + expect(body.get('redirect_uri')).toBe(HARDCOVER_REDIRECT_URI); + const verifier = body.get('code_verifier')!; + expect(verifier).toMatch(/^[A-Za-z0-9_-]{43,128}$/); + const next = await oauth.beginAuthorization(); + if (!next.ok) throw new Error('start failed'); + const url = new URL(next.value); + expect(url.searchParams.get('scope')).toBe('read:catalog'); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + expect(url.searchParams.get('code_challenge')).toHaveLength(43); + expect(next.value).not.toContain(verifier); +}); + +test('state and issuer mismatches cannot exchange a code', async () => { + const { oauth } = setup(); + const params = await callback(oauth); + expect((await oauth.completeAuthorization({ ...params, state: 'wrong' })).ok).toBe(false); + expect((await oauth.completeAuthorization({ ...params, iss: 'https://attacker.test' })).ok).toBe(false); + expect(request).not.toHaveBeenCalled(); +}); + +test('denial is handled without contacting the token endpoint', async () => { + const { oauth } = setup(); + const params = await callback(oauth); + expect((await oauth.completeAuthorization({ ...params, error: 'access_denied' })).ok).toBe(false); + expect(request).not.toHaveBeenCalled(); +}); + +test('successful connection stores tokens only in SecretStorage and rejects callback replay', async () => { + const { oauth, settings, secrets } = setup(); + const params = await callback(oauth); + respond(tokens); + expect((await oauth.completeAuthorization(params)).ok).toBe(true); + expect(oauth.isConnected()).toBe(true); + expect(JSON.stringify(settings)).not.toContain('secret'); + expect(secrets.size).toBe(1); + expect(await oauth.getAccessToken()).toEqual({ ok: true, value: 'access-secret' }); + expect((await oauth.completeAuthorization(params)).ok).toBe(false); + expect(request).toHaveBeenCalledTimes(1); +}); + +test('expired access tokens rotate once for concurrent requests', async () => { + const { oauth, settings, secrets } = setup(); + const params = await callback(oauth); + respond(tokens); + await oauth.completeAuthorization(params); + const stored = JSON.parse(secrets.get(settings.HardcoverOAuthSecretId)!); + secrets.set(settings.HardcoverOAuthSecretId, JSON.stringify({ ...stored, expiresAt: 0 })); + respond({ ...tokens, access_token: 'new-access', refresh_token: 'new-refresh' }); + const results = await Promise.all([oauth.getAccessToken(), oauth.getAccessToken()]); + expect(results).toEqual([ + { ok: true, value: 'new-access' }, + { ok: true, value: 'new-access' }, + ]); + expect(request).toHaveBeenCalledTimes(2); + expect(JSON.parse(secrets.get(settings.HardcoverOAuthSecretId)!).refreshToken).toBe('new-refresh'); +}); + +test('disconnect revokes refresh credentials before clearing local storage', async () => { + const { oauth, secrets } = setup(); + const params = await callback(oauth); + respond(tokens); + await oauth.completeAuthorization(params); + respond({}); + expect((await oauth.disconnect()).ok).toBe(true); + expect(oauth.isConnected()).toBe(false); + expect([...secrets.values()]).toEqual(['']); + const options = request.mock.calls[1][0] as obsidian.RequestUrlParam; + expect(options.url).toBe('https://api.hardcover.app/oauth2/revoke'); + expect(new URLSearchParams(options.body as string).get('token_type_hint')).toBe('refresh_token'); +}); + +test('failed revocation preserves credentials so the user can retry', async () => { + const { oauth } = setup(); + const params = await callback(oauth); + respond(tokens); + await oauth.completeAuthorization(params); + respond({}, 503); + expect((await oauth.disconnect()).ok).toBe(false); + expect(oauth.isConnected()).toBe(true); +}); + +test('failed and malformed exchanges do not store credentials or leak transport secrets', async () => { + for (const value of [{ ...tokens, token_type: 'unknown' }, { access_token: 'secret' }, null]) { + const { oauth, secrets } = setup(); + const params = await callback(oauth); + respond(value); + expect((await oauth.completeAuthorization(params)).ok).toBe(false); + expect(secrets.size).toBe(0); + } + const { oauth } = setup(); + const params = await callback(oauth); + request.mockRejectedValueOnce(new Error('authorization-code refresh-secret')); + const result = await oauth.completeAuthorization(params); + expect(JSON.stringify(result)).not.toContain('authorization-code'); + expect(JSON.stringify(result)).not.toContain('refresh-secret'); +}); + +test('changing the client or unloading cancels pending sign-in', async () => { + const { oauth, settings } = setup(); + const params = await callback(oauth); + settings.HardcoverClientId = 'other'; + expect((await oauth.completeAuthorization(params)).ok).toBe(false); + settings.HardcoverClientId = 'test-client'; + oauth.cancelPendingAuthorization(); + expect((await oauth.completeAuthorization(params)).ok).toBe(false); + expect(request).not.toHaveBeenCalled(); +}); From 0f7295e5bee879aec8142508f22248af08e7cd86 Mon Sep 17 00:00:00 2001 From: Tyce Herrman Date: Fri, 16 Oct 2026 13:04:10 -0400 Subject: [PATCH 3/3] fix: preserve Hardcover credentials across OAuth races --- README.md | 2 +- packages/obsidian/src/api/HardcoverOAuth.ts | 69 +++++++----- tests/hardcover-oauth.test.ts | 118 ++++++++++++++++++++ 3 files changed, 161 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index e5f5dcb..9dc7f58 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ Now you select the result you want, and the plugin will cast its magic, creating Hardcover is an optional book provider, disabled by default. It uses OAuth authorization-code flow with S256 PKCE, requesting only `read:catalog` (public catalog metadata and search). No personal access token or client secret is required. -For development, register a **Mobile, desktop, or CLI** public application at [Hardcover Developer Apps](https://hardcover.app/account/developer-apps). Register `obsidian://media-db-hardcover` as its redirect URI, allow only `read:catalog`, and disable Device Authorization Grant. Enter its public client ID in **Hardcover OAuth client ID** in Media DB settings. A production client registration should be owned by the project maintainer; this PR does not ship a contributor's test client ID. +For development, register a **Mobile, desktop, or CLI** public application at [Hardcover Developer Apps](https://hardcover.app/account/developer-apps). Register `obsidian://media-db-hardcover` as its redirect URI, allow only `read:catalog`, and disable Device Authorization Grant. Enter its public client ID in **Hardcover OAuth client ID** in Media DB settings. A production client registration should be owned by the project maintainer; the plugin does not include a default client ID. Click **Connect**, authorize catalog access in your browser, and return to the vault that started the connection. Enable **HardcoverAPI** under **Book settings**, then search for books normally or select HardcoverAPI in advanced search. OAuth access and rotating refresh credentials are stored only in Obsidian SecretStorage; settings contain a secret identifier. **Disconnect** revokes the refresh credentials before clearing the local connection. If revocation fails, reconnect to the network and retry Disconnect. diff --git a/packages/obsidian/src/api/HardcoverOAuth.ts b/packages/obsidian/src/api/HardcoverOAuth.ts index d05a721..bb8b0ad 100644 --- a/packages/obsidian/src/api/HardcoverOAuth.ts +++ b/packages/obsidian/src/api/HardcoverOAuth.ts @@ -38,6 +38,8 @@ export class HardcoverOAuth { private pending?: PendingAuthorization; private refreshing?: Promise>; private generation = 0; + private authGeneration = 0; + private disconnecting = false; constructor(private plugin: MediaDbPlugin) {} @@ -68,13 +70,14 @@ export class HardcoverOAuth { } async beginAuthorization(): Promise> { + if (this.disconnecting) return this.failure('Hardcover is disconnecting. Please try again.'); const clientId = this.plugin.settings.HardcoverClientId.trim(); if (!clientId) return this.failure('Configure the public Hardcover OAuth client ID first.'); - const generation = ++this.generation; + const generation = ++this.authGeneration; const verifier = randomString(); const state = randomString(); const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)); - if (generation !== this.generation) return this.failure('Hardcover connection was cancelled.'); + if (generation !== this.authGeneration) return this.failure('Hardcover connection was cancelled.'); this.pending = { state, verifier, clientId, expiresAt: Date.now() + 10 * 60 * 1000 }; const params = new URLSearchParams({ response_type: 'code', @@ -89,6 +92,7 @@ export class HardcoverOAuth { } async completeAuthorization(params: Record): Promise> { + if (this.disconnecting) return this.failure('Hardcover is disconnecting. Please try again.'); const pending = this.pending; if ( !pending || @@ -101,7 +105,7 @@ export class HardcoverOAuth { } this.pending = undefined; if (params.error || !params.code) return this.failure('Hardcover sign-in was cancelled or denied.'); - const generation = this.generation; + const generation = this.authGeneration; const response = await this.exchange({ grant_type: 'authorization_code', code: params.code, @@ -110,7 +114,8 @@ export class HardcoverOAuth { client_id: pending.clientId, }); if (!response.ok) return response; - if (generation !== this.generation) return this.failure('Hardcover connection was cancelled.'); + if (generation !== this.authGeneration) return this.failure('Hardcover connection was cancelled.'); + this.generation++; await this.storeTokens(response.value); return ok(undefined); } @@ -152,6 +157,7 @@ export class HardcoverOAuth { } async getAccessToken(): Promise> { + if (this.disconnecting) return this.failure('Hardcover is disconnecting. Please try again.'); const tokens = this.readTokens(); if (!tokens) return this.failure('Connect to Hardcover in Media DB settings first.'); if (tokens.expiresAt > Date.now() + 60_000) return ok(tokens.accessToken); @@ -167,39 +173,48 @@ export class HardcoverOAuth { private async refreshTokens(tokens: Tokens, generation: number): Promise> { const result = await this.exchange({ grant_type: 'refresh_token', refresh_token: tokens.refreshToken, client_id: tokens.clientId }); if (!result.ok) return result; - if (generation !== this.generation || tokens.clientId !== this.plugin.settings.HardcoverClientId.trim()) - return this.failure('Hardcover connection changed. Connect again.'); + if (generation !== this.generation || this.readTokens(false)?.refreshToken !== tokens.refreshToken) return this.failure('Hardcover connection changed. Connect again.'); + // Preserve rotated credentials even if settings currently select another client. await this.storeTokens(result.value); + if (tokens.clientId !== this.plugin.settings.HardcoverClientId.trim()) return this.failure('Hardcover connection changed.'); return ok(result.value.accessToken); } async disconnect(): Promise> { - this.pending = undefined; - this.generation++; - const tokens = this.readTokens(false); - if (tokens) { - try { - const response = await requestUrl({ - url: `${ISSUER}/oauth2/revoke`, - method: 'POST', - contentType: 'application/x-www-form-urlencoded', - body: new URLSearchParams({ token: tokens.refreshToken, token_type_hint: 'refresh_token', client_id: tokens.clientId }).toString(), - throw: false, - }); - if (response.status !== 200) return this.failure('Hardcover could not revoke the connection. Please try Disconnect again.'); - } catch { - return this.failure('Hardcover could not revoke the connection. Please try Disconnect again.'); + if (this.disconnecting) return this.failure('Hardcover is already disconnecting.'); + this.disconnecting = true; + try { + this.cancelPendingAuthorization(); + // Finish rotation before revoking, so Disconnect uses the current refresh token. + await this.refreshing; + this.generation++; + const tokens = this.readTokens(false); + if (tokens) { + try { + const response = await requestUrl({ + url: `${ISSUER}/oauth2/revoke`, + method: 'POST', + contentType: 'application/x-www-form-urlencoded', + body: new URLSearchParams({ token: tokens.refreshToken, token_type_hint: 'refresh_token', client_id: tokens.clientId }).toString(), + throw: false, + }); + if (response.status !== 200) return this.failure('Hardcover could not revoke the connection. Please try Disconnect again.'); + } catch { + return this.failure('Hardcover could not revoke the connection. Please try Disconnect again.'); + } } + const id = this.plugin.settings.HardcoverOAuthSecretId; + if (id) this.plugin.app.secretStorage.setSecret(id, ''); + this.plugin.settings.HardcoverOAuthSecretId = ''; + await this.plugin.saveSettings(); + return ok(undefined); + } finally { + this.disconnecting = false; } - const id = this.plugin.settings.HardcoverOAuthSecretId; - if (id) this.plugin.app.secretStorage.setSecret(id, ''); - this.plugin.settings.HardcoverOAuthSecretId = ''; - await this.plugin.saveSettings(); - return ok(undefined); } cancelPendingAuthorization(): void { this.pending = undefined; - this.generation++; + this.authGeneration++; } } diff --git a/tests/hardcover-oauth.test.ts b/tests/hardcover-oauth.test.ts index 55f68b1..cce31d1 100644 --- a/tests/hardcover-oauth.test.ts +++ b/tests/hardcover-oauth.test.ts @@ -145,3 +145,121 @@ test('changing the client or unloading cancels pending sign-in', async () => { expect((await oauth.completeAuthorization(params)).ok).toBe(false); expect(request).not.toHaveBeenCalled(); }); + +async function connectedWithExpiredTokens() { + const context = setup(); + const params = await callback(context.oauth); + respond(tokens); + await context.oauth.completeAuthorization(params); + const read = () => JSON.parse(context.secrets.get(context.settings.HardcoverOAuthSecretId)!); + context.secrets.set(context.settings.HardcoverOAuthSecretId, JSON.stringify({ ...read(), expiresAt: 0 })); + return { ...context, read }; +} + +function delayedRefresh() { + let finish!: (value: obsidian.RequestUrlResponse) => void; + request.mockImplementationOnce( + () => + new Promise(resolve => { + finish = resolve; + }) as obsidian.RequestUrlResponsePromise, + ); + return () => finish({ status: 200, json: { ...tokens, access_token: 'rotated-access', refresh_token: 'rotated-refresh' } } as obsidian.RequestUrlResponse); +} + +test('canceling sign-in or starting a new sign-in does not discard an in-flight rotation', async () => { + for (const startAgain of [false, true]) { + const { oauth, read } = await connectedWithExpiredTokens(); + const finish = delayedRefresh(); + const refresh = oauth.getAccessToken(); + oauth.cancelPendingAuthorization(); + if (startAgain) await oauth.beginAuthorization(); + finish(); + expect((await refresh).ok).toBe(true); + expect(read().refreshToken).toBe('rotated-refresh'); + } +}); + +test('client edits preserve rotated credentials without returning a token for the wrong client', async () => { + const { oauth, settings, read } = await connectedWithExpiredTokens(); + const finish = delayedRefresh(); + const refresh = oauth.getAccessToken(); + oauth.cancelPendingAuthorization(); + settings.HardcoverClientId = 'other-client'; + finish(); + expect((await refresh).ok).toBe(false); + expect(read().refreshToken).toBe('rotated-refresh'); + expect(oauth.isConnected()).toBe(false); + settings.HardcoverClientId = 'test-client'; + expect(await oauth.getAccessToken()).toEqual({ ok: true, value: 'rotated-access' }); +}); + +test('an old refresh cannot overwrite a newly authorized connection', async () => { + const { oauth, read } = await connectedWithExpiredTokens(); + const finish = delayedRefresh(); + const refresh = oauth.getAccessToken(); + const params = await callback(oauth); + respond({ ...tokens, access_token: 'new-connection-access', refresh_token: 'new-connection-refresh' }); + expect((await oauth.completeAuthorization(params)).ok).toBe(true); + finish(); + expect((await refresh).ok).toBe(false); + expect(read().refreshToken).toBe('new-connection-refresh'); +}); + +test('disconnect waits for rotation, revokes the rotated token and blocks new connections', async () => { + const { oauth, secrets } = await connectedWithExpiredTokens(); + const finish = delayedRefresh(); + const refresh = oauth.getAccessToken(); + const params = await callback(oauth); + const disconnect = oauth.disconnect(); + expect((await oauth.beginAuthorization()).ok).toBe(false); + expect((await oauth.completeAuthorization(params)).ok).toBe(false); + expect((await oauth.getAccessToken()).ok).toBe(false); + respond({}); + finish(); + await refresh; + expect((await disconnect).ok).toBe(true); + const revoke = request.mock.calls.at(-1)![0] as obsidian.RequestUrlParam; + expect(new URLSearchParams(revoke.body as string).get('token')).toBe('rotated-refresh'); + expect(oauth.isConnected()).toBe(false); + expect([...secrets.values()]).toEqual(['']); +}); + +test('canceling authorization during its exchange prevents credentials from being stored', async () => { + const { oauth, secrets } = setup(); + const params = await callback(oauth); + const finish = delayedRefresh(); + const connection = oauth.completeAuthorization(params); + oauth.cancelPendingAuthorization(); + finish(); + expect((await connection).ok).toBe(false); + expect(secrets.size).toBe(0); +}); + +test('failed disconnect after rotation retains the current credentials for retry', async () => { + const { oauth, read } = await connectedWithExpiredTokens(); + const finish = delayedRefresh(); + const refresh = oauth.getAccessToken(); + const disconnect = oauth.disconnect(); + respond({}, 503); + finish(); + await refresh; + expect((await disconnect).ok).toBe(false); + expect(read().refreshToken).toBe('rotated-refresh'); + expect(oauth.isConnected()).toBe(true); + respond({}); + expect((await oauth.disconnect()).ok).toBe(true); + const retry = request.mock.calls.at(-1)![0] as obsidian.RequestUrlParam; + expect(new URLSearchParams(retry.body as string).get('token')).toBe('rotated-refresh'); +}); + +test('disconnect prevents an authorization exchange already in flight from restoring credentials', async () => { + const { oauth, secrets } = setup(); + const params = await callback(oauth); + const finish = delayedRefresh(); + const connection = oauth.completeAuthorization(params); + expect((await oauth.disconnect()).ok).toBe(true); + finish(); + expect((await connection).ok).toBe(false); + expect(secrets.size).toBe(0); +});