@okostream/mcp 0.12.8 → 0.12.9

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.
Files changed (2) hide show
  1. package/dist/server.js +420 -130
  2. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -70639,7 +70639,7 @@ var GUIDE_RESOURCES = [
70639
70639
  "1. Call `oko_whoami` and inspect scopes.",
70640
70640
  "2. If the workspace is unclear, call `oko_list_workspaces`.",
70641
70641
  "3. Call `oko_create_webinar` with title/date/workspace and video URL when known.",
70642
- "4. Call `oko_configure_blocks` for video, forms, CTA, banner, and Telegram settings.",
70642
+ "4. Call `oko_configure_blocks` for video, forms, CTA, banner, and Telegram settings (Telegram channels there need telegram:write plus the notification permission on the event).",
70643
70643
  "5. Call `oko_configure_cards` with stable card ids before uploading card images.",
70644
70644
  "6. Upload images with `oko_upload_card_images_batch` or `oko_upload_image`, then wait for variants.",
70645
70645
  "7. Call `oko_validate_event_setup` and fix blocking findings.",
@@ -70731,8 +70731,9 @@ var GUIDE_RESOURCES = [
70731
70731
  "1. Call `oko_create_telegram_link` and complete the one-time bot link in the organizer's account.",
70732
70732
  "2. Add `@OKOStreamBot` to the target channel as an administrator with posting permission.",
70733
70733
  "3. Use the claim code returned by the bot with `oko_claim_telegram_channel` and `confirm:true`.",
70734
- "4. Call `oko_list_telegram_channels`, then connect the selected id through `oko_configure_blocks`.",
70734
+ "4. Call `oko_list_telegram_channels`, then connect the selected id through `oko_configure_blocks` \u2014 its `telegramChannelIds` needs the telegram:write scope and the notification permission on the event, and is refused before the write without them.",
70735
70735
  "5. Publish or schedule with `oko_publish_telegram_post` only after showing the exact channel and copy.",
70736
+ " The caption is Telegram HTML: `<b>`, `<i>` and `<a href>` are markup. Escape `&`, `<` and `>` in any text copied from event fields, or Telegram rejects the message and the post lands in `failed`.",
70736
70737
  "6. Inspect delivery through `oko_list_telegram_posts`; avoid retrying `delivery_unknown` automatically.",
70737
70738
  "",
70738
70739
  "The MCP does not toggle the platform auto-reminder. Schedule an explicit reminder post or use web setup."
@@ -70855,6 +70856,18 @@ var DEFAULT_CHARACTER_LIMIT = 3e4;
70855
70856
  var MAX_COMPACT_ARRAY_ITEMS = 25;
70856
70857
  var MAX_COMPACT_OBJECT_KEYS = 60;
70857
70858
  var MAX_COMPACT_STRING_LENGTH = 2e3;
70859
+ function isFirestorePermissionDenied(error3) {
70860
+ if (!error3 || typeof error3 !== "object")
70861
+ return false;
70862
+ const code = error3.code;
70863
+ return code === "permission-denied" || code === "firestore/permission-denied" || code === "PERMISSION_DENIED";
70864
+ }
70865
+ function isFirestoreNotFound(error3) {
70866
+ if (!error3 || typeof error3 !== "object")
70867
+ return false;
70868
+ const code = error3.code;
70869
+ return code === "not-found" || code === "firestore/not-found" || code === "NOT_FOUND";
70870
+ }
70858
70871
  function serialize(value) {
70859
70872
  if (value == null)
70860
70873
  return value;
@@ -116130,6 +116143,41 @@ function readImageRoots() {
116130
116143
  }
116131
116144
  return roots;
116132
116145
  }
116146
+ var FIREBASE_FIELDS = [
116147
+ ["apiKey", ["OKO_FIREBASE_API_KEY", "NEXT_PUBLIC_FIREBASE_API_KEY"]],
116148
+ ["authDomain", ["OKO_FIREBASE_AUTH_DOMAIN", "NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN"]],
116149
+ ["databaseURL", ["OKO_FIREBASE_DATABASE_URL", "NEXT_PUBLIC_FIREBASE_DATABASE_URL"]],
116150
+ ["projectId", ["OKO_FIREBASE_PROJECT_ID", "NEXT_PUBLIC_FIREBASE_PROJECT_ID"]],
116151
+ ["storageBucket", ["OKO_FIREBASE_STORAGE_BUCKET", "NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET"]],
116152
+ ["messagingSenderId", ["OKO_FIREBASE_MESSAGING_SENDER_ID", "NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID"]],
116153
+ ["appId", ["OKO_FIREBASE_APP_ID", "NEXT_PUBLIC_FIREBASE_APP_ID"]],
116154
+ ["chatDatabaseURL", ["OKO_FIREBASE_CHAT_DB_URL", "NEXT_PUBLIC_FIREBASE_CHAT_DB_URL"]]
116155
+ ];
116156
+ function firebaseConfigError(source) {
116157
+ const overridden = [];
116158
+ const missing = [];
116159
+ for (const [field2, names] of FIREBASE_FIELDS) {
116160
+ const label = names[0] ?? field2;
116161
+ let value = null;
116162
+ for (const name7 of names) {
116163
+ const candidate = normalizeOptionalEnv(source[name7]);
116164
+ if (candidate) {
116165
+ value = candidate;
116166
+ break;
116167
+ }
116168
+ }
116169
+ if (value === null)
116170
+ missing.push(label);
116171
+ else if (value !== DEFAULT_FIREBASE_CONFIG[field2])
116172
+ overridden.push(label);
116173
+ }
116174
+ if (overridden.length === 0 || missing.length === 0)
116175
+ return null;
116176
+ return `OKO MCP: the Firebase config is only partially set. ${overridden.join(", ")} point at another project, while ${missing.join(", ")} are not set and would silently fall back to the built-in okostreamcore values. Set the whole Firebase config or none of it.`;
116177
+ }
116178
+ var firebaseConfigProblem = firebaseConfigError(process.env);
116179
+ if (firebaseConfigProblem)
116180
+ throw new Error(firebaseConfigProblem);
116133
116181
  var env = {
116134
116182
  apiKey: readOptionalEnv("OKO_API_KEY"),
116135
116183
  imageRoots: readImageRoots(),
@@ -116224,7 +116272,7 @@ function sanitizeScopes(scopes) {
116224
116272
  }
116225
116273
 
116226
116274
  // build/version.js
116227
- var OKO_MCP_VERSION = "0.12.8";
116275
+ var OKO_MCP_VERSION = "0.12.9";
116228
116276
 
116229
116277
  // build/auth.js
116230
116278
  var state = null;
@@ -116400,7 +116448,9 @@ async function getConnectionStatus() {
116400
116448
  if (error3 instanceof OkoAuthError) {
116401
116449
  return buildConnectionStatus(error3.status, authErrorOptions(error3));
116402
116450
  }
116403
- return buildConnectionStatus("unavailable");
116451
+ const detail = error3 instanceof Error ? `${error3.name}: ${error3.message}` : String(error3);
116452
+ console.error(`[oko-mcp] connection status: unexpected error \u2014 ${detail}`);
116453
+ return buildConnectionStatus("unavailable", { serverMessage: `Unexpected client-side error: ${detail}` });
116404
116454
  }
116405
116455
  }
116406
116456
  function authErrorOptions(error3) {
@@ -116455,14 +116505,14 @@ var telegramChannelSchema = external_exports.object({
116455
116505
  chatId: external_exports.number().int(),
116456
116506
  title: external_exports.string().max(200).optional(),
116457
116507
  username: external_exports.string().max(120).nullable().optional()
116458
- }).strict();
116508
+ }).passthrough();
116459
116509
  var canonicalTelegramChannelSchema = telegramChannelSchema.extend({
116460
116510
  workspaceIds: external_exports.array(external_exports.string()).max(50),
116461
116511
  owned: external_exports.boolean(),
116462
116512
  subscriberCount: external_exports.number().int().nonnegative().nullable().optional(),
116463
116513
  health: external_exports.enum(["connected", "needs_attention", "unknown"]).optional(),
116464
116514
  healthCheckedAt: external_exports.number().int().nonnegative().nullable().optional()
116465
- }).strict();
116515
+ }).passthrough();
116466
116516
 
116467
116517
  // build/tools/imagePipeline.js
116468
116518
  var import_undici = __toESM(require_undici(), 1);
@@ -116502,6 +116552,24 @@ function isPublicUnicastAddress(address) {
116502
116552
  return false;
116503
116553
  return import_ipaddr.default.parse(normalized).range() === "unicast";
116504
116554
  }
