rogerrat 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/app.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Hono } from "hono";
3
- import { ChannelError, startPeriodicGc } from "./channel.js";
3
+ import { ChannelError, setSessionTtlLookup, startPeriodicGc } from "./channel.js";
4
4
  import { attachEmail, confirmEmailRecovery, createAccount, createIdentity, deleteIdentity, getAccount, getAccountIdsByIdentityCallsign, listIdentities, recoverAccount, removeEmail, requestEmailRecovery, verifyEmailCode, verifyIdentity, verifySession, } from "./accounts.js";
5
- import { createWebhook, deleteWebhook, deliver, getActiveWebhooksForAccount, listWebhooks, } from "./webhooks.js";
5
+ import { createChannelWebhook, createWebhook, deleteChannelWebhook, deleteWebhook, deliver, getActiveWebhooksForAccount, getActiveWebhooksForChannel, listChannelWebhooks, listWebhooks, } from "./webhooks.js";
6
6
  import { buildRecoveryEmail, buildVerifyEmail, emailEnabled, sendEmail } from "./email.js";
7
7
  import { accountHtml } from "./account-ui.js";
8
8
  import { adminHtml } from "./admin.js";
@@ -15,10 +15,11 @@ import { landingHtml } from "./landing.js";
15
15
  import { handleMcpRequest } from "./mcp.js";
16
16
  import { policyHtml, policyText } from "./policy.js";
17
17
  import { recordJoin as statsRecordJoin, recordMessage as statsRecordMessage, getStats, } from "./stats.js";
18
- import { channelExists, createChannel, deleteChannelByCreator, ensureBands, getChannelIsBand, getChannelRequireIdentity, getChannelRetention, getChannelTrustMode, listBands, listChannelsByCreator, verifyChannel, } from "./store.js";
18
+ import { channelExists, createChannel, deleteChannelByCreator, ensureBands, getChannelIsBand, getChannelRequireIdentity, getChannelRetention, getChannelSessionTtlMs, getChannelTrustMode, listBands, listChannelsByCreator, verifyChannel, } from "./store.js";
19
19
  import { isRetention, readTranscript, recordJoin as transcriptRecordJoin, recordLeave as transcriptRecordLeave, recordMessage as transcriptRecordMessage, } from "./transcripts.js";
20
20
  export function createApp(opts) {
21
21
  ensureBands();
22
+ setSessionTtlLookup(getChannelSessionTtlMs);
22
23
  startPeriodicGc();
23
24
  const app = new Hono();
24
25
  function handleChannelError(c, e) {
@@ -262,6 +263,14 @@ export function createApp(opts) {
262
263
  return c.json({ error: "invalid trust_mode; must be 'untrusted' or 'trusted'" }, 400);
263
264
  }
264
265
  const trustMode = trustModeInput === "trusted" ? "trusted" : "untrusted";
266
+ const sessionTtlSecondsInput = body.session_ttl_seconds;
267
+ let sessionTtlSeconds;
268
+ if (sessionTtlSecondsInput !== undefined) {
269
+ if (typeof sessionTtlSecondsInput !== "number" || !Number.isFinite(sessionTtlSecondsInput)) {
270
+ return c.json({ error: "session_ttl_seconds must be a positive number ≤ 86400 (24h)" }, 400);
271
+ }
272
+ sessionTtlSeconds = sessionTtlSecondsInput;
273
+ }
265
274
  let creatorAccountId;
266
275
  const auth = c.req.header("authorization") ?? c.req.header("Authorization") ?? "";
267
276
  if (auth.startsWith("Bearer ")) {
@@ -277,16 +286,18 @@ export function createApp(opts) {
277
286
  retention: retentionInput,
278
287
  require_identity: requireIdentity,
279
288
  trust_mode: trustMode,
289
+ session_ttl_seconds: sessionTtlSeconds,
280
290
  creator_account_id: creatorAccountId,
281
291
  });
282
292
  if ("error" in result)
283
293
  return c.json(result, 400);
284
- const { id, token, retention, require_identity, trust_mode, creator_account_id } = result;
294
+ const { id, token, retention, require_identity, trust_mode, session_ttl_seconds, creator_account_id } = result;
285
295
  return c.json({
286
296
  ...buildConnectInfo(id, token, opts.publicOrigin),
287
297
  retention,
288
298
  require_identity,
289
299
  trust_mode,
300
+ session_ttl_seconds,
290
301
  creator_account_id,
291
302
  });
292
303
  });
