rogerrat 1.2.0 → 1.3.1
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 +98 -22
- package/dist/channel.js +10 -2
- package/dist/discovery.js +22 -11
- package/dist/landing.js +14 -2
- package/dist/store.js +22 -4
- package/dist/webhooks.js +43 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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 >=
|
|
383
|
-
|
|
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:
|
|
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
|
-
|
|
395
|
-
return { ok: true, limit:
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
|
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({
|
|
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
|
-
|
|
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 >
|
|
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
|
-
|
|
|
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
|
-
|
|
203
|
+
Two flavours, you pick:
|
|
202
204
|
|
|
203
|
-
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
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/landing.js
CHANGED
|
@@ -123,7 +123,7 @@ export function landingHtml() {
|
|
|
123
123
|
.panel { display: none; padding-top: 12px; }
|
|
124
124
|
.panel[aria-current="true"] { display: block; }
|
|
125
125
|
.panel p { color: var(--dim); font-size: 13px; margin: 0 0 10px; }
|
|
126
|
-
pre
|
|
126
|
+
pre {
|
|
127
127
|
font-family: inherit;
|
|
128
128
|
background: var(--bg);
|
|
129
129
|
border: 1px solid var(--line);
|
|
@@ -131,8 +131,20 @@ export function landingHtml() {
|
|
|
131
131
|
overflow-x: auto;
|
|
132
132
|
font-size: 13px;
|
|
133
133
|
user-select: all;
|
|
134
|
+
margin: 0;
|
|
135
|
+
white-space: pre-wrap;
|
|
136
|
+
word-break: break-all;
|
|
137
|
+
line-height: 1.45;
|
|
138
|
+
}
|
|
139
|
+
code {
|
|
140
|
+
font-family: inherit;
|
|
141
|
+
background: var(--bg);
|
|
142
|
+
border: 1px solid var(--line);
|
|
143
|
+
padding: 1px 6px;
|
|
144
|
+
font-size: 12px;
|
|
145
|
+
user-select: all;
|
|
134
146
|
}
|
|
135
|
-
pre {
|
|
147
|
+
pre code { background: none; border: none; padding: 0; font-size: inherit; }
|
|
136
148
|
.copy { font-size: 11px; color: var(--dim); margin-top: 6px; }
|
|
137
149
|
h2 { font-size: 22px; letter-spacing: -0.02em; margin: 56px 0 16px; }
|
|
138
150
|
ol { padding-left: 20px; }
|
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