Чужой идентификатор: владение в push и pull
Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.
Имя строки в «Подходе» придумывает телефон, а не сервер: без сети ждать номера не от кого. Значит, в теле запроса может приехать чужое имя, и это не взлом, а обычный POST. Приём пачки, собранный в прошлой статье, такому имени верит: он читает строку по имени, накладывает операцию, пишет итог — и итог получается чужой. Лечится это не проверкой внутри обработчика, а ключом, которым обработчик отпирает строку: под ключом с владельцем чужая строка для отправителя просто не существует. Вторая половина того же правила — выдача: условие видимости с владельцем внутри, одно на все читающие запросы. А отвечать на чужое имя приходится так же, как на несуществующее, — только статусов в этом протоколе нет, и «404» здесь оказывается вердиктом. Личность при всём этом сервер берёт не из тела запроса, а вычисляет заново на каждом запросе — в том числе на каждой отправке пачки.
Сервер части VI имён строкам не выдаёт. Их выдаёт телефон: журнал называет строки сам, чтобы подход мог сослаться на тренировку, созданную минуту назад без единого ответа сервера. Это условие задачи, а не недосмотр — в зале без сети номер просить не у кого. Плата за него ровно одна: имя приезжает в теле запроса, и подтвердить его сервер не может ничем. Подставить A-1 вместо своего имени не сложнее, чем поправить поле в отладчике браузера.
Приём пачки такому имени верит на слово. Он читает строку по имени, накладывает операцию на копию отметок, проверяет получившуюся строку и пишет итог — тем же «вставить или обновить», что и всегда. Ни на одном из трёх шагов не спрашивается, чья это строка. В модели прошлой статьи спрашивать было не у кого: пользователь там был один.
Сделаем двух. Аня записала подход — строка A-1. Боря присылает пачку с тем же именем: сначала правку веса, потом вставку целиком. Сервер один и тот же, отличается он единственным: ключом, которым отпирает строку итога. У первого это один row_id, как в миниатюре части V, у второго — пара (user_id, row_id). Прогноз: какие вердикты получит Боря у каждого, что останется в строке Ани и сколько строк будет у сервера?
У сервера с ключом из одного row_id обе операции Бори приняты. Правка веса — applied, то есть она легла в строку Ани как своя; следом вставка целиком перебила в той же строке и упражнение, и вес, и повторы. Строка Ани и строка Бори напечатались одинаково, потому что это одна и та же строка: у сервера их не две, а одна. Номер у неё теперь из счётчика Бори — два счётчика на одну строку ни о чём не договаривались, и что увидит по своему курсору Аня, зависит от того, чей счётчик успел уйти дальше.
У сервера с ключом-парой та же пачка разобрана иначе. Правка веса отвергнута с причиной нет полей: exercise, reps: под ключом Бори строки A-1 нет, наложение пошло на пустоту, и проверка строки после применения увидела неполную запись. Вставка целиком принята — и завела Боре его собственную строку. У сервера их стало две, у обеих row_id равен A-1, и строка Ани осталась ровно такой, какой была.
Заметьте, чего в лечении нет. Нет шага «а чья это строка?» — значит, нет и места, где его можно забыть. Ключ пары не добавляет проверки, он меняет то, что приём находит: частичная правка чужого имени отвергается ровно как правка несуществующей строки, а полная вставка заводит свою. Имя A-1 перестаёт быть общим на всех — как перестало быть общим имя операции, когда память вердиктов получила ключом пару.
Владение — это не проверка в обработчике, а ключ, которым обработчик отпирает строку. Проверку можно забыть, ключ — нет.
Правило читатель уже знает: чужой черновик отвечает так же, как несуществующая заметка, потому что отказ «нельзя» подтверждает существование. Там у правила было две половины: чужой черновик — 404, а чужая опубликованная заметка — 403, ведь её существование и так открыто. В дневнике второй половины нет: у подходов публичной стороны не бывает, и всё чужое скрыто целиком. Сама заметка про это стоит ещё в части про действия — на чужое и на несуществующее лучше отвечать одинаково.
А первая половина сталкивается с тем, что в протоколе синхронизации статусов нет. Код ответа здесь — про запрос целиком: нет токена, тело не разобралось, тело больше предела. По отдельной операции сервер отвечает вердиктом, и почему отказ по одной операции не 4xx, часть V показала прогоном: строгий сервер запирает нормальные записи ядовитой. Значит, «404» в этом протоколе — это rejected. Вопрос только в том, с какой причиной.
Ответ уже прозвучал, и он бесплатный: нет полей: exercise, reps — та самая причина, которой часть V отказывает правке строки, которой у сервера нет. Ключ пары чужую строку не находит, наложение идёт на пустоту, проверка строки после применения видит неполную запись — и отказ выходит тот же, слово в слово. Ничего специально «скрывающего» писать не пришлось.
Соблазн — написать. Отказ с внятной причиной легче отлаживать, а «нет полей» на чужой строке выглядит ложью: поля-то у неё есть. Заботливый сервер сначала найдёт строку по одному row_id, а потом объяснит: «строка другого пользователя». Посмотрим, что из этой заботы собирается. Имена строк телефон выдаёт подряд, поэтому, зная одно имя чужого устройства, знаешь и всю череду. У Ани три подхода; Боря перебирает первые шесть имён правкой веса и смотрит только на ответы. Прогноз: сколько разных ответов он получит у каждого сервера и что из них узнает?
Заботливому серверу хватило двух разных ответов, чтобы выдать карту: три имени из шести названы занятыми, и это ровно те три, что есть у Ани. Ни одного её поля Боря не увидел — и всё-таки знает, сколько у неё строк под этим устройством и где кончается её счётчик имён. Честный сервер отвечает одинаково на все шесть, и собрать из его ответов нельзя ничего.
Приставка A в примере — сокращение ради читаемости: настоящее имя строки начинается со случайного UUID устройства, и перебором его не угадать. Но угадывать чужие имена и не требуется — они приезжают к другому человеку законным путём.
Журнал выгружается в файл и загружается обратно, и загрузка кладёт операцию как есть — со своим именем и своей отметкой. Часть V на этом прямо настаивает: файл дневника можно раздавать свободно, потому что он не применяется, а предлагается, а что из него ново — решают имена операций. Аня отправила файл Боре в мессенджер, Боря открыл его у себя — и в его журнале оказались и её имена строк, и её имена операций. Дальше телефон отправит их серверу как неотправленные: для него они новые.
Под ключом-парой это не беда, а ровно то поведение, которое обещано: у Бори появится собственная копия дневника, потому что каждая строка заводится под его владельцем, а память вердиктов с ключом (user_id, op_id) не путает его вердикты с её. Под ключом из одного row_id тот же файл, открытый из любопытства, молча перепишет дневник Ани.
Разница в ответах — это и есть утечка. Не поля, не имена, а сам факт, что на два разных случая пришло два разных слова.
В выдаче владение стоит одним местом, и это уже разобранное правило: условие видимости — часть каждого читающего запроса, а не свойство списка, и репозиторий — единственная дверь к строкам. У синхронизации читающий запрос всего один, sync.pull, и условие у него из двух частей: user_id = $1 and seq > $2.
Казалось бы, забыть первую часть — это утечка, и только: приедет лишнее. У синхронизации выходит хуже. Номера выдаёт строка-счётчик каждого пользователя, то есть номер уникален только внутри человека: единица есть у Ани, единица есть и у Бори. А курсор — одно число, и двигается он за последнюю отданную строку страницы.
Соберём это на модели. У Ани три строки с номерами 1, 2, 3; у Бори две — их записал его планшет, а забирает телефон, который этих операций не писал: фильтр эха по префиксу имени их не отсеет. Телефон крутит обычный цикл, пока сервер говорит «ещё есть», и берёт по одной строке за заход. Прогноз: что приедет телефону, за сколько заходов и где встанет курсор — если владельца в условии забыли и если он на месте?
Без владельца в условии телефон Бори получил три чужих строки и ни одной своей. Дело не в порядке: строки с равными номерами сервер отдаёт в порядке, который ничем не обещан, — эту ловушку разбирала глава про данные, и последний ключ сортировки обязан быть уникальным. Но куда бы ни упал жребий, страница из одной строки берёт только одну из двух единиц, курсор после неё становится единицей, и вторая остаётся позади него навсегда. Ни ошибки, ни отказа, ни повтора: у Бори в дневнике чужие подходы, а своих нет. С владельцем в условии приехали ровно его две строки за два захода.
Тем же условием и тем же user_id отбираются надгробия: удалённая строка — обычная строка итога с полем deleted, и ничего особенного для неё в выдаче нет. Отдельного запроса «а теперь удалённые» не существует — а значит, нет и второго места, где можно забыть владельца. Ровно за это условие видимости и держат в одном месте: sync.pull — не единственный читающий запрос сервера, он единственный читающий запрос синхронизации.
Курсор чужого человека не значит ничего — пока владелец стоит в условии. Уберите его, и одно число начнёт говорить сразу про двоих.
Таблиц у главы три, и владелец стоит в ключе каждой. sync_heads — строка-счётчик, ключ user_id. sync_rows — итог строки, ключ (user_id, row_id) и индекс (user_id, seq) под выдачу. sync_verdicts — память вердиктов, ключ (user_id, op_id). У двух последних причина одна, и она в первой фразе статьи: имена придумывает клиент. У строки-счётчика владелец и есть весь ключ, и это другая причина: счётчик на то и раздельный, чтобы пачки одного человека не ждали чужих.
| Таблица | Ключ | Если владельца в ключе забыть |
|---|---|---|
sync_heads | user_id | Забывать нечего: строка на человека и есть ключ. Ошибка тут обратная — ключ шире: счётчиков у человека станет несколько, и номера перестанут быть порядком его правок |
sync_rows | (user_id, row_id) | Чужое имя строки угоняет строку: вставка целиком перезаписывает её, частичная правка ложится в неё как своя, а номер приходит из чужого счётчика |
sync_verdicts | (user_id, op_id) | Чужое имя операции отдаёт чужой вердикт: человек получает duplicate на запись, которой у него нет, и своей записи не дождётся никогда |
Ту же карту можно собирать и через память вердиктов, если ключом в ней оставить один op_id. Повтор уже принятой операции получает duplicate, новая — applied, и это два разных слова на два разных случая: отправитель перебирает имена операций и по ответу узнаёт, какие из них у сервера уже есть. Имя операции в «Подходе» — имя устройства со счётчиком, так что заодно выясняется, сколько операций успело сделать чужое устройство.
Ключ (user_id, op_id) закрывает и это: чужое имя операции разбирается заново, как своё, и duplicate отправитель получит только на свой собственный повтор. Прошлая статья показала это на модели — та же пачка от другого человека была разобрана целиком, хотя имена операций в ней были те же. Приятная особенность обоих ключей: они не добавляют ни одной проверки, поэтому и обойти их изнутри обработчика нечем.
В описании схемы итог строки выглядит так. Ключ объявляется тем же третьим аргументом pgTable, что и ключ памяти вердиктов, а рядом с ним встаёт индекс под выдачу:
Ключ пары меняет ещё одно место, и его легко не заметить. Итог строки пишется «вставить или обновить», а у этой формы есть цель конфликта — колонки, по которым Postgres понимает, что такая строка уже есть. Цель обязана совпасть с ключом, и вот что бывает, когда не совпала. В прогоне две таблицы, различающиеся только первичным ключом, у обеих есть строка Ани, и обеим приходит чужое имя от Бори. Прогноз: что сделает ON CONFLICT (row_id) DO UPDATE с таблицей, где ключ — один row_id, чем ответит та же команда таблице с ключом-парой и что останется в таблицах в конце?
import pg from 'pg'; // адрес базы — первой строкой и без пароля: видно, куда ушли запросы const url = new URL(process.env.DATABASE_URL); url.password = ''; console.log('база:', url.href); const db = new pg.Client({ connectionString: process.env.DATABASE_URL }); await db.connect(); // Итог строки: поля и номер последней правки. Две таблицы различаются ТОЛЬКО // первичным ключом — в первой он такой, как в миниатюре части V, где // пользователь один await db.query(`CREATE TABLE rows_by_row ( user_id text NOT NULL, row_id text NOT NULL, fields jsonb NOT NULL, seq bigint NOT NULL, PRIMARY KEY (row_id) )`); await db.query(`CREATE TABLE rows_by_pair ( user_id text NOT NULL, row_id text NOT NULL, fields jsonb NOT NULL, seq bigint NOT NULL, PRIMARY KEY (user_id, row_id) )`); const ANN = { exercise: 'жим', weightKg: 80, reps: 5 }; const BOB = { exercise: 'тяга', weightKg: 60, reps: 10 }; // строка Ани в обеих таблицах: имя «A-1» придумал её телефон for (const table of ['rows_by_row', 'rows_by_pair']) { await db.query(`INSERT INTO ${table} (user_id, row_id, fields, seq) VALUES ($1, $2, $3, $4)`, [ 'ann', 'A-1', ANN, 1, ]); } // «вставить или обновить» — этим сервер пишет итог строки после приёма операции const upsert = (table, target) => `INSERT INTO ${table} (user_id, row_id, fields, seq) VALUES ($1, $2, $3, $4) ON CONFLICT ${target} DO UPDATE SET user_id = excluded.user_id, fields = excluded.fields, seq = excluded.seq RETURNING user_id, row_id`; const state = async (table) => { const { rows } = await db.query(`SELECT user_id, row_id, fields FROM ${table} ORDER BY user_id, row_id`); return rows.map((row) => `${row.user_id}/${row.row_id} ${JSON.stringify(row.fields)}`).join(' | ') || 'строк нет'; }; // Боря присылает чужое имя строки: для сервера это обычный POST const one = await db.query(upsert('rows_by_row', '(row_id)'), ['bob', 'A-1', BOB, 1]); console.log('ключ (row_id): строк вернулось', one.rowCount, '→', `${one.rows[0].user_id}/${one.rows[0].row_id}`); console.log(' в таблице:', await state('rows_by_row')); // тот же запрос против ключа-пары try { const same = await db.query(upsert('rows_by_pair', '(row_id)'), ['bob', 'A-1', BOB, 1]); console.log('ключ пары, цель конфликта (row_id): строк вернулось', same.rowCount); } catch (error) { console.log('ключ пары, цель конфликта (row_id):', error.code, '·', error.message); } // цель конфликта — весь ключ const pair = await db.query(upsert('rows_by_pair', '(user_id, row_id)'), ['bob', 'A-1', BOB, 1]); console.log('цель конфликта (user_id, row_id): строк вернулось', pair.rowCount, '→', `${pair.rows[0].user_id}/${pair.rows[0].row_id}`); console.log(' в таблице:', await state('rows_by_pair')); // выдача: то же условие владения, что у всякого читающего запроса for (const table of ['rows_by_row', 'rows_by_pair']) { const mine = await db.query(`SELECT row_id FROM ${table} WHERE user_id = $1 AND seq > $2 ORDER BY seq`, ['ann', 0]); console.log(`выдача Ане из ${table}: строк`, mine.rowCount); } await db.end();
база: postgres://postgres@127.0.0.1:<порт-базы>/app
ключ (row_id): строк вернулось 1 → bob/A-1
в таблице: bob/A-1 {"reps":10,"exercise":"тяга","weightKg":60}
ключ пары, цель конфликта (row_id): 42P10 · there is no unique or exclusion constraint matching the ON CONFLICT specification
цель конфликта (user_id, row_id): строк вернулось 1 → bob/A-1
в таблице: ann/A-1 {"reps":5,"exercise":"жим","weightKg":80} | bob/A-1 {"reps":10,"exercise":"тяга","weightKg":60}
выдача Ане из rows_by_row: строк 0
выдача Ане из rows_by_pair: строк 1У таблицы с ключом из одного row_id команда прошла и вернула строку — ту самую, что была у Ани, только теперь она числится за Борей. DO UPDATE перезаписал и поля, и user_id: владелец оказался обычной колонкой, а обычную колонку присваивают. Строк в таблице по-прежнему одна, а выдача Ане отдаёт ноль — её подхода у сервера больше нет, и сообщения об этом никому не было.
У таблицы с ключом-парой та же команда даже не выполнилась: 42P10, «there is no unique or exclusion constraint matching the ON CONFLICT specification». Цель конфликта обязана совпасть с уникальным индексом таблицы — своим или тем, что стоит за первичным ключом, — а по одному row_id у этой таблицы индекса нет; в сообщении база называет его ограничением. Это и есть вторая польза от ключа, который шире: неверную форму записи он превращает в ошибку при первом же запросе, а не в тихую потерю через полгода. С целью (user_id, row_id) та же вставка завела Боре собственную строку: строк стало две, у обеих row_id равен A-1, а выдача Ане по-прежнему отдаёт её одну. Присваивание user_id = excluded.user_id при таком ключе лишнее по построению: строка, найденная по ключу-паре, уже принадлежит тому же владельцу.
И последнее место, где тот же ключ работает молча. У отказа в части VI появится поле со строкой целиком: сервер прикладывает её, чтобы телефон пересобрал строку из своего и чужого. Читается она тем же ключом пары — поэтому на правку чужого имени приходит rejected с той же причиной и пустой строкой: у отправителя такой строки нет, и вырезать из отказа чужие поля не приходится. Зачем строка в отказе нужна и что с ней делает клиент, разбирает статья про выдачу с курсора.
Тот же угон и тот же ключ ждут за пределами синхронизации. Имя фотографии телефон тоже придумывает сам, и чужое имя перезаписало бы чужое фото — статья про загрузку файлов берёт пару (user_id, photo_id) и отвечает на чужое имя как на несуществующее.
Цель конфликта — это ключ, записанный второй раз. Там, где две записи расходятся, база либо кричит сразу, либо молча пишет не туда.
Синхронизация у автора работает в другом приложении — том, откуда взята ловушка с номером и порядком фиксаций. Одно там устроено так же, другое иначе, и второе честнее назвать сразу: имена строк выдаёт сервер. Сообщение получает id колонкой generated always as identity, подставить своё приложение не может — значит, угона строки по чужому имени там не бывает вовсе.
Клиентское имя там всё-таки есть — client_mid, которым телефон делает повтор отправки безобидным. И уникальность у него ровно такая, как в этой статье: не одна колонка, а пара (chat_id, client_mid). Владелец здесь не пользователь, а диалог, но принцип тот же: имя, придуманное клиентом, уникально только внутри области, которой распоряжается сервер. А область эта проверена до того, как имя пущено в дело:
Три вещи стоит заметить. Первая: условие владения диалогом вынесено в функцию — то самое правило «одна проверка на все пишущие обработчики» из главы про доступ. Держится оно не везде: findChatContext, с которого начинается отправка, выписывает тот же and(…) по месту, и такую копию не поймает ни компилятор, ни сторож. В выдаче догона условие того же вида — and(eq(messages.userId, userId), gt(messages.seq, cursor)). Вторая: чужой диалог отвечает CHAT_NOT_FOUND, то есть как несуществующий, и до дедупа по client_mid дело не доходит — владелец проверен раньше, чем клиентское имя пущено в дело. Третья: индекс (user_id, seq) — тот же, что у sync_rows этой главы, и стоит он ровно для того же: страница отбирается по владельцу и номеру сразу.
И честная граница, которую стоит назвать прямо, пока читатель не решил, что владение решает всё. Владение закрывает дневник от других пользователей — и только от них. Оператор сервера видит его целиком: сквозного шифрования в «Подходе» нет, и это выбор, а не забытая задача. Сервер, который не может прочитать данные, — другой сервер: проверку строки после применения ему пришлось бы выбросить, а слияние по полям с отметками — целиком отдать клиенту.
Клиентское имя годится в ключ только вместе с областью, которой распоряжается сервер: имя приехало в теле запроса, область — нет.
- Имя строки придумывает телефон, поэтому чужое имя в теле запроса — не взлом, а обычный
POST. Приём пачки без владельца в ключе строки угоняет чужую строку: вставка целиком перезаписывает её, частичная правка ложится в неё как своя. - Лечение — первичный ключ
(user_id, row_id), а не проверка внутри обработчика. Под ключом пары чужая строка для отправителя не существует: правка отвергается как правка несуществующей строки, вставка заводит его собственную. Проверку можно забыть, ключ — нет. - «404» в протоколе части V — это не статус и не ошибка запроса, а
rejectedс той же причинойнет полей: …, что у правки несуществующей строки. Отдельный ответ «строка другого пользователя» из двух слов складывает карту чужих имён — ровно так же, как это сделал бы 403 на чужой черновик в главе про доступ. - В выдаче владение стоит одним условием:
user_id = $1 and seq > $2, и теми же двумя частями отбираются надгробия. Забытыйuser_idтут не просто утечка: номера у каждого свои, поэтому страница берёт одну строку из двух с равным номером, а курсор уходит за неё — и свои строки теряются молча. - Владелец — часть ключа всех трёх таблиц главы:
sync_heads (user_id),sync_rows (user_id, row_id),sync_verdicts (user_id, op_id). Цель конфликта у «вставить или обновить» обязана совпасть с ключом: у таблицы с ключом-парой неверная цель — ошибка42P10, у таблицы с ключом из одногоrow_id— тихий угон. - Границу стоит назвать прямо: владение закрывает дневник от других пользователей и не закрывает от оператора сервера. Сквозного шифрования в «Подходе» нет осознанно — сервер, который не может прочитать строку, не сможет и проверить её после применения.
1/4Приём пачки нашёл строку по имени A-1, наложил на неё правку веса и записал итог. Владельца в ключе строки нет, имя прислал другой человек. Что произошло?
2/4Чем отказ на правку чужой строки отличается от отказа на правку строки, которой у сервера нет?
3/4В sync.pull забыли user_id в условии. Номера выдают строки-счётчики пользователей, телефон заходит с limit 1. Что с ним будет?
4/4Первичный ключ sync_rows — (user_id, row_id), а «вставить или обновить» написано с целью конфликта (row_id). Чем ответит Postgres?
Цель конфликта, которая не совпала с ключом
Итог строки пишется вот так, а первичный ключ таблицы — (user_id, row_id). Скажите, что сделает этот код, и исправьте его. Второй вопрос: что сделал бы тот же код, если бы ключом таблицы был один row_id?
С ключом (user_id, row_id) запрос не выполнится вовсе: Postgres ответит 42P10 — «there is no unique or exclusion constraint matching the ON CONFLICT specification». Цель конфликта обязана совпасть с уникальным индексом таблицы, а по одному row_id у неё индекса нет. Лечение — весь ключ в цели:
userId из set заодно можно убрать: строка найдена по ключу-паре, значит, владелец у неё уже тот же, и присваивание ничего не меняет.
А с ключом из одного row_id исходный код отработал бы молча и сделал бы худшее из возможного: нашёл чужую строку по имени, присвоил ей нового владельца вместе с полями — и у прежнего владельца строка бы исчезла. Ошибка 42P10 в этом свете не помеха, а страховка: она не даёт написать угон случайно.
Отказ, который рассказал лишнее
Разработчик устал искать причину отказов в журнале и добавил в приём пачки ветку. Тесты зелёные, отладка стала удобнее. Скажите, что теперь можно собрать чужими руками, и предложите замену, от которой отладка не пострадает.
Собрать можно карту чужих имён строк. Отправитель перебирает имена частичной правкой и смотрит на причину: «строка другого пользователя» значит «имя занято», нет полей: … — «имени нет ни у кого». Полей он не видит, но узнаёт, сколько у другого человека записей и под какими именами, а имена в «Подходе» несут имя устройства — то есть заодно и сколько устройств у владельца.
Замена: ветку убрать целиком. Причина отказа принадлежит отправителю, и для него чужая строка обязана быть неотличима от несуществующей. Отладка от этого не страдает, потому что искомое пишется в журнал сервера — строкой с постоянным сообщением и полями user_id, row_id и вердикт. Разница простая: журнал читает тот, кто сервер держит, а причину отказа — тот, кто прислал операцию.
Полезно заметить и то, что чинить-то было нечего: «нет полей» на чужой строке выглядит ложью только с точки зрения сервера, который видит обе. С точки зрения отправителя это правда — у него такой строки нет.
Курсор чужого человека
Курсор клиент хранит у себя и присылает в каждом sync.pull: обычное число, ничем не подписанное. Значит, прислать можно любое — в том числе подсмотренное у другого человека. Ответьте на три вопроса: что узнает отправитель, прислав чужой курсор; что случится, если он пришлёт число больше любого выданного; и надо ли курсор прятать.
Не узнает ничего. Условие выдачи — user_id = $1 and seq > $2, и user_id берётся не из запроса, а из проверенной личности. Чужое число попадает только во вторую половину условия и отбирает его собственные строки: номера у каждого свои, и про другого человека это число не говорит ничего.
Курсор больше любого выданного даёт пустую страницу и hasMore: false — своими же строками отправитель себя и обделил, пока не сбросит курсор в ноль. Курсор меньше нужного безобиден по другой причине: приедут уже применённые строки, а слияние по полям идемпотентно — ни одна отметка не сдвинется, экран не дрогнет.
Прятать курсор незачем: он не право, а закладка. Право — это user_id, и его отправитель не присылает вовсе. Обратное правило тоже стоит помнить: как только в условии выдачи появляется что-то, приехавшее от клиента, кроме курсора и предела, — это уже не закладка, а разрешение, и проверять его придётся всерьёз.