agent-relay-server 0.32.1 → 0.32.3

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 (97) hide show
  1. package/docs/openapi.json +57 -127
  2. package/package.json +1 -1
  3. package/public/assets/{activity-C6nbfryG.js → activity-DT1JGHnp.js} +2 -2
  4. package/public/assets/{activity-C6nbfryG.js.map → activity-DT1JGHnp.js.map} +1 -1
  5. package/public/assets/{agent-profiles-FEITAgHs.js → agent-profiles-CrMemMkZ.js} +2 -2
  6. package/public/assets/{agent-profiles-FEITAgHs.js.map → agent-profiles-CrMemMkZ.js.map} +1 -1
  7. package/public/assets/{agents-D4S0yIbe.js → agents-Bl-rrgOy.js} +2 -2
  8. package/public/assets/{agents-D4S0yIbe.js.map → agents-Bl-rrgOy.js.map} +1 -1
  9. package/public/assets/{analytics-DM2g62T_.js → analytics-a663ak56.js} +2 -2
  10. package/public/assets/{analytics-DM2g62T_.js.map → analytics-a663ak56.js.map} +1 -1
  11. package/public/assets/{automation-3D2pQa1C.js → automation-CiaLThdO.js} +2 -2
  12. package/public/assets/{automation-3D2pQa1C.js.map → automation-CiaLThdO.js.map} +1 -1
  13. package/public/assets/{branch-state-badge-Bi4IbkOZ.js → branch-state-badge-D4ur3m3_.js} +2 -2
  14. package/public/assets/{branch-state-badge-Bi4IbkOZ.js.map → branch-state-badge-D4ur3m3_.js.map} +1 -1
  15. package/public/assets/{channels-QNp7zmA_.js → channels-o9KLTHoK.js} +2 -2
  16. package/public/assets/{channels-QNp7zmA_.js.map → channels-o9KLTHoK.js.map} +1 -1
  17. package/public/assets/{chat-jeXt_SFs.js → chat-5hvHZcAe.js} +2 -2
  18. package/public/assets/{chat-jeXt_SFs.js.map → chat-5hvHZcAe.js.map} +1 -1
  19. package/public/assets/{connectors-BGJARDui.js → connectors-CdC806mA.js} +2 -2
  20. package/public/assets/{connectors-BGJARDui.js.map → connectors-CdC806mA.js.map} +1 -1
  21. package/public/assets/{formatted-body-impl-B7FgqkYL.js → formatted-body-impl-Ca74OAEH.js} +2 -2
  22. package/public/assets/{formatted-body-impl-B7FgqkYL.js.map → formatted-body-impl-Ca74OAEH.js.map} +1 -1
  23. package/public/assets/{index-2m9mT8kV.js → index-C_33ymaw.js} +6 -6
  24. package/public/assets/{index-2m9mT8kV.js.map → index-C_33ymaw.js.map} +1 -1
  25. package/public/assets/{integrations-CJm8-FcG.js → integrations-1nxMizDY.js} +2 -2
  26. package/public/assets/{integrations-CJm8-FcG.js.map → integrations-1nxMizDY.js.map} +1 -1
  27. package/public/assets/{maintenance-CBvZrVAG.js → maintenance-DiFNzNPN.js} +2 -2
  28. package/public/assets/{maintenance-CBvZrVAG.js.map → maintenance-DiFNzNPN.js.map} +1 -1
  29. package/public/assets/{managed-agents-Dcmm8YKt.js → managed-agents-Do3dKvfj.js} +2 -2
  30. package/public/assets/{managed-agents-Dcmm8YKt.js.map → managed-agents-Do3dKvfj.js.map} +1 -1
  31. package/public/assets/{markdown-preview-impl-7xjqdiEu.js → markdown-preview-impl-CLA0J255.js} +2 -2
  32. package/public/assets/{markdown-preview-impl-7xjqdiEu.js.map → markdown-preview-impl-CLA0J255.js.map} +1 -1
  33. package/public/assets/{memory-BmGNW61h.js → memory-IjwqFzBd.js} +2 -2
  34. package/public/assets/{memory-BmGNW61h.js.map → memory-IjwqFzBd.js.map} +1 -1
  35. package/public/assets/{messages-BvMMhoy-.js → messages-DjvWqHyn.js} +2 -2
  36. package/public/assets/{messages-BvMMhoy-.js.map → messages-DjvWqHyn.js.map} +1 -1
  37. package/public/assets/{orchestrators-DsstaupT.js → orchestrators-D2IqDxDT.js} +2 -2
  38. package/public/assets/{orchestrators-DsstaupT.js.map → orchestrators-D2IqDxDT.js.map} +1 -1
  39. package/public/assets/{overview-kK6PTce3.js → overview-DKC3TbAh.js} +2 -2
  40. package/public/assets/{overview-kK6PTce3.js.map → overview-DKC3TbAh.js.map} +1 -1
  41. package/public/assets/{pairs-BEFvTW6X.js → pairs-WpKCPE1n.js} +2 -2
  42. package/public/assets/{pairs-BEFvTW6X.js.map → pairs-WpKCPE1n.js.map} +1 -1
  43. package/public/assets/{security-Dc5wZwv0.js → security-BF7ZtPQe.js} +2 -2
  44. package/public/assets/{security-Dc5wZwv0.js.map → security-BF7ZtPQe.js.map} +1 -1
  45. package/public/assets/{settings-CEtJrORa.js → settings-CQnjrTa-.js} +2 -2
  46. package/public/assets/{settings-CEtJrORa.js.map → settings-CQnjrTa-.js.map} +1 -1
  47. package/public/assets/{store-DkmReBlH.js → store-C9VcSo05.js} +2 -2
  48. package/public/assets/{store-DkmReBlH.js.map → store-C9VcSo05.js.map} +1 -1
  49. package/public/assets/{tasks-pQKtxqeV.js → tasks-CbN_GSSb.js} +2 -2
  50. package/public/assets/{tasks-pQKtxqeV.js.map → tasks-CbN_GSSb.js.map} +1 -1
  51. package/public/assets/{terminal-viewer-impl-Cc769mYy.js → terminal-viewer-impl-BJRohThT.js} +2 -2
  52. package/public/assets/{terminal-viewer-impl-Cc769mYy.js.map → terminal-viewer-impl-BJRohThT.js.map} +1 -1
  53. package/public/assets/{work-queue-DjAanr02.js → work-queue-C5xLBLmm.js} +2 -2
  54. package/public/assets/{work-queue-DjAanr02.js.map → work-queue-C5xLBLmm.js.map} +1 -1
  55. package/public/assets/{workspaces-DLBNyR4k.js → workspaces-D91H3wDX.js} +2 -2
  56. package/public/assets/{workspaces-DLBNyR4k.js.map → workspaces-D91H3wDX.js.map} +1 -1
  57. package/public/index.html +2 -2
  58. package/scripts/orchestrator-spawn-smoke.ts +2 -1
  59. package/src/automations.ts +2 -4
  60. package/src/managed-policy.ts +2 -4
  61. package/src/mcp.ts +3 -3
  62. package/src/ratchet-files.ts +37 -0
  63. package/src/routes/_shared.ts +376 -0
  64. package/src/routes/activity.ts +61 -0
  65. package/src/routes/agent-profiles.ts +47 -0
  66. package/src/routes/agent-sessions.ts +488 -0
  67. package/src/routes/agents-spawn.ts +274 -0
  68. package/src/routes/agents.ts +251 -0
  69. package/src/routes/artifacts.ts +226 -0
  70. package/src/routes/automations.ts +83 -0
  71. package/src/routes/commands.ts +317 -0
  72. package/src/routes/config.ts +66 -0
  73. package/src/routes/connectors.ts +108 -0
  74. package/src/routes/inbox.ts +142 -0
  75. package/src/routes/index.ts +293 -0
  76. package/src/routes/insights.ts +81 -0
  77. package/src/routes/integrations.ts +592 -0
  78. package/src/routes/memory.ts +337 -0
  79. package/src/routes/messages.ts +529 -0
  80. package/src/routes/orchestrator-bootstrap.ts +100 -0
  81. package/src/routes/orchestrator-proxy.ts +160 -0
  82. package/src/routes/orchestrator.ts +490 -0
  83. package/src/routes/pairs.ts +197 -0
  84. package/src/routes/provider-config.ts +112 -0
  85. package/src/routes/recipes.ts +113 -0
  86. package/src/routes/spawn-policy.ts +231 -0
  87. package/src/routes/spec.ts +54 -0
  88. package/src/routes/sse.ts +9 -0
  89. package/src/routes/stats.ts +32 -0
  90. package/src/routes/steward.ts +45 -0
  91. package/src/routes/tasks.ts +174 -0
  92. package/src/routes/tokens.ts +311 -0
  93. package/src/routes/workspaces.ts +355 -0
  94. package/src/routes.ts +3 -6892
  95. package/src/runtime-tokens.ts +17 -8
  96. package/src/security.ts +0 -2
  97. package/src/validation.ts +134 -0
