@okostream/mcp 0.9.3 → 0.11.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.
package/dist/tools.js CHANGED
@@ -940,7 +940,7 @@ function validateEventSetup(eventId, event) {
940
940
  add(errors, "title_missing", "title", "Event title is required.");
941
941
  const video = asRecord(event.video);
942
942
  if (!safeString(video.embedUrl).trim()) {
943
- add(warnings, "video_missing", "video.embedUrl", "No normalized video URL is configured.");
943
+ add(warnings, "video_missing", "video.embedUrl", "No normalized video URL is configured. This warning is for the organizer only and never appears as page text above other blocks. Without a video URL the player slot shows a small 'video not configured' notice, but when the video placeholder block is enabled and switched on, the placeholder fully covers that slot and viewers see only the placeholder.");
944
944
  }
945
945
  const meta = asRecord(event.meta);
946
946
  const eventDateMs = toMillis(meta.eventDate);
@@ -1156,6 +1156,35 @@ function timestampCursorFrom(value) {
1156
1156
  const millis = toMillis(value);
1157
1157
  return millis == null ? null : String(millis);
1158
1158
  }
1159
+ function parseIsoBound(value, label) {
1160
+ if (value === undefined)
1161
+ return null;
1162
+ const parsed = Date.parse(value);
1163
+ if (!Number.isFinite(parsed)) {
1164
+ throw new Error(`Invalid ${label}: expected an ISO timestamp such as 2026-08-28T00:00:00+03:00.`);
1165
+ }
1166
+ return Timestamp.fromMillis(parsed);
1167
+ }
1168
+ // Компактная карточка события из полного (сериализованного) документа —
1169
+ // той же формы, что grant-проекция в eventAccess.
1170
+ function compactEventFromFullDoc(event) {
1171
+ const meta = (event.meta && typeof event.meta === "object" ? event.meta : {});
1172
+ const telegram = (event.telegram && typeof event.telegram === "object" ? event.telegram : {});
1173
+ return {
1174
+ id: event.id,
1175
+ ownerId: event.ownerId ?? null,
1176
+ workspaceId: event.workspaceId ?? null,
1177
+ title: event.title ?? null,
1178
+ createdAt: event.createdAt ?? null,
1179
+ eventDate: meta.eventDate ?? null,
1180
+ speakerName: meta.speakerName ?? null,
1181
+ pinned: event.pinned === true,
1182
+ telegramEnabled: telegram.enabled === true,
1183
+ trashed: Boolean(event.trashedAt),
1184
+ trashedAt: event.trashedAt ?? null,
1185
+ activeAnalyticsSessionId: event.activeAnalyticsSessionId ?? null,
1186
+ };
1187
+ }
1159
1188
  function compactAnalyticsSession(raw, options) {
1160
1189
  const session = serialize(raw);
1161
1190
  const timeline = Array.isArray(session.timeline) ? session.timeline : null;
@@ -1343,7 +1372,7 @@ export function registerTools(server) {
1343
1372
  ],
1344
1373
  returns: "uid, updated field names, and the resulting profile snapshot.",
1345
1374
  examples: ["Use when the organizer asks to change their visible OKO name or timezone."],
1346
- notes: ["This edits only the current organizer's own userProfiles document and does not require extra workspace/event scopes."],
1375
+ notes: ["Edits the organizer profile that owns this key. Requires workspace:write: the key holder is not necessarily the organizer, so a read-only key must not rewrite the owner's name, avatar or timezone."],
1347
1376
  }),