116555
+ function orderAddressCandidates(addresses, maxCandidates = 4) {
116556
+ const known = addresses.filter((item) => item.family === 4 || item.family === 6);
116557
+ const preferred = known[0]?.family;
116558
+ if (preferred === void 0)
116559
+ return [];
116560
+ const first = known.filter((item) => item.family === preferred);
116561
+ const second = known.filter((item) => item.family !== preferred);
116562
+ const ordered = [];
116563
+ for (let index = 0; index < Math.max(first.length, second.length); index += 1) {
116564
+ const fromFirst = first[index];
116565
+ const fromSecond = second[index];
116566
+ if (fromFirst)
116567
+ ordered.push(fromFirst);
116568
+ if (fromSecond)
116569
+ ordered.push(fromSecond);
116570
+ }
116571
+ return ordered.slice(0, maxCandidates);
116572
+ }
116505
116573
  function pinnedLookup(address, family) {
116506
116574
  return (_hostname, options2, callback) => {
116507
116575
  if (options2 && typeof options2 === "object" && options2.all) {
@@ -116534,13 +116602,7 @@ function legacyEventAllowsMode(data, uid, mode) {
116534
116602
  const fields = mode === "read" ? ["accessibleBy", "editors"] : [EVENT_ACCESS_FIELD_BY_MODE[mode]];
116535
116603
  return fields.some((field2) => Array.isArray(data[field2]) && data[field2].includes(uid));
116536
116604
  }
116537
- function isFirestorePermissionDenied(error3) {
116538
- if (!error3 || typeof error3 !== "object")
116539
- return false;
116540
- const code = error3.code;
116541
- return code === "permission-denied" || code === "firestore/permission-denied" || code === "PERMISSION_DENIED";
116542
- }
116543
- async function readEvent(eventId, uid, mode = "read") {
116605
+ async function readEventAccess(eventId, uid, mode) {
116544
116606
  const [snap, grant] = await Promise.all([
116545
116607
  getDoc(doc(firestore, "events", eventId)),
116546
116608
  getDoc(doc(firestore, "eventAccess", uid, "grants", eventId)).catch((error3) => {
@@ -116553,6 +116615,13 @@ async function readEvent(eventId, uid, mode = "read") {
116553
116615
  throw new Error("Event not found.");
116554
116616
  const data = snap.data();
116555
116617
  const allowed = grant === null ? legacyEventAllowsMode(data, uid, mode) : data.ownerId === uid || grant.exists() && grantAllowsMode(grant.data(), mode);
116618
+ return { data, allowed };
116619
+ }
116620
+ async function eventGrantsMode(eventId, uid, mode) {
116621
+ return (await readEventAccess(eventId, uid, mode)).allowed;
116622
+ }
116623
+ async function readEvent(eventId, uid, mode = "read") {
116624
+ const { data, allowed } = await readEventAccess(eventId, uid, mode);
116556
116625
  if (!allowed) {
116557
116626
  throw new Error(`Event does not grant ${mode} access to this organizer.`);
116558
116627
  }
@@ -116615,10 +116684,6 @@ function nonPublicResolutionMessage(hostname2, addresses) {
116615
116684
  const environmentHint = fakeIp ? "Addresses in 198.18.0.0/15 mean a VPN or proxy with DNS interception (fake-IP mode) is active on this machine: every public host resolves there, so no remote URL can be verified." : "If other public hosts resolve to private addresses too, a VPN or proxy with DNS interception is active on this machine.";
116616
116685
  return `Image URL resolves to a non-public IP address (${hostname2} \u2192 ${list}), which is not allowed. ${environmentHint} Workarounds: upload the file from disk via source.path (allowed directories come from OKO_IMAGE_ROOTS; the organizer can download the image first), or disable the VPN/proxy for this upload.`;
116617
116686
  }
116618
- var remoteFetchNotes = [];
116619
- function takeRemoteFetchNotes() {
116620
- return remoteFetchNotes.splice(0, remoteFetchNotes.length);
116621
- }
116622
116687
  async function resolveRemoteImageAddress(url) {
116623
116688
  if (url.protocol !== "https:") {
116624
116689
  throw new Error("Image URL must use https://.");
@@ -116635,18 +116700,18 @@ async function resolveRemoteImageAddress(url) {
116635
116700
  if (!isPublicUnicastAddress(literalAddress)) {
116636
116701
  throw new Error(`Image URL points to a private/local IP address (${literalAddress}), which is not allowed. Use a public HTTPS image URL, or upload the file from disk via source.path (requires OKO_IMAGE_ROOTS).`);
116637
116702
  }
116638
- return { address: literalAddress, family: literalFamily, transparentProxy: false };
116703
+ return { addresses: [{ address: literalAddress, family: literalFamily }], transparentProxy: false };
116639
116704
  }
116640
116705
  const addresses = await lookup(url.hostname, { all: true });
116641
116706
  const transparentProxy = addresses.length > 0 && addresses.every((item) => isTransparentProxyAddress(item.address));
116642
116707
  if (!transparentProxy && (addresses.length === 0 || addresses.some((item) => !isPublicUnicastAddress(item.address)))) {
116643
116708
  throw new Error(nonPublicResolutionMessage(url.hostname, addresses.map((item) => item.address)));
116644
116709
  }
116645
- const selected = addresses[0];
116646
- if (!selected || selected.family !== 4 && selected.family !== 6) {
116710
+ const candidates = orderAddressCandidates(addresses);
116711
+ if (candidates.length === 0) {
116647
116712
  throw new Error("Image URL host could not be resolved safely.");
116648
116713
  }
116649
- return { address: selected.address, family: selected.family, transparentProxy };
116714
+ return { addresses: candidates, transparentProxy };
116650
116715
  }
116651
116716
  async function responseBufferWithLimit(response, maxBytes) {
116652
116717
  const body = response.body;
@@ -116697,32 +116762,52 @@ function shareLinkHint(url) {
116697
116762
  }
116698
116763
  return " Use a direct link to the image file (right-click the image \u2192 copy image address), or download the file and pass source.path.";
116699
116764
  }
116700
- async function readRemoteImage(rawUrl, redirects = 0) {
116765
+ async function readRemoteImage(rawUrl, redirects = 0, notes = []) {
116701
116766
  if (redirects > REMOTE_IMAGE_MAX_REDIRECTS) {
116702
116767
  throw new Error("Image URL has too many redirects.");
116703
116768
  }
116704
116769
  const url = new URL(rawUrl);
116705
116770
  const pinned = await resolveRemoteImageAddress(url);
116706
116771
  if (pinned.transparentProxy && redirects === 0) {
116707
- remoteFetchNotes.push(`Image fetched through a VPN/proxy with DNS interception: ${url.hostname} resolved to ${pinned.address}, so OKO could not verify the destination address itself; the connection went through the proxy. This is expected on such machines and fine for public image links.`);
116772
+ notes.push(`Image fetched through a VPN/proxy with DNS interception: ${url.hostname} resolved to ${pinned.addresses[0]?.address}, so OKO could not verify the destination address itself; the connection went through the proxy. This is expected on such machines and fine for public image links.`);
116773
+ }
116774
+ let lastConnectError = null;
116775
+ for (const candidate of pinned.addresses) {
116776
+ const attempt = await readRemoteImageFrom(url, rawUrl, candidate, redirects);
116777
+ if ("buffer" in attempt)
116778
+ return attempt.buffer;
116779
+ if ("redirectTo" in attempt)
116780
+ return readRemoteImage(attempt.redirectTo, redirects + 1, notes);
116781
+ lastConnectError = attempt.connectError;
116708
116782
  }
116783
+ throw new Error(`Image URL host ${url.hostname} could not be reached at any of its addresses (${pinned.addresses.map((item) => item.address).join(", ")}): ${lastConnectError?.message ?? "connection failed"}.`);
116784
+ }
116785
+ async function readRemoteImageFrom(url, rawUrl, candidate, redirects) {
116709
116786
  const dispatcher = new import_undici.Agent({
116710
- connect: { lookup: pinnedLookup(pinned.address, pinned.family) }
116787
+ connect: { lookup: pinnedLookup(candidate.address, candidate.family) }
116711
116788
  });
116712
116789
  const controller = new AbortController();
116713
116790
  const timeout = setTimeout(() => controller.abort(), REMOTE_IMAGE_TIMEOUT_MS);
116714
116791
  try {
116715
- const response = await (0, import_undici.fetch)(url, {
116716
- redirect: "manual",
116717
- signal: controller.signal,
116718
- headers: { accept: "image/avif,image/webp,image/png,image/jpeg,image/gif" },
116719
- dispatcher
116720
- });
116792
+ let response;
116793
+ try {
116794
+ response = await (0, import_undici.fetch)(url, {
116795
+ redirect: "manual",
116796
+ signal: controller.signal,
116797
+ headers: { accept: "image/avif,image/webp,image/png,image/jpeg,image/gif" },
116798
+ dispatcher
116799
+ });
116800
+ } catch (err) {
116801
+ if (err instanceof Error && err.name === "AbortError") {
116802
+ throw new Error("Image URL fetch timed out.");
116803
+ }
116804
+ return { connectError: err instanceof Error ? err : new Error(String(err)) };
116805
+ }
116721
116806
  if (response.status >= 300 && response.status < 400) {
116722
116807
  const location2 = response.headers.get("location");
116723
116808
  if (!location2)
116724
116809
  throw new Error(`Image URL redirected with HTTP ${response.status} but no Location header.`);
116725
- return readRemoteImage(new URL(location2, url).toString(), redirects + 1);
116810
+ return { redirectTo: new URL(location2, url).toString() };
116726
116811
  }
116727
116812
  if (!response.ok) {
116728
116813
  throw new Error(`Image URL fetch failed with HTTP ${response.status}.`);
@@ -116738,7 +116823,7 @@ async function readRemoteImage(rawUrl, redirects = 0) {
116738
116823
  if (Number.isFinite(contentLength) && contentLength > MAX_SOURCE_IMAGE_BYTES) {
116739
116824
  throw new Error("Image URL is larger than 15MB.");
116740
116825
  }
116741
- return await responseBufferWithLimit(response, MAX_SOURCE_IMAGE_BYTES);
116826
+ return { buffer: await responseBufferWithLimit(response, MAX_SOURCE_IMAGE_BYTES) };
116742
116827
  } catch (err) {
116743
116828
  if (err instanceof Error && err.name === "AbortError") {
116744
116829
  throw new Error("Image URL fetch timed out.");
@@ -116827,10 +116912,10 @@ async function uploadImageFromSource(eventId, kind, cardId, source) {
116827
116912
  serverFailure = refusal;
116828
116913
  }
116829
116914
  let buffer;
116915
+ const fetchNotes = [];
116830
116916
  try {
116831
- buffer = await readRemoteImage(source.url);
116917
+ buffer = await readRemoteImage(source.url, 0, fetchNotes);
116832
116918
  } catch (localError) {
116833
- takeRemoteFetchNotes();
116834
116919
  throw new Error(remoteImageUnavailableMessage(source.url, serverFailure, localError));
116835
116920
  }
116836
116921
  const uploaded = await uploadMasterImage(eventId, kind, cardId, { imageBase64: buffer.toString("base64") });
@@ -116838,7 +116923,7 @@ async function uploadImageFromSource(eventId, kind, cardId, source) {
116838
116923
  uploaded,
116839
116924
  sourceBytes: buffer.byteLength,
116840
116925
  sourceVia: "client",
116841
- notes: [localFallbackNote(serverFailure), ...takeRemoteFetchNotes()]
116926
+ notes: [localFallbackNote(serverFailure), ...fetchNotes]
116842
116927
  };
116843
116928
  }
116844
116929
  function readImageRef(eventData, kind, cardId) {
@@ -116945,20 +117030,23 @@ function normalizeNullableText(value) {
116945
117030
  return null;
116946
117031
  return value.trim() || null;
116947
117032
  }
117033
+ var CARD_LINK_SCHEME = /^(https?:\/\/|mailto:|tel:|\/(?!\/))/i;
117034
+ var CARD_BARE_DOMAIN = /^[\p{L}\d-]+(\.[\p{L}\d-]+)+(:\d+)?(\/\S*)?$/u;
116948
117035
  function normalizeCardButtonUrl(value) {
116949
117036
  const trimmed = normalizeNullableText(value);
116950
117037
  if (!trimmed)
116951
117038
  return null;
117039
+ if (CARD_LINK_SCHEME.test(trimmed))
117040
+ return trimmed;
117041
+ if (CARD_BARE_DOMAIN.test(trimmed))
117042
+ return `https://${trimmed}`;
116952
117043
  let url;
116953
117044
  try {
116954
117045
  url = new URL(trimmed);
116955
117046
  } catch {
116956
- throw new Error(`Invalid card buttonUrl "${trimmed}". Use http(s), mailto, tel, or set formId instead.`);
117047
+ throw new Error(`Invalid card buttonUrl "${trimmed}". Use http(s), mailto, tel, a site-relative /path, or set formId instead.`);
116957
117048
  }
116958
- if (!["http:", "https:", "mailto:", "tel:"].includes(url.protocol)) {
116959
- throw new Error(`Unsupported card buttonUrl protocol "${url.protocol}". Use http(s), mailto, tel, or set formId instead.`);
116960
- }
116961
- return trimmed;
117049
+ throw new Error(`Unsupported card buttonUrl protocol "${url.protocol}". Use http(s), mailto, tel, a site-relative /path, or set formId instead.`);
116962
117050
  }
116963
117051
  function eventFormIds(eventData) {
116964
117052
  const rawForms = Array.isArray(eventData.forms) ? eventData.forms : [];
@@ -117730,6 +117818,7 @@ function detectAndNormalize(url) {
117730
117818
  }
117731
117819
 
117732
117820
  // build/tools/formatting.js
117821
+ var RETIRED_SESSION_FIELDS = ["nmoEvaluation"];
117733
117822
  function cursorMeta(count, max, hasMore, nextCursor) {
117734
117823
  return {
117735
117824
  count,
@@ -117753,8 +117842,8 @@ function assertEmbeddableUrl(value) {
117753
117842
  } catch {
117754
117843
  throw new Error(`Invalid videoUrl "${value}". Provide an https:// link or a full <iframe> embed code.`);
117755
117844
  }
117756
- if (url.protocol !== "https:" && url.protocol !== "http:") {
117757
- throw new Error(`Unsupported videoUrl protocol "${url.protocol}". Only http(s) links can be embedded.`);
117845
+ if (url.protocol !== "https:") {
117846
+ throw new Error(`Unsupported videoUrl protocol "${url.protocol}". The player must be served over https:// \u2014 http:// links are refused by the platform.`);
117758
117847
  }
117759
117848
  return value;
117760
117849
  }
@@ -117798,15 +117887,31 @@ function timestampCursorFrom(value) {
117798
117887
  const millis = toMillis(value);
117799
117888
  return millis == null ? null : String(millis);
117800
117889
  }
117890
+ var ISO_WITH_OFFSET = /(?:Z|[+-]\d{2}:?\d{2})$/;
117801
117891
  function parseIsoBound(value, label) {
117802
117892
  if (value === void 0)
117803
117893
  return null;
117804
117894
  const parsed = Date.parse(value);
117805
- if (!Number.isFinite(parsed)) {
117806
- throw new Error(`Invalid ${label}: expected an ISO timestamp such as 2026-08-28T00:00:00+03:00.`);
117895
+ if (!Number.isFinite(parsed) || !ISO_WITH_OFFSET.test(value.trim())) {
117896
+ throw new Error(`Invalid ${label}: expected an ISO timestamp with an explicit offset, such as 2026-08-28T00:00:00+03:00 or 2026-08-27T21:00:00Z.`);
117807
117897
  }
117808
117898
  return Timestamp.fromMillis(parsed);
117809
117899
  }
117900
+ function absoluteHttpUrl(value, label) {
117901
+ const trimmed = (value ?? "").trim();
117902
+ if (!trimmed)
117903
+ return null;
117904
+ let url;
117905
+ try {
117906
+ url = new URL(trimmed);
117907
+ } catch {
117908
+ throw new Error(`Invalid ${label} "${trimmed}": Telegram needs an absolute http(s) URL.`);
117909
+ }
117910
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
117911
+ throw new Error(`Unsupported ${label} protocol "${url.protocol}": Telegram needs an absolute http(s) URL.`);
117912
+ }
117913
+ return trimmed;
117914
+ }
117810
117915
  function compactAnalyticsSession(raw, options2) {
117811
117916
  const session = serialize(raw);
117812
117917
  const timeline = Array.isArray(session.timeline) ? session.timeline : null;
@@ -117832,7 +117937,8 @@ function compactAnalyticsSession(raw, options2) {
117832
117937
  session.finalSnapshotOmitted = true;
117833
117938
  }
117834
117939
  }
117835
- delete session[["n", "moEvaluation"].join("")];
117940
+ for (const field2 of RETIRED_SESSION_FIELDS)
117941
+ delete session[field2];
117836
117942
  return session;
117837
117943
  }
117838
117944
  function numberMetric(value) {
@@ -117903,15 +118009,78 @@ function copyableTelegram(source) {
117903
118009
 
117904
118010
  // build/tools/workspaceAccess.js
117905
118011
  import { randomBytes as randomBytes2 } from "node:crypto";
117906
- async function readWorkspaceForOwner(workspaceId, uid) {
117907
- const snap = await getDoc(doc(firestore, "workspaces", workspaceId));
117908
- if (!snap.exists())
118012
+
118013
+ // build/personalWorkspace.js
118014
+ var PERSONAL_WORKSPACE_NAME = "Personal";
118015
+ function personalWorkspaceRecord(uid) {
118016
+ return { id: uid, name: PERSONAL_WORKSPACE_NAME, ownerId: uid, personal: true };
118017
+ }
118018
+ function resolveWorkspaceForOwner(workspaceId, uid, snapshot) {
118019
+ if (!snapshot.exists) {
118020
+ if (workspaceId === uid)
118021
+ return personalWorkspaceRecord(uid);
117909
118022
  throw new Error("Workspace not found.");
117910
- const data = snap.data();
118023
+ }
118024
+ const data = snapshot.data ?? {};
117911
118025
  if (data.ownerId !== uid)
117912
118026
  throw new Error("Only the workspace owner can manage this workspace.");
117913
118027
  return data;
117914
118028
  }
118029
+ function refusalMayMeanMissing(error3, workspaceId, uid) {
118030
+ if (workspaceId !== uid)
118031
+ return false;
118032
+ return isFirestorePermissionDenied(error3) || isFirestoreNotFound(error3);
118033
+ }
118034
+ function withSyntheticPersonalWorkspace(uid, rows) {
118035
+ if (rows.some((row) => row.id === uid))
118036
+ return rows;
118037
+ return [{ ...personalWorkspaceRecord(uid), isOwner: true }, ...rows];
118038
+ }
118039
+ function isSharedWorkspaceId(workspaceId) {
118040
+ return /^[A-Za-z0-9]{20}$/.test(workspaceId);
118041
+ }
118042
+ function workspaceMemberRemoval(existed, workspaceId, targetUid) {
118043
+ if (existed)
118044
+ return { removed: true };
118045
+ return {
118046
+ removed: false,
118047
+ missing: true,
118048
+ hints: [`No share for ${targetUid} exists in workspace ${workspaceId}: nothing was removed. List the members with oko_list_workspace_access before removing one.`]
118049
+ };
118050
+ }
118051
+ function personalWorkspaceLimitMessage(action) {
118052
+ return `A personal workspace has no members or invites: it exists for its owner alone, and the platform refuses ${action} there. Create a shared workspace with oko_create_workspace, move the event into it with oko_move_event, and repeat this call with the shared workspace id.`;
118053
+ }
118054
+
118055
+ // build/tools/workspaceAccess.js
118056
+ async function readWorkspaceForOwnerDetailed(workspaceId, uid) {
118057
+ let snap;
118058
+ try {
118059
+ snap = await getDoc(doc(firestore, "workspaces", workspaceId));
118060
+ } catch (error3) {
118061
+ if (!refusalMayMeanMissing(error3, workspaceId, uid))
118062
+ throw error3;
118063
+ const owned = await getDocs(query2(collection(firestore, "workspaces"), where("ownerId", "==", uid)));
118064
+ const existing = owned.docs.find((item) => item.id === workspaceId);
118065
+ if (existing) {
118066
+ return {
118067
+ workspace: resolveWorkspaceForOwner(workspaceId, uid, { exists: true, data: existing.data() }),
118068
+ synthesized: false
118069
+ };
118070
+ }
118071
+ return { workspace: personalWorkspaceRecord(uid), synthesized: true };
118072
+ }
118073
+ return {
118074
+ workspace: resolveWorkspaceForOwner(workspaceId, uid, {
118075
+ exists: snap.exists(),
118076
+ data: snap.exists() ? snap.data() : void 0
118077
+ }),
118078
+ synthesized: !snap.exists()
118079
+ };
118080
+ }
118081
+ async function readWorkspaceForOwner(workspaceId, uid) {
118082
+ return (await readWorkspaceForOwnerDetailed(workspaceId, uid)).workspace;
118083
+ }
117915
118084
  function normalizeWorkspacePermissions(value) {
117916
118085
  const out = {};
117917
118086
  for (const key of WORKSPACE_PERMISSION_KEYS) {
@@ -117938,7 +118107,7 @@ async function listVisibleWorkspaces(uid, includeShared) {
117938
118107
  out.push({ id: workspaceId, ...serialize(wsSnap.data()), isOwner: false, permissions: serialize(shareSnap.data()?.permissions ?? {}) });
117939
118108
  }
117940
118109
  }
117941
- return out;
118110
+ return withSyntheticPersonalWorkspace(uid, out);
117942
118111
  }
117943
118112
  async function getChatRole(eventId, uid) {
117944
118113
  const snap = await get(ref(chatRtdb, `settings/${eventId}/roles/${uid}`));
@@ -118024,7 +118193,7 @@ function registerChatTools(server2) {
118024
118193
  const { uid } = await authed(["chat:moderate"]);
118025
118194
  await readEvent(eventId, uid, "moderate");
118026
118195
  requireConfirm(confirm, "oko_delete_chat_message", { eventId, messageId });
118027
- await set(ref(chatRtdb, `mod/deleted/${eventId}/${messageId}`), { deletedAt: serverTimestamp(), ts: Date.now(), by: uid });
118196
+ await set(ref(chatRtdb, `mod/deleted/${eventId}/${messageId}`), { deletedAt: serverTimestamp(), ts: Date.now(), by: uid, reason: "manual" });
118028
118197
  return textResult({ eventId, messageId, deleted: true });
118029
118198
  });
118030
118199
  server2.registerTool("oko_mute_user", {
@@ -118112,7 +118281,7 @@ function registerChatTools(server2) {
118112
118281
  title: "Set chat settings",
118113
118282
  description: toolDescription({
118114
118283
  summary: "Set slow mode and automod settings for an event chat.",
118115
- args: ["eventId: target event.", "slowModeMs: optional slow mode delay, 0-60000 ms.", "automodEnabled: optional automod toggle.", "confirm: must be true."],
118284
+ args: ["eventId: target event.", "slowModeMs: optional slow mode delay, 0-60000 ms.", "automodEnabled: optional automod toggle.", "allowViewerLinks: optional; true lets viewers post links (the chat rule and automod both honour it; default false).", "confirm: must be true."],
118116
118285
  returns: "eventId and updated RTDB setting paths.",
118117
118286
  examples: ["Use slowModeMs=5000 to slow busy chats; use automodEnabled=false as an emergency kill switch."]
118118
118287
  }),
@@ -118120,19 +118289,22 @@ function registerChatTools(server2) {
118120
118289
  eventId: eventIdField,
118121
118290
  slowModeMs: external_exports.number().int().min(0).max(6e4).optional().describe("Slow mode delay in milliseconds."),
118122
118291
  automodEnabled: external_exports.boolean().optional().describe("Enable or disable chat automoderation."),
118292
+ allowViewerLinks: external_exports.boolean().optional().describe("Allow viewers to post links in chat (default false: the rule blocks links from viewers)."),
118123
118293
  confirm: external_exports.boolean().optional().describe("Required true to change live chat settings.")
118124
118294
  },
118125
118295
  outputSchema: basicEventOutputSchema,
118126
118296
  annotations: idempotentWriteTool
118127
- }, async ({ eventId, slowModeMs, automodEnabled, confirm }) => {
118297
+ }, async ({ eventId, slowModeMs, automodEnabled, allowViewerLinks, confirm }) => {
118128
118298
  const { uid } = await authed(["chat:moderate"]);
118129
118299
  await readEvent(eventId, uid, "moderate");
118130
- requireConfirm(confirm, "oko_set_chat_settings", { eventId, slowModeMs: slowModeMs ?? null, automodEnabled: automodEnabled ?? null });
118300
+ requireConfirm(confirm, "oko_set_chat_settings", { eventId, slowModeMs: slowModeMs ?? null, automodEnabled: automodEnabled ?? null, allowViewerLinks: allowViewerLinks ?? null });
118131
118301
  const patch = {};
118132
118302
  if (slowModeMs !== void 0)
118133
118303
  patch[`settings/${eventId}/slowModeMs`] = slowModeMs;
118134
118304
  if (automodEnabled !== void 0)
118135
118305
  patch[`settings/${eventId}/automodEnabled`] = automodEnabled;
118306
+ if (allowViewerLinks !== void 0)
118307
+ patch[`settings/${eventId}/allowViewerLinks`] = allowViewerLinks;
118136
118308
  if (Object.keys(patch).length === 0) {
118137
118309
  throw new Error("Provide slowModeMs and/or automodEnabled to change chat settings.");
118138
118310
  }
@@ -118319,6 +118491,17 @@ function validateEventSetup(eventId, event) {
118319
118491
  }
118320
118492
 
118321
118493
  // build/tools/events.js
118494
+ function telegramBlocksPatch(args) {
118495
+ if (args.telegramChannelIds === void 0)
118496
+ return {};
118497
+ return {
118498
+ "telegram.enabled": args.telegramChannelIds.length > 0,
118499
+ "telegram.channelIds": args.telegramChannelIds
118500
+ };
118501
+ }
118502
+ function configureBlocksScopes(args) {
118503
+ return Object.keys(telegramBlocksPatch(args)).length > 0 ? ["setup:write", "telegram:write"] : ["setup:write"];
118504
+ }
118322
118505
  function registerEventTools(server2) {
118323
118506
  server2.registerTool("oko_list_my_events", {
118324
118507
  title: "List my events",
@@ -118386,7 +118569,8 @@ function registerEventTools(server2) {
118386
118569
  return { id: item.id, ...data };
118387
118570
  });
118388
118571
  }
118389
- return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor) }, { truncationHint: 'Use a smaller limit, cursor pagination, or detail="compact".' });
118572
+ const projectionHint = args.detail === "compact" ? { hints: ['Compact rows are a projection rebuilt after each change and can lag the event by seconds (title, date, permissions). For fields that must be current, call oko_get_event or use detail="full".'] } : {};
118573
+ return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor), ...projectionHint }, { truncationHint: 'Use a smaller limit, cursor pagination, or detail="compact".' });
118390
118574
  }
118391
118575
  throw new Error("Event list not received: the eventAccess index returned an empty result.");
118392
118576
  });
@@ -118398,7 +118582,7 @@ function registerEventTools(server2) {
118398
118582
  returns: "full event document fields plus id and public event URL.",
118399
118583
  examples: ["Use after oko_create_webinar to verify setup and return the event URL to the user."],
118400
118584
  notes: [
118401
- "blocks.chat.mode is a legacy field written by older cabinet versions and read by nothing; chat visibility is blocks.chat.visibility (always | hidden). Ignore mode."
118585
+ "blocks.chat.mode and blocks.chat.visibility are both legacy: the event page reads neither \u2014 the chat panel exists exactly when blocks.chat.enabled is true, and sending is opened or closed live with oko_set_chat_mode. Ignore both fields."
118402
118586
  ]
118403
118587
  }),
118404
118588
  inputSchema: { eventId: eventIdField },
@@ -118558,20 +118742,21 @@ function registerEventTools(server2) {
118558
118742
  title: "Configure event blocks",
118559
118743
  description: toolDescription({
118560
118744
  summary: "Configure supported event setup blocks, forms, and Telegram channel ids.",
118561
- exampleCall: { "eventId": "Kx9pQ2mR7tL4vB8nW3cY", "chat": { "enabled": true, "visibility": "always" }, "promoBanner": { "enabled": false }, "primaryCta": { "enabled": true, "title": "Get the slides", "buttonLabel": "Download", "buttonUrl": "https://example.com/slides" }, "videoPlaceholder": { "enabled": true, "mode": "glass", "showTimer": true, "title": "The webinar starts at 14:00", "subtitle": "Moscow time" } },
118745
+ exampleCall: { "eventId": "Kx9pQ2mR7tL4vB8nW3cY", "chat": { "enabled": true }, "promoBanner": { "enabled": false }, "primaryCta": { "enabled": true, "title": "Get the slides", "buttonLabel": "Download", "buttonUrl": "https://example.com/slides" }, "videoPlaceholder": { "enabled": true, "mode": "glass", "showTimer": true, "title": "The webinar starts at 14:00", "subtitle": "Moscow time" } },
118562
118746
  args: [
118563
118747
  "eventId: event to edit.",
118564
118748
  "chat/promoBanner/primaryCta/videoPlaceholder: optional block patches.",
118565
118749
  "forms: replaces configured lead forms when provided.",
118566
- "telegramChannelIds: replaces configured Telegram channels when provided."
118750
+ "telegramChannelIds: replaces configured Telegram channels when provided; needs the telegram:write scope AND the notification permission on the event, and is refused before anything is written when either is missing."
118567
118751
  ],
118568
118752
  returns: "event id and the list of Firestore fields updated.",
118569
- examples: ["Use after oko_create_webinar to set chat visibility, CTA/form binding, and other audience blocks."],
118753
+ examples: ["Use after oko_create_webinar to enable chat, bind a CTA/form, and configure other audience blocks."],
118570
118754
  notes: [
118571
118755
  "A block toggle and its image are independent: enabling a block here works with an image already uploaded via oko_upload_image, and uploading an image never enables the block by itself.",
118572
118756
  "promoBanner has no text field by design: the banner is image+link. blocks.promoBanner.text seen in event documents is legacy and is never rendered on the event page.",
118573
118757
  'videoPlaceholder.enabled only allows the placeholder; viewers see it after oko_set_block_visibility with block "placeholder" and visible true (a live override, stored outside the event document). Chat, CTA, banner and cards appear as soon as they are enabled here.',
118574
- "Setup toggle vs live override: enabled=false removes a block from the page for everyone and persists (its uploaded image stays); oko_set_block_visibility hides or shows an enabled block temporarily and is the tool for the live show. Uploading an image never enables a block."
118758
+ "Setup toggle vs live override: enabled=false removes a block from the page for everyone and persists (its uploaded image stays); oko_set_block_visibility hides or shows an enabled block temporarily and is the tool for the live show. Uploading an image never enables a block.",
118759
+ "telegramChannelIds is the only field here that is not a page setting: it decides which channel carries this event's messages, and the automatic reminder goes out under the event owner's name. Omit the field to configure blocks with setup:write alone; pass it only with a key that holds telegram:write and for an organizer who may send notifications for this event."
118575
118760
  ]
118576
118761
  }),
118577
118762
  inputSchema: {
@@ -118586,8 +118771,12 @@ function registerEventTools(server2) {
118586
118771
  outputSchema: updateOutputSchema,
118587
118772
  annotations: writeTool
118588
118773
  }, async (args) => {
118589
- const { uid } = await authed(["setup:write"]);
118774
+ const { uid } = await authed(configureBlocksScopes(args));
118590
118775
  const current = await readEvent(args.eventId, uid, "edit");
118776
+ const telegramPatch = telegramBlocksPatch(args);
118777
+ if (Object.keys(telegramPatch).length > 0 && !await eventGrantsMode(args.eventId, uid, "notify")) {
118778
+ throw new Error("Configuring Telegram channels needs the notification permission on this event, not just setup rights: the channels decide where this event's messages go, and the reminder is sent under the event owner's name. Ask the event owner (or the workspace owner) to grant sendNotifications, or call this tool without telegramChannelIds \u2014 every other block is still yours to configure.");
118779
+ }
118591
118780
  const patch = {};
118592
118781
  if (args.chat?.enabled !== void 0)
118593
118782
  patch["blocks.chat.enabled"] = args.chat.enabled;
@@ -118643,10 +118832,7 @@ function registerEventTools(server2) {
118643
118832
  placeholder: field2.placeholder?.trim() || null
118644
118833
  }))
118645
118834
  }));
118646
- if (args.telegramChannelIds !== void 0) {
118647
- patch["telegram.enabled"] = args.telegramChannelIds.length > 0;
118648
- patch["telegram.channelIds"] = args.telegramChannelIds;
118649
- }
118835
+ Object.assign(patch, telegramPatch);
118650
118836
  const formsAfterPatch = patch.forms ?? current.forms;
118651
118837
  const knownFormIds = new Set((Array.isArray(formsAfterPatch) ? formsAfterPatch : []).map((form) => typeof form?.id === "string" ? form.id : null).filter((id) => Boolean(id)));
118652
118838
  for (const [field2, value] of [
@@ -118659,6 +118845,9 @@ function registerEventTools(server2) {
118659
118845
  }
118660
118846
  await updateDoc(doc(firestore, "events", args.eventId), patch);
118661
118847
  const hints = [];
118848
+ if (args.chat?.visibility !== void 0) {
118849
+ hints.push("blocks.chat.visibility was stored but the event page does not read it: the chat panel exists exactly when blocks.chat.enabled is true, and sending is opened or closed live with oko_set_chat_mode. Drop the field.");
118850
+ }
118662
118851
  const currentBlocks = current.blocks && typeof current.blocks === "object" ? current.blocks : {};
118663
118852
  const bannerHasImage = Boolean(safeString(currentBlocks.promoBanner?.imageUrl) || safeString(currentBlocks.promoBanner?.imageMasterUrl));
118664
118853
  if (args.promoBanner?.enabled === false && bannerHasImage) {
@@ -118758,6 +118947,9 @@ function registerEventTools(server2) {
118758
118947
  const warnings = removedCardsWithImages.length > 0 ? [`Removed cards had images and their Storage files were not deleted: ${removedCardsWithImages.join(", ")}.`] : [];
118759
118948
  if (finalCards.repairedIdPositions.length > 0) {
118760
118949
  warnings.push(`Cards at position(s) ${finalCards.repairedIdPositions.join(", ")} had no usable id in Firestore; a temporary one was generated, so their previous images could not be matched and were dropped. Re-upload images for these cards.`);
118950
+ if (args.items === void 0) {
118951
+ warnings.push("The ids shown for those positions are provisional (generated on read) and were NOT stored: oko_upload_image and oko_delete_image will not accept them. Save the cards first by passing items to oko_configure_cards \u2014 the saved ids are the ones to use.");
118952
+ }
118761
118953
  }
118762
118954
  return textResult({
118763
118955
  eventId: args.eventId,
@@ -118867,8 +119059,16 @@ function registerImageTools(server2) {
118867
119059
  }
118868
119060
  }
118869
119061
  const uploadedCardIds = new Set(images.map((image) => image.cardId).filter((cardId) => !failures.some((failure) => failure.cardId === cardId)));
118870
- const variants = uploadedCardIds.size > 0 ? await waitForImageVariantTargets(uid, eventId, [...uploadedCardIds].map((cardId) => ({ kind: "card", cardId })), timeoutSec) : [];
118871
- const warnings = variants.filter((item) => !item.ready).map((item) => `Card ${item.cardId ?? "unknown"} variants are not ready yet: ${item.reason ?? "unknown"}.`);
119062
+ let variants = [];
119063
+ const warnings = [];
119064
+ if (uploadedCardIds.size > 0) {
119065
+ try {
119066
+ variants = await waitForImageVariantTargets(uid, eventId, [...uploadedCardIds].map((cardId) => ({ kind: "card", cardId })), timeoutSec);
119067
+ } catch (error3) {
119068
+ warnings.push(`Uploads succeeded, but waiting for their variants failed: ${error3 instanceof Error ? error3.message : String(error3)}. Check readiness later with oko_wait_for_image_variants.`);
119069
+ }
119070
+ }
119071
+ warnings.push(...variants.filter((item) => !item.ready).map((item) => `Card ${item.cardId ?? "unknown"} variants are not ready yet: ${item.reason ?? "unknown"}.`));
118872
119072
  warnings.push(...failures.map((failure) => `Card ${failure.cardId} upload failed: ${failure.error}`));
118873
119073
  return textResult({
118874
119074
  eventId,
@@ -119030,14 +119230,37 @@ async function recordToolAudit(toolName, outcome, args, startedAt, error3) {
119030
119230
  // build/tools/instrumentation.js
119031
119231
  var REPORT_URL = `${APP_ORIGIN}/contact`;
119032
119232
  var KEYS_URL = `${APP_ORIGIN}/app/mcp`;
119033
- var SCOPE_WORKAROUNDS = {
119034
- oko_trash_event: "This key cannot delete events. Tell the organizer which event to remove; they can do it in the OKO cabinet, or create a key with the full preset.",
119035
- oko_delete_event_permanently: "This key cannot delete events. Tell the organizer which event to remove; they can do it in the OKO cabinet, or create a key with the full preset.",
119036
- oko_publish_telegram_post: "This key cannot publish to Telegram. The organizer can publish the announcement from the OKO cabinet, or create a key with the full preset."
119233
+ var PERMISSION_GATED_ARGS = {
119234
+ telegramChannelIds: "telegramChannelIds \u2014 needs the telegram:write scope and the notification permission on the event"
119037
119235
  };
119236
+ var SCOPE_WORKAROUNDS = {
119237
+ "oko_trash_event|events:delete": { text: "This key cannot delete events. Tell the organizer which event to remove; they can do it in the OKO cabinet, or create a key with the full preset." },
119238
+ "oko_delete_event_permanently|events:delete": { text: "This key cannot delete events. Tell the organizer which event to remove; they can do it in the OKO cabinet, or create a key with the full preset." },
119239
+ "oko_publish_telegram_post|telegram:write": { text: "This key cannot publish to Telegram. The organizer can publish the announcement from the OKO cabinet, or create a key with the full preset." },
119240
+ // 🔴 The most frequent refusal of this whole release (`ADV-A6-19`): an
119241
+ // `operator` key calling oko_configure_blocks with channel ids. The advice
119242
+ // promises that the rest of the call would work — true only when the key can
119243
+ // configure blocks at all.
119244
+ "oko_configure_blocks|telegram:write": {
119245
+ text: "Repeat the call WITHOUT telegramChannelIds: every other block, form and CTA in it will be configured normally. Choosing which channel carries an event is a send permission, so the channels themselves need a key with the full preset (or telegram:write) and an organizer allowed to send notifications for that event.",
119246
+ requires: ["setup:write"]
119247
+ }
119248
+ };
119249
+ function scopeWorkaroundFor(toolName, missing, granted) {
119250
+ const entry = SCOPE_WORKAROUNDS[`${toolName}|${missing}`];
119251
+ if (!entry)
119252
+ return void 0;
119253
+ if (entry.requires?.some((scope) => !granted.includes(scope)))
119254
+ return void 0;
119255
+ return entry.text;
119256
+ }
119038
119257
  function presetsWithScope2(scope) {
119039
119258
  return Object.entries(MCP_PRESETS).filter(([, scopes]) => scopes.includes(scope)).map(([name7]) => name7);
119040
119259
  }
119260
+ function permissionGatedArgsIn(args) {
119261
+ const source = args && typeof args === "object" ? args : {};
119262
+ return Object.entries(PERMISSION_GATED_ARGS).filter(([key]) => source[key] !== void 0).map(([, explanation]) => explanation);
119263
+ }
119041
119264
  function idsFromArgs(args) {
119042
119265
  if (!args || typeof args !== "object")
119043
119266
  return {};
@@ -119052,7 +119275,7 @@ function idsFromArgs(args) {
119052
119275
  function scopeRejectionResult(toolName, scope) {
119053
119276
  const granted = currentScopes();
119054
119277
  const presets = presetsWithScope2(scope);
119055
- const workaround = SCOPE_WORKAROUNDS[toolName];
119278
+ const workaround = scopeWorkaroundFor(toolName, scope, granted);
119056
119279
  return {
119057
119280
  ...textResult({
119058
119281
  errorCode: "SCOPE_REQUIRED",
@@ -119091,22 +119314,27 @@ function platformRejectionResult(toolName, args, readOnly) {
119091
119314
  isError: true
119092
119315
  };
119093
119316
  }
119317
+ const gated = permissionGatedArgsIn(args);
119094
119318
  return {
119095
119319
  ...textResult({
119096
119320
  errorCode: "PLATFORM_REJECTED_WRITE",
119097
119321
  tool: toolName,
119098
119322
  ...ids,
119099
- message: "OKO's security rules refused this write. The request reached the platform and was rejected server-side; nothing was changed.",
119323
+ message: "OKO's security rules refused this write. The request reached the platform and was rejected server-side; nothing was changed. A rule can refuse the whole write because of a single field in it.",
119324
+ mayBeCausedBy: [
119325
+ `A field that needs a permission of its own: ${gated.length ? gated.join("; ") : "none of the fields this client knows to be permission-gated is present in this call, but the platform may gate others"}.`,
119326
+ `A permission on the target itself: this key holds ${currentScopes().join(", ") || "no scopes"} and acts for the organizer behind it, who may not hold the right this particular field requires.`
119327
+ ],
119100
119328
  notCausedBy: [
119101
- `API key scopes: this key holds ${currentScopes().join(", ") || "no scopes"}, and the tool's scope check passed before the write.`,
119102
119329
  `Client version: ${OKO_MCP_VERSION}${update2.updateAvailable ? ` (an update to ${update2.latest} is available, but rules apply to every version equally)` : " (current)"}.`,
119103
119330
  "Wording or parameter choice: the same rule rejects the same data regardless of phrasing."
119104
119331
  ],
119105
119332
  whatToDo: [
119106
119333
  "Do not retry with different parameters and do not create the event again: retries hit the same rule and only produce duplicates.",
119107
- "Tell the organizer plainly that the OKO platform refused this change; it is a platform-side issue, not their input.",
119334
+ ...gated.length ? [`Repeat the call WITHOUT ${gated.length > 1 ? "those fields" : "that field"} first: if it succeeds, the refusal was about ${gated.length > 1 ? "them" : "it"} \u2014 the organizer then needs a key with the missing scope, or the missing permission on the event, and the rest of the change is already saved.`] : [],
119335
+ "If no such field is involved, tell the organizer plainly that the OKO platform refused this change.",
119108
119336
  `Ask them to report it to OKO at ${REPORT_URL} with the "report" block below, so it can be fixed on the platform side.`,
119109
- ...toolName === "oko_create_webinar" ? ["If the organizer needs the page today: oko_duplicate_event of an existing event usually works when creation is refused."] : []
119337
+ ...toolName === "oko_create_webinar" ? ["If the organizer needs the page today: oko_duplicate_event of an existing event often works when creation is refused \u2014 but pick a source event WITHOUT Telegram channels, or hold telegram:write: duplicating a Telegram-configured event needs the same permission that creating one does."] : []
119110
119338
  ],
119111
119339
  report: {
119112
119340
  tool: toolName,
@@ -119175,6 +119403,17 @@ function installToolInstrumentation(server2) {
119175
119403
  }
119176
119404
 
119177
119405
  // build/tools/leads.js
119406
+ function leadSubmittedMs(lead) {
119407
+ const value = lead.submittedAt;
119408
+ if (typeof value === "number" && Number.isFinite(value))
119409
+ return value;
119410
+ if (typeof value === "string") {
119411
+ const parsed = Date.parse(value);
119412
+ if (Number.isFinite(parsed))
119413
+ return parsed;
119414
+ }
119415
+ return 0;
119416
+ }
119178
119417
  function registerLeadTools(server2) {
119179
119418
  server2.registerTool("oko_list_leads", {
119180
119419
  title: "List form leads",
@@ -119187,7 +119426,7 @@ function registerLeadTools(server2) {
119187
119426
  "limit: max rows, default 50, max 200.",
119188
119427
  "cursor: lead document id returned as nextCursor by the previous page."
119189
119428
  ],
119190
- returns: "leads or csv plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
119429
+ returns: "leads or csv plus count, limit, hasMore/has_more, nextCursor/next_cursor. Without formId the rows are newest-first across pages; with formId they are newest-first WITHIN a page only, because paging there follows document ids \u2014 to rank one form's submissions by time, read every page and sort by submittedAt.",
119191
119430
  examples: ["Use formId to export one form; if Firestore requests an index, create it from the error link and rerun."]
119192
119431
  }),
119193
119432
  inputSchema: {
@@ -119203,10 +119442,21 @@ function registerLeadTools(server2) {
119203
119442
  const { uid } = await authed(["leads:read"]);
119204
119443
  await readEvent(eventId, uid, "analytics");
119205
119444
  const base = collection(firestore, "events", eventId, "leads");
119206
- const constraints = formId ? [where("formId", "==", formId), orderBy(documentId$1()), ...cursor ? [startAfter(cursor)] : [], limit(max + 1)] : [orderBy(documentId$1()), ...cursor ? [startAfter(cursor)] : [], limit(max + 1)];
119445
+ let constraints;
119446
+ if (formId) {
119447
+ constraints = [where("formId", "==", formId), orderBy(documentId$1()), ...cursor ? [startAfter(cursor)] : [], limit(max + 1)];
119448
+ } else {
119449
+ const cursorSnap = cursor ? await getDoc(doc(base, cursor)) : null;
119450
+ if (cursor && !cursorSnap?.exists()) {
119451
+ throw new Error(`Lead cursor ${cursor} no longer exists (the submission was deleted). Restart the listing without a cursor.`);
119452
+ }
119453
+ constraints = [orderBy("submittedAt", "desc"), ...cursorSnap ? [startAfter(cursorSnap)] : [], limit(max + 1)];
119454
+ }
119207
119455
  const snap = await getDocs(query2(base, ...constraints));
119208
119456
  const page = snap.docs.slice(0, max);
119209
119457
  const leads = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
119458
+ if (formId)
119459
+ leads.sort((a, b) => leadSubmittedMs(b) - leadSubmittedMs(a));
119210
119460
  const hasMore = snap.docs.length > max;
119211
119461
  const nextCursor = hasMore ? page.at(-1)?.id ?? null : null;
119212
119462
  const meta2 = cursorMeta(leads.length, max, hasMore, nextCursor);
@@ -119248,6 +119498,13 @@ function registerLeadTools(server2) {
119248
119498
 
119249
119499
  // build/tools/lifecycle.js
119250
119500
  function registerLifecycleTools(server2) {
119501
+ const moveToTrash = async (toolName, { eventId, confirm }) => {
119502
+ const { uid } = await authed(["events:delete"]);
119503
+ const event = await readEvent(eventId, uid, "delete");
119504
+ requireConfirm(confirm, toolName, { eventId, title: event.title });
119505
+ const trash = httpsCallable(functions, "trashEvent");
119506
+ return textResult((await trash({ eventId })).data);
119507
+ };
119251
119508
  server2.registerTool("oko_delete_event", {
119252
119509
  title: "Move event to trash",
119253
119510
  description: toolDescription({
@@ -119260,14 +119517,7 @@ function registerLifecycleTools(server2) {
119260
119517
  inputSchema: { eventId: eventIdField, confirm: external_exports.boolean().optional().describe("Required true to move the event to trash.") },
119261
119518
  outputSchema: external_exports.object({ trashed: external_exports.string(), purgeAfter: external_exports.number().nullable() }).passthrough(),
119262
119519
  annotations: destructiveTool
119263
- }, async ({ eventId, confirm }) => {
119264
- const { uid } = await authed(["events:delete"]);
119265
- const data = await readEvent(eventId, uid, "delete");
119266
- requireConfirm(confirm, "oko_delete_event", { eventId, title: data.title });
119267
- const trash = httpsCallable(functions, "trashEvent");
119268
- const result = await trash({ eventId });
119269
- return textResult(result.data);
119270
- });
119520
+ }, async ({ eventId, confirm }) => moveToTrash("oko_delete_event", { eventId, confirm }));
119271
119521
  server2.registerTool("oko_trash_event", {
119272
119522
  title: "Move event to trash",
119273
119523
  description: toolDescription({
@@ -119280,13 +119530,7 @@ function registerLifecycleTools(server2) {
119280
119530
  inputSchema: { eventId: eventIdField, confirm: external_exports.boolean().optional().describe("Required true to move the event to trash.") },
119281
119531
  outputSchema: external_exports.object({ trashed: external_exports.string(), purgeAfter: external_exports.number().nullable() }).passthrough(),
119282
119532
  annotations: destructiveTool
119283
- }, async ({ eventId, confirm }) => {
119284
- const { uid } = await authed(["events:delete"]);
119285
- const event = await readEvent(eventId, uid, "delete");
119286
- requireConfirm(confirm, "oko_trash_event", { eventId, title: event.title });
119287
- const trash = httpsCallable(functions, "trashEvent");
119288
- return textResult((await trash({ eventId })).data);
119289
- });
119533
+ }, async ({ eventId, confirm }) => moveToTrash("oko_trash_event", { eventId, confirm }));
119290
119534
  server2.registerTool("oko_restore_event", {
119291
119535
  title: "Restore event",
119292
119536
  description: toolDescription({
@@ -119346,7 +119590,8 @@ function registerLifecycleTools(server2) {
119346
119590
  notes: [
119347
119591
  "Copies title, video, meta, blocks, forms, and Telegram config; does not copy analytics sessions or leads.",
119348
119592
  "Live overrides are NOT copied: the copy starts with chat mode public and the placeholder hidden, whatever the source had. Set them again with oko_set_chat_mode and oko_set_block_visibility on the new event.",
119349
- "Requires only events:write plus createEvents in the target workspace. To move (not copy) an event, use oko_move_event with the same scope."
119593
+ "Requires only events:write plus createEvents in the target workspace. To move (not copy) an event, use oko_move_event with the same scope.",
119594
+ "Telegram config is copied only when the key holds telegram:write AND the organizer may send notifications for the source event; otherwise the copy starts with Telegram off and a hint says so. Deciding which channel carries an event is a send permission, not a setup one."
119350
119595
  ]
119351
119596
  }),
119352
119597
  inputSchema: {
@@ -119360,6 +119605,8 @@ function registerLifecycleTools(server2) {
119360
119605
  const source = await readEvent(eventId, uid, "read");
119361
119606
  const workspaceId = targetWorkspaceId ?? safeString(source.workspaceId, uid);
119362
119607
  await assertCanCreateInWorkspace(uid, workspaceId);
119608
+ const sourceHasTelegram = !!source.telegram && typeof source.telegram === "object";
119609
+ const mayCopyTelegram = sourceHasTelegram && currentScopes().includes("telegram:write") && await eventGrantsMode(eventId, uid, "notify");
119363
119610
  const newRef = await addEventWithRtdbMirror({
119364
119611
  ownerId: uid,
119365
119612
  workspaceId,
@@ -119375,7 +119622,7 @@ function registerLifecycleTools(server2) {
119375
119622
  // function skips events with this flag forever: a copy of an event whose
119376
119623
  // reminder has already gone out would never get its own — silently
119377
119624
  // (review 02.09, R3b-5).
119378
- ...source.telegram && typeof source.telegram === "object" ? { telegram: copyableTelegram(source.telegram) } : {}
119625
+ ...mayCopyTelegram ? { telegram: copyableTelegram(source.telegram) } : {}
119379
119626
  });
119380
119627
  return textResult({
119381
119628
  id: newRef.id,
@@ -119383,7 +119630,9 @@ function registerLifecycleTools(server2) {
119383
119630
  workspaceId,
119384
119631
  ...eventLinks(newRef.id),
119385
119632
  liveStateNotCopied: ["chatMode", "placeholderVisible", "liveBadge", "block visibility overrides"],
119633
+ telegramCopied: mayCopyTelegram,
119386
119634
  hints: [
119635
+ ...sourceHasTelegram && !mayCopyTelegram ? ["Telegram config was NOT copied: this key needs telegram:write and the notification permission on the source event. The copy starts with Telegram off; ask the event owner to grant the permission, then configure channels on the copy."] : [],
119387
119636
  "Live state was not copied: chat mode is public and the placeholder is hidden on the copy. If the source had chat off or the placeholder shown, set them again with oko_set_chat_mode and oko_set_block_visibility on the new event.",
119388
119637
  "The copy is live at publicUrl immediately; edit title, eventDate, and video with oko_update_event, then run oko_validate_event_setup."
119389
119638
  ]
@@ -119414,6 +119663,14 @@ function registerLifecycleTools(server2) {
119414
119663
  requireConfirm(confirm, "oko_move_event", { eventId, targetWorkspaceId });
119415
119664
  const moveEvent = httpsCallable(functions, "moveEventToWorkspace");
119416
119665
  const result = await moveEvent({ eventId, targetWorkspaceId });
119666
+ if (result.data.rtdbMirrorUpdated === false) {
119667
+ return textResult({
119668
+ ...result.data,
119669
+ hints: [
119670
+ "The event document moved, but the live-control mirror in the main RTDB did not confirm: members of the ORIGINAL workspace keep live control (start/end sessions, live badge, analytics) and members of the target workspace do not have it yet. Retry oko_move_event with the same target \u2014 the call is idempotent \u2014 and check oko_get_live_state afterwards."
119671
+ ]
119672
+ });
119673
+ }
119417
119674
  return textResult(result.data);
119418
119675
  });
119419
119676
  }
@@ -119484,18 +119741,15 @@ function registerLiveTools(server2) {
119484
119741
  };
119485
119742
  if (block === "chat") {
119486
119743
  const modeRef = ref(rtdb, `events/${eventId}/ctrl/chatMode`);
119487
- const legacyRef = ref(rtdb, `events/${eventId}/ctrl/chatVisible`);
119488
- if (visible === null) {
119489
- await Promise.all([remove(modeRef), remove(legacyRef)]);
119490
- } else if (visible === false) {
119491
- await set(modeRef, "off");
119492
- await remove(legacyRef);
119744
+ const patch = { chatVisible: null };
119745
+ if (visible === null || visible === false) {
119746
+ patch.chatMode = visible === false ? "off" : null;
119493
119747
  } else {
119494
119748
  const current = await get(modeRef);
119495
119749
  if (current.val() === "off")
119496
- await remove(modeRef);
119497
- await remove(legacyRef);
119750
+ patch.chatMode = null;
119498
119751
  }
119752
+ await update(ref(rtdb, `events/${eventId}/ctrl`), patch);
119499
119753
  return textResult({ eventId, block, visible, hints });
119500
119754
  }
119501
119755
  const target = ref(rtdb, `events/${eventId}/ctrl/${paths[block]}`);
@@ -119520,7 +119774,7 @@ function registerLiveTools(server2) {
119520
119774
  const { uid } = await authed(["live:control"]);
119521
119775
  await readEvent(eventId, uid, "live");
119522
119776
  requireConfirm(confirm, "oko_refresh_viewers", { eventId });
119523
- await set(ref(rtdb, `events/${eventId}/ctrl/refreshTick`), Date.now());
119777
+ await runTransaction(ref(rtdb, `events/${eventId}/ctrl/refreshTick`), (current) => (current ?? 0) + 1);
119524
119778
  return textResult({ eventId, refreshed: true });
119525
119779
  });
119526
119780
  server2.registerTool("oko_set_live_badge", {
@@ -119564,23 +119818,26 @@ function registerLiveTools(server2) {
119564
119818
  server2.registerTool("oko_set_chat_mode", {
119565
119819
  title: "Set chat mode",
119566
119820
  description: toolDescription({
119567
- summary: "Set who can send chat messages on the public event page.",
119821
+ summary: "Open or close chat sending on the public event page.",
119568
119822
  exampleCall: { "eventId": "Kx9pQ2mR7tL4vB8nW3cY", "mode": "off", "confirm": true },
119569
119823
  args: [
119570
119824
  "eventId: event to control.",
119571
- "mode: public (anyone), auth (signed-in viewers only), owner (organizers only), off (input closed, history stays visible), or null to reset to the product default (public).",
119825
+ "mode: public (anyone can send), off (input closed, history stays visible), or null to reset to the product default (public).",
119572
119826
  "confirm: must be true."
119573
119827
  ],
119574
119828
  returns: "eventId and the effective chat mode.",
119575
119829
  examples: ['Use mode="off" to pause a chat storm; use mode=null or "public" to reopen.'],
119576
119830
  notes: [
119577
119831
  'This is the full replacement for the legacy pause via oko_set_block_visibility(block="chat"): prefer oko_set_chat_mode.',
119578
- "History always stays visible to viewers; mode only gates sending."
119832
+ "There are no other levels: the server has exactly two states, sending open or closed. History is always visible; the mode never restricts who can read or hides anyone's messages."
119579
119833
  ]
119580
119834
  }),
119581
119835
  inputSchema: {
119582
119836
  eventId: eventIdField,
119583
- mode: external_exports.enum(["public", "auth", "owner", "off"]).nullable().describe("Chat mode; null resets the override to the default."),
119837
+ // LD5a-2 / A8 (owner decision 03.09): "auth" and "owner" were promised
119838
+ // here and enforced nowhere — the rule and every client only check
119839
+ // `mode !== "off"`. The contract now says what the server does.
119840
+ mode: external_exports.enum(["public", "off"]).nullable().describe("Chat mode: public or off; null resets the override to the default."),
119584
119841
  confirm: external_exports.boolean().optional().describe("Required true to change the live chat mode.")
119585
119842
  },
119586
119843
  outputSchema: basicEventOutputSchema,
@@ -119589,11 +119846,7 @@ function registerLiveTools(server2) {
119589
119846
  const { uid } = await authed(["live:control"]);
119590
119847
  await readEvent(eventId, uid, "live");
119591
119848
  requireConfirm(confirm, "oko_set_chat_mode", { eventId, mode });
119592
- const target = ref(rtdb, `events/${eventId}/ctrl/chatMode`);
119593
- if (mode === null)
119594
- await remove(target);
119595
- else
119596
- await set(target, mode);
119849
+ await update(ref(rtdb, `events/${eventId}/ctrl`), { chatMode: mode, chatVisible: null });
119597
119850
  return textResult({ eventId, mode });
119598
119851
  });
119599
119852
  server2.registerTool("oko_force_viewer_reload", {
@@ -119686,7 +119939,7 @@ function registerNotificationTools(server2) {
119686
119939
  ],
119687
119940
  returns: "markedRead count and notification ids updated.",
119688
119941
  examples: ["Use after oko_list_notifications when the user asks to clear the bell/unread state."],
119689
- notes: ["Provide either notificationIds or allUnread=true. This can update only the current organizer's own notifications."]
119942
+ notes: ["Provide either notificationIds or allUnread=true. This can update only the current organizer's own notifications.", "Requires workspace:read (marking your own notifications read is not a workspace write)."]
119690
119943
  }),
119691
119944
  inputSchema: {
119692
119945
  notificationIds: external_exports.array(external_exports.string().min(1).max(160)).max(100).optional().describe("Specific notification document ids."),
@@ -119696,7 +119949,7 @@ function registerNotificationTools(server2) {
119696
119949
  outputSchema: notificationsMarkOutputSchema,
119697
119950
  annotations: idempotentWriteTool
119698
119951
  }, async ({ notificationIds, allUnread, limit: max }) => {
119699
- const { uid } = await authed(["workspace:write"]);
119952
+ const { uid } = await authed(["workspace:read"]);
119700
119953
  if (Boolean(notificationIds?.length) === allUnread) {
119701
119954
  throw new Error("Provide either notificationIds or allUnread=true.");
119702
119955
  }
@@ -120152,7 +120405,7 @@ function registerTelegramTools(server2) {
120152
120405
  "limit: max posts, default 30, max 100.",
120153
120406
  "cursor: createdAt cursor returned by previous page."
120154
120407
  ],
120155
- returns: "posts plus cursor pagination metadata.",
120408
+ returns: "posts plus cursor pagination metadata. A failed post may carry deliveryError: sender_right_revoked (the author lost the notification permission and the queued post was cancelled on purpose) or orphaned_pending (delivery never started).",
120156
120409
  examples: ["Use to review scheduled/sent/failed Telegram posts before canceling or cloning."]
120157
120410
  }),
120158
120411
  inputSchema: {
@@ -120218,18 +120471,21 @@ function registerTelegramTools(server2) {
120218
120471
  args: [
120219
120472
  "eventId: event used for access checks and default button URL.",
120220
120473
  "channelId: Telegram channel id configured for the organizer.",
120221
- "caption: post caption, max 4096 chars.",
120474
+ "caption: post caption, max 4096 chars, parsed as Telegram HTML.",
120222
120475
  "scheduleAt: optional ISO datetime; omitted means immediate send.",
120223
120476
  "confirm: must be true for both immediate and scheduled publication."
120224
120477
  ],
120225
120478
  returns: "postId, scheduled flag, and sent flag.",
120226
120479
  examples: ["Use scheduleAt for scheduled posts; for immediate send, show the exact channel/caption and ask for confirmation."],
120227
- notes: ["External side effect: immediate sends can publish to real Telegram channels."]
120480
+ notes: [
120481
+ "External side effect: immediate sends can publish to real Telegram channels.",
120482
+ "The caption is parsed as Telegram HTML, so <b>, <i> and <a href> are markup. Text copied from event fields (title, speaker, description) must have &, < and > escaped as &amp;, &lt; and &gt; first \u2014 an unescaped < makes Telegram reject the whole message and the post ends up failed."
120483
+ ]
120228
120484
  }),
120229
120485
  inputSchema: {
120230
120486
  eventId: eventIdField,
120231
120487
  channelId: external_exports.number().int().describe("Telegram channel id."),
120232
- caption: external_exports.string().min(1).max(4096).describe("Telegram post caption."),
120488
+ caption: external_exports.string().min(1).max(4096).describe("Telegram post caption, parsed as Telegram HTML; escape &, < and > in text taken from event fields."),
120233
120489
  imageUrl: external_exports.string().nullable().optional().describe("Optional image URL."),
120234
120490
  buttonLabel: external_exports.string().max(64).nullable().optional().describe("Optional inline button label."),
120235
120491
  buttonUrl: external_exports.string().max(1e3).nullable().optional().describe("Optional inline button URL; defaults to event URL when buttonLabel is set."),
@@ -120267,8 +120523,10 @@ function registerTelegramTools(server2) {
120267
120523
  channelId: args.channelId,
120268
120524
  channelKey: String(args.channelId),
120269
120525
  caption: args.caption.trim(),
120270
- imageUrl: args.imageUrl?.trim() || null,
120271
- buttons: args.buttonLabel?.trim() ? [{ label: args.buttonLabel.trim(), url: args.buttonUrl?.trim() || eventUrl(args.eventId) }] : [],
120526
+ // R3c-10 (04.09): a wrong protocol used to surface only from Telegram at
120527
+ // delivery, leaving the post `failed`; the check now precedes the write.
120528
+ imageUrl: absoluteHttpUrl(args.imageUrl, "imageUrl"),
120529
+ buttons: args.buttonLabel?.trim() ? [{ label: args.buttonLabel.trim(), url: absoluteHttpUrl(args.buttonUrl, "buttonUrl") ?? eventUrl(args.eventId) }] : [],
120272
120530
  // 🔴 Immediate sending is `pending`, as in the web, not `draft`. A break
120273
120531
  // between creating the document and sending left the post a "draft",
120274
120532
  // indistinguishable from one never sent: CF15 picks up only `scheduled`,
@@ -120342,8 +120600,17 @@ function registerWorkspaceTools(server2) {
120342
120600
  annotations: idempotentWriteTool
120343
120601
  }, async ({ workspaceId, name: name7 }) => {
120344
120602
  const { uid } = await authed(["workspace:write"]);
120345
- await readWorkspaceForOwner(workspaceId, uid);
120346
- await updateDoc(doc(firestore, "workspaces", workspaceId), { name: name7.trim() });
120603
+ const { synthesized } = await readWorkspaceForOwnerDetailed(workspaceId, uid);
120604
+ if (synthesized) {
120605
+ await setDoc(doc(firestore, "workspaces", workspaceId), {
120606
+ name: name7.trim(),
120607
+ ownerId: uid,
120608
+ personal: true,
120609
+ createdAt: serverTimestamp2()
120610
+ });
120611
+ } else {
120612
+ await updateDoc(doc(firestore, "workspaces", workspaceId), { name: name7.trim() });
120613
+ }
120347
120614
  return textResult({ workspaceId, updated: ["name"] });
120348
120615
  });
120349
120616
  server2.registerTool("oko_delete_workspace", {
@@ -120361,12 +120628,12 @@ function registerWorkspaceTools(server2) {
120361
120628
  }, async ({ workspaceId, confirm }) => {
120362
120629
  const { uid } = await authed(["workspace:write"]);
120363
120630
  const workspace = await readWorkspaceForOwner(workspaceId, uid);
120364
- requireConfirm(confirm, "oko_delete_workspace", { workspaceId, name: workspace.name, personal: workspace.personal });
120365
120631
  if (workspace.personal === true)
120366
120632
  throw new Error("Personal workspace cannot be deleted.");
120633
+ requireConfirm(confirm, "oko_delete_workspace", { workspaceId, name: workspace.name, personal: workspace.personal });
120367
120634
  const removeWorkspace = httpsCallable(functions, "deleteWorkspaceCompletely");
120368
120635
  const removed = await removeWorkspace({ workspaceId });
120369
- return textResult({ ...removed.data, workspaceId, deleted: true });
120636
+ return textResult({ ...removed.data, deleted: removed.data.deleted === true });
120370
120637
  });
120371
120638
  server2.registerTool("oko_list_workspace_access", {
120372
120639
  title: "List workspace access",
@@ -120387,16 +120654,26 @@ function registerWorkspaceTools(server2) {
120387
120654
  getDocs(query2(collection(firestore, "workspaceInvites"), where("workspaceId", "==", workspaceId)))
120388
120655
  ]);
120389
120656
  const members = [];
120657
+ let anyProfileRestricted = false;
120390
120658
  for (const share of sharesSnap.docs) {
120391
- const profile = await getDoc(doc(firestore, "userProfiles", share.id)).then((snap) => snap.exists() ? serialize(snap.data()) : null).catch(() => null);
120659
+ const profile = await getDoc(doc(firestore, "userProfiles", share.id)).then((snap) => ({ value: snap.exists() ? serialize(snap.data()) : null, restricted: false })).catch((error3) => {
120660
+ if (isFirestorePermissionDenied(error3))
120661
+ return { value: null, restricted: true };
120662
+ console.error(`[oko-mcp] userProfiles/${share.id} read failed: ${error3 instanceof Error ? error3.message : String(error3)}`);
120663
+ return { value: null, restricted: false, failed: true };
120664
+ });
120665
+ if (profile.restricted)
120666
+ anyProfileRestricted = true;
120392
120667
  members.push({
120393
120668
  uid: share.id,
120394
120669
  ...serialize(share.data()),
120395
- profile
120670
+ profile: profile.value,
120671
+ profileAccess: profile.restricted ? "restricted" : "failed" in profile ? "unavailable" : "readable"
120396
120672
  });
120397
120673
  }
120398
120674
  const invites = invitesSnap.docs.map((invite) => ({ code: invite.id, ...serialize(invite.data()) }));
120399
- return textResult({ workspaceId, workspace: { id: workspaceId, ...serialize(workspace) }, members, invites });
120675
+ const hints = anyProfileRestricted ? ["Some members report profileAccess=restricted: this client may not read other people's profiles, so their display name and avatar are unavailable here even though the OKO cabinet shows them. Refer to those members by uid or by the email stored on the share."] : [];
120676
+ return textResult({ workspaceId, workspace: { id: workspaceId, ...serialize(workspace) }, members, invites, hints });
120400
120677
  });
120401
120678
  server2.registerTool("oko_update_workspace_member", {
120402
120679
  title: "Update workspace member",
@@ -120420,6 +120697,11 @@ function registerWorkspaceTools(server2) {
120420
120697
  }, async (args) => {
120421
120698
  const { uid } = await authed(["workspace:write"]);
120422
120699
  const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
120700
+ if (!isSharedWorkspaceId(args.workspaceId))
120701
+ throw new Error(personalWorkspaceLimitMessage("adding or changing members"));
120702
+ if (args.uid === workspace.ownerId) {
120703
+ throw new Error("The workspace owner already holds every permission; a share for the owner is not needed and would be undone by the platform. Pick another member uid.");
120704
+ }
120423
120705
  requireConfirm(args.confirm, "oko_update_workspace_member", { workspaceId: args.workspaceId, workspaceName: workspace.name, targetUid: args.uid, permissions: args.permissions });
120424
120706
  const shareRef = doc(firestore, "workspaces", args.workspaceId, "shares", args.uid);
120425
120707
  const existing = await getDoc(shareRef);
@@ -120464,9 +120746,15 @@ function registerWorkspaceTools(server2) {
120464
120746
  }, async ({ workspaceId, uid: targetUid, confirm }) => {
120465
120747
  const { uid } = await authed(["workspace:write"]);
120466
120748
  const workspace = await readWorkspaceForOwner(workspaceId, uid);
120749
+ if (!isSharedWorkspaceId(workspaceId))
120750
+ throw new Error(personalWorkspaceLimitMessage("removing members"));
120467
120751
  requireConfirm(confirm, "oko_remove_workspace_member", { workspaceId, workspaceName: workspace.name, targetUid });
120468
- await deleteDoc(doc(firestore, "workspaces", workspaceId, "shares", targetUid));
120469
- return textResult({ workspaceId, uid: targetUid, removed: true });
120752
+ const shareRef = doc(firestore, "workspaces", workspaceId, "shares", targetUid);
120753
+ const outcome = workspaceMemberRemoval((await getDoc(shareRef)).exists(), workspaceId, targetUid);
120754
+ if (outcome.removed === false)
120755
+ return textResult({ workspaceId, uid: targetUid, ...outcome });
120756
+ await deleteDoc(shareRef);
120757
+ return textResult({ workspaceId, uid: targetUid, ...outcome });
120470
120758
  });
120471
120759
  server2.registerTool("oko_list_workspace_invites", {
120472
120760
  title: "List workspace invites",
@@ -120569,6 +120857,8 @@ function registerWorkspaceTools(server2) {
120569
120857
  }, async (args) => {
120570
120858
  const { uid } = await authed(["workspace:write"]);
120571
120859
  const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
120860
+ if (!isSharedWorkspaceId(args.workspaceId))
120861
+ throw new Error(personalWorkspaceLimitMessage("creating invites"));
120572
120862
  requireConfirm(args.confirm, "oko_create_workspace_invite", { workspaceId: args.workspaceId, code: args.code, email: args.email });
120573
120863
  const code = (args.code?.trim() || randomInviteCode()).toUpperCase();
120574
120864
  const url = inviteUrl(code);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okostream/mcp",
3
- "version": "0.12.8",
3
+ "version": "0.12.9",
4
4
  "description": "Control OKO Stream webinars from Claude, Codex, and other MCP clients.",
5
5
  "license": "UNLICENSED",
6
6
  "author": {