fix(api): honor the request's origin as the writer label — app rows stop wearing "web"
Release / build-and-push (push) Successful in 3m36s

Every web-API write endpoint hardcoded Source:"web", so the Android
app's own pushes landed with last_sync_source/last_modified_source
"web" — the app was indistinguishable from the web SPA in progress
conflicts, device_sync_data, and annotation bookkeeping. The app CAN
say who it is (bookmark create already carried origin → origin_source);
the server just ignored it for labeling.

All six write sites (progress PUT, bookmark/note/highlight create+PUT)
now parse an ADDITIVE optional "origin" key, normalized through one
gate: "android"/"ios" pass through, anything absent/unknown stays
"web" (the label keys conflict claims and device_sync_data — free-form
client strings would pollute them). Precedence on progress: a paired
X-Device-Token still outranks origin. Bookmark origin_source now rides
the same gate. The web SPA sends no origin — its rows are unchanged;
KOReader/Kobo handlers keep their own labels.

Wire discipline: additive request keys only (rule 2), response shapes
untouched, pins changelog widened in-commit, locked by
TestOriginWriterAttribution (android lands, default stays web, unknown
normalizes). Full battery: only the two known pre-existing failures
(TestOPDSLibraryFolders rot, TestWebSocketPingPong — both reproduce
with the change stashed).
This commit is contained in:
John O'Keefe committed 2026-10-17 21:44:27 -04:00
1 parent 8f05f1116e
commit 45b8542e25
4 files changed
+130 -10

No files matched your search

+38 -10
View File
@@ -107,16 +107,35 @@ type UpdateMediaItemRequest struct {
Abridged bool `form:"abridged" json:"abridged"`
}
// originSource normalizes a client-supplied origin label into the writer
// Source ("android"/"ios"); anything absent or unknown stays "web". The
// web API serves several first-party clients, and the label is what
// last_modified_source / last_sync_source record — a client that
// identifies itself gets honest attribution. Keep the set tight: the
// label also keys device_sync_data and progress-conflict claims.
func originSource(origin string) string {
switch origin {
case "android", "ios":
return origin
}
return "web"
}
// CreateMediaNoteRequest represents the request for creating a media note
type CreateMediaNoteRequest struct {
Content string `json:"content" validate:"required,min=1,max=100000"`
Position string `json:"position"`
// Optional client label recorded as the writer (see originSource).
Origin string `json:"origin"`
}
// UpdateMediaNoteRequest represents the request for updating a media note
type UpdateMediaNoteRequest struct {
Content string `json:"content" validate:"required,min=1,max=100000"`
Position string `json:"position"`
// Optional client label ("android", "ios") recorded as the writer in
// last_modified_source; absent/unknown stays "web".
Origin string `json:"origin"`
}
// CreateMediaHighlightRequest represents the request for creating a media highlight
@@ -132,6 +151,8 @@ type CreateMediaHighlightRequest struct {
PercentageStart float64 `json:"percentage_start"`
PercentageEnd float64 `json:"percentage_end"`
ChapterReference int32 `json:"chapter_reference"`
// Optional client label recorded as the writer (see originSource).
Origin string `json:"origin"`
}
// UpdateMediaHighlightRequest represents the request for updating a media highlight
@@ -147,6 +168,8 @@ type UpdateMediaHighlightRequest struct {
PercentageStart float64 `json:"percentage_start"`
PercentageEnd float64 `json:"percentage_end"`
ChapterReference int32 `json:"chapter_reference"`
// Optional client label recorded as the writer (see originSource).
Origin string `json:"origin"`
}
// CreateMediaBookmarkRequest represents the request for creating a media bookmark
@@ -177,6 +200,9 @@ type UpdateMediaBookmarkRequest struct {
ChapterNumber int32 `json:"chapter_number"`
Percentage float64 `json:"percentage"`
ChapterReference int32 `json:"chapter_reference"`
// Optional client label recorded as the writer in
// last_modified_source (see originSource).
Origin string `json:"origin"`
}
type MediaHandler struct {
@@ -1270,6 +1296,11 @@ func (mh *MediaHandler) UpdateMediaReadingProgress(c *echo.Context) error {
req.MediaItemID = pgtype.UUID{Bytes: mediaUUID, Valid: true}
req.UserID = pgtype.UUID{Bytes: userUUID, Valid: true}
req.Source = deviceSource
if deviceSource == "web" {
// No paired device claimed this push: an explicit client origin
// ("android"/"ios") is the next-best attribution.
req.Source = originSource(req.Origin)
}
req.DeviceID = deviceID
req.DeviceType = deviceType
req.DeviceName = deviceLabel
@@ -1829,7 +1860,7 @@ func (mh *MediaHandler) CreateMediaNote(c *echo.Context) error {
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Content: req.Content,
Position: req.Position,
Source: "web",
Source: originSource(req.Origin),
ModifiedAt: time.Now(),
})
if err != nil {
@@ -1895,7 +1926,7 @@ func (mh *MediaHandler) UpdateMediaNote(c *echo.Context) error {
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
Content: req.Content,
Position: req.Position,
Source: "web",
Source: originSource(req.Origin),
ModifiedAt: time.Now(),
NoteID: pgtype.UUID{Bytes: noteUUID, Valid: true},
})
@@ -1995,7 +2026,7 @@ func (mh *MediaHandler) CreateMediaHighlight(c *echo.Context) error {
PercentageStart: req.PercentageStart,
PercentageEnd: req.PercentageEnd,
ChapterReference: req.ChapterReference,
Source: "web",
Source: originSource(req.Origin),
ModifiedAt: time.Now(),
})
if err != nil {
@@ -2067,7 +2098,7 @@ func (mh *MediaHandler) UpdateMediaHighlight(c *echo.Context) error {
PercentageStart: req.PercentageStart,
PercentageEnd: req.PercentageEnd,
ChapterReference: req.ChapterReference,
Source: "web",
Source: originSource(req.Origin),
ModifiedAt: time.Now(),
// The PUT targets this exact row (from the URL): identity must
// not be re-derived from content — a device echo has usually
@@ -2156,10 +2187,7 @@ func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error {
// The sync-aware path (dedup + LWW + tombstones) is the only path: the
// annotation service is a required constructor dependency.
origin := req.Origin
if origin == "" {
origin = "web"
}
origin := originSource(req.Origin)
result, err := mh.annotationSvc.SaveBookmark(c.Request().Context(), wsync.SaveBookmarkRequest{
MediaItemID: pgtype.UUID{Bytes: mediaUUID, Valid: true},
UserID: pgtype.UUID{Bytes: userUUID, Valid: true},
@@ -2171,7 +2199,7 @@ func (mh *MediaHandler) CreateMediaBookmark(c *echo.Context) error {
CFIPosition: req.CfiPosition,
PercentageLoc: req.Percentage,
ChapterReference: req.ChapterReference,
Source: "web",
Source: originSource(req.Origin),
OriginSource: origin,
ModifiedAt: time.Now(),
})
@@ -2222,7 +2250,7 @@ func (mh *MediaHandler) UpdateMediaBookmark(c *echo.Context) error {
CFIPosition: req.CfiPosition,
PercentageLoc: req.Percentage,
ChapterReference: req.ChapterReference,
Source: "web",
Source: originSource(req.Origin),
ModifiedAt: time.Now(),
BookmarkID: pgtype.UUID{Bytes: bookmarkUUID, Valid: true},
})