1348
1377
  inputSchema: {
1349
1378
  displayName: profileDisplayNameSchema.optional().describe("Display name, max 40 chars. Null clears it."),
@@ -1352,7 +1381,7 @@ export function registerTools(server) {
1352
1381
  outputSchema: profileOutputSchema,
1353
1382
  annotations: idempotentWriteTool,
1354
1383
  }, async ({ displayName, timezone }) => {
1355
- const { uid } = await authed([]);
1384
+ const { uid } = await authed(["workspace:write"]);
1356
1385
  const patch = { updatedAt: serverTimestamp() };
1357
1386
  if (displayName !== undefined)
1358
1387
  patch.displayName = displayName?.trim() || null;
@@ -1464,11 +1493,17 @@ export function registerTools(server) {
1464
1493
  ]);
1465
1494
  const members = [];
1466
1495
  for (const share of sharesSnap.docs) {
1467
- const profileSnap = await getDoc(doc(firestore, "userProfiles", share.id));
1496
+ // Правила разрешают читать чужой профиль только владельцу платформы,
1497
+ // поэтому на любом рабочем пространстве с участниками это чтение
1498
+ // заканчивается отказом. Без обработки инструмент падал целиком вместо
1499
+ // того, чтобы вернуть состав команды без необязательных профилей.
1500
+ const profile = await getDoc(doc(firestore, "userProfiles", share.id))
1501
+ .then((snap) => (snap.exists() ? serialize(snap.data()) : null))
1502
+ .catch(() => null);
1468
1503
  members.push({
1469
1504
  uid: share.id,
1470
1505
  ...serialize(share.data()),
1471
- profile: profileSnap.exists() ? serialize(profileSnap.data()) : null,
1506
+ profile,
1472
1507
  });
1473
1508
  }
1474
1509
  const invites = invitesSnap.docs.map((invite) => ({ code: invite.id, ...serialize(invite.data()) }));
@@ -1597,24 +1632,40 @@ export function registerTools(server) {
1597
1632
  summary: "List OKO events visible to the authenticated organizer.",
1598
1633
  args: [
1599
1634
  "limit: maximum events to return, default 30, max 100.",
1600
- "cursor: timestamp cursor returned as nextCursor by the previous page.",
1635
+ "cursor: pagination cursor returned as nextCursor by the previous page; reuse it with the same filters.",
1601
1636
  "workspaceId: optional workspace filter.",
1637
+ 'detail: "compact" (default) returns the light card projection; "full" returns complete event documents.',
1638
+ "from/to: optional ISO timestamps bounding meta.eventDate; sorting switches to eventDate descending and undated events are excluded.",
1602
1639
  ],
1603
1640
  returns: "events plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
1604
- examples: ["Use limit=10 first; if hasMore is true, call again with cursor=nextCursor."],
1641
+ examples: [
1642
+ "Use limit=10 first; if hasMore is true, call again with cursor=nextCursor.",
1643
+ 'For "today\'s webinar" pass from/to covering the day; use detail="full" only when block contents are needed.',
1644
+ ],
1605
1645
  }),
1606
1646
  inputSchema: {
1607
1647
  limit: limitField(),
1608
1648
  cursor: cursorField,
1609
1649
  workspaceId: z.string().optional().describe("Optional workspace id to filter returned events."),
1650
+ detail: z.enum(["compact", "full"]).default("compact").describe('Response shape: "compact" card projection (default) or "full" event documents.'),
1651
+ from: z.string().optional().describe("Include only events with meta.eventDate at or after this ISO timestamp."),
1652
+ to: z.string().optional().describe("Include only events with meta.eventDate at or before this ISO timestamp."),
1610
1653
  },
1611
1654
  outputSchema: eventsOutputSchema,
1612
1655
  annotations: readOnlyTool,
1613
1656
  }, async (args) => {
1614
1657
  const { uid } = await authed(["events:read"]);
1658
+ const fromTs = parseIsoBound(args.from, "from");
1659
+ const toTs = parseIsoBound(args.to, "to");
1660
+ const dateFiltered = fromTs !== null || toTs !== null;
1661
+ const orderField = dateFiltered ? "eventDate" : "createdAt";
1615
1662
  const constraints = [
1616
- ...(args.workspaceId ? [where("workspaceId", "==", args.workspaceId)] : []),
1617
- orderBy("createdAt", "desc"),
1663
+ // При датном фильтре workspaceId отсеивается по загруженной странице:
1664
+ // составного индекса grants(workspaceId+eventDate) нет намеренно.
1665
+ ...(!dateFiltered && args.workspaceId ? [where("workspaceId", "==", args.workspaceId)] : []),
1666
+ ...(fromTs ? [where("eventDate", ">=", fromTs)] : []),
1667
+ ...(toTs ? [where("eventDate", "<=", toTs)] : []),
1668
+ orderBy(orderField, "desc"),
1618
1669
  limit(args.limit + 1),
1619
1670
  ];
1620
1671
  if (args.cursor)
@@ -1626,12 +1677,29 @@ export function registerTools(server) {
1626
1677
  });
