@okostream/mcp 0.11.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -36,8 +36,13 @@ export function registerChatTools(server) {
36
36
  ? [orderByKey(), endBefore(cursor), limitToLast(max + 1)]
37
37
  : [orderByKey(), limitToLast(max + 1)];
38
38
  const snap = await get(dbQuery(ref(chatRtdb, `messages/${eventId}`), ...constraints));
39
+ // 🔴 Побайтовое сравнение, а не `localeCompare`. RTDB упорядочивает
40
+ // push-ключи по кодам символов, а `localeCompare` учитывает локаль и
41
+ // сортирует регистр наоборот: push-ключи содержат и цифры, и обе
42
+ // раскладки букв, поэтому порядок расходился с серверным. Курсор брался
43
+ // не тот, и страницы повторялись или пропускались.
39
44
  const entries = Object.entries((snap.val() ?? {}))
40
- .sort(([a], [b]) => b.localeCompare(a));
45
+ .sort(([a], [b]) => (a < b ? 1 : a > b ? -1 : 0));
41
46
  const { page, hasMore } = pickPage(entries, max);
42
47
  const messages = page.map(([id, message]) => ({ id, ...serialize(message) }));
43
48
  const nextCursor = hasMore ? messages.at(-1)?.id ?? null : null;
@@ -68,8 +73,18 @@ export function registerChatTools(server) {
68
73
  if (!msgRef.key)
69
74
  throw new Error("Could not allocate chat message id");
70
75
  const timestamp = rtdbServerTimestamp();
76
+ // 🔴 Форма сообщения обязана совпадать с `prepareChatMessage`
77
+ // (`web/src/lib/chatMessages.ts`) — единственным контрактом записи чата
78
+ // по Pattern 26. Импортировать его сюда нельзя: MCP отдельный пакет, у
79
+ // него нет доступа к web. Поэтому копия, и она УЖЕ разошлась: без `trim`
80
+ // сообщение из одних пробелов проходило и публиковалось всем зрителям, а
81
+ // имя уходило без обрезки до 24 символов.
82
+ const messageText = text.trim();
83
+ if (!messageText)
84
+ throw new Error("Chat message text cannot be empty or whitespace only.");
85
+ const messageName = name.trim().slice(0, 24) || "Organizer";
71
86
  await update(ref(chatRtdb), {
72
- [`messages/${eventId}/${msgRef.key}`]: { text, uid, name, role, ts: timestamp },
87
+ [`messages/${eventId}/${msgRef.key}`]: { text: messageText, uid, name: messageName, role, ts: timestamp },
73
88
  [`rate_limits/${eventId}/${uid}`]: { ts: timestamp, messageId: msgRef.key },
74
89
  });
75
90
  await runTransaction(ref(rtdb, `events/${eventId}/agg/chatMessages`), (n) => (n ?? 0) + 1)
@@ -1,10 +1,16 @@
1
1
  // Создание события: проверка права на пространство и запись с зеркалом RTDB.
2
2
  //
3
3
  // Зеркало ownerId/workspaceId в основной RTDB — опора правил доступа, и
4
- // пишет его сервер (CF32), а не клиент. Поэтому создание идёт парой:
5
- // документ Firestore, затем ensureEventRuntime. Если вторая половина не
6
- // удалась, документ откатывается — событие без зеркала недоступно правилам
7
- // и выглядело бы как «создалось, но сломано».
4
+ // пишет его сервер, а не клиент. Создание идёт парой: документ Firestore,
5
+ // затем ensureEventRuntime (CF32) — она ставит зеркало сразу, не дожидаясь
6
+ // триггера.
7
+ //
8
+ // 🔴 Отката здесь намеренно НЕТ, хотя прежняя редакция этой шапки его
9
+ // обещала. Сбой CF32 не оставляет событие сломанным: зеркало всё равно
10
+ // поставит CF20 `onEventCreated` — она пишет ownerId/workspaceId сама
11
+ // (`functions/src/eventsLifecycle.ts`) и объявлена с `retry: true`, то есть
12
+ // доставка гарантирована. Удалять документ из-за сбоя быстрого пути значило
13
+ // бы терять событие там, где медленный путь его чинит.
8
14
  import { addDoc, collection, doc, getDoc } from "firebase/firestore";
9
15
  import { httpsCallable } from "firebase/functions";
10
16
  import { firestore, functions } from "../firebase.js";
@@ -9,7 +9,7 @@ import { z } from "zod";
9
9
  import { serialize, textResult } from "../utils.js";
10
10
  import { authed, eventLinks, randomId } from "./context.js";
11
11
  import { cardsConfigOutputSchema, createWebinarOutputSchema, cursorField, eventIdField, eventOutputSchema, eventsOutputSchema, formSchema, limitField, readOnlyTool, setupValidationOutputSchema, toolDescription, updateOutputSchema, writeTool } from "./schemas.js";
12
- import { buildCardsItems, CARD_IMAGE_RATIOS, cardHasImage, cardItemInputSchema, eventFormIds, MAX_CARD_ITEMS, readCardsConfig } from "./imagePipeline.js";
12
+ import { buildCardsItems, CARD_IMAGE_RATIOS, cardHasImage, cardItemInputSchema, eventFormIds, MAX_CARD_ITEMS, normalizeCardButtonUrl, readCardsConfig, writeEventPatchWithCards } from "./imagePipeline.js";
13
13
  import { collection, doc, getDoc, getDocs, limit, orderBy, query, serverTimestamp, startAfter, Timestamp, updateDoc, where } from "firebase/firestore";
14
14
  import { addEventWithRtdbMirror, assertCanCreateInWorkspace } from "./eventCreate.js";
15
15
  import { cursorMeta, normalizeVideo, parseIsoBound, parseTimestampCursor, pickPage, timestampCursorFrom } from "./formatting.js";
@@ -293,8 +293,13 @@ export function registerEventTools(server) {
293
293
  patch["blocks.chat.visibility"] = args.chat.visibility;
294
294
  if (args.promoBanner?.enabled !== undefined)
295
295
  patch["blocks.promoBanner.enabled"] = args.promoBanner.enabled;
296
+ // 🔴 Протокол проверяется так же, как у карточек в этом же файле
297
+ // (`normalizeCardButtonUrl`). Оба значения попадают в `href` кнопки на
298
+ // публичной странице: `javascript:` там — исполнение кода у зрителя, а
299
+ // единственным отличием ссылки баннера от ссылки карточки было то, что
300
+ // её никто не проверял.
296
301
  if (args.promoBanner?.link !== undefined)
297
- patch["blocks.promoBanner.link"] = args.promoBanner.link;
302
+ patch["blocks.promoBanner.link"] = normalizeCardButtonUrl(args.promoBanner.link);
298
303
  if (args.promoBanner?.visibility !== undefined)
299
304
  patch["blocks.promoBanner.visibility"] = args.promoBanner.visibility;
300
305
  if (args.promoBanner?.formId !== undefined)
@@ -310,7 +315,7 @@ export function registerEventTools(server) {
310
315
  if (args.primaryCta?.buttonLabel !== undefined)
311
316
  patch["blocks.primaryCta.buttonLabel"] = args.primaryCta.buttonLabel?.trim() || null;
312
317
  if (args.primaryCta?.buttonUrl !== undefined)
313
- patch["blocks.primaryCta.buttonUrl"] = args.primaryCta.buttonUrl?.trim() || null;
318
+ patch["blocks.primaryCta.buttonUrl"] = normalizeCardButtonUrl(args.primaryCta.buttonUrl);
314
319
  if (args.primaryCta?.formId !== undefined)
315
320
  patch["blocks.primaryCta.formId"] = args.primaryCta.formId;
316
321
  if (args.videoPlaceholder?.enabled !== undefined)
@@ -391,6 +396,7 @@ export function registerEventTools(server) {
391
396
  visibility: args.visibility ?? currentCards.visibility,
392
397
  cardImageRatio: args.cardImageRatio ?? currentCards.cardImageRatio,
393
398
  items: currentCards.items,
399
+ repairedIdPositions: currentCards.repairedIdPositions,
394
400
  };
395
401
  const generatedCardIds = [];
396
402
  const preservedImageCardIds = [];
@@ -402,27 +408,48 @@ export function registerEventTools(server) {
402
408
  patch["blocks.cards.visibility"] = args.visibility;
403
409
  if (args.cardImageRatio !== undefined)
404
410
  patch["blocks.cards.cardImageRatio"] = args.cardImageRatio;
405
- if (args.items !== undefined) {
411
+ // 🔴 Карточки пересобираются ВНУТРИ транзакции, из свежего документа.
412
+ // Массив `blocks.cards.items` пишется целиком, а между чтением события и
413
+ // записью успевает отработать CF4 и дописать в него `imageVariants`,
414
+ // `imageUrl` и размеры. Реконструкция из устаревшего снимка их стирала —
415
+ // картинка у зрителя откатывалась к мастеру или пропадала. Это Pattern 23,
416
+ // уже разобранный на веб-стороне и вернувшийся сюда через MCP.
417
+ //
418
+ // Отчёт (что создано, что сохранено, что удалено) тоже считается внутри:
419
+ // иначе он описывал бы не ту запись, которая случилась.
420
+ const buildItems = args.items === undefined ? null : (fresh) => {
421
+ const freshCards = readCardsConfig(fresh);
406
422
  const built = buildCardsItems({
407
423
  inputs: args.items,
408
- existing: currentCards.items,
409
- formIds: eventFormIds(event),
424
+ existing: freshCards.items,
425
+ formIds: eventFormIds(fresh),
410
426
  });
411
427
  finalCards.items = built.items;
428
+ generatedCardIds.length = 0;
429
+ preservedImageCardIds.length = 0;
412
430
  generatedCardIds.push(...built.generatedCardIds);
413
431
  preservedImageCardIds.push(...built.preservedImageCardIds);
414
432
  const nextIds = new Set(built.items.map((item) => item.id));
415
- const removed = currentCards.items.filter((item) => !nextIds.has(item.id));
433
+ const removed = freshCards.items.filter((item) => !nextIds.has(item.id));
416
434
  removedCardIds = removed.map((item) => item.id);
417
435
  removedCardsWithImages = removed.filter(cardHasImage).map((item) => item.id);
418
- patch["blocks.cards.items"] = built.items;
419
- }
420
- if (Object.keys(patch).length > 0) {
421
- await updateDoc(doc(firestore, "events", args.eventId), patch);
436
+ return built.items;
437
+ };
438
+ if (Object.keys(patch).length > 0 || buildItems) {
439
+ await writeEventPatchWithCards(args.eventId, patch, buildItems);
422
440
  }
423
441
  const warnings = removedCardsWithImages.length > 0
424
442
  ? [`Removed cards had images and their Storage files were not deleted: ${removedCardsWithImages.join(", ")}.`]
425
443
  : [];
444
+ // 🔴 Карточка без пригодного `id` получает подставленный при чтении, и он
445
+ // не совпадает ни с чем в документе: прежняя запись не находится, её
446
+ // картинка обнуляется. Пока это молчало, организатор видел успешный ответ
447
+ // и пропавшее изображение без единого объяснения.
448
+ if (finalCards.repairedIdPositions.length > 0) {
449
+ warnings.push(`Cards at position(s) ${finalCards.repairedIdPositions.join(", ")} had no usable id in Firestore; `
450
+ + "a temporary one was generated, so their previous images could not be matched and were dropped. "
451
+ + "Re-upload images for these cards.");
452
+ }
426
453
  return textResult({
427
454
  eventId: args.eventId,
428
455
  updated: Object.keys(patch),
@@ -26,6 +26,27 @@ export function pickPage(items, max) {
26
26
  hasMore: items.length > max,
27
27
  };
28
28
  }
29
+ /**
30
+ * Значение `video.embedUrl` попадает прямо в `<iframe src>` на публичной
31
+ * странице события. Набор протоколов здесь строже, чем у кнопок
32
+ * (`normalizeCardButtonUrl`): `mailto:` и `tel:` в кадре бессмысленны, а
33
+ * `javascript:` — исполняемый код в контексте okostream.app у всех зрителей.
34
+ * Для распознанного провайдера адрес строим мы сами, но проверяем всё равно
35
+ * итоговое значение: так защита не зависит от того, какой ветвью оно пришло.
36
+ */
37
+ function assertEmbeddableUrl(value) {
38
+ let url;
39
+ try {
40
+ url = new URL(value);
41
+ }
42
+ catch {
43
+ throw new Error(`Invalid videoUrl "${value}". Provide an https:// link or a full <iframe> embed code.`);
44
+ }
45
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
46
+ throw new Error(`Unsupported videoUrl protocol "${url.protocol}". Only http(s) links can be embedded.`);
47
+ }
48
+ return value;
49
+ }
29
50
  export function normalizeVideo(videoUrl) {
30
51
  const raw = videoUrl?.trim();
31
52
  if (!raw)
@@ -35,7 +56,7 @@ export function normalizeVideo(videoUrl) {
35
56
  if (detected.provider !== "unknown" && !detected.normalizedUrl) {
36
57
  throw new Error(`Could not extract a valid ${detected.provider} video id from videoUrl.`);
37
58
  }
38
- return { originalUrl: raw, embedUrl: detected.normalizedUrl ?? effective };
59
+ return { originalUrl: raw, embedUrl: assertEmbeddableUrl(detected.normalizedUrl ?? effective) };
39
60
  }
40
61
  export function csvRows(headers, rows) {
41
62
  return [headers, ...rows].map((row) => row.map(csvCell).join(",")).join("\n");
@@ -176,7 +176,13 @@ export async function readRemoteImage(rawUrl, redirects = 0) {
176
176
  }
177
177
  finally {
178
178
  clearTimeout(timeout);
179
- await dispatcher.close();
179
+ // 🔴 `destroy()`, а не `close()`. `close()` в undici ЖДЁТ завершения
180
+ // незавершённых запросов — а когда защита по размеру бросает из
181
+ // `responseBufferWithLimit`, тело ответа остаётся непрочитанным. Публичный
182
+ // сервер с открытым chunked-ответом подвешивал инструмент бесконечно
183
+ // именно здесь: ограничение уже сработало, а выйти из функции было
184
+ // нельзя. `destroy()` отменяет то, что не дочитано.
185
+ await dispatcher.destroy();
180
186
  }
181
187
  }
182
188
  export async function readLocalImage(inputPath) {
@@ -281,12 +287,16 @@ export function readCardsConfig(eventData) {
281
287
  const blocks = (eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {});
282
288
  const raw = (blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {});
283
289
  const rawItems = Array.isArray(raw.items) ? raw.items : [];
290
+ const repairedIdPositions = [];
284
291
  const items = rawItems
285
292
  .filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
286
- .map((item) => {
293
+ .map((item, position) => {
294
+ const hasUsableId = typeof item.id === "string" && CARD_ID_PATTERN.test(item.id);
295
+ if (!hasUsableId)
296
+ repairedIdPositions.push(position);
287
297
  const fallbackId = randomUUID();
288
298
  return {
289
- id: typeof item.id === "string" && CARD_ID_PATTERN.test(item.id) ? item.id : fallbackId,
299
+ id: hasUsableId ? item.id : fallbackId,
290
300
  title: safeString(item.title).trim() || null,
291
301
  text: safeString(item.text).trim() || null,
292
302
  buttonLabel: safeString(item.buttonLabel).trim() || null,
@@ -305,6 +315,7 @@ export function readCardsConfig(eventData) {
305
315
  visibility: raw.visibility === "hidden" ? "hidden" : "always",
306
316
  cardImageRatio: CARD_IMAGE_RATIOS.includes(ratio) ? ratio : "16:9",
307
317
  items,
318
+ repairedIdPositions,
308
319
  };
309
320
  }
310
321
  export function normalizeNullableText(value) {
@@ -435,6 +446,37 @@ export async function updateCardImageInTransaction(eventId, cardId, patch) {
435
446
  });
436
447
  });
437
448
  }
449
+ /**
450
+ * Записывает патч события, пересобирая массив карточек из СВЕЖЕГО документа.
451
+ *
452
+ * 🔴 Зачем транзакция. `blocks.cards.items` — массив, и запись его целиком
453
+ * затирает всё, чего не было в прочитанном снимке. Между чтением события и
454
+ * записью успевает отработать CF4: она дописывает `imageVariants`, `imageUrl`
455
+ * и размеры по стабильному id карточки. Реконструкция из устаревшего снимка
456
+ * стирала эти поля — картинка у зрителя откатывалась к мастеру или пропадала.
457
+ *
458
+ * Это Pattern 23, разобранный на веб-стороне (там Setup уже сливает
459
+ * метаданные транзакционно) и вернувшийся через MCP: то же место, тот же
460
+ * массив, только другой клиент.
461
+ *
462
+ * `buildItems` получает свежие данные документа и обязан строить итоговый
463
+ * массив из них, а не из снимка, снятого до транзакции.
464
+ */
465
+ export async function writeEventPatchWithCards(eventId, basePatch, buildItems) {
466
+ const eventRef = doc(firestore, "events", eventId);
467
+ await runFirestoreTransaction(firestore, async (transaction) => {
468
+ const snap = await transaction.get(eventRef);
469
+ if (!snap.exists())
470
+ throw new Error("Event not found.");
471
+ const patch = { ...basePatch };
472
+ if (buildItems) {
473
+ patch["blocks.cards.items"] = buildItems(snap.data());
474
+ }
475
+ if (Object.keys(patch).length === 0)
476
+ return;
477
+ transaction.update(eventRef, patch);
478
+ });
479
+ }
438
480
  export function imageVariantReason(ref) {
439
481
  if (!ref.imageMasterUrl && !ref.imageUrl)
440
482
  return "image_missing";
@@ -501,23 +543,24 @@ export async function uploadEventImageForUser(uid, eventId, kind, cardId, source
501
543
  const deleteWarnings = [];
502
544
  let deletesAttempted = 0;
503
545
  const expectedCardPath = kind === "card" ? imageStoragePath(eventId, uid, kind, cardId) : null;
546
+ // 🔴 Сначала загрузка, потом удаление старого. Прежде варианты карточки
547
+ // стирались ДО `uploadMasterImage`, и упавшая загрузка оставляла документ
548
+ // Firestore со ссылками на удалённые объекты — зритель получал 404, а
549
+ // повтор восстановить их уже не мог. Соседняя ветка для не-карточек (ниже)
550
+ // всегда делала правильно; расходились только они.
551
+ //
552
+ // Порядок безопасен: новый master пишется по тому же пути, и CF4
553
+ // перезаписывает все три варианта заново. Худшее, что даёт отказ CF4, —
554
+ // варианты от прежней картинки рядом с новым мастером, и это заметно
555
+ // меньшая беда, чем битые ссылки.
556
+ const uploaded = await uploadMasterImage(eventId, kind, cardId, sourceBuffer);
557
+ if (!uploaded)
558
+ throw new Error("Image upload did not return a result.");
504
559
  if (expectedCardPath && previousMasterPath === expectedCardPath) {
505
560
  const variantPaths = variantPathsFromMasterPath(expectedCardPath);
506
561
  deletesAttempted += variantPaths.length;
507
562
  deleteWarnings.push(...await deleteStoragePaths(eventId, variantPaths));
508
563
  }
509
- let uploaded = null;
510
- try {
511
- uploaded = await uploadMasterImage(eventId, kind, cardId, sourceBuffer);
512
- }
513
- catch (err) {
514
- if (uploaded) {
515
- await deleteStoragePaths(eventId, [uploaded.storagePath, ...variantPathsFromMasterPath(uploaded.storagePath)]).catch(() => []);
516
- }
517
- throw err;
518
- }
519
- if (!uploaded)
520
- throw new Error("Image upload did not return a result.");
521
564
  if (kind !== "card" || (previousMasterPath && previousMasterPath !== uploaded.storagePath) || (!previous.imageMasterUrl && previous.imageUrl)) {
522
565
  const cleanup = await deleteStorageUrls(eventId, previous.imageMasterUrl, previous.imageUrl);
523
566
  deletesAttempted += cleanup.attempted;
@@ -165,7 +165,12 @@ export function registerImageTools(server) {
165
165
  const event = await readEvent(eventId, uid, "edit");
166
166
  const previous = readImageRef(event, kind, cardId);
167
167
  requireConfirm(confirm, "oko_delete_image", { eventId, kind, cardId: cardId ?? null, imageId: previous.imageId });
168
- const cleanup = await deleteStorageUrls(eventId, previous.imageMasterUrl, previous.imageUrl);
168
+ // 🔴 Сначала снимаем ссылки, потом удаляем объекты. Прежде порядок был
169
+ // обратный: упавший `updateDoc` оставлял документ со ссылками на уже
170
+ // удалённые файлы — зритель видел 404, а повтор восстановить их не мог,
171
+ // потому что объектов больше нет. В обратном порядке отказ Storage
172
+ // оставляет осиротевшие файлы, и это поправимо: их подберёт CF31 при
173
+ // удалении события, а ссылок на них уже нет.
169
174
  if (kind === "promoBanner") {
170
175
  await updateDoc(doc(firestore, "events", eventId), {
171
176
  "blocks.promoBanner.imageMasterUrl": deleteField(),
@@ -193,6 +198,10 @@ export function registerImageTools(server) {
193
198
  imageHeight: null,
194
199
  });
195
200
  }
201
+ // Удаление объектов — best-effort и уже после того, как на них никто не
202
+ // ссылается: его отказ попадёт в предупреждения ответа, а не в битые
203
+ // ссылки у зрителя.
204
+ const cleanup = await deleteStorageUrls(eventId, previous.imageMasterUrl, previous.imageUrl);
196
205
  return textResult({
197
206
  eventId,
198
207
  kind,
@@ -3,7 +3,7 @@
3
3
  // Документы создают только Cloud Functions; отсюда доступны чтение, отметка
4
4
  // и удаление своих. Текст уведомления не хранится — клиент собирает его из
5
5
  // типа и meta, поэтому наружу уходит структура, а не готовая строка.
6
- import { collection, doc, getDocs, limit, orderBy, query, startAfter, updateDoc, where } from "firebase/firestore";
6
+ import { collection, doc, getDocs, limit, orderBy, query, startAfter, updateDoc } from "firebase/firestore";
7
7
  import { z } from "zod";
8
8
  import { firestore } from "../firebase.js";
9
9
  import { serialize, textResult } from "../utils.js";
@@ -73,14 +73,31 @@ export function registerNotificationTools(server) {
73
73
  outputSchema: notificationsMarkOutputSchema,
74
74
  annotations: idempotentWriteTool,
75
75
  }, async ({ notificationIds, allUnread, limit: max }) => {
76
- const { uid } = await authed(["workspace:read"]);
76
+ // 🔴 Гейт намеренно на запись, хотя инструмент читается как «пометить
77
+ // прочитанным». Он делает `updateDoc` и сам помечен `idempotentWriteTool`;
78
+ // пока гейтом стоял `workspace:read`, ключ пресета `readOnly`
79
+ // (`events:read · analytics:read · workspace:read · leads:read`) мог
80
+ // погасить до 200 уведомлений организатора. Единственный смысл пресета
81
+ // «только чтение» — что он не пишет. Ущерб здесь не в чужих данных
82
+ // (пишем только в свои), а в нарушенном обещании: организатор не увидит
83
+ // уведомлений о выданных правах и созданных событиях.
84
+ const { uid } = await authed(["workspace:write"]);
77
85
  if (Boolean(notificationIds?.length) === allUnread) {
78
86
  throw new Error("Provide either notificationIds or allUnread=true.");
79
87
  }
80
88
  let ids = [...new Set(notificationIds ?? [])];
81
89
  if (allUnread) {
82
- const snap = await getDocs(query(collection(firestore, "userNotifications", uid, "items"), where("read", "==", false), limit(max)));
83
- ids = snap.docs.map((item) => item.id);
90
+ // 🔴 Отбор обязан совпадать с тем, что список ПОКАЗЫВАЕТ как
91
+ // непрочитанное, иначе получается неустранимая петля: список считает
92
+ // непрочитанным всё, где `read !== true` (в том числе документ без
93
+ // поля), а `where("read", "==", false)` документ без поля не находит —
94
+ // и не находит его же `where("read", "!=", true)`, потому что запросы
95
+ // с неравенством в Firestore пропускают документы без поля вовсе.
96
+ // Такое уведомление висело бы вечно, а модель не могла бы его погасить.
97
+ // Поэтому фильтруем клиентом тем же предикатом, что и список, набирая
98
+ // с запасом — приём уже применён в `oko_list_notifications` выше.
99
+ const snap = await getDocs(query(collection(firestore, "userNotifications", uid, "items"), limit(Math.min(max * 5 + 1, 501))));
100
+ ids = snap.docs.filter((item) => item.data().read !== true).slice(0, max).map((item) => item.id);
84
101
  }
85
102
  await Promise.all(ids.map((id) => updateDoc(doc(firestore, "userNotifications", uid, "items", id), { read: true })));
86
103
  return textResult({ markedRead: ids.length, notificationIds: ids });
@@ -35,7 +35,10 @@ export function registerSessionTools(server) {
35
35
  throw new Error("An analytics session is already active.");
36
36
  const start = httpsCallable(functions, "startAnalyticsSessionV3");
37
37
  const result = await start({ eventId });
38
- return textResult({ started: true, ...result.data });
38
+ // 🔴 `eventId` обязателен по `basicEventOutputSchema`. Без него SDK
39
+ // отвергал ответ ПОСЛЕ того, как сессия уже стартовала: клиент видел
40
+ // ошибку схемы и считал, что запуска не было, — Pattern 18.
41
+ return textResult({ eventId, started: true, ...result.data });
39
42
  });
40
43
  server.registerTool("oko_end_session", {
41
44
  title: "End analytics session",
@@ -58,7 +61,9 @@ export function registerSessionTools(server) {
58
61
  requireConfirm(confirm, "oko_end_session", { eventId, sessionId });
59
62
  const end = httpsCallable(functions, "endAnalyticsSessionV3");
60
63
  const result = await end({ eventId, sessionId });
61
- return textResult(result.data);
64
+ // Тот же случай, что у запуска: сессия уже завершена и отчёт сохранён,
65
+ // а ответ без `eventId` не проходил собственную схему.
66
+ return textResult({ eventId, ...result.data });
62
67
  });
63
68
  server.registerTool("oko_fix_orphan_session", {
64
69
  title: "Fix orphan analytics session",
@@ -93,6 +93,13 @@ export function validateEventSetup(eventId, event) {
93
93
  }
94
94
  }
95
95
  const cards = readCardsConfig(event);
96
+ // 🔴 Карточка без пригодного `id` — это ошибка данных, а не мелочь: id
97
+ // связывает запись с её картинкой в Storage и с обратной записью CF4.
98
+ // Пока подстановка молчала, проверка не могла назвать причину, по которой
99
+ // у карточки пропадает изображение при каждой правке.
100
+ cards.repairedIdPositions.forEach((position) => {
101
+ add(errors, "card_id_missing", `blocks.cards.items[${position}].id`, "Card has no usable id in Firestore; its image cannot be matched and will be dropped on the next update.");
102
+ });
96
103
  if (cards.enabled) {
97
104
  if (cards.items.length === 0)
98
105
  add(warnings, "cards_empty", "blocks.cards.items", "Cards block is enabled but has no cards.");
@@ -170,10 +170,15 @@ export function registerTelegramTools(server) {
170
170
  if (cursor)
171
171
  constraints.splice(2, 0, startAfter(parseTimestampCursor(cursor)));
172
172
  const snap = await getDocs(query(collection(firestore, "telegramPosts"), ...constraints));
173
- const { page, hasMore } = pickPage(snap.docs, max);
174
- let posts = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
175
- if (status)
176
- posts = posts.filter((post) => post.status === status);
173
+ // 🔴 Фильтр применяется ДО нарезки страницы. Пока он стоял после,
174
+ // `hasMore` и `count` считались по нефильтрованной выборке: пустой
175
+ // результат приходил с `hasMore: false`, и это читается как «постов с
176
+ // таким статусом нет», хотя они есть на следующих страницах.
177
+ const matching = status
178
+ ? snap.docs.filter((item) => item.data().status === status)
179
+ : snap.docs;
180
+ const { page, hasMore } = pickPage(matching, max);
181
+ const posts = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
177
182
  const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().createdAt) : null;
178
183
  return textResult({ posts, ...cursorMeta(posts.length, max, hasMore, nextCursor) });
179
184
  });
@@ -87,7 +87,19 @@ export function registerWorkspaceTools(server) {
87
87
  requireConfirm(confirm, "oko_delete_workspace", { workspaceId, name: workspace.name, personal: workspace.personal });
88
88
  if (workspace.personal === true)
89
89
  throw new Error("Personal workspace cannot be deleted.");
90
- const eventsSnap = await getDocs(query(collection(firestore, "events"), where("workspaceId", "==", workspaceId), limit(1)));
90
+ // 🔴 Спрашиваем индекс доступа, а не коллекцию событий. `allow list` по
91
+ // `events` требует `hasPlatformOwnerRole()`, который у MCP-сессии ложен по
92
+ // построению (`mcp_<keyId>` выпускается с `owner: false`) — то есть
93
+ // прежний запрос падал `permission-denied` ВСЕГДА, и инструмент не мог
94
+ // удалить ни одного пространства. Индекс `eventAccess/{uid}/grants` — тот
95
+ // же источник, которым пользуется `oko_list_my_events`.
96
+ //
97
+ // Ограничение названо честно: grants перечисляют события, к которым
98
+ // доступ есть у ЭТОГО организатора. Событие в пространстве, к которому он
99
+ // доступа не имеет, проверка не увидит — но владелец пространства
100
+ // получает grant на все его события через CF18, так что для владельца,
101
+ // а только он сюда и доходит (`readWorkspaceForOwner` выше), совпадает.
102
+ const eventsSnap = await getDocs(query(collection(firestore, "eventAccess", uid, "grants"), where("workspaceId", "==", workspaceId), limit(1)));
91
103
  if (!eventsSnap.empty)
92
104
  throw new Error("Workspace still has events. Move or delete its events before deleting the workspace.");
93
105
  await deleteDoc(doc(firestore, "workspaces", workspaceId));
@@ -159,7 +171,16 @@ export function registerWorkspaceTools(server) {
159
171
  sharedAt: serverTimestamp(),
160
172
  sharedBy: uid,
161
173
  }, { merge: true });
162
- return textResult({ workspaceId: args.workspaceId, uid: args.uid, updated: true });
174
+ // 🔴 `updateOutputSchema` объявляет `updated` МАССИВОМ имён полей, а не
175
+ // флагом. Пока возвращался `true`, SDK отвергал ответ уже после записи
176
+ // прав: клиент видел ошибку и считал, что доступ не выдан, — а он выдан.
177
+ // Из всех мест Pattern 18 это самое опасное: расходятся представления о
178
+ // том, кто что может.
179
+ return textResult({
180
+ workspaceId: args.workspaceId,
181
+ uid: args.uid,
182
+ updated: ["email", "comment", "permissions"],
183
+ });
163
184
  });
164
185
  server.registerTool("oko_remove_workspace_member", {
165
186
  title: "Remove workspace member",
@@ -252,7 +273,7 @@ export function registerWorkspaceTools(server) {
252
273
  summary: "Create a workspace invite for a known OKO workspace.",
253
274
  args: [
254
275
  "workspaceId: workspace owned by the authenticated organizer.",
255
- "code: optional invite code, 4-64 chars; omitted means generated.",
276
+ "code: optional invite code, 16-64 chars; omitted means generated (recommended).",
256
277
  "email: optional intended recipient email.",
257
278
  "permissions: optional whitelist of workspace permission booleans.",
258
279
  "confirm: must be true.",
@@ -262,7 +283,14 @@ export function registerWorkspaceTools(server) {
262
283
  }),
263
284
  inputSchema: {
264
285
  workspaceId: z.string().min(1),
265
- code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH).optional(),
286
+ // 🔴 Минимум 16, а не 4: код приглашения — предъявительский секрет.
287
+ // Правило Firestore допускает `get` любого приглашения по коду, а CF19
288
+ // выдаёт по нему права, то есть короткий код перебирается. Генератор
289
+ // платформы даёт 16 шестнадцатеричных знаков — заданный вручную не
290
+ // должен быть слабее. У соседних инструментов (`claim`, `revoke`)
291
+ // минимум остаётся 4 намеренно: они принимают УЖЕ существующие коды,
292
+ // включая исторические шестисимвольные.
293
+ code: z.string().min(16).max(MAX_WORKSPACE_INVITE_CODE_LENGTH).optional(),
266
294
  email: z.string().email().optional(),
267
295
  comment: z.string().max(500).default(""),
268
296
  permissions: workspacePermissionsSchema,
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by scripts/generateMcpVersion.mjs from mcp/package.json. Do not edit.
2
- export const OKO_MCP_VERSION = "0.11.2";
2
+ export const OKO_MCP_VERSION = "0.12.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okostream/mcp",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "description": "Control OKO Stream webinars from Claude, Codex, and other MCP clients.",
5
5
  "license": "UNLICENSED",
6
6
  "author": {