From 5550a9a0e5c483526efa0c97d689394ee293c012 Mon Sep 17 00:00:00 2001 From: sabristratos Date: Fri, 16 Oct 2026 16:36:58 +0100 Subject: [PATCH 1/2] Add a Seekquel connector Links a Seekquel account by device code and sends progress, finished books and highlights to Seekquel's KOSync surface, keyed by the document hash with the title and author attached so Seekquel matches the file itself. --- README.md | 3 +- app/src/Settings.jsx | 1 + assets/icons/seekquel.png | Bin 0 -> 7592 bytes src/connectors/registry.ts | 2 + src/connectors/seekquel.ts | 318 +++++++++++++++++++++++++++++++++++++ src/routes/landing.ts | 1 + src/routes/web.ts | 8 +- test/connectors.test.ts | 2 +- test/seekquel.test.ts | 254 +++++++++++++++++++++++++++++ 9 files changed, 585 insertions(+), 4 deletions(-) create mode 100644 assets/icons/seekquel.png create mode 100644 src/connectors/seekquel.ts create mode 100644 test/seekquel.test.ts diff --git a/README.md b/README.md index 816cde6..3f5d687 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ and any KOReader device. only the sync-server URL. Same accounts, same auth, same endpoints as `sync.koreader.rocks`. - **Better multi-device sync.** Progress is stored per device and the newest position wins, fixing the ping-pong you get with stock kosync servers. -- **Server-side service connectors.** Link services such as Hardcover, Micro.blog, Audiobookshelf, BookOrbit, and Readwise Reader once; readers continue speaking standard KOSync while the server updates external reading state. +- **Server-side service connectors.** Link services such as Hardcover, Micro.blog, Audiobookshelf, BookOrbit, Readwise Reader, and Seekquel once; readers continue speaking standard KOSync while the server updates external reading state. - **Lossless CrossPoint sync.** An extended API carries the full CrossPoint position (spine, paragraph, anchor, page hints), not just a lossy xpath + percentage. - **Bookmarks, clippings, and reading stats.** Delta sync with tombstones for bookmarks and @@ -96,6 +96,7 @@ DATABASE_PATH=./data/crosspoint.db PORT=8080 node dist/index.js | `LEGACY_WEB` | _(unset)_ | The website is the CrossPoint Sync app at `/app/` (the old account pages redirect there; Kindle setup keeps its pages). Set `1` to keep the old server-rendered website instead. | | `HARDCOVER_CLIENT_ID` | CrossPoint Sync's app | Optional. Hardcover OAuth client id for "Connect Hardcover" sign-in. The built-in id is CrossPoint Sync's public Hardcover app and works for self-hosted servers too; set this only to use your own Hardcover developer app. | | `SEARCHAPI_KEY` | _(unset)_ | Optional, paid ([searchapi.io](https://www.searchapi.io)). Last fallback for print page counts: Amazon search + product "Print length", 2 requests per book, only for books the free sources miss. Cached forever. | +| `SEEKQUEL_URL` | `https://api.seekquel.app` | Optional. The Seekquel API the Seekquel connector links to and sends to. Only for testing against a local Seekquel. | | `CORS_ORIGINS` | `*` | Origins allowed to call the sync API from browsers (comma-separated). The default wildcard is safe: the API authenticates with headers, not cookies, and the web UI's cookie routes never get CORS headers. | ### Link Micro.blog diff --git a/app/src/Settings.jsx b/app/src/Settings.jsx index 228584e..3f89521 100644 --- a/app/src/Settings.jsx +++ b/app/src/Settings.jsx @@ -44,6 +44,7 @@ const HINTS = { bookfusion: 'Syncs reading progress with BookFusion. You approve the request on bookfusion.com.', audiobookshelf: 'Keeps your place between the ebook and the audiobook. Create an API key in Audiobookshelf under Settings, Users, API Keys.', bookorbit: 'Syncs progress both ways with your BookOrbit server, and adds your clippings as highlights.', + seekquel: 'Logs your reading in your Seekquel library: where you are in each book, the books you finish, and your highlights. You approve the request in the Seekquel app.', spotify: 'Moves your progress forward when you listen ahead in the Spotify audiobook, and resumes it there. Audiobooks are in the US, UK, Canada, Ireland, Australia and New Zealand.', kindle: 'Linking needs the CrossPoint Kindle Link browser extension.', } diff --git a/assets/icons/seekquel.png b/assets/icons/seekquel.png new file mode 100644 index 0000000000000000000000000000000000000000..1c60c3fede5f7cce6aaedd6829693928ddc31499 GIT binary patch literal 7592 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 KMNUMnLSTZ~6Pio_ literal 0 HcmV?d00001 diff --git a/src/connectors/registry.ts b/src/connectors/registry.ts index 573b148..b4a81d5 100644 --- a/src/connectors/registry.ts +++ b/src/connectors/registry.ts @@ -9,6 +9,7 @@ import { microblogConnector } from './microblog.js'; import { kindleConnector } from './kindle.js'; import { bookorbitConnector } from './bookorbit.js'; import { spotifyConnector } from './spotify.js'; +import { seekquelConnector } from './seekquel.js'; /** All connectors known to this build. */ const CONNECTORS: Connector[] = [ @@ -20,6 +21,7 @@ const CONNECTORS: Connector[] = [ audiobookshelfConnector, bookorbitConnector, spotifyConnector, + seekquelConnector, microblogConnector, kindleConnector, ]; diff --git a/src/connectors/seekquel.ts b/src/connectors/seekquel.ts new file mode 100644 index 0000000..5e0f811 --- /dev/null +++ b/src/connectors/seekquel.ts @@ -0,0 +1,318 @@ +import crypto from 'node:crypto'; +import { documentMeta, getMatchSnapshot, setMatchSnapshot } from './store.js'; +import type { + Connector, + ConnectorContext, + Credential, + DeviceLinkPoll, + DeviceLinkStart, + DocumentMeta, + HttpTransport, + Match, + OutboundEvent, + PushResult, + ValidateResult, +} from './types.js'; + +/** + * Seekquel connector (Tier 1). Sends reading progress, finished books and + * highlights to a Seekquel account (seekquel.app), a book tracker. + * + * Seekquel speaks KOSync under /koreader and keys books by the same document + * hash we store, so there is no book matching here: the document id is sent as + * is, with the title, author and filename we know, and Seekquel matches the + * file to a book on its side. A file it cannot place waits in the reader's + * Seekquel library until they say which book it is. + * + * Linking is Seekquel's device-code pairing: the user approves a code in the + * Seekquel app and the poll returns a per-link key, sent as x-auth-user. The + * key is the whole credential; Seekquel never reads the KOSync password. + * + * Highlights and the finished status attach to a book, so Seekquel answers 409 + * until the file is linked to one. Seekquel matches a file a few seconds after + * the first progress push that carries its title, so a 409 is retried on the + * queue's backoff; a file nobody links dead-letters with a message saying so, + * and "Sync now" sends it again once the reader picks the book in Seekquel. + * + * A finished book is marked Read once, then not again until the device has gone + * back below the end. Every sync of a book at 100% fans out as 'finished', and + * re-sending Read each time would undo a re-read the reader started in Seekquel + * while the device still shows the old copy at the end. + */ + +const DEFAULT_BASE = 'https://api.seekquel.app'; +const CLIENT = 'crosspoint-sync'; +const DEVICE_NAME = 'CrossPoint Sync'; +const ACCEPT = 'application/vnd.koreader.v1+json'; +const MAX_TEXT = 500; +const MAX_XPATH = 120; +const NOT_LINKED = 409; +const CONNECTOR_ID = 'seekquel'; +const NOT_LINKED_ERROR = 'not linked to a book in Seekquel yet; choose the book in Seekquel, then Sync now'; + +/** + * Per-book state kept on the match: whether Read has been sent since the device + * last went below the end, and to which link. Matches outlive an unlink, so a + * relink (perhaps to another Seekquel account) must not inherit the old answer. + */ +interface FinishSnapshot { + readSent: boolean; + link: string; +} + +interface SeekquelCred extends Credential { + server: string; + key: string; +} + +/** Seekquel's API origin. SEEKQUEL_URL points a test install at a local Seekquel. */ +export function seekquelBase(env: NodeJS.ProcessEnv = process.env): string { + let url = env.SEEKQUEL_URL?.trim() || DEFAULT_BASE; + while (url.endsWith('/')) url = url.slice(0, -1); + return url; +} + +function parseCred(cred: Credential): SeekquelCred | null { + const server = typeof cred.server === 'string' ? cred.server.trim() : ''; + const key = typeof cred.key === 'string' ? cred.key.trim() : ''; + if (!server || !key) return null; + return { server, key }; +} + +function headers(c: SeekquelCred): Record { + return { 'x-auth-user': c.key, accept: ACCEPT, 'content-type': 'application/json' }; +} + +function clip(value: string | null | undefined, max = MAX_TEXT): string | null { + if (!value) return null; + const chars = Array.from(value.trim()); + return chars.length === 0 ? null : chars.slice(0, max).join(''); +} + +async function readJson(res: { json(): Promise }): Promise> { + try { + const body = await res.json(); + return body && typeof body === 'object' ? (body as Record) : {}; + } catch { + return {}; + } +} + +function failure(status: number, what: string): PushResult { + if (status === 401 || status === 403) return { ok: false, retryable: false, needsReauth: true, error: 'unauthorized' }; + if (status === 429) return { ok: false, retryable: true, error: 'rate limited' }; + if (status >= 500) return { ok: false, retryable: true, error: `server ${status}` }; + return { ok: false, retryable: false, error: `${what}: unexpected status ${status}` }; +} + +async function validate(cred: Credential, http: HttpTransport): Promise { + const c = parseCred(cred); + if (!c) return { ok: false, error: 'server and key are required' }; + try { + const res = await http(`${c.server}/users/auth`, { method: 'GET', headers: headers(c) }); + if (res.status === 200) return { ok: true, accountLabel: 'Seekquel' }; + if (res.status === 401) return { ok: false, error: 'Seekquel no longer accepts this link. Link it again.' }; + return { ok: false, error: `unexpected status ${res.status}` }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +// Seekquel keys books by our document hash: matching is identity, no network. +async function match(_cred: Credential, doc: DocumentMeta): Promise { + return { externalId: doc.document, confidence: 1, title: doc.title, author: doc.author }; +} + +/** The freshest metadata we hold: a document's title often arrives after its first sync. */ +function metadataFor(m: Match, ev: OutboundEvent, ctx?: ConnectorContext): Record | null { + const meta = ctx ? documentMeta(ctx.db, ctx.userId, ev.document) : null; + const fields: Record = { + title: clip(meta?.title ?? m.title), + authors: clip(meta?.author ?? m.author), + filename: clip(meta?.filename), + }; + const present = Object.entries(fields).filter((entry): entry is [string, string] => entry[1] !== null); + return present.length > 0 ? Object.fromEntries(present) : null; +} + +/** CrossPoint's position object, minus an xpath longer than Seekquel stores. */ +function positionFor(ev: OutboundEvent): Record | null { + if (!ev.position) return null; + const position = { ...ev.position }; + if (typeof position.xpath === 'string' && position.xpath.length > MAX_XPATH) delete position.xpath; + return position; +} + +async function pushProgress( + c: SeekquelCred, + m: Match, + ev: OutboundEvent, + http: HttpTransport, + ctx?: ConnectorContext +): Promise { + const percentage = Math.max(0, Math.min(1, ev.percentage ?? 0)); + const body: Record = { + document: m.externalId, + // KOSync refuses an empty progress string; fall back to the percentage. + progress: ev.progress && ev.progress.length > 0 ? ev.progress : String(percentage), + percentage, + device: DEVICE_NAME, + device_id: CLIENT, + }; + const position = positionFor(ev); + if (position) body.position = position; + const metadata = metadataFor(m, ev, ctx); + if (metadata) body.metadata = metadata; + + const res = await http(`${c.server}/syncs/progress`, { method: 'PUT', headers: headers(c), body: JSON.stringify(body) }); + return res.status >= 200 && res.status < 300 ? { ok: true } : failure(res.status, 'progress'); +} + +function linkId(c: SeekquelCred): string { + return crypto.createHash('sha256').update(`${c.server} +${c.key}`).digest('hex').slice(0, 16); +} + +function readAlreadySent(c: SeekquelCred, m: Match, ctx?: ConnectorContext): boolean { + if (!ctx) return false; + const snapshot = getMatchSnapshot(ctx.db, ctx.userId, CONNECTOR_ID, m.externalId); + return snapshot?.readSent === true && snapshot.link === linkId(c); +} + +function rememberReadSent(c: SeekquelCred, m: Match, readSent: boolean, ctx?: ConnectorContext): void { + if (!ctx) return; + const snapshot: FinishSnapshot = { readSent, link: linkId(c) }; + setMatchSnapshot(ctx.db, ctx.userId, CONNECTOR_ID, m.externalId, snapshot); +} + +async function pushFinished(c: SeekquelCred, m: Match, http: HttpTransport, ctx?: ConnectorContext): Promise { + if (readAlreadySent(c, m, ctx)) return { ok: true }; + const res = await http(`${c.server}/documents/${encodeURIComponent(m.externalId)}/status`, { + method: 'PUT', + headers: headers(c), + body: JSON.stringify({ status: 'read' }), + }); + if (res.status === NOT_LINKED) return { ok: false, retryable: true, error: NOT_LINKED_ERROR }; + if (res.status < 200 || res.status >= 300) return failure(res.status, 'status'); + rememberReadSent(c, m, true, ctx); + return { ok: true }; +} + +/** Stable per highlight, so a re-sent or edited highlight updates rather than duplicates. */ +function highlightId(document: string, h: NonNullable): string { + const anchor = [document, h.spine ?? '', h.startOffset ?? '', h.endOffset ?? '', h.text].join('\n'); + return `crosspoint-sync:${crypto.createHash('sha1').update(anchor).digest('hex')}`; +} + +async function pushHighlight(c: SeekquelCred, m: Match, ev: OutboundEvent, http: HttpTransport): Promise { + const h = ev.highlight; + if (!h || !h.text.trim()) return { ok: false, retryable: false, error: 'empty highlight' }; + const createdAt = h.highlightedAt ?? ev.timestamp; + const highlight: Record = { + external_id: highlightId(m.externalId, h), + text: h.text, + created_at: new Date(createdAt * 1000).toISOString(), + }; + if (h.note) highlight.note = h.note; + const chapter = clip(h.chapter); + if (chapter) highlight.chapter = chapter; + + const res = await http(`${c.server}/highlights`, { + method: 'POST', + headers: headers(c), + body: JSON.stringify({ document: m.externalId, highlights: [highlight] }), + }); + if (res.status === NOT_LINKED) return { ok: false, retryable: true, error: NOT_LINKED_ERROR }; + return res.status >= 200 && res.status < 300 ? { ok: true } : failure(res.status, 'highlight'); +} + +async function push( + cred: Credential, + m: Match, + ev: OutboundEvent, + http: HttpTransport, + ctx?: ConnectorContext +): Promise { + const c = parseCred(cred); + if (!c) return { ok: false, retryable: false, needsReauth: true, error: 'bad credential' }; + try { + if (ev.kind === 'highlight') return await pushHighlight(c, m, ev, http); + const progress = await pushProgress(c, m, ev, http, ctx); + if (!progress.ok) return progress; + if (ev.kind !== 'finished') { + if (readAlreadySent(c, m, ctx)) rememberReadSent(c, m, false, ctx); + return progress; + } + return await pushFinished(c, m, http, ctx); + } catch (err) { + return { ok: false, retryable: true, error: err instanceof Error ? err.message : String(err) }; + } +} + +// --- Device-code linking flow ------------------------------------------------- + +async function beginLink(http: HttpTransport): Promise { + const res = await http(`${seekquelBase()}/koreader/pair/start`, { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify({ device_name: DEVICE_NAME, client: CLIENT }), + }); + const body = await readJson(res); + if (res.status !== 201 || typeof body.device_code !== 'string' || typeof body.user_code !== 'string') { + const message = typeof body.message === 'string' ? body.message : `status ${res.status}`; + throw new Error(`Seekquel did not start the link: ${message}`); + } + const complete = typeof body.verification_uri_complete === 'string' ? body.verification_uri_complete : null; + return { + deviceCode: body.device_code, + userCode: body.user_code, + verificationUri: complete ? complete.split('?')[0] : 'https://seekquel.app/settings/integrations', + verificationUriComplete: complete ?? undefined, + interval: Number(body.interval) || 5, + expiresIn: Number(body.expires_in) || 900, + }; +} + +async function pollLink(deviceCode: string, http: HttpTransport): Promise { + const base = seekquelBase(); + const res = await http(`${base}/koreader/pair/poll`, { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify({ device_code: deviceCode }), + }); + const body = await readJson(res); + if (res.status === 200 && typeof body.key === 'string' && body.key) { + return { status: 'ok', credential: { server: `${base}/koreader`, key: body.key }, accountLabel: 'Seekquel' }; + } + switch (body.error?.type) { + case 'authorization_pending': + case 'slow_down': + return { status: 'pending' }; + case 'access_denied': + return { status: 'denied', error: 'The request was declined in Seekquel.' }; + case 'expired_token': + case 'invalid_grant': + return { status: 'expired', error: 'The code expired. Start again.' }; + default: + return res.status >= 500 || res.status === 429 + ? { status: 'pending' } + : { status: 'error', error: body.error?.type ?? `status ${res.status}` }; + } +} + +export const seekquelConnector: Connector = { + id: CONNECTOR_ID, + displayName: 'Seekquel', + tier: 1, + capabilities: { read: false, write: true }, + carries: ['progress', 'finished', 'highlight'], + credentialKind: 'device_code', + beta: true, + // Keyed by our document hash: Seekquel matches the file itself, so always send. + matchBy: 'document', + validate, + match, + push, + beginLink, + pollLink, +}; diff --git a/src/routes/landing.ts b/src/routes/landing.ts index 625b40b..342f4ce 100644 --- a/src/routes/landing.ts +++ b/src/routes/landing.ts @@ -47,6 +47,7 @@ const SERVICES: [string, string, string, 'ready' | 'beta'][] = [ ['microblog', 'Micro.blog', 'Keep your Currently reading and Finished shelves current.', 'ready'], ['audiobookshelf', 'Audiobookshelf', 'Switch between the ebook and the audiobook without losing your place.', 'ready'], ['bookorbit', 'BookOrbit', 'Two-way progress with your own library, clippings as highlights.', 'beta'], + ['seekquel', 'Seekquel', 'Log your progress, finished books and highlights in your Seekquel library.', 'beta'], ]; const STYLE = ` diff --git a/src/routes/web.ts b/src/routes/web.ts index 712bebc..b9d224f 100644 --- a/src/routes/web.ts +++ b/src/routes/web.ts @@ -25,7 +25,7 @@ const OG_IMAGE = fs.readFileSync(path.join(ASSETS_DIR, 'og.jpg')); // Service app icons, served at /icons/:id.png. Loaded once at boot; a missing // file just means no icon for that service (the UI falls back gracefully). const SERVICE_ICONS = new Map(); -for (const id of ['kosync', 'hardcover', 'audiobookshelf', 'bookfusion', 'readwise', 'microblog', 'bookorbit', 'spotify', 'kindle']) { +for (const id of ['kosync', 'hardcover', 'audiobookshelf', 'bookfusion', 'readwise', 'microblog', 'bookorbit', 'seekquel', 'spotify', 'kindle']) { try { SERVICE_ICONS.set(id, fs.readFileSync(path.join(ASSETS_DIR, 'icons', `${id}.png`))); } catch { @@ -286,6 +286,9 @@ const LANDING = shell(
BookOrbit
Keep your place in sync, both ways, with your own BookOrbit library. Clippings show up as highlights, and finished books are marked Read.
beta
+
Seekquel
+
Log your reading in Seekquel, a book tracker: where you are in each book, the books you finish, and your highlights.
+ beta