1627
1678
  if (snap !== null) {
1628
1679
  const { page, hasMore } = pickPage(snap.docs, args.limit);
1629
- const eventDocuments = await Promise.all(page.map((item) => (getDoc(doc(firestore, "events", item.id)))));
1630
- const events = eventDocuments.flatMap((item) => (item.exists()
1631
- ? [{ id: item.id, ...serialize(item.data()) }]
1632
- : []));
1633
- const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().createdAt) : null;
1634
- return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor) });
1680
+ const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data()[orderField]) : null;
1681
+ const pageItems = dateFiltered && args.workspaceId
1682
+ ? page.filter((item) => item.data().workspaceId === args.workspaceId)
1683
+ : page;
1684
+ let events;
1685
+ if (args.detail === "full") {
1686
+ const eventDocuments = await Promise.all(pageItems.map((item) => (getDoc(doc(firestore, "events", item.id)))));
1687
+ events = eventDocuments.flatMap((item) => (item.exists()
1688
+ ? [{ id: item.id, ...serialize(item.data()) }]
1689
+ : []));
1690
+ }
1691
+ else {
1692
+ // Компактный режим отдаёт grant-проекцию как есть — без чтения
1693
+ // полных документов событий.
1694
+ events = pageItems.map((item) => {
1695
+ const data = serialize(item.data());
1696
+ delete data.schemaVersion;
1697
+ delete data.uid;
1698
+ delete data.eventId;
1699
+ return { id: item.id, ...data };
1700
+ });
1701
+ }
1702
+ return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor) }, { truncationHint: 'Use a smaller limit, cursor pagination, or detail="compact".' });
1635
1703
  }
1636
1704
  const legacyConstraints = [
1637
1705
  where("accessibleBy", "array-contains", uid),
@@ -1643,14 +1711,26 @@ export function registerTools(server) {
1643
1711
  }
1644
1712
  const legacySnap = await getDocs(query(collection(firestore, "events"), ...legacyConstraints));
1645
1713
  const { page, hasMore } = pickPage(legacySnap.docs, args.limit);
1714
+ const fromMs = fromTs ? fromTs.toMillis() : null;
1715
+ const toMs = toTs ? toTs.toMillis() : null;
1646
1716
  const events = page
1647
1717
  .map((item) => ({
1648
1718
  id: item.id,
1649
1719
  ...serialize(item.data()),
1650
1720
  }))
1651
- .filter((event) => !args.workspaceId || event.workspaceId === args.workspaceId);
1721
+ .filter((event) => !args.workspaceId || event.workspaceId === args.workspaceId)
1722
+ .filter((event) => {
1723
+ if (fromMs === null && toMs === null)
1724
+ return true;
1725
+ const meta = (event.meta && typeof event.meta === "object" ? event.meta : {});
1726
+ const ms = typeof meta.eventDate === "string" ? Date.parse(meta.eventDate) : NaN;
1727
+ if (!Number.isFinite(ms))
1728
+ return false;
1729
+ return (fromMs === null || ms >= fromMs) && (toMs === null || ms <= toMs);
1730
+ })
1731
+ .map((event) => (args.detail === "full" ? event : compactEventFromFullDoc(event)));
1652
1732
  const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().createdAt) : null;
1653
- return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor) });
1733
+ return textResult({ events, ...cursorMeta(events.length, args.limit, hasMore, nextCursor) }, { truncationHint: 'Use a smaller limit, cursor pagination, or detail="compact".' });
1654
1734
  });
