@opengeni/api-router 0.14.4 → 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/dist/app.d.ts +6 -1
- package/dist/app.js +5 -1
- package/dist/auth/managed-auth.d.ts +2 -2
- package/dist/{chunk-36PW33ND.js → chunk-ICRZC3JH.js} +11438 -8894
- package/dist/chunk-ICRZC3JH.js.map +1 -0
- package/dist/index.js +16 -5
- package/dist/index.js.map +1 -1
- package/dist/integrations/slack-bot.d.ts +30 -1
- package/dist/integrations/slack-interactions.d.ts +47 -0
- package/dist/integrations/social-api.d.ts +91 -0
- package/dist/integrations/social-oauth.d.ts +84 -0
- package/dist/routes/workspace-artifacts.d.ts +3 -0
- package/package.json +10 -10
- package/src/app.ts +115 -28
- package/src/http/auth.ts +9 -1
- package/src/index.ts +17 -4
- package/src/integrations/oauth-client.ts +27 -10
- package/src/integrations/slack-bot.ts +100 -14
- package/src/integrations/slack-interactions.ts +1033 -0
- package/src/integrations/social-api.ts +504 -0
- package/src/integrations/social-oauth.ts +745 -0
- package/src/mcp/server.ts +383 -0
- package/src/routes/sessions.ts +23 -10
- package/src/routes/social.ts +67 -2
- package/src/routes/workspace-artifacts.ts +274 -0
- package/dist/chunk-36PW33ND.js.map +0 -1
package/src/mcp/server.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import {
|
|
2
3
|
CreateScheduledTaskRequest,
|
|
3
4
|
DEFAULT_FIRST_PARTY_MCP_TOOLS,
|
|
@@ -24,6 +25,8 @@ import {
|
|
|
24
25
|
type SessionAuthorizationSurface,
|
|
25
26
|
type Session,
|
|
26
27
|
UpdateScheduledTaskRequest,
|
|
28
|
+
normalizeWorkspaceArtifactSlug,
|
|
29
|
+
WORKSPACE_ARTIFACT_HTML_MAX_UTF8_BYTES,
|
|
27
30
|
} from "@opengeni/contracts";
|
|
28
31
|
import {
|
|
29
32
|
correctWorkspaceMemory,
|
|
@@ -54,6 +57,8 @@ import {
|
|
|
54
57
|
listRigVersionMonitoringSummaries,
|
|
55
58
|
listSocialConnections,
|
|
56
59
|
listSocialPosts,
|
|
60
|
+
recordAuditEvent,
|
|
61
|
+
recordSyncedSocialPosts,
|
|
57
62
|
listVariableSets,
|
|
58
63
|
MEMORY_CORRECT_TOOL_DESCRIPTION,
|
|
59
64
|
MEMORY_SAVE_TOOL_DESCRIPTION,
|
|
@@ -70,6 +75,12 @@ import {
|
|
|
70
75
|
upsertSessionGoalWithEvent,
|
|
71
76
|
RigChangeAlreadyVerifyingError,
|
|
72
77
|
RigChangeTransitionError,
|
|
78
|
+
createWorkspaceArtifact,
|
|
79
|
+
getWorkspaceArtifact,
|
|
80
|
+
getWorkspaceArtifactContentRef,
|
|
81
|
+
listWorkspaceArtifacts,
|
|
82
|
+
publishWorkspaceArtifactVersion,
|
|
83
|
+
rollbackWorkspaceArtifact,
|
|
73
84
|
} from "@opengeni/db";
|
|
74
85
|
import { appendAndPublishEvents, publishDurableSessionEvents } from "@opengeni/events";
|
|
75
86
|
import {
|
|
@@ -100,6 +111,13 @@ import {
|
|
|
100
111
|
listWorkspaceGitHubRepositories,
|
|
101
112
|
} from "../github-access";
|
|
102
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";
|
|
103
121
|
import {
|
|
104
122
|
promoteVerifiedDefinitionEditChangeForApi,
|
|
105
123
|
proposeRigChangeForApi,
|
|
@@ -219,6 +237,15 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
|
|
|
219
237
|
social_connections_list: { allOf: ["connections:read"] },
|
|
220
238
|
social_posts_recent: { allOf: ["connections:read"] },
|
|
221
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"] },
|
|
222
249
|
scheduled_tasks_list: { anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"] },
|
|
223
250
|
scheduled_tasks_get: { anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"] },
|
|
224
251
|
scheduled_tasks_create: { allOf: ["scheduled_tasks:manage"] },
|
|
@@ -237,6 +264,11 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
|
|
|
237
264
|
slack_bot_file_content: { allOf: ["connections:read"] },
|
|
238
265
|
slack_bot_post_message: { allOf: ["connections:read"] },
|
|
239
266
|
slack_bot_delete_message: { allOf: ["connections:read"] },
|
|
267
|
+
artifacts_list: { sessionRequired: true, allOf: ["artifacts:read"] },
|
|
268
|
+
artifacts_get_source: { sessionRequired: true, allOf: ["artifacts:read"] },
|
|
269
|
+
artifacts_create: { sessionRequired: true, allOf: ["artifacts:publish"] },
|
|
270
|
+
artifacts_publish: { sessionRequired: true, allOf: ["artifacts:publish"] },
|
|
271
|
+
artifacts_rollback: { sessionRequired: true, allOf: ["artifacts:publish"] },
|
|
240
272
|
} satisfies Record<FirstPartyMcpToolName, FirstPartyToolAuthorization>;
|
|
241
273
|
|
|
242
274
|
const FIRST_PARTY_MCP_TOOL_NAME_SET = new Set<string>(FIRST_PARTY_MCP_TOOL_NAMES);
|
|
@@ -378,6 +410,9 @@ export function buildOpenGeniMcpServer(
|
|
|
378
410
|
if (!toolspaceMode && sessionId !== null && preferenceAttemptClaims(grant) !== null) {
|
|
379
411
|
registerPreferenceRegistryTools(server, deps, grant, json);
|
|
380
412
|
}
|
|
413
|
+
if (!toolspaceMode && sessionId !== null && preferenceAttemptClaims(grant) !== null) {
|
|
414
|
+
registerWorkspaceArtifactTools(server, deps, grant, sessionId, json);
|
|
415
|
+
}
|
|
381
416
|
|
|
382
417
|
// Fleet tools (M7 bring-your-own-compute): list / attach / swap / run_on /
|
|
383
418
|
// provision over the session's Modal box + the workspace's enrolled machines.
|
|
@@ -545,6 +580,162 @@ export function buildOpenGeniMcpServer(
|
|
|
545
580
|
});
|
|
546
581
|
},
|
|
547
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
|
+
);
|
|
548
739
|
}
|
|
549
740
|
|
|
550
741
|
if (!toolspaceMode || can("scheduled_tasks:manage") || can("scheduled_tasks:run")) {
|
|
@@ -1282,6 +1473,198 @@ async function authorizeFirstPartySession(
|
|
|
1282
1473
|
});
|
|
1283
1474
|
}
|
|
1284
1475
|
|
|
1476
|
+
function registerWorkspaceArtifactTools(
|
|
1477
|
+
server: McpServer,
|
|
1478
|
+
deps: ApiRouteDeps,
|
|
1479
|
+
grant: AccessGrant,
|
|
1480
|
+
sessionId: string,
|
|
1481
|
+
json: JsonResult,
|
|
1482
|
+
): void {
|
|
1483
|
+
const attempt = () => {
|
|
1484
|
+
const claims = preferenceAttemptClaims(grant);
|
|
1485
|
+
if (!claims) throw new Error("Exact signed artifact attempt authority is required.");
|
|
1486
|
+
return claims;
|
|
1487
|
+
};
|
|
1488
|
+
const authorize = async () => {
|
|
1489
|
+
await authorizeFirstPartySession(deps, grant, sessionId, "session.first_party_mcp.call");
|
|
1490
|
+
};
|
|
1491
|
+
const prepare = (html: string) => {
|
|
1492
|
+
if (!deps.objectStorage) throw new Error("Object storage is not configured");
|
|
1493
|
+
const bytes = new TextEncoder().encode(html);
|
|
1494
|
+
if (bytes.byteLength < 1 || bytes.byteLength > WORKSPACE_ARTIFACT_HTML_MAX_UTF8_BYTES) {
|
|
1495
|
+
throw new Error(
|
|
1496
|
+
`Artifact HTML must be 1-${WORKSPACE_ARTIFACT_HTML_MAX_UTF8_BYTES} UTF-8 bytes`,
|
|
1497
|
+
);
|
|
1498
|
+
}
|
|
1499
|
+
const contentSha256 = createHash("sha256").update(bytes).digest("hex");
|
|
1500
|
+
const contentKey = `workspaces/${grant.workspaceId}/workspace-artifacts/blobs/${contentSha256}.html`;
|
|
1501
|
+
return {
|
|
1502
|
+
contentKey,
|
|
1503
|
+
contentSha256,
|
|
1504
|
+
sizeBytes: bytes.byteLength,
|
|
1505
|
+
persistContent: async () => {
|
|
1506
|
+
await deps.objectStorage!.putObject({
|
|
1507
|
+
key: contentKey,
|
|
1508
|
+
contentType: "text/html; charset=utf-8",
|
|
1509
|
+
body: bytes,
|
|
1510
|
+
sha256: contentSha256,
|
|
1511
|
+
});
|
|
1512
|
+
},
|
|
1513
|
+
};
|
|
1514
|
+
};
|
|
1515
|
+
const provenance = (
|
|
1516
|
+
idempotencyKey: string,
|
|
1517
|
+
sourceToolName: "artifacts_create" | "artifacts_publish" | "artifacts_rollback",
|
|
1518
|
+
) => {
|
|
1519
|
+
const claims = attempt();
|
|
1520
|
+
return {
|
|
1521
|
+
accountId: grant.accountId,
|
|
1522
|
+
workspaceId: grant.workspaceId,
|
|
1523
|
+
operationKey: `attempt:${createHash("sha256")
|
|
1524
|
+
.update(
|
|
1525
|
+
`${claims.sessionId}:${claims.turnId}:${claims.attemptId}:${claims.executionGeneration}:${idempotencyKey}`,
|
|
1526
|
+
)
|
|
1527
|
+
.digest("hex")}`,
|
|
1528
|
+
actorSubjectId: grant.subjectId,
|
|
1529
|
+
sourceSessionId: claims.sessionId,
|
|
1530
|
+
sourceTurnId: claims.turnId,
|
|
1531
|
+
sourceAttemptId: claims.attemptId,
|
|
1532
|
+
sourceExecutionGeneration: claims.executionGeneration,
|
|
1533
|
+
sourceToolName,
|
|
1534
|
+
};
|
|
1535
|
+
};
|
|
1536
|
+
|
|
1537
|
+
server.registerTool(
|
|
1538
|
+
"artifacts_list",
|
|
1539
|
+
{
|
|
1540
|
+
description:
|
|
1541
|
+
"List the generic published artifacts in this workspace and their current versions.",
|
|
1542
|
+
inputSchema: {},
|
|
1543
|
+
},
|
|
1544
|
+
async () => {
|
|
1545
|
+
await authorize();
|
|
1546
|
+
return json(await listWorkspaceArtifacts(deps.db, grant.workspaceId));
|
|
1547
|
+
},
|
|
1548
|
+
);
|
|
1549
|
+
|
|
1550
|
+
server.registerTool(
|
|
1551
|
+
"artifacts_get_source",
|
|
1552
|
+
{
|
|
1553
|
+
description:
|
|
1554
|
+
"Read an artifact's metadata and exact HTML source. Omit versionId for the current version.",
|
|
1555
|
+
inputSchema: {
|
|
1556
|
+
artifactId: z4.string().uuid(),
|
|
1557
|
+
versionId: z4.string().uuid().optional(),
|
|
1558
|
+
},
|
|
1559
|
+
},
|
|
1560
|
+
async ({ artifactId, versionId }) => {
|
|
1561
|
+
await authorize();
|
|
1562
|
+
if (!deps.objectStorage) throw new Error("Object storage is not configured");
|
|
1563
|
+
const [detail, ref] = await Promise.all([
|
|
1564
|
+
getWorkspaceArtifact(deps.db, grant.workspaceId, artifactId),
|
|
1565
|
+
getWorkspaceArtifactContentRef(deps.db, grant.workspaceId, artifactId, versionId),
|
|
1566
|
+
]);
|
|
1567
|
+
const object = await deps.objectStorage.getObjectBytes(ref.contentKey);
|
|
1568
|
+
if (!object) throw new Error("Artifact content is unavailable");
|
|
1569
|
+
const actualHash = createHash("sha256").update(object.bytes).digest("hex");
|
|
1570
|
+
if (actualHash !== ref.version.contentSha256)
|
|
1571
|
+
throw new Error("Artifact content failed integrity verification");
|
|
1572
|
+
return json({ detail, version: ref.version, html: new TextDecoder().decode(object.bytes) });
|
|
1573
|
+
},
|
|
1574
|
+
);
|
|
1575
|
+
|
|
1576
|
+
server.registerTool(
|
|
1577
|
+
"artifacts_create",
|
|
1578
|
+
{
|
|
1579
|
+
description:
|
|
1580
|
+
"Create and publish a generic static workspace artifact from a complete, self-contained HTML document with inline CSS. JavaScript and active or navigation-capable markup do not render in the MVP.",
|
|
1581
|
+
inputSchema: {
|
|
1582
|
+
title: z4.string().min(1).max(120),
|
|
1583
|
+
description: z4.string().max(2000).nullable().optional(),
|
|
1584
|
+
slug: z4
|
|
1585
|
+
.string()
|
|
1586
|
+
.regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/)
|
|
1587
|
+
.max(96)
|
|
1588
|
+
.optional(),
|
|
1589
|
+
html: z4.string().min(1).max(WORKSPACE_ARTIFACT_HTML_MAX_UTF8_BYTES),
|
|
1590
|
+
idempotencyKey: z4.string().min(1).max(200),
|
|
1591
|
+
},
|
|
1592
|
+
},
|
|
1593
|
+
async ({ title, description, slug, html, idempotencyKey }) => {
|
|
1594
|
+
await authorize();
|
|
1595
|
+
const artifactId = crypto.randomUUID();
|
|
1596
|
+
const slugBase = slug ?? (normalizeWorkspaceArtifactSlug(title) || "artifact");
|
|
1597
|
+
const resolvedSlug = slug ?? `${slugBase.slice(0, 87)}-${artifactId.slice(0, 8)}`;
|
|
1598
|
+
return json(
|
|
1599
|
+
await createWorkspaceArtifact(deps.db, {
|
|
1600
|
+
artifactId,
|
|
1601
|
+
slug: resolvedSlug,
|
|
1602
|
+
title,
|
|
1603
|
+
description: description ?? null,
|
|
1604
|
+
...prepare(html),
|
|
1605
|
+
...provenance(idempotencyKey, "artifacts_create"),
|
|
1606
|
+
}),
|
|
1607
|
+
);
|
|
1608
|
+
},
|
|
1609
|
+
);
|
|
1610
|
+
|
|
1611
|
+
server.registerTool(
|
|
1612
|
+
"artifacts_publish",
|
|
1613
|
+
{
|
|
1614
|
+
description:
|
|
1615
|
+
"Publish a new immutable static HTML/CSS version. JavaScript and active or navigation-capable markup do not render in the MVP. First read the current source and pass its version id for optimistic concurrency.",
|
|
1616
|
+
inputSchema: {
|
|
1617
|
+
artifactId: z4.string().uuid(),
|
|
1618
|
+
expectedCurrentVersionId: z4.string().uuid(),
|
|
1619
|
+
title: z4.string().min(1).max(120).optional(),
|
|
1620
|
+
description: z4.string().max(2000).nullable().optional(),
|
|
1621
|
+
html: z4.string().min(1).max(WORKSPACE_ARTIFACT_HTML_MAX_UTF8_BYTES),
|
|
1622
|
+
idempotencyKey: z4.string().min(1).max(200),
|
|
1623
|
+
},
|
|
1624
|
+
},
|
|
1625
|
+
async ({ artifactId, expectedCurrentVersionId, title, description, html, idempotencyKey }) => {
|
|
1626
|
+
await authorize();
|
|
1627
|
+
return json(
|
|
1628
|
+
await publishWorkspaceArtifactVersion(deps.db, {
|
|
1629
|
+
artifactId,
|
|
1630
|
+
expectedCurrentVersionId,
|
|
1631
|
+
...(title !== undefined ? { title } : {}),
|
|
1632
|
+
...(description !== undefined ? { description } : {}),
|
|
1633
|
+
...prepare(html),
|
|
1634
|
+
...provenance(idempotencyKey, "artifacts_publish"),
|
|
1635
|
+
}),
|
|
1636
|
+
);
|
|
1637
|
+
},
|
|
1638
|
+
);
|
|
1639
|
+
|
|
1640
|
+
server.registerTool(
|
|
1641
|
+
"artifacts_rollback",
|
|
1642
|
+
{
|
|
1643
|
+
description:
|
|
1644
|
+
"Promote an existing immutable artifact version back to current without rewriting history.",
|
|
1645
|
+
inputSchema: {
|
|
1646
|
+
artifactId: z4.string().uuid(),
|
|
1647
|
+
versionId: z4.string().uuid(),
|
|
1648
|
+
expectedCurrentVersionId: z4.string().uuid(),
|
|
1649
|
+
reason: z4.string().min(1).max(4096),
|
|
1650
|
+
idempotencyKey: z4.string().min(1).max(200),
|
|
1651
|
+
},
|
|
1652
|
+
},
|
|
1653
|
+
async ({ artifactId, versionId, expectedCurrentVersionId, reason, idempotencyKey }) => {
|
|
1654
|
+
await authorize();
|
|
1655
|
+
return json(
|
|
1656
|
+
await rollbackWorkspaceArtifact(deps.db, {
|
|
1657
|
+
artifactId,
|
|
1658
|
+
versionId,
|
|
1659
|
+
expectedCurrentVersionId,
|
|
1660
|
+
reason,
|
|
1661
|
+
...provenance(idempotencyKey, "artifacts_rollback"),
|
|
1662
|
+
}),
|
|
1663
|
+
);
|
|
1664
|
+
},
|
|
1665
|
+
);
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1285
1668
|
function preferenceAttemptClaims(grant: AccessGrant): {
|
|
1286
1669
|
sessionId: string;
|
|
1287
1670
|
turnId: string;
|
package/src/routes/sessions.ts
CHANGED
|
@@ -296,16 +296,14 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
296
296
|
),
|
|
297
297
|
});
|
|
298
298
|
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
299
|
+
// Every deployment has one fail-closed authorization seam for every HTTP
|
|
300
|
+
// session surface. The core boundary always enforces durable OpenGeni-owned
|
|
301
|
+
// private-session rules; an embedding host port can add narrower policy.
|
|
302
|
+
// Register it before the routes so a newly added path cannot accidentally
|
|
303
|
+
// inherit workspace access without an explicit operation classification. The
|
|
304
|
+
// long-lived event stream performs its own initial check and bounded
|
|
305
|
+
// reauthorization below.
|
|
304
306
|
const authorizeSessionHttp: MiddlewareHandler = async (c, next) => {
|
|
305
|
-
if (!deps.sessionAuthorization) {
|
|
306
|
-
await next();
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
307
|
const workspaceId = c.req.param("workspaceId") ?? "";
|
|
310
308
|
const sessionId = c.req.param("sessionId") ?? "";
|
|
311
309
|
const operation = sessionAuthorizationOperationForHttp(
|
|
@@ -320,6 +318,10 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
320
318
|
if (!operation) {
|
|
321
319
|
throw sessionAuthorizationHttpError(new SessionAuthorizationUnavailableError());
|
|
322
320
|
}
|
|
321
|
+
if (operation === "session.codex_account.write" && !deps.sessionAuthorization) {
|
|
322
|
+
await next();
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
323
325
|
const grant = await requireAccessGrant(c, deps, workspaceId);
|
|
324
326
|
try {
|
|
325
327
|
const authorization = await requireSessionAuthorization(deps, grant, {
|
|
@@ -587,7 +589,7 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
587
589
|
// account id isn't in the workspace.
|
|
588
590
|
app.post("/v1/workspaces/:workspaceId/sessions/:sessionId/codex-account", async (c) => {
|
|
589
591
|
const workspaceId = c.req.param("workspaceId");
|
|
590
|
-
await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
592
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "sessions:control");
|
|
591
593
|
const sessionId = c.req.param("sessionId");
|
|
592
594
|
const body = (await c.req.json()) as { target?: string };
|
|
593
595
|
const target = typeof body.target === "string" ? body.target : "";
|
|
@@ -596,6 +598,17 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
596
598
|
message: 'target is required ("auto" or an account id)',
|
|
597
599
|
});
|
|
598
600
|
}
|
|
601
|
+
if (!deps.sessionAuthorization) {
|
|
602
|
+
try {
|
|
603
|
+
await requireSessionAuthorization(deps, grant, {
|
|
604
|
+
sessionId,
|
|
605
|
+
operation: "session.codex_account.write",
|
|
606
|
+
surface: "http",
|
|
607
|
+
});
|
|
608
|
+
} catch (error) {
|
|
609
|
+
throw sessionAuthorizationHttpError(error);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
599
612
|
const pinned = target === "auto" ? null : target;
|
|
600
613
|
const mutation = await withCodexCapacityMutation(
|
|
601
614
|
db,
|
package/src/routes/social.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import {
|
|
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");
|