@opengeni/api-router 0.15.1 → 0.15.4

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/src/mcp/server.ts CHANGED
@@ -57,6 +57,8 @@ import {
57
57
  listRigVersionMonitoringSummaries,
58
58
  listSocialConnections,
59
59
  listSocialPosts,
60
+ recordAuditEvent,
61
+ recordSyncedSocialPosts,
60
62
  listVariableSets,
61
63
  MEMORY_CORRECT_TOOL_DESCRIPTION,
62
64
  MEMORY_SAVE_TOOL_DESCRIPTION,
@@ -109,6 +111,13 @@ import {
109
111
  listWorkspaceGitHubRepositories,
110
112
  } from "../github-access";
111
113
  import { githubBrowserBaseUrl, githubBrowserGrantClaims } from "../github-browser-flow";
114
+ import {
115
+ socialMentionsLive,
116
+ socialOwnPostsLive,
117
+ socialPostReply,
118
+ socialSearchLive,
119
+ socialThreadLive,
120
+ } from "../integrations/social-api";
112
121
  import {
113
122
  promoteVerifiedDefinitionEditChangeForApi,
114
123
  proposeRigChangeForApi,
@@ -228,6 +237,15 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
228
237
  social_connections_list: { allOf: ["connections:read"] },
229
238
  social_posts_recent: { allOf: ["connections:read"] },
230
239
  social_daily_analysis_context: { allOf: ["connections:read"] },
240
+ social_search_live: { allOf: ["connections:read"] },
241
+ social_mentions_live: { allOf: ["connections:read"] },
242
+ social_thread_fetch: { allOf: ["connections:read"] },
243
+ // Writes the social_posts store, so it takes the write scope like the REST
244
+ // equivalent (POST /social/posts is workspace:admin).
245
+ social_posts_sync: { allOf: ["connections:write"] },
246
+ // Publishes under the user's identity: connections:write keeps it out of the
247
+ // default agent permission set, unlike the read-only social tools above.
248
+ social_post_reply: { allOf: ["connections:write"] },
231
249
  scheduled_tasks_list: { anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"] },
232
250
  scheduled_tasks_get: { anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"] },
233
251
  scheduled_tasks_create: { allOf: ["scheduled_tasks:manage"] },
@@ -562,6 +580,162 @@ export function buildOpenGeniMcpServer(
562
580
  });
563
581
  },
564
582
  );
583
+
584
+ // Live provider reads (X / Reddit). Tokens are resolved and used entirely
585
+ // host-side; the agent only ever sees normalized post payloads.
586
+ server.registerTool(
587
+ "social_search_live",
588
+ {
589
+ description:
590
+ "Search live conversations on a connected social account (X recent search or Reddit search). Use social_connections_list first to find the connectionId. For Reddit, pass subreddit to scope the search.",
591
+ inputSchema: {
592
+ connectionId: z4.string().uuid(),
593
+ query: z4.string().min(1).max(512),
594
+ subreddit: z4.string().min(1).max(100).optional(),
595
+ limit: z4.number().int().positive().optional(),
596
+ },
597
+ },
598
+ async ({ connectionId, query, subreddit, limit }) => {
599
+ const result = await socialSearchLive(
600
+ deps,
601
+ { workspaceId: grant.workspaceId, connectionId },
602
+ { query, subreddit, limit },
603
+ );
604
+ return json({ provider: result.connection.provider, posts: result.posts });
605
+ },
606
+ );
607
+
608
+ server.registerTool(
609
+ "social_mentions_live",
610
+ {
611
+ description:
612
+ "Fetch live mentions of the connected account (X mentions timeline, or the Reddit inbox with username mentions and comment replies).",
613
+ inputSchema: {
614
+ connectionId: z4.string().uuid(),
615
+ sinceId: z4.string().optional(),
616
+ limit: z4.number().int().positive().optional(),
617
+ },
618
+ },
619
+ async ({ connectionId, sinceId, limit }) => {
620
+ const result = await socialMentionsLive(
621
+ deps,
622
+ { workspaceId: grant.workspaceId, connectionId },
623
+ { sinceId, limit },
624
+ );
625
+ return json({ provider: result.connection.provider, posts: result.posts });
626
+ },
627
+ );
628
+
629
+ server.registerTool(
630
+ "social_thread_fetch",
631
+ {
632
+ description:
633
+ "Fetch a live conversation thread: for X pass a tweet id (returns the conversation), for Reddit pass a post id or t3_ fullname (returns the post plus top comments).",
634
+ inputSchema: {
635
+ connectionId: z4.string().uuid(),
636
+ id: z4.string().min(1).max(100),
637
+ limit: z4.number().int().positive().optional(),
638
+ },
639
+ },
640
+ async ({ connectionId, id, limit }) => {
641
+ const result = await socialThreadLive(
642
+ deps,
643
+ { workspaceId: grant.workspaceId, connectionId },
644
+ { id, limit },
645
+ );
646
+ return json({ provider: result.connection.provider, posts: result.posts });
647
+ },
648
+ );
649
+ }
650
+
651
+ // Writes are gated on connections:write (never in the default first-party
652
+ // agent permission set) so scheduled tasks must opt in, and deployments can
653
+ // additionally wrap posting in a requireApproval policy.
654
+ if (!toolspaceMode || can("connections:write")) {
655
+ server.registerTool(
656
+ "social_posts_sync",
657
+ {
658
+ description:
659
+ "Sync the connected account's own recent posts from the provider into OpenGeni's social_posts store (idempotent), so social_posts_recent and daily analysis see fresh data.",
660
+ inputSchema: {
661
+ connectionId: z4.string().uuid(),
662
+ limit: z4.number().int().positive().optional(),
663
+ },
664
+ },
665
+ async ({ connectionId, limit }) => {
666
+ const result = await socialOwnPostsLive(
667
+ deps,
668
+ { workspaceId: grant.workspaceId, connectionId },
669
+ { limit },
670
+ );
671
+ // A post without a provider timestamp is skipped rather than recorded
672
+ // at sync time: publishedAt drives analysis windows, and the dedup
673
+ // index would freeze a fabricated date forever.
674
+ const datedPosts = result.posts.filter((post) => post.createdAt !== null);
675
+ const synced = await recordSyncedSocialPosts(deps.db, {
676
+ accountId: grant.accountId,
677
+ workspaceId: grant.workspaceId,
678
+ connectionId,
679
+ posts: datedPosts.map((post) => ({
680
+ externalPostId: post.id,
681
+ url: post.url,
682
+ authorHandle: post.author,
683
+ text: post.text,
684
+ publishedAt: new Date(post.createdAt!),
685
+ metrics: post.metrics,
686
+ })),
687
+ });
688
+ return json({
689
+ provider: result.connection.provider,
690
+ fetched: result.posts.length,
691
+ inserted: synced.inserted,
692
+ skipped: synced.skipped,
693
+ skippedMissingDate: result.posts.length - datedPosts.length,
694
+ });
695
+ },
696
+ );
697
+
698
+ server.registerTool(
699
+ "social_post_reply",
700
+ {
701
+ description:
702
+ "Publish a reply from the connected social account. X: inReplyToId is the tweet id to reply to. Reddit: inReplyToId is a fullname (t3_<post> or t1_<comment>). Draft and get approval before calling this — it posts publicly and immediately.",
703
+ inputSchema: {
704
+ connectionId: z4.string().uuid(),
705
+ inReplyToId: z4.string().min(1).max(100),
706
+ text: z4.string().min(1).max(10000),
707
+ },
708
+ },
709
+ async ({ connectionId, inReplyToId, text }) => {
710
+ const result = await socialPostReply(
711
+ deps,
712
+ { workspaceId: grant.workspaceId, connectionId },
713
+ { inReplyToId, text },
714
+ );
715
+ // Outbound publishes leave a durable, secret-free receipt (house
716
+ // pattern: the Slack bot post audit), so who posted what where stays
717
+ // answerable after the session is gone.
718
+ await recordAuditEvent(deps.db, {
719
+ accountId: grant.accountId,
720
+ workspaceId: grant.workspaceId,
721
+ subjectId: grant.subjectId,
722
+ action: "social.post_reply",
723
+ targetType: "social_connection",
724
+ targetId: connectionId,
725
+ metadata: {
726
+ provider: result.connection.provider,
727
+ inReplyToId,
728
+ postedId: result.postedId,
729
+ url: result.url,
730
+ },
731
+ });
732
+ return json({
733
+ provider: result.connection.provider,
734
+ postedId: result.postedId,
735
+ url: result.url,
736
+ });
737
+ },
738
+ );
565
739
  }
