agent-relay-server 0.4.15 → 0.4.17

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/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
- import { initDb, reapStaleAgents, pruneOfflineAgents, pruneOldMessages } from "./db";
2
+ import { initDb, reapStaleAgents, pruneOfflineAgents, pruneOldMessages, releaseExpiredClaims } from "./db";
3
3
  import { matchRoute } from "./routes";
4
- import { emitAgentStatus, emitAgentRemoved } from "./sse";
4
+ import { emitAgentStatus, emitAgentRemoved, emitMessageClaimReleased, emitTaskChanged } from "./sse";
5
5
  import { resolve, sep } from "path";
6
6
  import {
7
7
  REAP_INTERVAL_MS,
@@ -14,8 +14,10 @@ import {
14
14
  applyCors,
15
15
  assertSafeNetworkConfig,
16
16
  corsPreflight,
17
+ forbidden,
17
18
  getIntegrationAuth,
18
19
  isAuthorized,
20
+ isScopedRequestAuthorized,
19
21
  isOriginAllowed,
20
22
  unauthorized,
21
23
  } from "./security";
@@ -38,6 +40,13 @@ function startServer(): void {
38
40
  initDb(DB_PATH);
39
41
 
40
42
  setInterval(() => {
43
+ const released = releaseExpiredClaims();
44
+ if (released.messageIds.length > 0 || released.tasks.length > 0) {
45
+ console.log(`released ${released.messageIds.length} expired message claim(s), ${released.tasks.length} expired task claim(s)`);
46
+ for (const id of released.messageIds) emitMessageClaimReleased(id);
47
+ for (const task of released.tasks) emitTaskChanged(task, "task.updated");
48
+ }
49
+
41
50
  const reaped = reapStaleAgents(STALE_TTL_MS);
42
51
  if (reaped.length > 0) {
43
52
  console.log(`reaped ${reaped.length} stale agent(s)`);
@@ -87,9 +96,10 @@ export function createFetchHandler(
87
96
  if (matched) {
88
97
  const integrationAuth = getIntegrationAuth(req);
89
98
  if (!isAuthorized(req)) {
90
- if (!integrationAuth || !url.pathname.startsWith("/api/integrations/")) {
99
+ if (!integrationAuth) {
91
100
  return unauthorized(req);
92
101
  }
102
+ if (!isScopedRequestAuthorized(req)) return forbidden(req);
93
103
  }
94
104
  const response = await matched.handler(req, matched.params);
95
105
  applyCors(req, response);
package/src/routes.ts CHANGED
@@ -5,10 +5,12 @@ import {
5
5
  findAgentsByCapability,
6
6
  setStatus,
7
7
  setLabel,
8
+ setTags,
8
9
  markReady,
9
10
  heartbeat,
10
11
  deleteAgent,
11
12
  sendMessage,
13
+ sendMessageWithResult,
12
14
  getMessage,
13
15
  getThread,
14
16
  claimMessage,
@@ -17,18 +19,29 @@ import {
17
19
  markRead,
18
20
  deleteMessage,
19
21
  getStats,
22
+ getHealth,
20
23
  getLatestMessageId,
21
24
  ingestIntegrationEvent,
22
25
  listTasks,
23
26
  getTask,
24
27
  listTaskEvents,
25
28
  claimTask,
29
+ renewTaskClaim,
26
30
  updateTaskStatus,
31
+ renewMessageClaim,
32
+ createPair,
33
+ getPair,
34
+ listPairs,
35
+ acceptPair,
36
+ rejectPair,
37
+ endPair,
38
+ sendPairMessage,
27
39
  createCallbackDelivery,
28
40
  finishCallbackDelivery,
41
+ validateAgentSession,
29
42
  ValidationError,
30
43
  } from "./db";
31
- import type { IntegrationEventInput, RegisterAgentInput, SendMessageInput, TaskStatus, TaskStatusInput } from "./types";
44
+ import type { AgentSessionGuard, CreatePairInput, IntegrationEventInput, PairActionInput, PairMessageInput, PairStatus, RegisterAgentInput, SendMessageInput, TaskStatus, TaskStatusInput } from "./types";
32
45
  import { getIntegrationTokens, INTEGRATION_RATE_LIMIT_PER_MINUTE, MAX_BODY_BYTES } from "./config";
33
46
  import {
34
47
  getIntegrationAuth,
@@ -129,6 +142,7 @@ function parseQueryInt(
129
142
  const VALID_AGENT_STATUSES = ["online", "idle", "busy", "offline"] as const;
130
143
  const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
131
144
  const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "done", "failed", "canceled"] as const;
145
+ const VALID_PAIR_STATUSES = ["pending", "active", "ended", "rejected", "expired"] as const;
132
146
  const integrationRateBuckets = new Map<string, { windowStart: number; count: number }>();
133
147
 
134
148
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -195,6 +209,33 @@ function cleanPositiveId(value: unknown, field: string): number | undefined {
195
209
  return value;
196
210
  }
197
211
 
212
+ function cleanEpoch(value: unknown, field: string): number | undefined {
213
+ if (value === undefined || value === null) return undefined;
214
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
215
+ throw new ValidationError(`${field} must be a non-negative integer`);
216
+ }
217
+ return value;
218
+ }
219
+
220
+ function normalizeAgentSessionGuard(req: Request, body: unknown): AgentSessionGuard | undefined {
221
+ const record = isRecord(body) ? body : {};
222
+ const headerInstance = req.headers.get("x-agent-relay-instance-id") ?? undefined;
223
+ const headerEpoch = req.headers.get("x-agent-relay-epoch") ?? undefined;
224
+ const instanceId = cleanString(headerInstance ?? record.instanceId, "instanceId", { max: 200 });
225
+ const rawEpoch = headerEpoch === undefined ? record.epoch : Number(headerEpoch);
226
+ const epoch = cleanEpoch(rawEpoch, "epoch");
227
+
228
+ if (!instanceId && epoch === undefined) return undefined;
229
+ if (!instanceId) throw new ValidationError("instanceId required when epoch is provided");
230
+ return { instanceId, epoch };
231
+ }
232
+
233
+ function agentSessionStatus(errorMessage: string | undefined): number {
234
+ if (errorMessage === "agent not found") return 404;
235
+ if (errorMessage === "stale agent instance") return 409;
236
+ return 400;
237
+ }
238
+
198
239
  function normalizeAgentInput(body: unknown): RegisterAgentInput {
199
240
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
200
241
  const status = cleanString(body.status, "status", { max: 20 });
@@ -222,6 +263,8 @@ function normalizeAgentInput(body: unknown): RegisterAgentInput {
222
263
  if (machine) input.machine = machine;
223
264
  const rig = cleanString(body.rig, "rig", { max: 120 });
224
265
  if (rig) input.rig = rig;
266
+ const instanceId = cleanString(body.instanceId, "instanceId", { max: 200 });
267
+ if (instanceId) input.instanceId = instanceId;
225
268
  const meta = cleanMeta(body.meta);
226
269
  if (meta) input.meta = meta;
227
270
 
@@ -245,6 +288,7 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
245
288
  type: type as SendMessageInput["type"] | undefined,
246
289
  replyTo: cleanPositiveId(body.replyTo, "replyTo"),
247
290
  claimable: body.claimable as boolean | undefined,
291
+ idempotencyKey: cleanString(body.idempotencyKey, "idempotencyKey", { max: 240 }),
248
292
  };
249
293
 
250
294
  const channel = cleanString(body.channel, "channel", { max: 120 });
@@ -282,12 +326,66 @@ function normalizeTaskStatusInput(body: unknown): TaskStatusInput {
282
326
  return {
283
327
  status: status as TaskStatus,
284
328
  agentId: cleanString(body.agentId, "agentId", { max: 200 }),
329
+ instanceId: cleanString(body.instanceId, "instanceId", { max: 200 }),
330
+ epoch: cleanEpoch(body.epoch, "epoch"),
285
331
  result: cleanString(body.result, "result", { max: MAX_BODY_BYTES }),
286
332
  body: cleanString(body.body, "body", { max: MAX_BODY_BYTES }),
287
333
  metadata: cleanMeta(body.metadata),
288
334
  };
289
335
  }
290
336
 
337
+ function cleanTtlMs(value: unknown): number | undefined {
338
+ if (value === undefined || value === null) return undefined;
339
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
340
+ throw new ValidationError("ttlMs must be a positive integer");
341
+ }
342
+ return value;
343
+ }
344
+
345
+ function normalizeCreatePairInput(body: unknown): CreatePairInput {
346
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
347
+ return {
348
+ from: cleanString(body.from, "from", { required: true, max: 200 })!,
349
+ target: cleanString(body.target, "target", { required: true, max: 200 })!,
350
+ objective: cleanString(body.objective, "objective", { max: 2000 }),
351
+ ttlMs: cleanTtlMs(body.ttlMs),
352
+ meta: cleanMeta(body.meta),
353
+ };
354
+ }
355
+
356
+ function normalizePairActionInput(body: unknown): PairActionInput {
357
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
358
+ return {
359
+ agentId: cleanString(body.agentId, "agentId", { required: true, max: 200 })!,
360
+ reason: cleanString(body.reason, "reason", { max: 1000 }),
361
+ };
362
+ }
363
+
364
+ function normalizePairMessageInput(body: unknown): PairMessageInput {
365
+ if (!isRecord(body)) throw new ValidationError("JSON object body required");
366
+ return {
367
+ from: cleanString(body.from, "from", { required: true, max: 200 })!,
368
+ body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
369
+ subject: cleanString(body.subject, "subject", { max: 200 }),
370
+ };
371
+ }
372
+
373
+ function pairErrorStatus(code: string): number {
374
+ if (code === "not_found") return 404;
375
+ if (code === "busy") return 409;
376
+ if (code === "ambiguous") return 409;
377
+ if (code === "forbidden") return 403;
378
+ return 400;
379
+ }
380
+
381
+ function pairError(result: { error: string; code: string; matches?: unknown[]; busy?: unknown[] }): Response {
382
+ return json({
383
+ error: result.error,
384
+ ...(result.matches ? { matches: result.matches } : {}),
385
+ ...(result.busy ? { busy: result.busy } : {}),
386
+ }, pairErrorStatus(result.code));
387
+ }
388
+
291
389
  function checkIntegrationRateLimit(name: string): boolean {
292
390
  const now = Date.now();
293
391
  const windowMs = 60_000;
@@ -377,13 +475,31 @@ const patchAgentStatus: Handler = async (req, params) => {
377
475
  if (!body?.status) return error("status required");
378
476
  const valid = ["online", "idle", "busy", "offline"];
379
477
  if (!valid.includes(body.status)) return error(`status must be one of: ${valid.join(", ")}`);
380
- if (!setStatus(params.id!, body.status as any)) return error("agent not found", 404);
478
+ try {
479
+ const guard = normalizeAgentSessionGuard(req, body);
480
+ const session = validateAgentSession(params.id!, guard);
481
+ if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
482
+ if (!setStatus(params.id!, body.status as any, guard)) return error("agent not found", 404);
483
+ } catch (e) {
484
+ if (e instanceof ValidationError) return error(e.message, 400);
485
+ throw e;
486
+ }
381
487
  emitAgentStatus(params.id!);
382
488
  return json({ ok: true });
383
489
  };
384
490
 
385
- const postHeartbeat: Handler = (_req, params) => {
386
- return heartbeat(params.id!) ? json({ ok: true }) : error("agent not found", 404);
491
+ const postHeartbeat: Handler = async (req, params) => {
492
+ const parsed = await parseBody<unknown>(req);
493
+ if (!parsed.ok) return error(parsed.error, parsed.status);
494
+ try {
495
+ const guard = normalizeAgentSessionGuard(req, parsed.body);
496
+ const session = validateAgentSession(params.id!, guard);
497
+ if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
498
+ return heartbeat(params.id!, guard) ? json({ ok: true }) : error("agent not found", 404);
499
+ } catch (e) {
500
+ if (e instanceof ValidationError) return error(e.message, 400);
501
+ throw e;
502
+ }
387
503
  };
388
504
 
389
505
  const patchAgentLabel: Handler = async (req, params) => {
@@ -396,12 +512,36 @@ const patchAgentLabel: Handler = async (req, params) => {
396
512
  return json({ ok: true });
397
513
  };
398
514
 
515
+ const patchAgentTags: Handler = async (req, params) => {
516
+ const parsed = await parseBody<unknown>(req);
517
+ if (!parsed.ok) return error(parsed.error, parsed.status);
518
+ try {
519
+ const tags = isRecord(parsed.body) ? cleanStringArray(parsed.body.tags, "tags") : undefined;
520
+ if (!tags) return error("tags field required");
521
+ const agent = setTags(params.id!, tags);
522
+ if (!agent) return error("agent not found", 404);
523
+ emitAgentStatus(params.id!);
524
+ return json(agent);
525
+ } catch (e) {
526
+ if (e instanceof ValidationError) return error(e.message, 400);
527
+ throw e;
528
+ }
529
+ };
530
+
399
531
  const patchAgentReady: Handler = async (req, params) => {
400
532
  const parsed = await parseBody<{ ready: boolean }>(req);
401
533
  if (!parsed.ok) return error(parsed.error, parsed.status);
402
534
  const body = parsed.body;
403
535
  if (body === null || typeof body.ready !== "boolean") return error("ready field required (boolean)");
404
- if (!markReady(params.id!, body.ready)) return error("agent not found", 404);
536
+ try {
537
+ const guard = normalizeAgentSessionGuard(req, body);
538
+ const session = validateAgentSession(params.id!, guard);
539
+ if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
540
+ if (!markReady(params.id!, body.ready, guard)) return error("agent not found", 404);
541
+ } catch (e) {
542
+ if (e instanceof ValidationError) return error(e.message, 400);
543
+ throw e;
544
+ }
405
545
  emitAgentStatus(params.id!);
406
546
  return json({ ok: true });
407
547
  };
@@ -424,9 +564,13 @@ const postMessage: Handler = async (req) => {
424
564
  const parsed = await parseBody<unknown>(req);
425
565
  if (!parsed.ok) return error(parsed.error, parsed.status);
426
566
  try {
427
- const msg = sendMessage(normalizeMessageInput(parsed.body));
428
- emitNewMessage(msg);
429
- return json(msg, 201);
567
+ const input = normalizeMessageInput(parsed.body);
568
+ if (!input.idempotencyKey) {
569
+ input.idempotencyKey = cleanString(req.headers.get("Idempotency-Key") ?? undefined, "idempotencyKey", { max: 240 });
570
+ }
571
+ const result = sendMessageWithResult(input);
572
+ if (result.created) emitNewMessage(result.message);
573
+ return json(result.message, result.created ? 201 : 200);
430
574
  } catch (e) {
431
575
  if (e instanceof ValidationError) return error(e.message, 400);
432
576
  throw e;
@@ -514,9 +658,16 @@ const postClaimMessage: Handler = async (req, params) => {
514
658
  if (!parsed.ok) return error(parsed.error, parsed.status);
515
659
  const body = parsed.body;
516
660
  if (!body?.agentId) return error("agentId required");
517
- const result = claimMessage(id, body.agentId);
661
+ let guard: AgentSessionGuard | undefined;
662
+ try {
663
+ guard = normalizeAgentSessionGuard(req, body);
664
+ } catch (e) {
665
+ if (e instanceof ValidationError) return error(e.message, 400);
666
+ throw e;
667
+ }
668
+ const result = claimMessage(id, body.agentId, guard);
518
669
  if (result.ok) {
519
- emitMessageClaimed(id, body.agentId);
670
+ emitMessageClaimed(id, body.agentId, getMessage(id)?.claimExpiresAt);
520
671
  if (result.task) {
521
672
  emitTaskChanged(result.task, "task.claimed");
522
673
  void dispatchTaskCallbacks(result.task.id, "task.claimed");
@@ -531,6 +682,34 @@ const postClaimMessage: Handler = async (req, params) => {
531
682
  return error(result.error!, status);
532
683
  };
533
684
 
685
+ const postRenewMessageClaim: Handler = async (req, params) => {
686
+ const id = parseId(params.id);
687
+ if (id === null) return error("invalid message id");
688
+ const parsed = await parseBody<{ agentId: string }>(req);
689
+ if (!parsed.ok) return error(parsed.error, parsed.status);
690
+ const agentId = parsed.body?.agentId;
691
+ if (!agentId) return error("agentId required");
692
+ let guard: AgentSessionGuard | undefined;
693
+ try {
694
+ guard = normalizeAgentSessionGuard(req, parsed.body);
695
+ } catch (e) {
696
+ if (e instanceof ValidationError) return error(e.message, 400);
697
+ throw e;
698
+ }
699
+ const result = renewMessageClaim(id, agentId, guard);
700
+ if (!result.ok) {
701
+ const status =
702
+ result.error === "message not found" ? 404 :
703
+ result.error === "claiming agent not found" ? 400 :
704
+ result.error === "message is not claimable" ? 400 :
705
+ 409;
706
+ return error(result.error!, status);
707
+ }
708
+ emitMessageClaimed(id, agentId, getMessage(id)?.claimExpiresAt);
709
+ if (result.task) emitTaskChanged(result.task, "task.updated");
710
+ return json({ ok: true, claimExpiresAt: getMessage(id)?.claimExpiresAt });
711
+ };
712
+
534
713
  const patchMessage: Handler = async (req, params) => {
535
714
  const id = parseId(params.id);
536
715
  if (id === null) return error("invalid message id");
@@ -612,16 +791,52 @@ const postClaimTask: Handler = async (req, params) => {
612
791
  if (!parsed.ok) return error(parsed.error, parsed.status);
613
792
  const agentId = parsed.body?.agentId;
614
793
  if (!agentId) return error("agentId required");
615
- const result = claimTask(id, agentId);
794
+ let guard: AgentSessionGuard | undefined;
795
+ try {
796
+ guard = normalizeAgentSessionGuard(req, parsed.body);
797
+ } catch (e) {
798
+ if (e instanceof ValidationError) return error(e.message, 400);
799
+ throw e;
800
+ }
801
+ const result = claimTask(id, agentId, guard);
616
802
  if (!result.ok) {
617
- const status = result.error === "task not found" ? 404 : result.error?.includes("race") ? 409 : 400;
803
+ const status = result.error === "task not found" ? 404 : result.error?.includes("race") || result.error === "stale agent instance" ? 409 : 400;
618
804
  return error(result.error!, status);
619
805
  }
620
806
  emitTaskChanged(result.task!, "task.claimed");
807
+ if (result.task!.messageId) {
808
+ emitMessageClaimed(result.task!.messageId, agentId, getMessage(result.task!.messageId)?.claimExpiresAt);
809
+ }
621
810
  void dispatchTaskCallbacks(id, "task.claimed");
622
811
  return json(result.task);
623
812
  };
624
813
 
814
+ const postRenewTaskClaim: Handler = async (req, params) => {
815
+ const id = parseId(params.id);
816
+ if (id === null) return error("invalid task id");
817
+ const parsed = await parseBody<{ agentId: string }>(req);
818
+ if (!parsed.ok) return error(parsed.error, parsed.status);
819
+ const agentId = parsed.body?.agentId;
820
+ if (!agentId) return error("agentId required");
821
+ let guard: AgentSessionGuard | undefined;
822
+ try {
823
+ guard = normalizeAgentSessionGuard(req, parsed.body);
824
+ } catch (e) {
825
+ if (e instanceof ValidationError) return error(e.message, 400);
826
+ throw e;
827
+ }
828
+ const result = renewTaskClaim(id, agentId, guard);
829
+ if (!result.ok) {
830
+ const status = result.error === "task not found" ? 404 : 409;
831
+ return error(result.error!, status);
832
+ }
833
+ emitTaskChanged(result.task!, "task.updated");
834
+ if (result.task!.messageId) {
835
+ emitMessageClaimed(result.task!.messageId, agentId, getMessage(result.task!.messageId)?.claimExpiresAt);
836
+ }
837
+ return json(result.task);
838
+ };
839
+
625
840
  const patchTaskStatus: Handler = async (req, params) => {
626
841
  const id = parseId(params.id);
627
842
  if (id === null) return error("invalid task id");
@@ -629,7 +844,7 @@ const patchTaskStatus: Handler = async (req, params) => {
629
844
  if (!parsed.ok) return error(parsed.error, parsed.status);
630
845
  try {
631
846
  const result = updateTaskStatus(id, normalizeTaskStatusInput(parsed.body));
632
- if (!result.ok) return error(result.error!, result.error === "task not found" ? 404 : 400);
847
+ if (!result.ok) return error(result.error!, result.error === "task not found" ? 404 : agentSessionStatus(result.error));
633
848
  emitTaskChanged(result.task!, "task.status");
634
849
  void dispatchTaskCallbacks(id, "task.status");
635
850
  return json({ task: result.task, event: result.event });
@@ -639,6 +854,95 @@ const patchTaskStatus: Handler = async (req, params) => {
639
854
  }
640
855
  };
641
856
 
857
+ // --- Pair sessions ---
858
+
859
+ const postPair: Handler = async (req) => {
860
+ const parsed = await parseBody<unknown>(req);
861
+ if (!parsed.ok) return error(parsed.error, parsed.status);
862
+ try {
863
+ const result = createPair(normalizeCreatePairInput(parsed.body));
864
+ if (!result.ok) return pairError(result);
865
+ emitNewMessage(result.invite);
866
+ return json({ pair: result.pair, invite: result.invite }, 201);
867
+ } catch (e) {
868
+ if (e instanceof ValidationError) return error(e.message, 400);
869
+ throw e;
870
+ }
871
+ };
872
+
873
+ const getPairs: Handler = (req) => {
874
+ const url = new URL(req.url);
875
+ const status = url.searchParams.get("status") ?? undefined;
876
+ if (status && !VALID_PAIR_STATUSES.includes(status as any)) {
877
+ return error(`status must be one of: ${VALID_PAIR_STATUSES.join(", ")}`);
878
+ }
879
+ return json(listPairs({
880
+ agentId: url.searchParams.get("agent") ?? undefined,
881
+ status: status as PairStatus | undefined,
882
+ }));
883
+ };
884
+
885
+ const getPairById: Handler = (_req, params) => {
886
+ const pair = getPair(params.id!);
887
+ return pair ? json(pair) : error("pair not found", 404);
888
+ };
889
+
890
+ const postAcceptPair: Handler = async (req, params) => {
891
+ const parsed = await parseBody<unknown>(req);
892
+ if (!parsed.ok) return error(parsed.error, parsed.status);
893
+ try {
894
+ const result = acceptPair(params.id!, normalizePairActionInput(parsed.body));
895
+ if (!result.ok) return pairError(result);
896
+ for (const notice of result.notices) emitNewMessage(notice);
897
+ return json(result.pair);
898
+ } catch (e) {
899
+ if (e instanceof ValidationError) return error(e.message, 400);
900
+ throw e;
901
+ }
902
+ };
903
+
904
+ const postRejectPair: Handler = async (req, params) => {
905
+ const parsed = await parseBody<unknown>(req);
906
+ if (!parsed.ok) return error(parsed.error, parsed.status);
907
+ try {
908
+ const result = rejectPair(params.id!, normalizePairActionInput(parsed.body));
909
+ if (!result.ok) return pairError(result);
910
+ emitNewMessage(result.notice);
911
+ return json(result.pair);
912
+ } catch (e) {
913
+ if (e instanceof ValidationError) return error(e.message, 400);
914
+ throw e;
915
+ }
916
+ };
917
+
918
+ const postHangupPair: Handler = async (req, params) => {
919
+ const parsed = await parseBody<unknown>(req);
920
+ if (!parsed.ok) return error(parsed.error, parsed.status);
921
+ try {
922
+ const result = endPair(params.id!, normalizePairActionInput(parsed.body));
923
+ if (!result.ok) return pairError(result);
924
+ if (result.notice) emitNewMessage(result.notice);
925
+ return json(result.pair);
926
+ } catch (e) {
927
+ if (e instanceof ValidationError) return error(e.message, 400);
928
+ throw e;
929
+ }
930
+ };
931
+
932
+ const postPairMessage: Handler = async (req, params) => {
933
+ const parsed = await parseBody<unknown>(req);
934
+ if (!parsed.ok) return error(parsed.error, parsed.status);
935
+ try {
936
+ const result = sendPairMessage(params.id!, normalizePairMessageInput(parsed.body));
937
+ if (!result.ok) return pairError(result);
938
+ emitNewMessage(result.message);
939
+ return json({ pair: result.pair, message: result.message }, 201);
940
+ } catch (e) {
941
+ if (e instanceof ValidationError) return error(e.message, 400);
942
+ throw e;
943
+ }
944
+ };
945
+
642
946
  // --- SSE ---
643
947
 
644
948
  const getEvents: Handler = (req) => {
@@ -650,6 +954,7 @@ const getEvents: Handler = (req) => {
650
954
  // --- Stats ---
651
955
 
652
956
  const getStatsRoute: Handler = () => json(getStats());
957
+ const getHealthRoute: Handler = () => json(getHealth());
653
958
 
654
959
  // --- Router ---
655
960
 
@@ -681,6 +986,7 @@ const routes: Route[] = [
681
986
  route("PATCH", "/api/agents/:id/status", patchAgentStatus),
682
987
  route("PATCH", "/api/agents/:id/ready", patchAgentReady),
683
988
  route("PATCH", "/api/agents/:id/label", patchAgentLabel),
989
+ route("PATCH", "/api/agents/:id/tags", patchAgentTags),
684
990
  route("POST", "/api/agents/:id/heartbeat", postHeartbeat),
685
991
  route("DELETE", "/api/agents/:id", deleteAgentById),
686
992
 
@@ -691,6 +997,7 @@ const routes: Route[] = [
691
997
  route("GET", "/api/messages/:id", getMessageById),
692
998
  route("GET", "/api/messages/:id/thread", getMessageThread),
693
999
  route("POST", "/api/messages/:id/claim", postClaimMessage),
1000
+ route("POST", "/api/messages/:id/claim/renew", postRenewMessageClaim),
694
1001
  route("PATCH", "/api/messages/:id", patchMessage),
695
1002
  route("DELETE", "/api/messages/:id", deleteMessageById),
696
1003
 
@@ -699,10 +1006,20 @@ const routes: Route[] = [
699
1006
  route("GET", "/api/tasks/:id", getTaskById),
700
1007
  route("GET", "/api/tasks/:id/events", getTaskEvents),
701
1008
  route("POST", "/api/tasks/:id/claim", postClaimTask),
1009
+ route("POST", "/api/tasks/:id/claim/renew", postRenewTaskClaim),
702
1010
  route("PATCH", "/api/tasks/:id/status", patchTaskStatus),
703
1011
 
1012
+ route("POST", "/api/pairs", postPair),
1013
+ route("GET", "/api/pairs", getPairs),
1014
+ route("GET", "/api/pairs/:id", getPairById),
1015
+ route("POST", "/api/pairs/:id/accept", postAcceptPair),
1016
+ route("POST", "/api/pairs/:id/reject", postRejectPair),
1017
+ route("POST", "/api/pairs/:id/hangup", postHangupPair),
1018
+ route("POST", "/api/pairs/:id/messages", postPairMessage),
1019
+
704
1020
  route("GET", "/api/events", getEvents),
705
1021
  route("GET", "/api/stats", getStatsRoute),
1022
+ route("GET", "/api/health", getHealthRoute),
706
1023
  ];
707
1024
 
708
1025
  export function matchRoute(
package/src/security.ts CHANGED
@@ -45,7 +45,7 @@ export function corsPreflight(req: Request): Response {
45
45
  const response = new Response(null, {
46
46
  headers: {
47
47
  "Access-Control-Allow-Methods": "GET, POST, PATCH, DELETE, OPTIONS",
48
- "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Agent-Relay-Token",
48
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, X-Agent-Relay-Token, Idempotency-Key, X-Agent-Relay-Instance-Id, X-Agent-Relay-Epoch",
49
49
  "Access-Control-Max-Age": "600",
50
50
  },
51
51
  });
@@ -80,6 +80,36 @@ export function isIntegrationAllowed(
80
80
  return true;
81
81
  }
82
82
 
83
+ export function requiredScopeFor(method: string, pathname: string): string | null {
84
+ if (pathname === "/api/stats") return "stats:read";
85
+ if (pathname === "/api/health") return "health:read";
86
+ if (pathname === "/api/events") return "events:read";
87
+ if (pathname.startsWith("/api/integrations/")) return method === "GET" ? "integrations:read" : "integrations:write";
88
+ if (pathname.startsWith("/api/agents")) return method === "GET" ? "agents:read" : "agents:write";
89
+ if (pathname.startsWith("/api/messages")) return method === "GET" ? "messages:read" : "messages:write";
90
+ if (pathname.startsWith("/api/tasks")) return method === "GET" ? "tasks:read" : "tasks:write";
91
+ if (pathname.startsWith("/api/pairs")) return method === "GET" ? "pairs:read" : "pairs:write";
92
+ if (pathname.startsWith("/api/system/")) return "system:write";
93
+ return null;
94
+ }
95
+
96
+ export function isScopedRequestAuthorized(req: Request): boolean {
97
+ const auth = getIntegrationAuth(req);
98
+ if (!auth) return false;
99
+ const pathname = new URL(req.url).pathname;
100
+ if (pathname.startsWith("/api/integrations/") && req.method !== "GET") {
101
+ return hasIntegrationScope(auth, "integrations:write") ||
102
+ hasIntegrationScope(auth, "tasks:create") ||
103
+ hasIntegrationScope(auth, "events:create");
104
+ }
105
+ const scope = requiredScopeFor(req.method, pathname);
106
+ return scope ? hasIntegrationScope(auth, scope) : false;
107
+ }
108
+
109
+ export function forbidden(req: Request): Response {
110
+ return applyCors(req, Response.json({ error: "forbidden" }, { status: 403 }));
111
+ }
112
+
83
113
  export function unauthorized(req: Request): Response {
84
114
  const response = Response.json({ error: "unauthorized" }, { status: 401 });
85
115
  response.headers.set("WWW-Authenticate", "Bearer");
package/src/sse.ts CHANGED
@@ -97,9 +97,15 @@ export function emitAgentRemoved(agentId: string) {
97
97
  }
98
98
  }
99
99
 
100
- export function emitMessageClaimed(messageId: number, claimedBy: string) {
100
+ export function emitMessageClaimed(messageId: number, claimedBy: string, claimExpiresAt?: number) {
101
101
  for (const conn of connections.values()) {
102
- send(conn, "message.claimed", { messageId, claimedBy });
102
+ send(conn, "message.claimed", { messageId, claimedBy, claimExpiresAt });
103
+ }
104
+ }
105
+
106
+ export function emitMessageClaimReleased(messageId: number) {
107
+ for (const conn of connections.values()) {
108
+ send(conn, "message.claim_released", { messageId });
103
109
  }
104
110
  }
105
111