@@ -0,0 +1,81 @@
1
+ // Auto-split from routes.ts (#299). Domain: insights.
2
+ import { ValidationError } from "../db";
3
+ import { cleanString } from "../validation";
4
+ import { emitConfigChanged } from "../sse";
5
+ import { error, json, parseBody, type Handler } from "./_shared";
6
+ import { getInsightsConfigEntry, setInsightsConfig } from "../config-store";
7
+ import { getObservationStats, listObservationProjects, listObservations, recordObservation } from "../insights-db";
8
+ import { isRecord } from "agent-relay-sdk";
9
+
10
+ export const getInsightsConfigRoute: Handler = () => json(getInsightsConfigEntry());
11
+
12
+ export const putInsightsConfigRoute: Handler = async (req) => {
13
+ const parsed = await parseBody<unknown>(req);
14
+ if (!parsed.ok) return error(parsed.error, parsed.status);
15
+ try {
16
+ const value = isRecord(parsed.body) && Object.prototype.hasOwnProperty.call(parsed.body, "value")
17
+ ? parsed.body.value
18
+ : parsed.body;
19
+ const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
20
+ const entry = setInsightsConfig(value, updatedBy);
21
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
22
+ return json(entry, entry.version === 1 ? 201 : 200);
23
+ } catch (e) {
24
+ if (e instanceof ValidationError) return error(e.message, 400);
25
+ throw e;
26
+ }
27
+ };
28
+
29
+ export const getInsightsObservationsRoute: Handler = (req) => {
30
+ const url = new URL(req.url);
31
+ const project = url.searchParams.get("project") ?? undefined;
32
+ const signal = url.searchParams.get("signal") ?? undefined;
33
+ const sessionId = url.searchParams.get("session") ?? undefined;
34
+ const limitRaw = url.searchParams.get("limit");
35
+ const limit = limitRaw ? Number(limitRaw) : undefined;
36
+ const observations = listObservations({ project, signal, sessionId, limit: Number.isFinite(limit) ? limit : undefined });
37
+ return json({
38
+ observations,
39
+ stats: getObservationStats(signal),
40
+ projects: listObservationProjects(),
41
+ });
42
+ };
43
+
44
+ function sanitizeObservationOccurredAt(occurredAt: unknown): number | undefined {
45
+ if (typeof occurredAt !== "number" || !Number.isFinite(occurredAt)) return undefined;
46
+ if (occurredAt <= 0 || occurredAt > Date.now() + 60_000) return undefined;
47
+ return Math.floor(occurredAt);
48
+ }
49
+
50
+ export const postInsightsObservationRoute: Handler = async (req) => {
51
+ const parsed = await parseBody<unknown>(req);
52
+ if (!parsed.ok) return error(parsed.error, parsed.status);
53
+ if (!isRecord(parsed.body)) return error("JSON object body required", 400);
54
+ // Master switch + per-signal toggle gate ingestion, so flipping a feature off in the
55
+ // dashboard actually stops data landing — not just hides it.
56
+ const config = getInsightsConfigEntry().value;
57
+ if (!config.enabled) return error("insights disabled", 409);
58
+ const body = parsed.body;
59
+ const signal = typeof body.signal === "string" ? body.signal.trim() : "";
60
+ if (signal === "introspection" && !config.introspection.enabled) return error("introspection disabled", 409);
61
+ if (signal === "context_ratio" && !config.contextRatio.enabled) return error("contextRatio disabled", 409);
62
+ try {
63
+ const observation = recordObservation({
64
+ sessionId: typeof body.sessionId === "string" ? body.sessionId : "",
65
+ agentId: typeof body.agentId === "string" ? body.agentId : undefined,
66
+ project: typeof body.project === "string" ? body.project : undefined,
67
+ signal,
68
+ value: isRecord(body.value) ? body.value : {},
69
+ outcome: isRecord(body.outcome) ? body.outcome : undefined,
70
+ source: body.source === "server" ? "server" : "agent",
71
+ // For insights the event time IS the record time (end-of-session). When the Runner
72
+ // backfilled this through its durable outbox (#196), occurredAt preserves the real
73
+ // moment rather than the later server-receive time.
74
+ createdAt: sanitizeObservationOccurredAt(body.occurredAt),
75
+ });
76
+ return json(observation, 201);
77
+ } catch (e) {
78
+ if (e instanceof ValidationError) return error(e.message, 400);
79
+ throw e;
80
+ }
81
+ };
@@ -0,0 +1,592 @@
1
+ // Auto-split from routes.ts (#299). Domain: integrations.
2
+ import { INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES, getIntegrationTokens, type IntegrationTokenConfig } from "../config";
3
+ import { VALID_ARTIFACT_KINDS, VALID_ARTIFACT_ROLES, VALID_TASK_STATUSES, agentSessionStatus, authorizeRoute, cleanAttachmentRefs, dispatchTaskCallbacks, error, json, normalizeAgentSessionGuard, parseBody, reactionEmoji, sendReactionNotificationToAuthor, validateChannelAttachmentSourceRef, withPayloadAttachments, type Handler } from "./_shared";
4
+ import { ValidationError, evaluatePoolBindings, findMessageByTelegramSource, getAgent, getChannel, getMessage, ingestIntegrationEvent, isIntegrationRegistryEnabled, listChannelBindings, listChannels, listIntegrationRegistry, listIntegrationTaskStats, resolveChannelRoutes, sendMessageWithResult, setMessageReaction, upsertChannelBinding, upsertIntegrationRegistry, validateAgentSession } from "../db";
5
+ import { cleanMeta, cleanString, cleanStringArray, optionalEnum } from "../validation";
6
+ import { emitChannelActivity, emitMessageQueued, emitMessageReactionUpdated, emitNewMessage, emitTaskChanged } from "../sse";
7
+ import { getComponentAuth, getIntegrationAuth, hasIntegrationScope, isRequestAuthorizedFor } from "../security";
8
+ import { isRecord } from "agent-relay-sdk";
9
+ import { type ChannelBinding, type ChannelBindingMode, type ChannelRouteTarget, type ChannelSummary, type IntegrationEventInput, type IntegrationSummary } from "../types";
10
+
11
+ const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator", "pool", "policy"] as const;
12
+
13
+ const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
14
+
15
+ const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
16
+
17
+ const integrationRateBuckets = new Map<string, { windowStart: number; count: number }>();
18
+
19
+ function normalizeChannelBindingInput(body: unknown): {
20
+ channelId: string;
21
+ conversationId?: string;
22
+ target: ChannelRouteTarget;
23
+ mode?: ChannelBindingMode;
24
+ priority?: number;
25
+ } {
26
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
27
+ const channelId = cleanString(body.channelId, "channelId", { required: true, max: 200 })!;
28
+ const conversationId = cleanString(body.conversationId, "conversationId", { max: 300 });
29
+ const mode = optionalEnum(body.mode, "mode", VALID_CHANNEL_BINDING_MODES, "exclusive") as ChannelBindingMode | undefined;
30
+ const priority = typeof body.priority === "number" && Number.isSafeInteger(body.priority) ? body.priority : undefined;
31
+
32
+ let target: ChannelRouteTarget | undefined;
33
+ if (typeof body.target === "string") {
34
+ target = routeTargetFromAddress(body.target);
35
+ } else if (isRecord(body.target)) {
36
+ const type = optionalEnum(body.target.type, "target.type", VALID_CHANNEL_BINDING_TARGET_TYPES)!;
37
+ const id = type === "broadcast" ? undefined : cleanString(body.target.id, "target.id", { required: true, max: 240 })!;
38
+ if (type === "orchestrator") throw new ValidationError("orchestrator channel targets are not supported yet");
39
+ target = type === "broadcast" ? { type: "broadcast" } : { type, id } as ChannelRouteTarget;
40
+ }
41
+ if (!target) throw new ValidationError("target required");
42
+
43
+ return { channelId, conversationId, target, mode, priority };
44
+ }
45
+
46
+ function routeTargetFromAddress(target: string): ChannelRouteTarget {
47
+ if (target.startsWith("pool:")) return { type: "pool", id: target.slice("pool:".length) };
48
+ if (target.startsWith("policy:")) return { type: "policy", id: target.slice("policy:".length) };
49
+ if (target.startsWith("label:")) return { type: "label", id: target.slice("label:".length) };
50
+ if (target.startsWith("tag:")) return { type: "tag", id: target.slice("tag:".length) };
51
+ if (target.startsWith("cap:")) return { type: "capability", id: target.slice("cap:".length) };
52
+ if (target === "broadcast") return { type: "broadcast" };
53
+ if (target.startsWith("orchestrator:")) throw new ValidationError("orchestrator channel targets are not supported yet");
54
+ return { type: "agent", id: target };
55
+ }
56
+
57
+ function messageTargetForChannelTarget(target: ChannelRouteTarget, binding?: ChannelBinding): string {
58
+ if (target.type === "orchestrator") throw new ValidationError("orchestrator channel targets are not supported yet");
59
+ if (target.type === "pool") {
60
+ if (!binding?.poolAgentId) throw new ValidationError("pool slot is unclaimed — no eligible agent available");
61
+ return binding.poolAgentId;
62
+ }
63
+ if (target.type === "label") return `label:${target.id}`;
64
+ if (target.type === "tag") return `tag:${target.id}`;
65
+ if (target.type === "capability") return `cap:${target.id}`;
66
+ if (target.type === "broadcast") return "broadcast";
67
+ if (target.type === "policy") return `policy:${target.id}`;
68
+ return target.id;
69
+ }
70
+
71
+ function payloadConversationId(payload: Record<string, unknown>): string | undefined {
72
+ const conversation = payload.conversation;
73
+ if (isRecord(conversation)) return cleanString(conversation.id, "payload.conversation.id", { max: 300 });
74
+ return undefined;
75
+ }
76
+
77
+ function payloadDedupeKey(payload: Record<string, unknown>): string | undefined {
78
+ const dedupe = payload.dedupe;
79
+ if (isRecord(dedupe)) return cleanString(dedupe.key, "payload.dedupe.key", { max: 240 });
80
+ return undefined;
81
+ }
82
+
83
+ const EPHEMERAL_CHANNEL_EVENT_TYPES = new Set([
84
+ "typing.started",
85
+ "typing.stopped",
86
+ "message.read",
87
+ "message.delivered",
88
+ "presence.updated",
89
+ ]);
90
+
91
+ function channelPayloadEventType(payload: Record<string, unknown>): string {
92
+ const event = payload.event;
93
+ if (!isRecord(event)) throw new ValidationError("payload.event must be an object");
94
+ return cleanString(event.type, "payload.event.type", { required: true, max: 80 })!;
95
+ }
96
+
97
+ function validateChannelEnvelope(payload: Record<string, unknown>, channel: ChannelSummary): void {
98
+ const schema = cleanString(payload.schema, "payload.schema", { required: true, max: 80 });
99
+ if (schema !== "agent-relay.channel.v1") throw new ValidationError("payload.schema must be agent-relay.channel.v1");
100
+
101
+ const payloadChannel = payload.channel;
102
+ if (!isRecord(payloadChannel)) throw new ValidationError("payload.channel must be an object");
103
+ const provider = cleanString(payloadChannel.provider, "payload.channel.provider", { required: true, max: 80 });
104
+ const accountId = cleanString(payloadChannel.accountId, "payload.channel.accountId", { required: true, max: 200 });
105
+ const agentId = cleanString(payloadChannel.agentId, "payload.channel.agentId", { required: true, max: 200 });
106
+ if (provider !== channel.type || accountId !== channel.accountId || agentId !== channel.agentId) {
107
+ throw new ValidationError("payload.channel does not match channel identity");
108
+ }
109
+
110
+ const direction = cleanString(payload.direction, "payload.direction", { required: true, max: 20 });
111
+ if (direction !== "inbound" && direction !== "outbound") throw new ValidationError("payload.direction must be inbound or outbound");
112
+
113
+ const conversation = payload.conversation;
114
+ if (!isRecord(conversation)) throw new ValidationError("payload.conversation must be an object");
115
+ cleanString(conversation.id, "payload.conversation.id", { required: true, max: 300 });
116
+ cleanString(conversation.type, "payload.conversation.type", { required: true, max: 80 });
117
+
118
+ const actor = payload.actor;
119
+ if (!isRecord(actor)) throw new ValidationError("payload.actor must be an object");
120
+ cleanString(actor.id, "payload.actor.id", { required: true, max: 240 });
121
+ cleanString(actor.kind, "payload.actor.kind", { required: true, max: 80 });
122
+
123
+ channelPayloadEventType(payload);
124
+ validateChannelAttachmentRefs(payload);
125
+ }
126
+
127
+ function validateChannelPayloadContent(payload: Record<string, unknown>, allowActivity: boolean): void {
128
+ const hasMessage = isRecord(payload.message);
129
+ const hasAttachments = Array.isArray(payload.attachments);
130
+ const hasReaction = isRecord(payload.reaction);
131
+ const hasInteraction = isRecord(payload.interaction);
132
+ const hasActivity = isRecord(payload.activity);
133
+ if (!hasMessage && !hasAttachments && !hasReaction && !hasInteraction && !hasActivity) {
134
+ throw new ValidationError("channel payload must include message, attachments, reaction, interaction, or activity");
135
+ }
136
+ if (!allowActivity && hasActivity) throw new ValidationError("channel activity must use /api/channels/:id/activities");
137
+ }
138
+
139
+ function validateChannelAttachmentItem(item: unknown, field: string): void {
140
+ if (!isRecord(item)) throw new ValidationError(`${field} must be an object`);
141
+ const artifactId = cleanString(item.artifactId, `${field}.artifactId`, { max: 120 });
142
+ if (artifactId) {
143
+ optionalEnum(item.kind, `${field}.kind`, VALID_ARTIFACT_KINDS);
144
+ optionalEnum(item.role, `${field}.role`, VALID_ARTIFACT_ROLES);
145
+ cleanString(item.title, `${field}.title`, { max: 240 });
146
+ if (item.ref !== undefined) validateChannelAttachmentSourceRef(item.ref, `${field}.ref`);
147
+ return;
148
+ }
149
+ validateChannelAttachmentSourceRef(item.ref, `${field}.ref`);
150
+ }
151
+
152
+ function validateChannelAttachmentRefs(payload: Record<string, unknown>): void {
153
+ if (payload.attachments === undefined) return;
154
+ if (!Array.isArray(payload.attachments)) throw new ValidationError("payload.attachments must be an array");
155
+ for (const [index, item] of payload.attachments.entries()) {
156
+ validateChannelAttachmentItem(item, `payload.attachments[${index}]`);
157
+ }
158
+ }
159
+
160
+ function cleanChannelAttachmentRefs(value: unknown, field = "attachments"): Array<Record<string, unknown>> | undefined {
161
+ if (value === undefined || value === null) return undefined;
162
+ if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array`);
163
+ return value.map((item, index) => {
164
+ validateChannelAttachmentItem(item, `${field}[${index}]`);
165
+ return { ...(item as Record<string, unknown>) };
166
+ });
167
+ }
168
+
169
+ function normalizeChannelEventBody(body: unknown): {
170
+ body: string;
171
+ payload: Record<string, unknown>;
172
+ conversationId?: string;
173
+ idempotencyKey?: string;
174
+ } {
175
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
176
+ const embeddedPayload = cleanMeta(body.payload);
177
+ const topLevelAttachmentInput = embeddedPayload || body.artifacts !== undefined ? body.attachments ?? body.artifacts : undefined;
178
+ const topLevelAttachments = cleanChannelAttachmentRefs(topLevelAttachmentInput, "attachments");
179
+ const payload = withPayloadAttachments(embeddedPayload ?? body, topLevelAttachments);
180
+ const fallbackBody = cleanString(body.body, "body", { max: MAX_BODY_BYTES });
181
+ const text = isRecord(payload.message) ? cleanString(payload.message.text, "payload.message.text", { max: MAX_BODY_BYTES }) : undefined;
182
+ const conversationId = cleanString(body.conversationId, "conversationId", { max: 300 }) ?? payloadConversationId(payload);
183
+ return {
184
+ body: fallbackBody ?? text ?? "Channel event",
185
+ payload,
186
+ conversationId,
187
+ idempotencyKey: cleanString(body.idempotencyKey, "idempotencyKey", { max: 240 }) ?? payloadDedupeKey(payload),
188
+ };
189
+ }
190
+
191
+ function requireChannelSession(req: Request, body: unknown, channel: ChannelSummary): Response | undefined {
192
+ const guard = normalizeAgentSessionGuard(req, body);
193
+ if (!guard || (!guard.instanceId && guard.epoch === undefined)) return error("channel session guard required", 400);
194
+ const session = validateAgentSession(channel.agentId, guard);
195
+ if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
196
+ return undefined;
197
+ }
198
+
199
+ function normalizeIntegrationEvent(body: unknown): IntegrationEventInput {
200
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
201
+ const status = optionalEnum(body.status, "status", [...VALID_TASK_STATUSES, "resolved"] as const);
202
+ return {
203
+ source: cleanString(body.source, "source", { max: 120 }),
204
+ type: cleanString(body.type, "type", { max: 80 }) ?? "event",
205
+ severity: optionalEnum(body.severity, "severity", VALID_TASK_SEVERITIES, "info"),
206
+ status,
207
+ title: cleanString(body.title, "title", { required: true, max: 240 })!,
208
+ body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
209
+ target: cleanString(body.target, "target", { required: true, max: 200 })!,
210
+ channel: cleanString(body.channel, "channel", { max: 120 }),
211
+ dedupeKey: cleanString(body.dedupeKey, "dedupeKey", { max: 240 }),
212
+ externalUrl: cleanString(body.externalUrl, "externalUrl", { max: 1000 }),
213
+ attachments: cleanAttachmentRefs(body.attachments),
214
+ metadata: cleanMeta(body.metadata),
215
+ };
216
+ }
217
+
218
+ function normalizeIntegrationRegistryInput(name: string, body: unknown): {
219
+ name: string;
220
+ displayName?: string;
221
+ description?: string;
222
+ enabled?: boolean;
223
+ scopes?: string[];
224
+ targets?: string[];
225
+ channels?: string[];
226
+ type?: string;
227
+ icon?: string;
228
+ accentColor?: string;
229
+ tags?: string[];
230
+ homepageUrl?: string;
231
+ repositoryUrl?: string;
232
+ docsUrl?: string;
233
+ manifest?: Record<string, unknown>;
234
+ source: "api";
235
+ } {
236
+ if (!isRecord(body)) throw new ValidationError("body must be an object");
237
+ if (body.enabled !== undefined && typeof body.enabled !== "boolean") throw new ValidationError("enabled must be a boolean");
238
+ return {
239
+ name,
240
+ displayName: cleanString(body.displayName, "displayName", { max: 120 }),
241
+ description: cleanString(body.description, "description", { max: 1000 }),
242
+ enabled: body.enabled as boolean | undefined,
243
+ scopes: cleanStringArray(body.scopes, "scopes", { itemMax: 80, maxItems: 50 }),
244
+ targets: cleanStringArray(body.targets, "targets", { itemMax: 80, maxItems: 50 }),
245
+ channels: cleanStringArray(body.channels, "channels", { itemMax: 80, maxItems: 50 }),
246
+ type: cleanString(body.type, "type", { max: 80 }),
247
+ icon: cleanString(body.icon, "icon", { max: 80 }),
248
+ accentColor: cleanString(body.accentColor, "accentColor", { max: 32 }),
249
+ tags: cleanStringArray(body.tags, "tags", { itemMax: 80, maxItems: 50 }),
250
+ homepageUrl: cleanString(body.homepageUrl, "homepageUrl", { max: 500 }),
251
+ repositoryUrl: cleanString(body.repositoryUrl, "repositoryUrl", { max: 500 }),
252
+ docsUrl: cleanString(body.docsUrl, "docsUrl", { max: 500 }),
253
+ manifest: cleanMeta(body.manifest),
254
+ source: "api",
255
+ };
256
+ }
257
+
258
+ function channelIdFromIntegrationTarget(target: string): string | undefined {
259
+ if (!target.startsWith("channel:")) return undefined;
260
+ const channelId = target.slice("channel:".length).trim();
261
+ if (!channelId) throw new ValidationError("channel target id required");
262
+ return channelId;
263
+ }
264
+
265
+ function metadataWithResolvedChannelTarget(
266
+ metadata: Record<string, unknown> | undefined,
267
+ originalTarget: string,
268
+ channelId: string,
269
+ binding: ChannelBinding,
270
+ ): Record<string, unknown> {
271
+ return {
272
+ ...(metadata ?? {}),
273
+ relayRequestedTarget: originalTarget,
274
+ relayResolvedChannelId: channelId,
275
+ relayResolvedChannelBindingId: binding.id,
276
+ };
277
+ }
278
+
279
+ function resolveIntegrationEventTarget(input: IntegrationEventInput): IntegrationEventInput {
280
+ const channelId = channelIdFromIntegrationTarget(input.target);
281
+ if (!channelId) return input;
282
+
283
+ evaluatePoolBindings();
284
+ const channel = getChannel(channelId);
285
+ if (!channel) throw new ValidationError(`channel ${channelId} not found`);
286
+
287
+ const bindings = resolveChannelRoutes(channelId);
288
+ if (bindings.length === 0) throw new ValidationError(`channel ${channelId} has no binding`);
289
+ if (bindings.length > 1) throw new ValidationError(`channel ${channelId} has multiple bindings; integration channel targets must resolve to one backend agent`);
290
+
291
+ const binding = bindings[0]!;
292
+ const resolvedTarget = messageTargetForChannelTarget(binding.target, binding);
293
+ if (!resolvedTarget.startsWith("policy:") && !getAgent(resolvedTarget)) {
294
+ throw new ValidationError(`channel ${channelId} binding resolves to ${resolvedTarget}; integration channel targets must resolve to one backend agent`);
295
+ }
296
+
297
+ return {
298
+ ...input,
299
+ target: resolvedTarget,
300
+ channel: input.channel ?? channelId,
301
+ metadata: metadataWithResolvedChannelTarget(input.metadata, input.target, channelId, binding),
302
+ };
303
+ }
304
+
305
+ function checkIntegrationRateLimit(name: string): boolean {
306
+ const now = Date.now();
307
+ const windowMs = 60_000;
308
+ const bucket = integrationRateBuckets.get(name);
309
+ if (!bucket || now - bucket.windowStart >= windowMs) {
310
+ integrationRateBuckets.set(name, { windowStart: now, count: 1 });
311
+ return true;
312
+ }
313
+ bucket.count += 1;
314
+ return bucket.count <= INTEGRATION_RATE_LIMIT_PER_MINUTE;
315
+ }
316
+
317
+ function safeCallbackHost(url: string | undefined): string | undefined {
318
+ if (!url) return undefined;
319
+ try {
320
+ return new URL(url).host;
321
+ } catch {
322
+ return undefined;
323
+ }
324
+ }
325
+
326
+ function reconcileIntegrationRegistry(configured: Map<string, IntegrationTokenConfig>, observedStats: Map<string, ReturnType<typeof listIntegrationTaskStats>[number]>): Map<string, ReturnType<typeof listIntegrationRegistry>[number]> {
327
+ for (const config of configured.values()) {
328
+ upsertIntegrationRegistry({
329
+ name: config.name,
330
+ displayName: config.displayName,
331
+ description: config.description,
332
+ scopes: config.scopes,
333
+ targets: config.targets,
334
+ channels: config.channels,
335
+ type: config.type,
336
+ icon: config.icon,
337
+ accentColor: config.accentColor,
338
+ tags: config.tags,
339
+ homepageUrl: config.homepageUrl,
340
+ repositoryUrl: config.repositoryUrl,
341
+ docsUrl: config.docsUrl,
342
+ manifest: config.manifest,
343
+ source: "env",
344
+ });
345
+ }
346
+ for (const stats of observedStats.values()) {
347
+ upsertIntegrationRegistry({
348
+ name: stats.source,
349
+ enabled: true,
350
+ source: "observed",
351
+ });
352
+ }
353
+ return new Map(listIntegrationRegistry().map((integration) => [integration.name, integration]));
354
+ }
355
+
356
+ export const getIntegrations: Handler = () => {
357
+ const configured = new Map(getIntegrationTokens().map((integration) => [integration.name, integration]));
358
+ const observedStats = new Map(listIntegrationTaskStats().map((stats) => [stats.source, stats]));
359
+ const registry = reconcileIntegrationRegistry(configured, observedStats);
360
+ const names = [...new Set([...configured.keys(), ...observedStats.keys(), ...registry.keys()])].sort((a, b) => a.localeCompare(b));
361
+ const now = Date.now();
362
+
363
+ const integrations: IntegrationSummary[] = names.map((name) => {
364
+ const config = configured.get(name);
365
+ const registered = registry.get(name);
366
+ const taskStats = observedStats.get(name) ?? {
367
+ source: name,
368
+ tasks: 0,
369
+ openTasks: 0,
370
+ waitingTasks: 0,
371
+ failedTasks: 0,
372
+ };
373
+ const bucket = integrationRateBuckets.get(name);
374
+ const bucketCurrent = Boolean(bucket && now - bucket.windowStart < 60_000);
375
+
376
+ return {
377
+ name,
378
+ displayName: registered?.displayName,
379
+ description: registered?.description,
380
+ enabled: registered?.enabled ?? true,
381
+ configured: Boolean(config),
382
+ observed: observedStats.has(name),
383
+ type: registered?.type,
384
+ icon: registered?.icon,
385
+ accentColor: registered?.accentColor,
386
+ tags: registered?.tags ?? [],
387
+ homepageUrl: registered?.homepageUrl,
388
+ repositoryUrl: registered?.repositoryUrl,
389
+ docsUrl: registered?.docsUrl,
390
+ manifest: registered?.manifest,
391
+ scopes: registered?.scopes?.length ? registered.scopes : config?.scopes ?? [],
392
+ targets: registered?.targets?.length ? registered.targets : config?.targets ?? [],
393
+ channels: registered?.channels?.length ? registered.channels : config?.channels ?? [],
394
+ callbackHost: safeCallbackHost(config?.callbackUrl),
395
+ callbackConfigured: Boolean(config?.callbackUrl),
396
+ rateLimit: {
397
+ limitPerMinute: INTEGRATION_RATE_LIMIT_PER_MINUTE,
398
+ currentWindowCount: bucketCurrent ? bucket!.count : 0,
399
+ windowStartedAt: bucketCurrent ? bucket!.windowStart : undefined,
400
+ },
401
+ taskStats,
402
+ };
403
+ });
404
+
405
+ return json(integrations);
406
+ };
407
+
408
+ export const putIntegrationRegistryRoute: Handler = async (req, params) => {
409
+ const name = cleanString(params.name, "name", { required: true, max: 200 })!;
410
+ const parsed = await parseBody<unknown>(req);
411
+ if (!parsed.ok) return error(parsed.error, parsed.status);
412
+ try {
413
+ return json(upsertIntegrationRegistry(normalizeIntegrationRegistryInput(name, parsed.body)));
414
+ } catch (e) {
415
+ if (e instanceof ValidationError) return error(e.message, 400);
416
+ throw e;
417
+ }
418
+ };
419
+
420
+ export const getChannels: Handler = () => {
421
+ return json(listChannels());
422
+ };
423
+
424
+ export const getChannelBindings: Handler = (req) => {
425
+ const url = new URL(req.url);
426
+ const channelId = cleanString(url.searchParams.get("channelId") ?? undefined, "channelId", { max: 200 });
427
+ return json(listChannelBindings(channelId));
428
+ };
429
+
430
+ export const postChannelBinding: Handler = async (req) => {
431
+ const parsed = await parseBody<unknown>(req);
432
+ if (!parsed.ok) return error(parsed.error, parsed.status);
433
+ try {
434
+ const input = normalizeChannelBindingInput(parsed.body);
435
+ const channel = getChannel(input.channelId);
436
+ if (!channel) return error("channel not found", 404);
437
+ const sessionError = requireChannelSession(req, parsed.body, channel);
438
+ if (sessionError) return sessionError;
439
+ const binding = upsertChannelBinding(input);
440
+ return json(binding, 201);
441
+ } catch (e) {
442
+ if (e instanceof ValidationError) return error(e.message, 400);
443
+ throw e;
444
+ }
445
+ };
446
+
447
+ function scopedChannelIdempotencyKey(key: string | undefined, binding: ChannelBinding): string | undefined {
448
+ return key ? `${key}:${binding.id}` : undefined;
449
+ }
450
+
451
+ function applyInboundChannelReaction(payload: Record<string, unknown>): void {
452
+ const eventType = channelPayloadEventType(payload);
453
+ if (eventType !== "message.reaction") return;
454
+ const reaction = isRecord(payload.reaction) ? payload.reaction : undefined;
455
+ if (!reaction) return;
456
+ const target = isRecord(reaction.target) ? reaction.target : undefined;
457
+ const relayMessageId = target && typeof target.relayMessageId === "number" && Number.isSafeInteger(target.relayMessageId)
458
+ ? target.relayMessageId
459
+ : undefined;
460
+ const source = target && isRecord(target.source) ? target.source : undefined;
461
+ const telegram = source && isRecord(source.telegram) ? source.telegram : undefined;
462
+ if (!telegram && relayMessageId === undefined) return;
463
+
464
+ let parent = relayMessageId !== undefined ? getMessage(relayMessageId) : null;
465
+ if (!parent && telegram) {
466
+ const chatId = cleanString(telegram.chatId, "payload.reaction.target.source.telegram.chatId", { max: 120 });
467
+ const messageId = cleanString(telegram.messageId, "payload.reaction.target.source.telegram.messageId", { max: 120 });
468
+ if (!chatId || !messageId) return;
469
+ const channel = isRecord(payload.channel) ? payload.channel : undefined;
470
+ const accountId = channel ? cleanString(channel.accountId, "payload.channel.accountId", { max: 200 }) : undefined;
471
+ parent = findMessageByTelegramSource({ accountId, chatId, messageId });
472
+ }
473
+ if (!parent) return;
474
+
475
+ const actor = isRecord(payload.actor) ? payload.actor : {};
476
+ const actorId = cleanString(actor.id, "payload.actor.id", { max: 240 }) ?? "telegram";
477
+ const emoji = reactionEmoji(reaction.emoji ?? reaction.value ?? reaction.name, "payload.reaction.emoji");
478
+ const action = cleanString(reaction.action, "payload.reaction.action", { max: 20 }) === "remove" ? "remove" : "add";
479
+ const result = setMessageReaction({ messageId: parent.id, actorId, emoji, action });
480
+ if (result.ok) {
481
+ emitMessageReactionUpdated(result.message);
482
+ sendReactionNotificationToAuthor(result.message, actorId, emoji, action);
483
+ }
484
+ }
485
+
486
+ export const postChannelEvent: Handler = async (req, params) => {
487
+ const parsed = await parseBody<unknown>(req);
488
+ if (!parsed.ok) return error(parsed.error, parsed.status);
489
+ try {
490
+ const channel = getChannel(params.id!);
491
+ if (!channel) return error("channel not found", 404);
492
+ const sessionError = requireChannelSession(req, parsed.body, channel);
493
+ if (sessionError) return sessionError;
494
+ const denied = authorizeRoute(req, { scope: "channel:write", resource: { channel: channel.id } });
495
+ if (denied) return denied;
496
+ const input = normalizeChannelEventBody(parsed.body);
497
+ validateChannelEnvelope(input.payload, channel);
498
+ validateChannelPayloadContent(input.payload, false);
499
+ const eventType = channelPayloadEventType(input.payload);
500
+ if (EPHEMERAL_CHANNEL_EVENT_TYPES.has(eventType)) {
501
+ return error("ephemeral channel activity must use /api/channels/:id/activities", 400);
502
+ }
503
+ applyInboundChannelReaction(input.payload);
504
+ const bindings = resolveChannelRoutes(channel.id, input.conversationId);
505
+ if (!bindings.length) return error("channel has no binding", 409);
506
+ const results = bindings.map((binding) => sendMessageWithResult({
507
+ from: channel.agentId,
508
+ to: messageTargetForChannelTarget(binding.target, binding),
509
+ kind: "channel.event",
510
+ channel: channel.id,
511
+ body: input.body,
512
+ payload: input.payload,
513
+ idempotencyKey: scopedChannelIdempotencyKey(input.idempotencyKey, binding),
514
+ claimable: false,
515
+ }));
516
+ for (const result of results) {
517
+ if (!result.created) continue;
518
+ if (result.message.deliveryStatus === "queued") emitMessageQueued(result.message);
519
+ else emitNewMessage(result.message);
520
+ }
521
+ return json({
522
+ messages: results.map((result) => result.message),
523
+ bindings,
524
+ created: results.some((result) => result.created),
525
+ }, results.some((result) => result.created) ? 201 : 200);
526
+ } catch (e) {
527
+ if (e instanceof ValidationError) return error(e.message, 400);
528
+ throw e;
529
+ }
530
+ };
531
+
532
+ export const postChannelActivity: Handler = async (req, params) => {
533
+ const parsed = await parseBody<unknown>(req);
534
+ if (!parsed.ok) return error(parsed.error, parsed.status);
535
+ try {
536
+ const channel = getChannel(params.id!);
537
+ if (!channel) return error("channel not found", 404);
538
+ const sessionError = requireChannelSession(req, parsed.body, channel);
539
+ if (sessionError) return sessionError;
540
+ const input = normalizeChannelEventBody(parsed.body);
541
+ validateChannelEnvelope(input.payload, channel);
542
+ validateChannelPayloadContent(input.payload, true);
543
+ const eventType = channelPayloadEventType(input.payload);
544
+ if (!EPHEMERAL_CHANNEL_EVENT_TYPES.has(eventType) && !input.payload.activity) {
545
+ return error("channel activity payload must include an ephemeral event type or activity object", 400);
546
+ }
547
+ const activity = {
548
+ channelId: channel.id,
549
+ conversationId: input.conversationId,
550
+ payload: input.payload,
551
+ createdAt: Date.now(),
552
+ };
553
+ emitChannelActivity(activity);
554
+ return json({ ok: true, activity }, 202);
555
+ } catch (e) {
556
+ if (e instanceof ValidationError) return error(e.message, 400);
557
+ throw e;
558
+ }
559
+ };
560
+
561
+ export const postIntegrationEvent: Handler = async (req) => {
562
+ const auth = getIntegrationAuth(req);
563
+ const component = getComponentAuth(req);
564
+ const componentIntegration = component?.role === "integration" ? component : null;
565
+ const integrationName = auth?.name ?? componentIntegration?.sub;
566
+ if (!integrationName) return error("integration token required", 401);
567
+ if (!isIntegrationRegistryEnabled(integrationName)) return error("integration disabled", 403);
568
+ if (!checkIntegrationRateLimit(integrationName)) return error("integration rate limit exceeded", 429);
569
+ if (auth && !hasIntegrationScope(auth, "tasks:create") && !hasIntegrationScope(auth, "events:create")) {
570
+ return error("integration token missing tasks:create scope", 403);
571
+ }
572
+
573
+ const parsed = await parseBody<unknown>(req);
574
+ if (!parsed.ok) return error(parsed.error, parsed.status);
575
+ try {
576
+ const normalized = { ...normalizeIntegrationEvent(parsed.body), source: integrationName };
577
+ const requestedChannelId = channelIdFromIntegrationTarget(normalized.target);
578
+ const integrationScope = auth && !hasIntegrationScope(auth, "events:create") ? "task:write" : "integration:write";
579
+ if (!isRequestAuthorizedFor(req, { scope: integrationScope, resource: { integrationName, target: normalized.target, channel: normalized.channel ?? requestedChannelId } })) {
580
+ return error("integration token cannot target this task", 403);
581
+ }
582
+ const input = resolveIntegrationEventTarget(normalized);
583
+ const result = ingestIntegrationEvent(input, integrationName);
584
+ if (result.message) emitNewMessage(result.message);
585
+ emitTaskChanged(result.task, result.created ? "task.created" : "task.updated");
586
+ void dispatchTaskCallbacks(result.task.id, result.created ? "task.created" : "task.updated");
587
+ return json(result, result.created ? 201 : 200);
588
+ } catch (e) {
589
+ if (e instanceof ValidationError) return error(e.message, 400);
590
+ throw e;
591
+ }
592
+ };