566
740
 
567
741
  if (!toolspaceMode || can("scheduled_tasks:manage") || can("scheduled_tasks:run")) {
@@ -1,9 +1,15 @@
1
- import { CreateSocialConnectionRequest, CreateSocialPostRequest } from "@opengeni/contracts";
1
+ import {
2
+ CreateSocialConnectionRequest,
3
+ CreateSocialPostRequest,
4
+ OAuthStartResponse,
5
+ SocialOAuthStartRequest,
6
+ } from "@opengeni/contracts";
2
7
  import {
3
8
  createSocialConnection,
4
9
  createSocialPost,
5
10
  listSocialConnections,
6
11
  listSocialPosts,
12
+ updateSocialConnectionCredential,
7
13
  } from "@opengeni/db";
8
14
  import type { Hono } from "hono";
9
15
  import { HTTPException } from "hono/http-exception";
@@ -11,9 +17,10 @@ import { z } from "zod";
11
17
  import { requireAccessGrant } from "@opengeni/core";
12
18
  import type { ApiRouteDeps } from "@opengeni/core";
13
19
  import { boundedLimit } from "../http/common";
20
+ import { completeSocialOAuthCallback, startSocialOAuth } from "../integrations/social-oauth";
14
21
 
15
22
  export function registerSocialRoutes(app: Hono, deps: ApiRouteDeps): void {
16
- const { db } = deps;
23
+ const { db, settings, observability } = deps;
17
24
 
18
25
  app.get("/v1/workspaces/:workspaceId/social/connections", async (c) => {
19
26
  const workspaceId = c.req.param("workspaceId");
@@ -47,6 +54,64 @@ export function registerSocialRoutes(app: Hono, deps: ApiRouteDeps): void {
47
54
  }
48
55
  });
49
56
 
57
+ // Disconnect: drop the stored OAuth credential and disable the connection.
58
+ // The row stays (posts reference it and the audit trail needs the identity);
59
+ // reconnecting via the OAuth flow revives it.
60
+ app.delete("/v1/workspaces/:workspaceId/social/connections/:connectionId", async (c) => {
61
+ const workspaceId = c.req.param("workspaceId");
62
+ await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
63
+ const connection = await updateSocialConnectionCredential(db, {
64
+ workspaceId,
65
+ connectionId: c.req.param("connectionId"),
66
+ credentialEncrypted: null,
67
+ status: "disabled",
68
+ tokenMetadata: {},
69
+ });
70
+ if (!connection) {
71
+ throw new HTTPException(404, { message: "social connection not found" });
72
+ }
73
+ return c.json(connection);
74
+ });
75
+
76
+ // First-party social OAuth (X / Reddit). Distinct from the MCP integrations
77
+ // flow: providers are pinned, tokens land in social_connections, and the
78
+ // callback is unauthenticated (browser redirect) but bound by signed state.
79
+ app.post("/v1/workspaces/:workspaceId/social/oauth/start", async (c) => {
80
+ const workspaceId = c.req.param("workspaceId");
81
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
82
+ const parsed = SocialOAuthStartRequest.safeParse(await c.req.json());
83
+ if (!parsed.success) {
84
+ throw new HTTPException(400, {
85
+ message: parsed.error.issues[0]?.message ?? "invalid social OAuth start request",
86
+ });
87
+ }
88
+ const payload = parsed.data;
89
+ const result = await startSocialOAuth(
90
+ { db, settings, observability },
91
+ {
92
+ accountId: grant.accountId,
93
+ workspaceId,
94
+ subjectId: grant.subjectId,
95
+ requestUrl: c.req.url,
96
+ payload,
97
+ },
98
+ );
99
+ return c.json(OAuthStartResponse.parse(result));
100
+ });
101
+
102
+ app.get("/v1/social/oauth/callback", async (c) => {
103
+ const result = await completeSocialOAuthCallback(
104
+ { db, settings, observability },
105
+ {
106
+ code: c.req.query("code"),
107
+ state: c.req.query("state"),
108
+ error: c.req.query("error"),
109
+ requestUrl: c.req.url,
110
+ },
111
+ );
112
+ return c.redirect(result.redirectTo, 302);
113
+ });
114
+
50
115
  app.get("/v1/workspaces/:workspaceId/social/posts", async (c) => {
51
116
  const workspaceId = c.req.param("workspaceId");
52
117
  await requireAccessGrant(c, deps, workspaceId, "workspace:read");