From 04649a13c8543efdfef541cbc1f2c2afcb221419 Mon Sep 17 00:00:00 2001 From: Eszter Date: Sat, 24 Oct 2026 20:56:35 +0200 Subject: [PATCH] Let API clients say when a position was read A client that syncs some time after reading (an importer that runs every hour, say) had its progress stamped with the time of the request, so the reading timeline showed the read at the wrong time and an older position could count as the newest. PUT /api/v1/progress now takes an optional `timestamp` in unix seconds and stores that instead. A future time is capped at now; a missing one, or one before 2000, means now. /syncs/progress and the batch route keep using the time of the request, so a reader with an unset clock can't backdate its own progress. --- docs/API.md | 6 +++++ src/routes/kosync.ts | 19 +++++++++++++-- src/routes/v1/progress.ts | 2 +- test/progress-client-time.test.ts | 39 +++++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 test/progress-client-time.test.ts diff --git a/docs/API.md b/docs/API.md index a045227..b15b815 100644 --- a/docs/API.md +++ b/docs/API.md @@ -222,6 +222,12 @@ The kosync body plus a `position` object mapping 1:1 to the firmware's `CompactP Response: `{"document": "...", "timestamp": 1752345678}`. +An optional `timestamp` (unix seconds) says when the position was read, for clients that sync +later than they read. The stored time, the reading timeline and "newest wins" then use it instead +of the time of the request. A time in the future is capped at now; a missing one, or one before +2000, means now. `PUT /syncs/progress` and the batch route always use the time of the request, so +a reader with an unset clock can't backdate itself. + An invalid `position` is ignored (the kosync fields still sync); a missing `position` on a later PUT keeps the previously stored one for that device. diff --git a/src/routes/kosync.ts b/src/routes/kosync.ts index 6660117..05bb5c9 100644 --- a/src/routes/kosync.ts +++ b/src/routes/kosync.ts @@ -291,7 +291,8 @@ export function upsertProgress(db: DB, p: ProgressUpsert): void { */ export function parseProgressBody( userId: number, - body: unknown + body: unknown, + opts: { clientTime?: boolean } = {} ): { ok: true; record: ProgressUpsert } | { ok: false; code: number; message: string } { if (typeof body !== 'object' || body === null) { return { ok: false, code: 2003, message: 'Invalid request' }; @@ -328,11 +329,25 @@ export function parseProgressBody( progress: o.progress, position, metadata: parseMetadata(o.metadata), - updatedAt: nowSeconds(), + updatedAt: opts.clientTime ? clientTime(o.timestamp) : nowSeconds(), }, }; } +// Oldest client time taken as real; anything earlier is an unset clock. +const EARLIEST_CLIENT_TIME = 946684800; // 2000-01-01 + +/** + * When the position was read, for clients that sync later than they read (an + * importer running hourly, say). Whole unix seconds; a time in the future is + * capped at now, and a missing or implausible one falls back to now. + */ +function clientTime(value: unknown): number { + const now = nowSeconds(); + if (typeof value !== 'number' || !Number.isInteger(value) || value < EARLIEST_CLIENT_TIME) return now; + return Math.min(value, now); +} + /** * Stores a device's progress write the way the kosync PUT does: under the * document's canonical hash, as a position sample for fan-in replays, and fanned diff --git a/src/routes/v1/progress.ts b/src/routes/v1/progress.ts index c7e08b2..f302139 100644 --- a/src/routes/v1/progress.ts +++ b/src/routes/v1/progress.ts @@ -33,7 +33,7 @@ export function progressRoutes(db: DB, refreshProgress: ProgressRefresh = async return kosyncError(c, 403, 2003, 'Invalid request'); } const user = c.get('user'); - const parsed = parseProgressBody(user.id, body); + const parsed = parseProgressBody(user.id, body, { clientTime: true }); if (!parsed.ok) { return kosyncError(c, 403, parsed.code, parsed.message); } diff --git a/test/progress-client-time.test.ts b/test/progress-client-time.test.ts new file mode 100644 index 0000000..ff868d2 --- /dev/null +++ b/test/progress-client-time.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { DOC, makeTestApp, registerUser } from './helpers.js'; + +const READ_AT = 1767254400; // 2026-01-01T08:00:00Z + +describe('client time on PUT /api/v1/progress', () => { + it('keeps when the position was read, and only on the API route', async () => { + const { app } = makeTestApp(); + const { headers } = await registerUser(app); + const body = (percentage: number, device_id: string, timestamp?: unknown) => + JSON.stringify({ document: DOC, progress: 'p', percentage, device: device_id, device_id, ...(timestamp === undefined ? {} : { timestamp }) }); + const put = async (percentage: number, device_id: string, timestamp?: unknown) => + ((await (await app.request('/api/v1/progress', { method: 'PUT', headers, body: body(percentage, device_id, timestamp) })).json()) as { timestamp: number }).timestamp; + const now = () => Math.floor(Date.now() / 1000); + + // A past time is stored as given, and the timeline puts the read on that day. + expect(await put(0.2, 'importer', READ_AT)).toBe(READ_AT); + const listed = (await (await app.request('/api/v1/progress', { headers })).json()) as { items: { timestamp: number }[] }; + expect(listed.items[0].timestamp).toBe(READ_AT); + const activity = (await (await app.request('/api/v1/stats/activity', { headers })).json()) as { days: { day: string }[] }; + expect(activity.days.map((d) => d.day)).toContain('2026-01-01'); + + // The future is capped at now; garbage and an unset clock mean now. + const before = now(); + expect(await put(0.3, 'importer', now() + 86400)).toBeLessThanOrEqual(now()); + expect(await put(0.4, 'importer', '1767254400')).toBeGreaterThanOrEqual(before); + expect(await put(0.5, 'importer', 12)).toBeGreaterThanOrEqual(before); + + // An older position from one device doesn't beat a newer one from another. + await put(0.6, 'reader'); + await put(0.9, 'importer', READ_AT); + const newest = (await (await app.request(`/syncs/progress/${DOC}`, { headers })).json()) as { percentage: number }; + expect(newest.percentage).toBe(0.6); + + // The kosync route ignores a client time. + const res = await app.request('/syncs/progress', { method: 'PUT', headers, body: body(0.7, 'reader', READ_AT) }); + expect(((await res.json()) as { timestamp: number }).timestamp).toBeGreaterThanOrEqual(before); + }); +});