1655
1735
  server.registerTool("oko_get_event", {
1656
1736
  title: "Get event",
@@ -1743,9 +1823,12 @@ export function registerTools(server) {
1743
1823
  title: "Update event metadata",
1744
1824
  description: toolDescription({
1745
1825
  summary: "Update safe top-level event/setup metadata fields.",
1746
- args: ["eventId plus any subset of title, videoUrl, eventDate, speakerName, description, pinned."],
1826
+ args: ["eventId plus any subset of title, videoUrl, eventDate, speakerName, description, pinned, sessionMode, durationMinutes."],
1747
1827
  returns: "event id and the list of Firestore fields updated.",
1748
1828
  examples: ["Use for title/date/speaker/video edits; use oko_configure_blocks for blocks and forms."],
1829
+ notes: [
1830
+ "sessionMode 'schedule' makes OKO start and end the analytics session automatically at eventDate for durationMinutes; 'manual' keeps the Start/End buttons and a Telegram reminder 10 minutes before eventDate. This is still an analytics session, not an event status.",
1831
+ ],
1749
1832
  }),
1750
1833
  inputSchema: {
1751
1834
  eventId: eventIdField,
@@ -1755,6 +1838,8 @@ export function registerTools(server) {
1755
1838
  speakerName: z.string().max(160).nullable().optional().describe("New speaker display name, or null to clear."),
1756
1839
  description: z.string().max(3000).nullable().optional().describe("New description, or null to clear."),
1757
1840
  pinned: z.boolean().optional().describe("Whether this event is pinned in the dashboard."),
1841
+ sessionMode: z.enum(["manual", "schedule"]).optional().describe("Analytics session mode: manual Start/End buttons, or automatic start/end on schedule at eventDate."),
1842
+ durationMinutes: z.number().int().min(15).max(1440).optional().describe("Event duration in minutes (15-1440) used by schedule mode to end the session."),
1758
1843
  },
1759
1844
  outputSchema: updateOutputSchema,
1760
1845
  annotations: writeTool,
@@ -1765,7 +1850,9 @@ export function registerTools(server) {
1765
1850
  if (args.videoUrl !== undefined
1766
1851
  || args.eventDate !== undefined
1767
1852
  || args.speakerName !== undefined
1768
- || args.description !== undefined) {
1853
+ || args.description !== undefined
1854
+ || args.sessionMode !== undefined
1855
+ || args.durationMinutes !== undefined) {
1769
1856
  requiredScopes.add("setup:write");
1770
1857
  }
1771
1858
  if (requiredScopes.size === 0)
@@ -1788,6 +1875,10 @@ export function registerTools(server) {
1788
1875
  patch["meta.description"] = args.description?.trim() || null;
1789
1876
  if (args.pinned !== undefined)
1790
1877
  patch.pinned = args.pinned;
1878
+ if (args.sessionMode !== undefined)
1879
+ patch.analyticsSessionMode = args.sessionMode;
1880
+ if (args.durationMinutes !== undefined)
1881
+ patch["meta.durationMinutes"] = args.durationMinutes;
1791
1882
  await updateDoc(doc(firestore, "events", args.eventId), patch);
1792
1883
  return textResult({ id: args.eventId, updated: Object.keys(patch) });
1793
1884
  });
@@ -1803,6 +1894,10 @@ export function registerTools(server) {
1803
1894
  ],
1804
1895
  returns: "event id and the list of Firestore fields updated.",
1805
1896
  examples: ["Use after oko_create_webinar to set chat visibility, CTA/form binding, and other audience blocks."],
1897
+ notes: [
1898
+ "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.",
1899
+ "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.",
1900
+ ],
1806
1901
  }),
1807
1902
  inputSchema: {
1808
1903
  eventId: eventIdField,
@@ -1984,6 +2079,7 @@ export function registerTools(server) {
1984
2079
  'Use kind="card" with cardId after the card item already exists.',
1985
2080
  ],
1986
2081
  notes: [
2082
+ "Uploading an image does not enable its block: toggle the block independently via oko_configure_blocks, in either order.",
1987
2083
  "The MCP server reads local paths on the organizer machine.",
1988
2084
  "Remote URLs must be public HTTPS images, max 15MB; private/local hosts are blocked.",
1989
2085
  "For card images, imageId is the cardId so OKO CF4 can update the correct item.",
@@ -2328,6 +2424,9 @@ export function registerTools(server) {
2328
2424
  args: ["eventId: event to control.", "block: chat, banner, cta, cards, or placeholder.", "visible: true/false, or null to reset override.", "confirm: must be true."],
2329
2425
  returns: "eventId, block, and effective override value.",
2330
2426
  examples: ["Use visible=false to hide the CTA live; use visible=null to return to setup defaults."],
2427
+ notes: [
2428
+ "block=chat with visible=false PAUSES message input: viewers keep seeing the chat history but cannot send. It does not hide the chat panel.",
2429
+ ],
2331
2430
  }),
2332
2431
  inputSchema: {
2333
2432
  eventId: eventIdField,
@@ -2373,6 +2472,105 @@ export function registerTools(server) {
2373
2472
  await set(ref(rtdb, `events/${eventId}/ctrl/refreshTick`), Date.now());
2374
2473
  return textResult({ eventId, refreshed: true });
2375
2474
  });
2475
+ server.registerTool("oko_set_live_badge", {
2476
+ title: "Set live badge",
2477
+ description: toolDescription({
2478
+ summary: "Control the public 'Live' badge on the event page.",
2479
+ args: [
2480
+ "eventId: event to control.",
2481
+ "mode: session (follows the analytics session, product default), manual (badge shown now for durationMinutes), schedule (shown around meta.eventDate), hidden (never shown).",
2482
+ "durationMinutes: badge window for manual/schedule, 15-1440 minutes, default 120.",
2483
+ "confirm: must be true.",
2484
+ ],
2485
+ returns: "eventId, mode, durationMinutes.",
2486
+ examples: ['Use mode="manual" during an unscheduled stream; return to mode="session" afterwards.'],
2487
+ notes: ["The badge is a page indicator only; it never starts or stops video or analytics."],
2488
+ }),
2489
+ inputSchema: {
2490
+ eventId: eventIdField,
2491
+ mode: z.enum(["session", "manual", "schedule", "hidden"]).describe("Live badge mode."),
2492
+ durationMinutes: z.number().int().min(15).max(1440).optional().describe("Badge window in minutes for manual/schedule modes; default 120."),
2493
+ confirm: z.boolean().optional().describe("Required true to change the public live badge."),
2494
+ },
2495
+ outputSchema: basicEventOutputSchema,
2496
+ annotations: idempotentWriteTool,
2497
+ }, async ({ eventId, mode, durationMinutes, confirm }) => {
2498
+ const { uid } = await authed(["live:control"]);
2499
+ await readEvent(eventId, uid, "live");
2500
+ requireConfirm(confirm, "oko_set_live_badge", { eventId, mode, durationMinutes: durationMinutes ?? null });
2501
+ const value = { mode, durationMinutes: durationMinutes ?? 120 };
2502
+ await set(ref(rtdb, `events/${eventId}/ctrl/liveBadge`), value);
2503
+ return textResult({ eventId, ...value });
2504
+ });
2505
+ server.registerTool("oko_set_chat_mode", {
2506
+ title: "Set chat mode",
2507
+ description: toolDescription({
2508
+ summary: "Set who can send chat messages on the public event page.",
2509
+ args: [
2510
+ "eventId: event to control.",
2511
+ "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).",
2512
+ "confirm: must be true.",
2513
+ ],
2514
+ returns: "eventId and the effective chat mode.",
2515
+ examples: ['Use mode="off" to pause a chat storm; use mode=null or "public" to reopen.'],
2516
+ notes: [
2517
+ 'This is the full replacement for the legacy pause via oko_set_block_visibility(block="chat"): prefer oko_set_chat_mode.',
2518
+ "History always stays visible to viewers; mode only gates sending.",
2519
+ ],
2520
+ }),
2521
+ inputSchema: {
2522
+ eventId: eventIdField,
2523
+ mode: z.enum(["public", "auth", "owner", "off"]).nullable().describe("Chat mode; null resets the override to the default."),
2524
+ confirm: z.boolean().optional().describe("Required true to change the live chat mode."),
2525
+ },
2526
+ outputSchema: basicEventOutputSchema,
2527
+ annotations: idempotentWriteTool,
2528
+ }, async ({ eventId, mode, confirm }) => {
2529
+ const { uid } = await authed(["live:control"]);
2530
+ await readEvent(eventId, uid, "live");
2531
+ requireConfirm(confirm, "oko_set_chat_mode", { eventId, mode });
2532
+ const target = ref(rtdb, `events/${eventId}/ctrl/chatMode`);
2533
+ if (mode === null)
2534
+ await remove(target);
2535
+ else
2536
+ await set(target, mode);
2537
+ return textResult({ eventId, mode });
2538
+ });
2539
+ server.registerTool("oko_force_viewer_reload", {
2540
+ title: "Force viewer reload",
2541
+ description: toolDescription({
2542
+ summary: "Force a hard page reload for every connected viewer (emergency recovery).",
2543
+ args: ["eventId: event whose viewer pages must reload.", "confirm: must be true."],
2544
+ returns: "eventId and reloaded=true.",
2545
+ examples: ["Use only when viewer pages are stuck (broken bundle or state) and oko_refresh_viewers did not help."],
2546
+ notes: [
2547
+ "Server enforces a 120-second cooldown per event; a repeated call returns the seconds left.",
2548
+ "Viewers reload with up to 4 seconds of jitter. Prefer oko_refresh_viewers for ordinary content updates.",
2549
+ ],
2550
+ }),
2551
+ inputSchema: {
2552
+ eventId: eventIdField,
2553
+ confirm: z.boolean().optional().describe("Required true to hard-reload all viewer pages."),
2554
+ },
2555
+ outputSchema: basicEventOutputSchema,
2556
+ annotations: writeTool,
2557
+ }, async ({ eventId, confirm }) => {
2558
+ const { uid } = await authed(["live:control"]);
2559
+ await readEvent(eventId, uid, "live");
2560
+ requireConfirm(confirm, "oko_force_viewer_reload", { eventId });
2561
+ const forceReload = httpsCallable(functions, "forceViewerReload");
2562
+ try {
2563
+ await forceReload({ eventId });
2564
+ }
2565
+ catch (error) {
2566
+ const details = error.details;
2567
+ if (typeof details?.retryInSec === "number") {
2568
+ throw new Error(`Force reload is cooling down: retry in ${details.retryInSec} seconds.`);
2569
+ }
2570
+ throw error;
2571
+ }
2572
+ return textResult({ eventId, reloaded: true });
2573
+ });
2376
2574
  server.registerTool("oko_start_session", {
2377
2575
  title: "Start analytics session",
2378
2576
  description: toolDescription({
package/dist/utils.js CHANGED
@@ -52,7 +52,7 @@ export function textResult(data, options = {}) {
52
52
  structuredContent = {
53
53
  ...compactForText(structuredContent),
54
54
  truncated: true,
55
- truncationMessage: `Response JSON exceeded ${maxChars} characters. ${options.truncationHint ?? "Use limit/cursor or narrower include options."}`,
55
+ truncationMessage: `Response JSON exceeded ${maxChars} characters. ${options.truncationHint ?? "Use a smaller limit or cursor pagination."}`,
56
56
  };
57
57
  }
58
58
  return {
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.9.3";
2
+ export const OKO_MCP_VERSION = "0.11.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okostream/mcp",
3
- "version": "0.9.3",
3
+ "version": "0.11.0",
4
4
  "description": "Control OKO Stream webinars from Claude, Codex, and other MCP clients.",
5
5
  "license": "UNLICENSED",
6
6
  "author": {
@@ -50,7 +50,7 @@
50
50
  "dotenv": "^17.2.3",
51
51
  "firebase": "^12.16.0",
52
52
  "ipaddr.js": "2.4.0",
53
- "undici": "7.28.0",
53
+ "undici": "7.29.0",
54
54
  "zod": "^3.25.76"
55
55
  },
56
56
  "devDependencies": {