more on the way, and it's all open source

@@ -606,7 +609,8 @@ const HINTS = { kosync: 'Mirror your reading progress to another KOReader-compatible (KOSync) server, so your other devices see it too.', bookfusion: 'Connect your BookFusion account to sync reading progress. You will approve the request on bookfusion.com.', audiobookshelf: 'Sync your reading position to the matching audiobook on your Audiobookshelf server. Create an API key in Audiobookshelf under Settings, Users, API Keys.', - bookorbit: 'Sync reading progress both ways with your BookOrbit server. Books are matched by title and author, finishing a book marks it Read, and your clippings appear as highlights in the right spot. Sign in with your BookOrbit account.' + bookorbit: 'Sync reading progress both ways with your BookOrbit server. Books are matched by title and author, finishing a book marks it Read, and your clippings appear as highlights in the right spot. Sign in with your BookOrbit account.', + seekquel: 'Log your reading in your Seekquel library: where you are in each book, the books you finish, and your highlights. You will approve the request in the Seekquel app.' }; const TOKEN_HELP = { diff --git a/test/connectors.test.ts b/test/connectors.test.ts index c84f960..9a6c40e 100644 --- a/test/connectors.test.ts +++ b/test/connectors.test.ts @@ -57,7 +57,7 @@ describe('connector management API', () => { const ids = body.connectors.map((c: { id: string }) => c.id).sort(); // The classic (highlights-only) readwise connector is hidden; still // registered but not listed. Kindle is stealth: hidden until revealed. - expect(ids).toEqual(['audiobookshelf', 'bookfusion', 'bookorbit', 'hardcover', 'kosync', 'microblog', 'readwise-reader', 'spotify']); + expect(ids).toEqual(['audiobookshelf', 'bookfusion', 'bookorbit', 'hardcover', 'kosync', 'microblog', 'readwise-reader', 'seekquel', 'spotify']); expect(body.connectors.every((c: { linked: boolean }) => !c.linked)).toBe(true); }); diff --git a/test/seekquel.test.ts b/test/seekquel.test.ts new file mode 100644 index 0000000..03c6f17 --- /dev/null +++ b/test/seekquel.test.ts @@ -0,0 +1,254 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { DOC, makeTestApp, registerUser } from './helpers.js'; +import { resetEncryptionKeyCache } from '../src/crypto/secrets.js'; +import type { HttpTransport, OutboundEvent } from '../src/connectors/types.js'; +import { drainQueue } from '../src/connectors/runner.js'; +import { claimReady } from '../src/connectors/queue.js'; +import { seekquelBase, seekquelConnector } from '../src/connectors/seekquel.js'; + +function fakeTransport() { + const calls: { url: string; method: string; headers?: Record; body?: string }[] = []; + const handlers: { match: string; status: number; body: unknown }[] = []; + const t: HttpTransport = async (url, init) => { + calls.push({ url, method: init.method, headers: init.headers, body: init.body }); + const h = [...handlers].reverse().find((x) => url.includes(x.match)); + const status = h?.status ?? 200; + const body = h?.body ?? {}; + return { status, text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), json: async () => body }; + }; + return { transport: t, calls, on: (m: string, s: number, b: unknown) => handlers.push({ match: m, status: s, body: b }) }; +} + +const SERVER = 'https://api.seekquel.app/koreader'; +const CRED = { server: SERVER, key: 'ABCD-EFGH-IJKL-MNOP' }; +const MATCH = { externalId: DOC, confidence: 1, title: 'Piranesi', author: 'Susanna Clarke' }; +const progressEvent = (over: Partial = {}): OutboundEvent => ({ + kind: 'progress', + document: DOC, + percentage: 0.42, + progress: '/body/DocFragment[8]/body/p[4]/text()[1].96', + timestamp: 1_760_000_000, + ...over, +}); +const body = (call: { body?: string }) => JSON.parse(call.body ?? '{}'); + +const KEY = { TOKEN_ENC_KEY: 'a'.repeat(64) }; +beforeEach(() => { Object.assign(process.env, KEY); resetEncryptionKeyCache(); }); +afterEach(() => { delete process.env.TOKEN_ENC_KEY; delete process.env.SEEKQUEL_URL; resetEncryptionKeyCache(); }); + +describe('seekquel connector (unit)', () => { + it('points at Seekquel unless SEEKQUEL_URL says otherwise', () => { + expect(seekquelBase({})).toBe('https://api.seekquel.app'); + expect(seekquelBase({ SEEKQUEL_URL: 'http://localhost:8000/' })).toBe('http://localhost:8000'); + }); + + it('starts a device-code link as the crosspoint-sync client', async () => { + const fake = fakeTransport(); + fake.on('/koreader/pair/start', 201, { + device_code: 'd'.repeat(64), + user_code: 'WXYZ-2345', + verification_uri_complete: 'https://seekquel.app/settings/integrations/crosspoint-sync?pair=WXYZ-2345', + expires_in: 900, + interval: 5, + }); + const start = await seekquelConnector.beginLink!(fake.transport); + expect(body(fake.calls[0])).toEqual({ device_name: 'CrossPoint Sync', client: 'crosspoint-sync' }); + expect(start.userCode).toBe('WXYZ-2345'); + expect(start.verificationUri).toBe('https://seekquel.app/settings/integrations/crosspoint-sync'); + expect(start.verificationUriComplete).toContain('?pair=WXYZ-2345'); + }); + + it('says why when Seekquel refuses to start a link', async () => { + const fake = fakeTransport(); + fake.on('/koreader/pair/start', 422, { message: 'The selected client is invalid.' }); + await expect(seekquelConnector.beginLink!(fake.transport)).rejects.toThrow('The selected client is invalid.'); + }); + + it('maps each poll answer, and keeps the key once approved', async () => { + const fake = fakeTransport(); + fake.on('/koreader/pair/poll', 400, { error: { type: 'authorization_pending' } }); + expect((await seekquelConnector.pollLink!('dc', fake.transport)).status).toBe('pending'); + fake.on('/koreader/pair/poll', 400, { error: { type: 'access_denied' } }); + expect((await seekquelConnector.pollLink!('dc', fake.transport)).status).toBe('denied'); + fake.on('/koreader/pair/poll', 400, { error: { type: 'expired_token' } }); + expect((await seekquelConnector.pollLink!('dc', fake.transport)).status).toBe('expired'); + fake.on('/koreader/pair/poll', 200, { key: CRED.key, sync_url: SERVER, device_id: '01J' }); + const ok = await seekquelConnector.pollLink!('dc', fake.transport); + expect(ok).toMatchObject({ status: 'ok', credential: CRED }); + }); + + it('validates the key against /users/auth', async () => { + const fake = fakeTransport(); + fake.on('/users/auth', 200, { authorized: 'OK' }); + expect((await seekquelConnector.validate(CRED, fake.transport)).ok).toBe(true); + expect(fake.calls[0].headers?.['x-auth-user']).toBe(CRED.key); + fake.on('/users/auth', 401, {}); + expect((await seekquelConnector.validate(CRED, fake.transport)).ok).toBe(false); + }); + + it('matches by document id, with no network', async () => { + const fake = fakeTransport(); + const m = await seekquelConnector.match(CRED, { document: DOC, title: 'Piranesi', author: 'Susanna Clarke', filename: null }, fake.transport); + expect(m).toMatchObject({ externalId: DOC, confidence: 1, title: 'Piranesi' }); + expect(fake.calls).toHaveLength(0); + }); + + it('sends progress with the book metadata and the position, minus an xpath Seekquel cannot store', async () => { + const fake = fakeTransport(); + const r = await seekquelConnector.push( + CRED, + MATCH, + progressEvent({ position: { pctQ: 420000, spine: 7, page: 3, pages: 20, xpath: `/body/${'p'.repeat(200)}` } }), + fake.transport + ); + expect(r.ok).toBe(true); + const call = fake.calls[0]; + expect(call.method).toBe('PUT'); + expect(call.url).toBe(`${SERVER}/syncs/progress`); + expect(call.headers?.['x-auth-user']).toBe(CRED.key); + expect(body(call)).toEqual({ + document: DOC, + progress: '/body/DocFragment[8]/body/p[4]/text()[1].96', + percentage: 0.42, + device: 'CrossPoint Sync', + device_id: 'crosspoint-sync', + position: { pctQ: 420000, spine: 7, page: 3, pages: 20 }, + metadata: { title: 'Piranesi', authors: 'Susanna Clarke' }, + }); + }); + + it('marks a finished book Read after sending its place', async () => { + const fake = fakeTransport(); + const r = await seekquelConnector.push(CRED, MATCH, progressEvent({ kind: 'finished', percentage: 1 }), fake.transport); + expect(r.ok).toBe(true); + expect(fake.calls.map((c) => `${c.method} ${c.url}`)).toEqual([ + `PUT ${SERVER}/syncs/progress`, + `PUT ${SERVER}/documents/${DOC}/status`, + ]); + expect(body(fake.calls[1])).toEqual({ status: 'read' }); + }); + + it('sends a highlight with an id that stays the same when it is sent again', async () => { + const fake = fakeTransport(); + const ev: OutboundEvent = { + kind: 'highlight', + document: DOC, + timestamp: 1_760_000_000, + highlight: { text: 'The Beauty of the House is immeasurable', note: 'yes', chapter: 'Part 1', spine: 2, startOffset: 10, endOffset: 49, highlightedAt: 1_759_000_000 }, + }; + await seekquelConnector.push(CRED, MATCH, ev, fake.transport); + await seekquelConnector.push(CRED, MATCH, { ...ev, highlight: { ...ev.highlight!, note: 'edited' } }, fake.transport); + const [first, second] = fake.calls.map(body); + expect(fake.calls[0].url).toBe(`${SERVER}/highlights`); + expect(first.document).toBe(DOC); + expect(first.highlights[0]).toMatchObject({ + text: 'The Beauty of the House is immeasurable', + note: 'yes', + chapter: 'Part 1', + created_at: new Date(1_759_000_000 * 1000).toISOString(), + }); + expect(second.highlights[0].external_id).toBe(first.highlights[0].external_id); + }); + + it('retries highlights and the finished mark while Seekquel is still placing the book', async () => { + const fake = fakeTransport(); + fake.on('/highlights', 409, { error: { code: 'DOCUMENT_NOT_LINKED' } }); + fake.on('/status', 409, { error: { code: 'DOCUMENT_NOT_LINKED' } }); + const highlight = await seekquelConnector.push( + CRED, + MATCH, + { kind: 'highlight', document: DOC, timestamp: 1, highlight: { text: 'quote' } }, + fake.transport + ); + const finished = await seekquelConnector.push(CRED, MATCH, progressEvent({ kind: 'finished', percentage: 1 }), fake.transport); + expect(highlight).toMatchObject({ ok: false, retryable: true, error: expect.stringContaining('Seekquel') }); + expect(finished).toMatchObject({ ok: false, retryable: true, error: expect.stringContaining('Seekquel') }); + }); + + it('asks to link again on 401 and retries on 429 and server errors', async () => { + const fake = fakeTransport(); + fake.on('/syncs/progress', 401, {}); + expect(await seekquelConnector.push(CRED, MATCH, progressEvent(), fake.transport)).toMatchObject({ ok: false, needsReauth: true }); + fake.on('/syncs/progress', 429, {}); + expect(await seekquelConnector.push(CRED, MATCH, progressEvent(), fake.transport)).toMatchObject({ ok: false, retryable: true }); + fake.on('/syncs/progress', 503, {}); + expect(await seekquelConnector.push(CRED, MATCH, progressEvent(), fake.transport)).toMatchObject({ ok: false, retryable: true }); + }); +}); + +async function linkSeekquel(fake: ReturnType) { + const { app, db } = makeTestApp({}, { connectorTransport: fake.transport }); + const { headers } = await registerUser(app); + fake.on('/koreader/pair/start', 201, { device_code: 'd'.repeat(64), user_code: 'WXYZ-2345', verification_uri_complete: 'https://seekquel.app/settings/integrations/crosspoint-sync?pair=WXYZ-2345' }); + const begin = await app.request('/api/v1/connectors/seekquel/link/begin', { method: 'POST', headers }); + const { device_code } = await begin.json(); + fake.on('/koreader/pair/poll', 200, { key: CRED.key, sync_url: SERVER, device_id: '01J' }); + fake.on('/users/auth', 200, { authorized: 'OK' }); + const poll = await app.request('/api/v1/connectors/seekquel/link/poll', { method: 'POST', headers, body: JSON.stringify({ device_code }) }); + return { app, db, headers, linked: (await poll.json()).linked as boolean }; +} + +describe('seekquel connector (end to end)', () => { + it('links by device code, then delivers a device sync with the title the device sent', async () => { + const fake = fakeTransport(); + const { app, db } = makeTestApp({}, { connectorTransport: fake.transport }); + const { headers } = await registerUser(app); + + fake.on('/koreader/pair/start', 201, { device_code: 'd'.repeat(64), user_code: 'WXYZ-2345', verification_uri_complete: 'https://seekquel.app/settings/integrations/crosspoint-sync?pair=WXYZ-2345' }); + const begin = await app.request('/api/v1/connectors/seekquel/link/begin', { method: 'POST', headers }); + expect(begin.status).toBe(200); + const { device_code } = await begin.json(); + + fake.on('/koreader/pair/poll', 200, { key: CRED.key, sync_url: SERVER, device_id: '01J' }); + fake.on('/users/auth', 200, { authorized: 'OK' }); + const poll = await app.request('/api/v1/connectors/seekquel/link/poll', { method: 'POST', headers, body: JSON.stringify({ device_code }) }); + expect((await poll.json()).linked).toBe(true); + + await app.request('/syncs/progress', { + method: 'PUT', + headers, + body: JSON.stringify({ + document: DOC, + progress: '/body/p[2]', + percentage: 0.5, + device_id: 'd1', + metadata: { title: 'Piranesi', authors: 'Susanna Clarke', filename: 'piranesi.epub' }, + }), + }); + expect(claimReady(db, 10).length).toBeGreaterThan(0); + await drainQueue(db, fake.transport, 10); + + const sent = fake.calls.find((c) => c.url === `${SERVER}/syncs/progress`); + expect(sent?.method).toBe('PUT'); + expect(body(sent!)).toMatchObject({ + document: DOC, + percentage: 0.5, + device_id: 'crosspoint-sync', + metadata: { title: 'Piranesi', authors: 'Susanna Clarke', filename: 'piranesi.epub' }, + }); + }); + + it('marks a book Read once, and again only after the device has gone back below the end', async () => { + const fake = fakeTransport(); + const { app, db, headers, linked } = await linkSeekquel(fake); + expect(linked).toBe(true); + const sync = async (percentage: number) => { + await app.request('/syncs/progress', { + method: 'PUT', + headers, + body: JSON.stringify({ document: DOC, progress: '/body/p[9]', percentage, device_id: 'd1', metadata: { title: 'Piranesi', authors: 'Susanna Clarke' } }), + }); + await drainQueue(db, fake.transport, 10); + }; + const statusCalls = () => fake.calls.filter((c) => c.url.endsWith('/status')).length; + + await sync(1); + expect(statusCalls()).toBe(1); + await sync(1); + expect(statusCalls()).toBe(1); + await sync(0.1); + expect(statusCalls()).toBe(1); + await sync(1); + expect(statusCalls()).toBe(2); + }); +}); From c6234c0b5c2b9fca58b0c7f9f751986d5fa05793 Mon Sep 17 00:00:00 2001 From: sabristratos Date: Fri, 16 Oct 2026 17:13:23 +0100 Subject: [PATCH 2/2] Address review: 403 is not a relink, and a rejected link is not stored Only a 401 from Seekquel asks the reader to link again. The device-code poll now checks the connector's validation before storing a credential, as the token and OAuth paths already do. Seekquel's check treats only a 401 as a rejection, so a rate limit or a dropped connection while linking does not lose a link the reader just approved. --- src/connectors/seekquel.ts | 16 +++++++++++----- src/routes/v1/connectors.ts | 1 + test/seekquel.test.ts | 30 +++++++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/connectors/seekquel.ts b/src/connectors/seekquel.ts index 5e0f811..5f4ad7b 100644 --- a/src/connectors/seekquel.ts +++ b/src/connectors/seekquel.ts @@ -99,22 +99,28 @@ async function readJson(res: { json(): Promise }): Promise= 500) return { ok: false, retryable: true, error: `server ${status}` }; return { ok: false, retryable: false, error: `${what}: unexpected status ${status}` }; } +/** + * Only a 401 means Seekquel refused the key. The key being checked was issued by + * the pairing poll a moment ago, so a rate limit, a server error or a dropped + * connection here is no reason to throw away a link the reader just approved: + * the code is spent, and they would have to start again. + */ async function validate(cred: Credential, http: HttpTransport): Promise { const c = parseCred(cred); if (!c) return { ok: false, error: 'server and key are required' }; try { const res = await http(`${c.server}/users/auth`, { method: 'GET', headers: headers(c) }); - if (res.status === 200) return { ok: true, accountLabel: 'Seekquel' }; if (res.status === 401) return { ok: false, error: 'Seekquel no longer accepts this link. Link it again.' }; - return { ok: false, error: `unexpected status ${res.status}` }; - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; + return { ok: true, accountLabel: 'Seekquel' }; + } catch { + return { ok: true, accountLabel: 'Seekquel' }; } } diff --git a/src/routes/v1/connectors.ts b/src/routes/v1/connectors.ts index 598b97e..7bed5ed 100644 --- a/src/routes/v1/connectors.ts +++ b/src/routes/v1/connectors.ts @@ -283,6 +283,7 @@ export function connectorRoutes( const user = c.get('user'); // Confirm the freshly minted credential works, then store it. const v = await conn.validate(result.credential, transport); + if (!v.ok) return c.json({ status: 'error', error: v.error ?? 'Credential rejected' }, 400); upsertAccount(db, user.id, conn.id, result.credential, v.accountLabel ?? result.accountLabel ?? null); return c.json({ status: 'ok', linked: true }); } diff --git a/test/seekquel.test.ts b/test/seekquel.test.ts index 03c6f17..cece2fb 100644 --- a/test/seekquel.test.ts +++ b/test/seekquel.test.ts @@ -86,6 +86,14 @@ describe('seekquel connector (unit)', () => { expect((await seekquelConnector.validate(CRED, fake.transport)).ok).toBe(false); }); + it('keeps a just-approved link through a rate limit or server error on the check', async () => { + const fake = fakeTransport(); + fake.on('/users/auth', 429, {}); + expect((await seekquelConnector.validate(CRED, fake.transport)).ok).toBe(true); + fake.on('/users/auth', 503, {}); + expect((await seekquelConnector.validate(CRED, fake.transport)).ok).toBe(true); + }); + it('matches by document id, with no network', async () => { const fake = fakeTransport(); const m = await seekquelConnector.match(CRED, { document: DOC, title: 'Piranesi', author: 'Susanna Clarke', filename: null }, fake.transport); @@ -165,10 +173,14 @@ describe('seekquel connector (unit)', () => { expect(finished).toMatchObject({ ok: false, retryable: true, error: expect.stringContaining('Seekquel') }); }); - it('asks to link again on 401 and retries on 429 and server errors', async () => { + it('asks to link again on 401, gives up on 403 without asking, and retries on 429 and server errors', async () => { const fake = fakeTransport(); fake.on('/syncs/progress', 401, {}); expect(await seekquelConnector.push(CRED, MATCH, progressEvent(), fake.transport)).toMatchObject({ ok: false, needsReauth: true }); + fake.on('/syncs/progress', 403, {}); + const forbidden = await seekquelConnector.push(CRED, MATCH, progressEvent(), fake.transport); + expect(forbidden).toMatchObject({ ok: false, retryable: false }); + expect(forbidden).not.toHaveProperty('needsReauth'); fake.on('/syncs/progress', 429, {}); expect(await seekquelConnector.push(CRED, MATCH, progressEvent(), fake.transport)).toMatchObject({ ok: false, retryable: true }); fake.on('/syncs/progress', 503, {}); @@ -251,4 +263,20 @@ describe('seekquel connector (end to end)', () => { await sync(1); expect(statusCalls()).toBe(2); }); + + it('does not store a link whose key Seekquel refuses', async () => { + const fake = fakeTransport(); + const { app } = makeTestApp({}, { connectorTransport: fake.transport }); + const { headers } = await registerUser(app); + fake.on('/koreader/pair/start', 201, { device_code: 'd'.repeat(64), user_code: 'WXYZ-2345' }); + const begin = await app.request('/api/v1/connectors/seekquel/link/begin', { method: 'POST', headers }); + const { device_code } = await begin.json(); + fake.on('/koreader/pair/poll', 200, { key: CRED.key }); + fake.on('/users/auth', 401, {}); + const poll = await app.request('/api/v1/connectors/seekquel/link/poll', { method: 'POST', headers, body: JSON.stringify({ device_code }) }); + expect(poll.status).toBe(400); + expect((await poll.json()).status).toBe('error'); + const list = await (await app.request('/api/v1/connectors', { headers })).json(); + expect(list.connectors.find((c: { id: string }) => c.id === 'seekquel').linked).toBe(false); + }); });