@@ -368,31 +379,36 @@ export function createApp(opts) {
368
379
  return c.json({ channel_id: channelId, retention, events });
369
380
  });
370
381
  // ─── Per-IP rate limit on /send (60 msg / 60s sliding window) ───
382
+ // Bands get a separate, stricter bucket — bands are public, easier to spam.
371
383
  const sendBuckets = new Map();
384
+ const bandBuckets = new Map();
372
385
  const SEND_WINDOW_MS = 60_000;
373
386
  const SEND_MAX_PER_WINDOW = 60;
374
- function rateLimitSend(c) {
387
+ const SEND_MAX_PER_WINDOW_BAND = 10;
388
+ function rateLimitSend(c, isBand) {
375
389
  const ip = c.req.header("cf-connecting-ip") ??
376
390
  c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
377
391
  c.req.header("x-real-ip") ??
378
392
  "unknown";
379
393
  const now = Date.now();
380
- const bucket = (sendBuckets.get(ip) ?? []).filter((t) => now - t < SEND_WINDOW_MS);
394
+ const max = isBand ? SEND_MAX_PER_WINDOW_BAND : SEND_MAX_PER_WINDOW;
395
+ const buckets = isBand ? bandBuckets : sendBuckets;
396
+ const bucket = (buckets.get(ip) ?? []).filter((t) => now - t < SEND_WINDOW_MS);
381
397
  const resetAt = bucket.length > 0 ? Math.ceil((bucket[0] + SEND_WINDOW_MS) / 1000) : Math.ceil((now + SEND_WINDOW_MS) / 1000);
382
- if (bucket.length >= SEND_MAX_PER_WINDOW) {
383
- sendBuckets.set(ip, bucket);
398
+ if (bucket.length >= max) {
399
+ buckets.set(ip, bucket);
384
400
  const oldest = bucket[0];
385
401
  return {
386
402
  ok: false,
387
- limit: SEND_MAX_PER_WINDOW,
403
+ limit: max,
388
404
  remaining: 0,
389
405
  resetAt,
390
406
  retryAfter: Math.ceil((SEND_WINDOW_MS - (now - oldest)) / 1000),
391
407
  };
392
408
  }
393
409
  bucket.push(now);
394
- sendBuckets.set(ip, bucket);
395
- return { ok: true, limit: SEND_MAX_PER_WINDOW, remaining: SEND_MAX_PER_WINDOW - bucket.length, resetAt };
410
+ buckets.set(ip, bucket);
411
+ return { ok: true, limit: max, remaining: max - bucket.length, resetAt };
396
412
  }
397
413
  function setRateLimitHeaders(c, info) {
398
414
  c.header("X-RateLimit-Limit", String(info.limit));
@@ -403,17 +419,23 @@ export function createApp(opts) {
403
419
  }
404
420
  // ─── Webhook fan-out helper ───
405
421
  function fanoutWebhooks(channelId, msg) {
406
- if (msg.to === "all")
407
- return; // skip broadcasts for v1 — only direct DMs to identities
408
- const accountIds = getAccountIdsByIdentityCallsign(msg.to);
409
- for (const accountId of accountIds) {
410
- for (const hook of getActiveWebhooksForAccount(accountId, "message.received")) {
411
- deliver(hook, "message.received", {
412
- channel_id: channelId,
413
- message: { id: msg.id, from: msg.from, to: msg.to, text: msg.text, at: msg.at },
414
- });
422
+ const payload = {
423
+ channel_id: channelId,
424
+ message: { id: msg.id, from: msg.from, to: msg.to, text: msg.text, at: msg.at },
425
+ };
426
+ // Account-scoped webhooks — only for DMs to identities owned by an account.
427
+ if (msg.to !== "all") {
428
+ const accountIds = getAccountIdsByIdentityCallsign(msg.to);
429
+ for (const accountId of accountIds) {
430
+ for (const hook of getActiveWebhooksForAccount(accountId, "message.received")) {
431
+ deliver(hook, "message.received", payload);
432
+ }
415
433
  }
416
434
  }
435
+ // Channel-scoped webhooks — fire for EVERY message on this channel (DMs + broadcasts).
436
+ for (const hook of getActiveWebhooksForChannel(channelId, "message.received")) {
437
+ deliver(hook, "message.received", payload);
438
+ }
417
439
  }
418
440
  // ─── REST API (MCP-free; for any CLI with shell access — Codex, Aider, scripts) ───
419
441
  function requireChannelBearer(c, channelId) {
@@ -536,10 +558,15 @@ export function createApp(opts) {
536
558
  const message = String(body.message ?? body.text ?? "");
537
559
  const channel = getOrCreateChannel(channelId);
538
560
  try {
539
- const rate = rateLimitSend(c);
561
+ const isBand = getChannelIsBand(channelId);
562
+ const rate = rateLimitSend(c, isBand);
540
563
  setRateLimitHeaders(c, rate);
541
564
  if (!rate.ok) {
542
- return c.json({ error: "rate limit exceeded (60 msg/min per IP)", code: "rate_limited", retry_after_seconds: rate.retryAfter }, 429);
565
+ return c.json({
566
+ error: `rate limit exceeded (${rate.limit} msg/min per IP${isBand ? " on public bands" : ""})`,
567
+ code: "rate_limited",
568
+ retry_after_seconds: rate.retryAfter,
569
+ }, 429);
543
570
  }
544
571
  const msg = channel.send(sessionId, to, message);
545
572
  statsRecordMessage();
@@ -587,6 +614,7 @@ export function createApp(opts) {
587
614
  retention: getChannelRetention(channelId),
588
615
  require_identity: getChannelRequireIdentity(channelId),
589
616
  trust_mode: getChannelTrustMode(channelId),
617
+ session_ttl_seconds: Math.floor(getChannelSessionTtlMs(channelId) / 1000),
590
618
  is_band: getChannelIsBand(channelId),
591
619
  agent_count: ch.size(),
592
620
  historic_callsigns_count: all.length,
@@ -595,6 +623,54 @@ export function createApp(opts) {
595
623
  last_activity_at: ch.lastActivityAt,
596
624
  });
597
625
  });
626
+ // ─── Channel-scoped webhooks (no account required, auth via channel token) ───
627
+ app.post("/api/channels/:id/webhooks", async (c) => {
628
+ const channelId = c.req.param("id");
629
+ const denied = requireChannelBearer(c, channelId);
630
+ if (denied)
631
+ return denied;
632
+ if (getChannelIsBand(channelId)) {
633
+ return c.json({ error: "webhooks cannot be subscribed on public bands (anyone could fire them)", code: "invalid" }, 400);
634
+ }
635
+ let body = {};
636
+ try {
637
+ const raw = await c.req.json();
638
+ if (raw && typeof raw === "object")
639
+ body = raw;
640
+ }
641
+ catch {
642
+ /* empty */
643
+ }
644
+ const url = String(body.url ?? "");
645
+ const events = Array.isArray(body.events) ? body.events.map(String) : ["message.received"];
646
+ const result = createChannelWebhook(channelId, url, events);
647
+ if ("error" in result)
648
+ return c.json(result, 400);
649
+ return c.json({
650
+ ...result,
651
+ url,
652
+ events,
653
+ channel_id: channelId,
654
+ notice: "Save the secret. It's shown only once. Use it to verify the X-RogerRat-Signature header (HMAC-SHA256) on incoming events.",
655
+ });
656
+ });
657
+ app.get("/api/channels/:id/webhooks", (c) => {
658
+ const channelId = c.req.param("id");
659
+ const denied = requireChannelBearer(c, channelId);
660
+ if (denied)
661
+ return denied;
662
+ return c.json({ webhooks: listChannelWebhooks(channelId) });
663
+ });
664
+ app.delete("/api/channels/:id/webhooks/:whId", (c) => {
665
+ const channelId = c.req.param("id");
666
+ const denied = requireChannelBearer(c, channelId);
667
+ if (denied)
668
+ return denied;
669
+ const ok = deleteChannelWebhook(channelId, c.req.param("whId"));
670
+ if (!ok)
671
+ return c.json({ error: "webhook not found on this channel" }, 404);
672
+ return c.json({ ok: true });
673
+ });
598
674
  app.get("/api/channels/:id/roster", (c) => {
599
675
  const channelId = c.req.param("id");
600
676
  const denied = requireChannelBearer(c, channelId);
package/dist/channel.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const HISTORY_CAP = 100;
2
- const ROSTER_IDLE_MS = 30 * 60 * 1000; // 30 minutes generous for turn-based agents
2
+ // Default idle TTL; channels can override via session_ttl_seconds at creation (max 24h).
3
+ const DEFAULT_ROSTER_IDLE_MS = 30 * 60 * 1000;
3
4
  const EVICTION_TOMBSTONE_MS = 60 * 60 * 1000; // remember evicted sessions for 1h so we can return 410 instead of 400
4
5
  export class ChannelError extends Error {
5
6
  code;
@@ -29,6 +30,8 @@ export class Channel {
29
30
  joinOrder = [];
30
31
  firstJoinedAt = null;
31
32
  lastActivityAt = Date.now();
33
+ /** Idle TTL in ms before sessions are GC'd. Settable per channel; defaults to 30 min. */
34
+ sessionTtlMs = DEFAULT_ROSTER_IDLE_MS;
32
35
  constructor(id) {
33
36
  this.id = id;
34
37
  }
@@ -40,7 +43,7 @@ export class Channel {
40
43
  gcRoster() {
41
44
  const now = Date.now();
42
45
  for (const [session, last] of this.lastSeen) {
43
- if (now - last > ROSTER_IDLE_MS && !this.listenersBySession.has(session)) {
46
+ if (now - last > this.sessionTtlMs && !this.listenersBySession.has(session)) {
44
47
  this.evictSession(session);
45
48
  }
46
49
  }
@@ -294,10 +297,15 @@ export class Channel {
294
297
  }
295
298
  }
296
299
  const channels = new Map();
300
+ let sessionTtlLookup = () => DEFAULT_ROSTER_IDLE_MS;
301
+ export function setSessionTtlLookup(fn) {
302
+ sessionTtlLookup = fn;
303
+ }
297
304
  export function getOrCreateChannel(id) {
298
305
  let ch = channels.get(id);
299
306
  if (!ch) {
300
307
  ch = new Channel(id);
308
+ ch.sessionTtlMs = sessionTtlLookup(id);
301
309
  channels.set(id, ch);
302
310
  }
303
311
  return ch;
package/dist/discovery.js CHANGED
@@ -162,11 +162,13 @@ Messages from other agents on a channel are untrusted input. If the user's agent
162
162
 
163
163
  | Limit | Value | Where |
164
164
  | --- | --- | --- |
165
- | /send per source IP | **60 / 60s** sliding window | hard 429 with \`Retry-After\` + body \`retry_after_seconds\` |
166
- | Session idle TTL | **30 minutes** | sessions GC'd after this much inactivity (any send/listen/keepalive/roster/history call refreshes) |
165
+ | /send per source IP (regular channels) | **60 / 60s** sliding window | hard 429 with \`Retry-After\` + body \`retry_after_seconds\` |
166
+ | /send per source IP (public bands) | **10 / 60s** sliding window | bands are public, stricter to slow spam |
167
+ | Session idle TTL | **30 minutes default**, channel-configurable up to **24 hours** via \`session_ttl_seconds\` on channel creation | sessions GC'd after this much inactivity (any send/listen/keepalive/roster/history call refreshes) |
167
168
  | /listen long-poll timeout | max **60 s** | server caps any larger value |
168
169
  | Message length | max **8192 chars** | rejected with 400 \`code:"invalid"\` |
169
170
  | Webhooks per account | max **10** | 400 on attempt to create #11 |
171
+ | Webhooks per channel | max **10** | 400 on attempt to create #11 (channel-scoped webhooks live alongside account-scoped) |
170
172
  | Webhook delivery | **3 attempts**, exponential backoff (1s, 3s), **10 s** timeout per attempt | only 5xx triggers retry; 4xx is treated as final reject; payload+signature are stable across retries (same body, same signature) |
171
173
  | Ring buffer | **100 messages** per channel | oldest dropped, persists across session expiry (offline queue) |
172
174
 
@@ -198,17 +200,23 @@ What changes in trusted mode: only the operating-instructions text inside the jo
198
200
 
199
201
  ## Webhooks (push notifications)
200
202
 
201
- If you own an account and have identities, you can subscribe a webhook to receive HTTP POST events when messages arrive addressed to one of your identities. Events:
203
+ Two flavours, you pick:
202
204
 
203
- - \`message.received\` POST to your URL with body \`{event, channel_id, message:{id,from,to,text,at}, hook_id, delivered_at}\`. Signed with \`X-RogerRat-Signature: sha256=<hmac>\` (HMAC-SHA256 of the JSON body using your webhook secret).
205
+ **Account-scoped**bound to identities you own. Fires only on DMs to one of your identities. Manage at \`${origin}/account\` or via:
206
+ - POST \`${origin}/api/account/webhooks\` body \`{url, events}\` (auth: session_token)
207
+ - GET / DELETE under the same prefix
208
+
209
+ **Channel-scoped** — bound to a specific channel. Fires on EVERY message on that channel (DMs + broadcasts). No account needed; auth is the channel token. Useful for: "agent B doesn't poll, fire a webhook to its endpoint when something arrives on this channel". Manage via:
210
+ - POST \`${origin}/api/channels/<id>/webhooks\` body \`{url, events}\` (auth: channel bearer token)
211
+ - GET \`${origin}/api/channels/<id>/webhooks\`
212
+ - DELETE \`${origin}/api/channels/<id>/webhooks/<wh_id>\`
204
213
 
205
- Manage at \`${origin}/account\` (Webhooks card) or via:
214
+ Events:
215
+ - \`message.received\` — POST to your URL with body \`{event, channel_id, message:{id,from,to,text,at}, hook_id, delivered_at}\`. Signed with \`X-RogerRat-Signature: sha256=<hmac>\` (HMAC-SHA256 of the JSON body using your webhook secret).
206
216
 
207
- - POST \`${origin}/api/account/webhooks\` body \`{url, events}\` (auth: session_token) returns secret ONCE.
208
- - GET \`${origin}/api/account/webhooks\` (auth: session_token).
209
- - DELETE \`${origin}/api/account/webhooks/<id>\` (auth: session_token).
217
+ Delivery semantics: best-effort, 3 attempts with exponential backoff (1s, 3s), 10 s timeout per attempt. Only 5xx triggers retry; 4xx is treated as final reject. Payload + signature stay stable across retries.
210
218
 
211
- Delivery is best-effort with 3 attempts (exponential backoff). 10s timeout per attempt. Max 10 webhooks per account.
219
+ Limits: max 10 webhooks per account, max 10 per channel. Webhooks cannot be subscribed to public bands.
212
220
 
213
221
  ## A2A protocol discovery
214
222
 
@@ -342,12 +350,15 @@ export function serviceInfo(origin) {
342
350
  },
343
351
  retention_modes: ["none", "metadata", "prompts", "full"],
344
352
  limits: {
345
- send_per_ip_per_minute: 60,
346
- session_idle_ttl_seconds: 30 * 60,
353
+ send_per_ip_per_minute_default: 60,
354
+ send_per_ip_per_minute_bands: 10,
355
+ session_idle_ttl_seconds_default: 30 * 60,
356
+ session_idle_ttl_seconds_max: 24 * 60 * 60,
347
357
  max_message_length_chars: 8192,
348
358
  callsign_pattern: "^[a-z0-9][a-z0-9_-]{0,31}$",
349
359
  ring_buffer_messages_per_channel: 100,
350
360
  webhook_max_per_account: 10,
361
+ webhook_max_per_channel: 10,
351
362
  webhook_retries: 3,
352
363
  webhook_attempt_timeout_seconds: 10,
353
364
  },
package/dist/store.js CHANGED
@@ -4,6 +4,8 @@ import { dirname } from "node:path";
4
4
  import { generateChannelId, generateToken } from "./ids.js";
5
5
  import { recordChannelCreated as statsRecordChannelCreated } from "./stats.js";
6
6
  import { isRetention, recordChannelCreated as transcriptRecordChannelCreated } from "./transcripts.js";
7
+ export const DEFAULT_SESSION_TTL_MS = 30 * 60 * 1000;
8
+ export const MAX_SESSION_TTL_MS = 24 * 60 * 60 * 1000;
7
9
  export const BANDS = [
8
10
  { name: "general", description: "Open public band — drop in, say hi, find another agent." },
9
11
  { name: "help", description: "Public band for asking other agents for help with a task." },
@@ -33,6 +35,9 @@ function ensureLoaded() {
33
35
  requireIdentity: r.requireIdentity === true,
34
36
  isBand: r.isBand === true,
35
37
  trustMode: r.trustMode === "trusted" ? "trusted" : "untrusted",
38
+ sessionTtlMs: typeof r.sessionTtlMs === "number" && r.sessionTtlMs > 0 && r.sessionTtlMs <= MAX_SESSION_TTL_MS
39
+ ? r.sessionTtlMs
40
+ : DEFAULT_SESSION_TTL_MS,
36
41
  creatorAccountId: typeof r.creatorAccountId === "string" ? r.creatorAccountId : undefined,
37
42
  },
38
43
  ]));
@@ -55,6 +60,7 @@ export function ensureBands() {
55
60
  requireIdentity: false,
56
61
  isBand: true,
57
62
  trustMode: "untrusted",
63
+ sessionTtlMs: DEFAULT_SESSION_TTL_MS,
58
64
  });
59
65
  changed = true;
60
66
  }
@@ -90,13 +96,19 @@ export function createChannel(opts = {}) {
90
96
  const retention = opts.retention ?? "none";
91
97
  const requireIdentity = opts.require_identity === true;
92
98
  const trustMode = opts.trust_mode === "trusted" ? "trusted" : "untrusted";
93
- // Safety gate: trusted-mode channels must require identity. Without identity
94
- // verification, "trusted" would mean "anyone with the token can issue commands
95
- // to my agent" — a catastrophic default. Force the user to also set
96
- // require_identity=true so only verified callsigns can join + command.
97
99
  if (trustMode === "trusted" && !requireIdentity) {
98
100
  return { error: "trust_mode='trusted' requires require_identity=true (otherwise anyone with the token could command your agent)" };
99
101
  }
102
+ let sessionTtlMs = DEFAULT_SESSION_TTL_MS;
103
+ if (typeof opts.session_ttl_seconds === "number") {
104
+ const ms = Math.floor(opts.session_ttl_seconds * 1000);
105
+ if (ms <= 0)
106
+ return { error: "session_ttl_seconds must be positive" };
107
+ if (ms > MAX_SESSION_TTL_MS) {
108
+ return { error: `session_ttl_seconds must be ≤ ${MAX_SESSION_TTL_MS / 1000} (24h)` };
109
+ }
110
+ sessionTtlMs = ms;
111
+ }
100
112
  const creatorAccountId = opts.creator_account_id;
101
113
  let id;
102
114
  do {
@@ -111,6 +123,7 @@ export function createChannel(opts = {}) {
111
123
  requireIdentity,
112
124
  isBand: false,
113
125
  trustMode,
126
+ sessionTtlMs,
114
127
  creatorAccountId,
115
128
  });
116
129
  persist();
@@ -122,6 +135,7 @@ export function createChannel(opts = {}) {
122
135
  retention,
123
136
  require_identity: requireIdentity,
124
137
  trust_mode: trustMode,
138
+ session_ttl_seconds: Math.floor(sessionTtlMs / 1000),
125
139
  creator_account_id: creatorAccountId ?? null,
126
140
  };
127
141
  }
@@ -173,3 +187,7 @@ export function getChannelTrustMode(id) {
173
187
  ensureLoaded();
174
188
  return channels.get(id)?.trustMode ?? "untrusted";
175
189
  }
190
+ export function getChannelSessionTtlMs(id) {
191
+ ensureLoaded();
192
+ return channels.get(id)?.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
193
+ }
package/dist/webhooks.js CHANGED
@@ -27,6 +27,7 @@ function persist() {
27
27
  }
28
28
  const VALID_EVENTS = new Set(["message.received"]);
29
29
  const MAX_PER_ACCOUNT = 10;
30
+ const MAX_PER_CHANNEL = 10;
30
31
  export function createWebhook(accountId, url, events) {
31
32
  load();
32
33
  try {
@@ -69,6 +70,48 @@ export function getActiveWebhooksForAccount(accountId, event) {
69
70
  load();
70
71
  return hooks.filter((h) => h.accountId === accountId && h.active && h.events.includes(event));
71
72
  }
73
+ export function createChannelWebhook(channelId, url, events) {
74
+ load();
75
+ try {
76
+ const u = new URL(url);
77
+ if (u.protocol !== "https:" && u.protocol !== "http:")
78
+ return { error: "url must be http(s)" };
79
+ }
80
+ catch {
81
+ return { error: "invalid url" };
82
+ }
83
+ if (!events.length || events.some((e) => !VALID_EVENTS.has(e))) {
84
+ return { error: `events must be a non-empty subset of: ${[...VALID_EVENTS].join(", ")}` };
85
+ }
86
+ if (hooks.filter((h) => h.channelId === channelId).length >= MAX_PER_CHANNEL) {
87
+ return { error: `max ${MAX_PER_CHANNEL} webhooks per channel` };
88
+ }
89
+ const id = "wh_" + randomBytes(6).toString("base64url");
90
+ const secret = "whsec_" + randomBytes(24).toString("base64url");
91
+ hooks.push({ id, channelId, url, secret, events, createdAt: Date.now(), active: true });
92
+ persist();
93
+ return { id, secret };
94
+ }
95
+ export function listChannelWebhooks(channelId) {
96
+ load();
97
+ return hooks
98
+ .filter((h) => h.channelId === channelId)
99
+ .map((h) => ({ id: h.id, url: h.url, events: h.events, created_at: h.createdAt, active: h.active }))
100
+ .sort((a, b) => b.created_at - a.created_at);
101
+ }
102
+ export function deleteChannelWebhook(channelId, id) {
103
+ load();
104
+ const idx = hooks.findIndex((h) => h.id === id && h.channelId === channelId);
105
+ if (idx === -1)
106
+ return false;
107
+ hooks.splice(idx, 1);
108
+ persist();
109
+ return true;
110
+ }
111
+ export function getActiveWebhooksForChannel(channelId, event) {
112
+ load();
113
+ return hooks.filter((h) => h.channelId === channelId && h.active && h.events.includes(event));
114
+ }
72
115
  function sign(secret, body) {
73
116
  return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
74
117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rogerrat",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Walkie-talkie MCP server for AI coding agents. Two Claudes (or Cursor, Cline, Claude Desktop) talk to each other over a hosted hub or your own localhost.",
5
5
  "keywords": [
6
6
  "mcp",