rogerthat 1.24.6 → 1.25.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
@@ -5,24 +5,18 @@ import { fileURLToPath } from "node:url";
5
5
  import { Hono } from "hono";
6
6
  import { streamSSE } from "hono/streaming";
7
7
  import { ChannelError, isPriority, setSessionTtlLookup, startPeriodicGc, validateSuggestedReplies, validateAttachments, } from "./channel.js";
8
- import { attachEmail, confirmEmailRecovery, createAccount, createIdentity, deleteIdentity, getAccount, getAccountIdsByIdentityCallsign, listIdentities, recoverAccount, removeEmail, requestEmailRecovery, verifyEmailCode, verifyIdentity, verifySession, } from "./accounts.js";
9
- import { createChannelWebhook, createWebhook, deleteChannelWebhook, deleteWebhook, deliver, getActiveWebhooksForAccount, getActiveWebhooksForChannel, listChannelWebhooks, listWebhooks, } from "./webhooks.js";
10
- import { buildRecoveryEmail, buildVerifyEmail, emailEnabled, sendEmail } from "./email.js";
11
- import { accountHtml } from "./account-ui.js";
12
8
  import { adminHtml } from "./admin.js";
13
9
  import { getOrCreateChannel, listActiveChannels } from "./channel.js";
14
10
  // startPeriodicGc imported above with ChannelError
15
11
  import { buildConnectInfo } from "./connect.js";
16
12
  import { agentCard } from "./agentcard.js";
17
13
  import { llmsText, mcpDescriptor, serviceInfo } from "./discovery.js";
18
- import { landingHtml, phoneLandingHtml } from "./landing.js";
14
+ import { landingHtml } from "./landing.js";
19
15
  import { handleMcpRequest } from "./mcp.js";
20
- import { remoteHtml } from "./remote-ui.js";
21
- import { createRemoteControl, retrofitRemoteLink } from "./remote-control.js";
22
16
  import { policyHtml, policyText } from "./policy.js";
23
17
  import { applyPresetDefaults, getPreset, resolveMode, } from "./presets.js";
24
18
  import { recordJoin as statsRecordJoin, recordMessage as statsRecordMessage, getStats, } from "./stats.js";
25
- import { channelExists, createChannel, deleteChannelByCreator, setSessionTtlByCreator, ensureBands, getChannelIsBand, getChannelRequireIdentity, getChannelRetention, getChannelSessionTtlMs, getChannelTrustMode, hasOwnerPassword, listBands, listChannelsByCreator, verifyChannel, verifyOwnerPassword, } from "./store.js";
19
+ import { channelExists, createChannel, ensureBands, getChannelIsBand, getChannelRetention, getChannelSessionTtlMs, getChannelTrustMode, hasOwnerPassword, listBands, verifyChannel, verifyOwnerPassword, } from "./store.js";
26
20
  import { isRetention, readTranscript, recordJoin as transcriptRecordJoin, recordLeave as transcriptRecordLeave, recordMessage as transcriptRecordMessage, } from "./transcripts.js";
27
21
  export function createApp(opts) {
28
22
  ensureBands();
@@ -46,13 +40,7 @@ export function createApp(opts) {
46
40
  c.header("Referrer-Policy", "strict-origin-when-cross-origin");
47
41
  c.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
48
42
  c.header("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=()");
49
- c.header("Content-Security-Policy",
50
- // img-src https: needed so /remote can render inline base64 attachments
51
- // (data:) AND auto-preview image URLs that users paste from external
52
- // hosts (imgur, Drive shareable links, etc.). Tradeoff: a tracking-pixel
53
- // URL pasted in chat would load. We accept that for the human-in-the-loop
54
- // UX win — channels are private and the operator picks who they trust.
55
- "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data: https: https://prowl.world; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'");
43
+ c.header("Content-Security-Policy", "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; img-src 'self' data: https: https://prowl.world; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'");
56
44
  });
57
45
  function handleChannelError(c, e) {
58
46
  if (e instanceof ChannelError) {
@@ -72,9 +60,6 @@ export function createApp(opts) {
72
60
  if (accept.includes("application/json") && !accept.includes("text/html")) {
73
61
  return c.json(serviceInfo(opts.publicOrigin));
74
62
  }
75
- const mode = c.get("mode") ?? "default";
76
- if (mode === "phone")
77
- return c.html(phoneLandingHtml());
78
63
  return c.html(landingHtml());
79
64
  });
80
65
  app.get("/healthz", (c) => c.text("ok"));
@@ -115,213 +100,8 @@ export function createApp(opts) {
115
100
  // Fix the broken /docs/* links from the nav — redirect to llms.txt (the canonical agent doc).
116
101
  app.get("/docs/quickstart", (c) => c.redirect("/llms.txt", 302));
117
102
  app.get("/docs/*", (c) => c.redirect("/llms.txt", 302));
118
- app.get("/account", (c) => c.html(accountHtml()));
119
103
  app.get("/policy", (c) => c.html(policyHtml(opts.publicOrigin)));
120
- // Mobile-first remote-control chat. Drives an agent that's already joined
121
- // the same channel and looping on `wait`. Credentials are passed via the
122
- // URL fragment (#t=…&k=…&cs=…) — fragment never reaches the server, so the
123
- // bearer/identity_key don't end up in nginx logs or referrers.
124
- app.get("/remote/:channelId", (c) => {
125
- const id = c.req.param("channelId");
126
- if (!channelExists(id)) {
127
- return c.html(`<!doctype html><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>` +
128
- `<title>not found</title><body style="font-family:ui-monospace,Menlo,monospace;background:#f4ede0;padding:24px;color:#1a1a1a">` +
129
- `<h1 style="font-size:18px">Channel not found</h1>` +
130
- `<p style="color:#7a6f5f;font-size:14px">No channel <code>${id.replace(/[<>&]/g, "")}</code> on this server. The pair link may be stale or wrong.</p>` +
131
- `<p><a href="/account" style="color:#d6541f">→ go to /account</a></p></body>`, 404);
132
- }
133
- return c.html(remoteHtml(id));
134
- });
135
104
  app.get("/policy.txt", (c) => c.text(policyText(opts.publicOrigin)));
136
- // ─── Accounts (passwordless, recovery-token based) ───
137
- function requireSession(c) {
138
- const auth = c.req.header("authorization") ?? c.req.header("Authorization") ?? "";
139
- const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
140
- if (!token)
141
- return c.json({ error: "Authorization: Bearer <session_token> required" }, 401);
142
- const accountId = verifySession(token);
143
- if (!accountId)
144
- return c.json({ error: "invalid or expired session" }, 401);
145
- return { accountId };
146
- }
147
- app.post("/api/account", (c) => {
148
- const result = createAccount();
149
- return c.json({
150
- ...result,
151
- notice: "Save recovery_token in a password manager. It is shown only once and is the only way to recover this account. session_token is short-lived; re-issue via /api/account/recover.",
152
- });
153
- });
154
- app.post("/api/account/recover", async (c) => {
155
- let body = {};
156
- try {
157
- const raw = await c.req.json();
158
- if (raw && typeof raw === "object")
159
- body = raw;
160
- }
161
- catch {
162
- /* empty body */
163
- }
164
- const recoveryToken = String(body.recovery_token ?? "");
165
- if (!recoveryToken)
166
- return c.json({ error: "recovery_token required in body" }, 400);
167
- const result = recoverAccount(recoveryToken);
168
- if (!result)
169
- return c.json({ error: "invalid recovery_token" }, 401);
170
- return c.json(result);
171
- });
172
- app.get("/api/account", (c) => {
173
- const r = requireSession(c);
174
- if (r instanceof Response)
175
- return r;
176
- const account = getAccount(r.accountId);
177
- if (!account)
178
- return c.json({ error: "account not found" }, 404);
179
- return c.json({ ...account, identities: listIdentities(r.accountId) });
180
- });
181
- app.post("/api/account/identities", async (c) => {
182
- const r = requireSession(c);
183
- if (r instanceof Response)
184
- return r;
185
- let body = {};
186
- try {
187
- const raw = await c.req.json();
188
- if (raw && typeof raw === "object")
189
- body = raw;
190
- }
191
- catch {
192
- /* empty */
193
- }
194
- const callsign = String(body.callsign ?? "");
195
- if (!callsign)
196
- return c.json({ error: "callsign required in body" }, 400);
197
- const result = createIdentity(r.accountId, callsign);
198
- if ("error" in result)
199
- return c.json(result, 400);
200
- return c.json({
201
- ...result,
202
- notice: "Save identity_key. It is shown only once. Use it as Bearer auth when joining channels that require an identity.",
203
- });
204
- });
205
- app.get("/api/account/identities", (c) => {
206
- const r = requireSession(c);
207
- if (r instanceof Response)
208
- return r;
209
- return c.json({ identities: listIdentities(r.accountId) });
210
- });
211
- app.delete("/api/account/identities/:callsign", (c) => {
212
- const r = requireSession(c);
213
- if (r instanceof Response)
214
- return r;
215
- const cs = c.req.param("callsign");
216
- const ok = deleteIdentity(r.accountId, cs);
217
- if (!ok)
218
- return c.json({ error: "identity not found" }, 404);
219
- return c.json({ ok: true });
220
- });
221
- // ─── Email recovery (optional channel for "I lost my recovery_token") ───
222
- const recoveryHits = new Map();
223
- const RECOVERY_WINDOW_MS = 10 * 60 * 1000;
224
- const RECOVERY_MAX_PER_WINDOW = 3;
225
- function rateLimitRecover(c) {
226
- const ip = c.req.header("cf-connecting-ip") ??
227
- c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
228
- c.req.header("x-real-ip") ??
229
- "unknown";
230
- const now = Date.now();
231
- const bucket = recoveryHits.get(ip);
232
- if (!bucket || now - bucket.windowStart > RECOVERY_WINDOW_MS) {
233
- recoveryHits.set(ip, { count: 1, windowStart: now });
234
- return true;
235
- }
236
- if (bucket.count >= RECOVERY_MAX_PER_WINDOW)
237
- return false;
238
- bucket.count++;
239
- return true;
240
- }
241
- app.post("/api/account/email", async (c) => {
242
- const r = requireSession(c);
243
- if (r instanceof Response)
244
- return r;
245
- if (!emailEnabled())
246
- return c.json({ error: "email is not configured on this instance" }, 503);
247
- let body = {};
248
- try {
249
- const raw = await c.req.json();
250
- if (raw && typeof raw === "object")
251
- body = raw;
252
- }
253
- catch {
254
- /* empty */
255
- }
256
- const email = String(body.email ?? "");
257
- if (!email)
258
- return c.json({ error: "email required" }, 400);
259
- const result = attachEmail(r.accountId, email);
260
- if ("error" in result)
261
- return c.json(result, 400);
262
- try {
263
- const msg = buildVerifyEmail(opts.publicOrigin, result.code, r.accountId);
264
- await sendEmail(result.email, msg.subject, msg.text, msg.html);
265
- }
266
- catch (e) {
267
- return c.json({ error: "failed to send verification email: " + e.message }, 502);
268
- }
269
- return c.json({ ok: true, email: result.email, verification_sent: true });
270
- });
271
- app.delete("/api/account/email", (c) => {
272
- const r = requireSession(c);
273
- if (r instanceof Response)
274
- return r;
275
- removeEmail(r.accountId);
276
- return c.json({ ok: true });
277
- });
278
- app.get("/api/account/email-verify", (c) => {
279
- const code = c.req.query("code") ?? "";
280
- if (!code)
281
- return c.html(verifyResultPage("Missing code parameter.", false));
282
- const result = verifyEmailCode(code);
283
- if ("error" in result)
284
- return c.html(verifyResultPage(result.error, false));
285
- return c.html(verifyResultPage(`Verified ${result.email} for account ${result.accountId}.`, true));
286
- });
287
- app.post("/api/account/email-recover", async (c) => {
288
- if (!emailEnabled())
289
- return c.json({ error: "email is not configured on this instance" }, 503);
290
- if (!rateLimitRecover(c))
291
- return c.json({ error: "too many requests, try again later" }, 429);
292
- let body = {};
293
- try {
294
- const raw = await c.req.json();
295
- if (raw && typeof raw === "object")
296
- body = raw;
297
- }
298
- catch {
299
- /* empty */
300
- }
301
- const email = String(body.email ?? "");
302
- if (email) {
303
- const result = requestEmailRecovery(email);
304
- if (result) {
305
- try {
306
- const msg = buildRecoveryEmail(opts.publicOrigin, result.code);
307
- await sendEmail(email, msg.subject, msg.text, msg.html);
308
- }
309
- catch (e) {
310
- console.error("[recover] failed to send email:", e);
311
- }
312
- }
313
- }
314
- return c.json({ ok: true, message: "If this email is registered and verified, a recovery link was sent." });
315
- });
316
- app.get("/api/account/email-recover-confirm", (c) => {
317
- const code = c.req.query("code") ?? "";
318
- if (!code)
319
- return c.html(verifyResultPage("Missing code parameter.", false));
320
- const result = confirmEmailRecovery(code);
321
- if (!result)
322
- return c.html(verifyResultPage("Invalid or expired recovery link.", false));
323
- return c.html(recoveryAutoLoginPage(result.session_token));
324
- });
325
105
  app.post("/api/channels", async (c) => {
326
106
  let body = {};
327
107
  try {
@@ -336,7 +116,6 @@ export function createApp(opts) {
336
116
  if (retentionInput !== undefined && !isRetention(retentionInput)) {
337
117
  return c.json({ error: "invalid retention; must be one of none|metadata|prompts|full" }, 400);
338
118
  }
339
- const requireIdentityInput = body.require_identity;
340
119
  const trustModeInput = body.trust_mode;
341
120
  if (trustModeInput !== undefined && trustModeInput !== "untrusted" && trustModeInput !== "trusted") {
342
121
  return c.json({ error: "invalid trust_mode; must be 'untrusted' or 'trusted'" }, 400);
@@ -363,227 +142,36 @@ export function createApp(opts) {
363
142
  const mode = c.get("mode") ?? "default";
364
143
  const presetMerged = applyPresetDefaults(mode, {
365
144
  retention: retentionInput,
366
- require_identity: requireIdentityInput === true ? true : requireIdentityInput === false ? false : undefined,
367
145
  trust_mode: trustModeInput,
368
146
  session_ttl_seconds: sessionTtlSecondsInput,
369
147
  });
370
148
  const retention = presetMerged.retention;
371
- const requireIdentity = presetMerged.require_identity;
372
149
  const trustMode = presetMerged.trust_mode;
373
150
  const sessionTtlSeconds = presetMerged.session_ttl_seconds;
374
151
  // Auto-mint owner_password for presets that opt in (e.g. `go.`): gives
375
- // "trusted-authorized" trust posture without an identity dance.
152
+ // "trusted-authorized" trust posture without extra setup.
376
153
  const preset = getPreset(mode);
377
154
  if (!ownerPassword && preset?.autoMintOwnerPassword) {
378
155
  ownerPassword = randomUUID().replace(/-/g, "").slice(0, 16);
379
156
  }
380
- let creatorAccountId;
381
- const auth = c.req.header("authorization") ?? c.req.header("Authorization") ?? "";
382
- if (auth.startsWith("Bearer ")) {
383
- const sessionTok = auth.slice(7).trim();
384
- if (sessionTok) {
385
- const acc = verifySession(sessionTok);
386
- if (!acc)
387
- return c.json({ error: "invalid session token (omit Authorization for anonymous channel)" }, 401);
388
- creatorAccountId = acc;
389
- }
390
- }
391
157
  const result = createChannel({
392
158
  retention,
393
- require_identity: requireIdentity,
394
159
  trust_mode: trustMode,
395
160
  session_ttl_seconds: sessionTtlSeconds,
396
- creator_account_id: creatorAccountId,
397
161
  owner_password: ownerPassword,
398
162
  });
399
163
  if ("error" in result)
400
164
  return c.json(result, 400);
401
- const { id, token, retention: createdRetention, require_identity: createdRequireIdentity, trust_mode: createdTrustMode, session_ttl_seconds: createdTtl, creator_account_id, has_owner_password, } = result;
165
+ const { id, token, retention: createdRetention, trust_mode: createdTrustMode, session_ttl_seconds: createdTtl, has_owner_password, } = result;
402
166
  return c.json({
403
167
  ...buildConnectInfo(id, token, opts.publicOrigin, { ownerPassword, trustMode, mode }),
404
168
  retention: createdRetention,
405
- require_identity: createdRequireIdentity,
406
169
  trust_mode: createdTrustMode,
407
170
  session_ttl_seconds: createdTtl,
408
- creator_account_id,
409
171
  has_owner_password,
410
172
  owner_password: ownerPassword ?? null,
411
173
  });
412
174
  });
413
- app.get("/api/account/channels", (c) => {
414
- const r = requireSession(c);
415
- if (r instanceof Response)
416
- return r;
417
- const channelList = listChannelsByCreator(r.accountId).map((ch) => ({
418
- ...ch,
419
- agent_count: getOrCreateChannel(ch.id).size(),
420
- }));
421
- return c.json({ channels: channelList });
422
- });
423
- // One-shot bootstrap for the "drive the agent from my phone" flow. Creates
424
- // a private trusted channel + two identities (one for the agent on this
425
- // machine, one for the phone) and returns a mobile_url with creds in the
426
- // URL fragment. If the caller passes a session_token, the channel is bound
427
- // to that account; otherwise a fresh anonymous account is minted (the
428
- // recovery_token comes back so the caller can keep it if they want).
429
- app.post("/api/remote-control", async (c) => {
430
- let body = {};
431
- try {
432
- const raw = c.req.header("content-type")?.startsWith("application/json") ? await c.req.json() : {};
433
- if (raw && typeof raw === "object")
434
- body = raw;
435
- }
436
- catch {
437
- /* body optional */
438
- }
439
- const sessionToken = typeof body.session_token === "string" ? body.session_token : undefined;
440
- const result = await createRemoteControl({ publicOrigin: opts.publicOrigin, sessionToken });
441
- if ("error" in result) {
442
- const status = result.code === "unauthorized" ? 401 : 500;
443
- return c.json({ error: result.error }, status);
444
- }
445
- return c.json({
446
- ...result,
447
- notice: "Two-step phone flow: (1) open mobile_url on the phone; (2) type owner_password on the /remote setup screen to mark the phone session as human-authorized. The password is NOT embedded in mobile_url on purpose — relay it through a separate channel so a leaked URL alone can't impersonate the human. On the agent side (this machine), join with agent.identity_key + owner_password and then loop on `wait`.",
448
- });
449
- });
450
- // Retrofit a phone-control link onto an EXISTING channel. Use when the
451
- // operator originally created a plain channel and the human shows up later
452
- // wanting to drive from a phone — instead of forcing a new channel +
453
- // migrating all the agents, this mints a phone identity + (if not already
454
- // set) an owner_password, and returns a mobile_url/QR for the same channel.
455
- app.post("/api/channels/:id/remote-link", async (c) => {
456
- const channelId = c.req.param("id") ?? "";
457
- if (!channelId)
458
- return c.json({ error: "channel_id required" }, 400);
459
- let body = {};
460
- try {
461
- const raw = c.req.header("content-type")?.startsWith("application/json")
462
- ? await c.req.json()
463
- : {};
464
- if (raw && typeof raw === "object")
465
- body = raw;
466
- }
467
- catch {
468
- /* body optional */
469
- }
470
- const channelToken = typeof body.channel_token === "string" ? body.channel_token : "";
471
- const sessionToken = typeof body.session_token === "string" ? body.session_token : "";
472
- if (!channelToken)
473
- return c.json({ error: "channel_token required" }, 400);
474
- if (!sessionToken)
475
- return c.json({ error: "session_token required (account must own a phone identity)" }, 400);
476
- const result = await retrofitRemoteLink({
477
- publicOrigin: opts.publicOrigin,
478
- channelId,
479
- channelToken,
480
- sessionToken,
481
- });
482
- if ("error" in result) {
483
- const status = result.code === "unauthorized" ? 401 :
484
- result.code === "not_found" ? 404 :
485
- result.code === "bad_token" ? 403 :
486
- 500;
487
- return c.json({ error: result.error }, status);
488
- }
489
- return c.json({
490
- ...result,
491
- notice: result.owner_password_existing
492
- ? "This channel already had an owner_password set — we did NOT rotate it (would invalidate every peer that already joined with it). Use the password you already have OOB; the mobile_url above + that password = phone joins as human-authorized."
493
- : "Two-step phone flow: (1) open mobile_url on the phone; (2) type owner_password on the /remote setup screen. The password is NOT embedded in mobile_url on purpose — relay it through a separate channel so a leaked URL alone can't impersonate the human.",
494
- });
495
- });
496
- app.delete("/api/account/channels/:id", (c) => {
497
- const r = requireSession(c);
498
- if (r instanceof Response)
499
- return r;
500
- const channelId = c.req.param("id");
501
- const ok = deleteChannelByCreator(r.accountId, channelId);
502
- if (!ok)
503
- return c.json({ error: "channel not found or not yours" }, 404);
504
- return c.json({ ok: true });
505
- });
506
- // Mutate the idle session TTL on an existing channel. Owner-only — same gate
507
- // as DELETE. Use case: an agent started a 30-min channel for a quick task,
508
- // the conversation turned into a 4-hour debugging session, and the operator
509
- // wants to push TTL out to 24h instead of dealing with re-joins. Companion
510
- // to /api/channels/:id/remote-link — same "retrofit instead of recreate"
511
- // pattern.
512
- app.patch("/api/account/channels/:id/session-ttl", async (c) => {
513
- const r = requireSession(c);
514
- if (r instanceof Response)
515
- return r;
516
- const channelId = c.req.param("id") ?? "";
517
- if (!channelId)
518
- return c.json({ error: "channel_id required" }, 400);
519
- let body = {};
520
- try {
521
- const raw = c.req.header("content-type")?.startsWith("application/json")
522
- ? await c.req.json()
523
- : {};
524
- if (raw && typeof raw === "object")
525
- body = raw;
526
- }
527
- catch {
528
- /* body optional */
529
- }
530
- const ttl = typeof body.session_ttl_seconds === "number" ? body.session_ttl_seconds : NaN;
531
- const result = setSessionTtlByCreator(r.accountId, channelId, ttl);
532
- if ("error" in result) {
533
- const status = result.code === "not_found" ? 404 :
534
- result.code === "forbidden" ? 403 :
535
- 400;
536
- return c.json({ error: result.error }, status);
537
- }
538
- return c.json({
539
- ok: true,
540
- channel_id: channelId,
541
- session_ttl_seconds: result.session_ttl_seconds,
542
- notice: "New TTL applies to the next GC tick (within 60s). Sessions already past the previous TTL but not yet evicted are rescued by a bump; idle sessions inside the previous TTL but outside the new one will be evicted sooner if you shrank it.",
543
- });
544
- });
545
- // ─── Webhooks ───
546
- app.post("/api/account/webhooks", async (c) => {
547
- const r = requireSession(c);
548
- if (r instanceof Response)
549
- return r;
550
- let body = {};
551
- try {
552
- const raw = await c.req.json();
553
- if (raw && typeof raw === "object")
554
- body = raw;
555
- }
556
- catch {
557
- /* empty */
558
- }
559
- const url = String(body.url ?? "");
560
- const events = Array.isArray(body.events) ? body.events.map(String) : ["message.received"];
561
- const result = createWebhook(r.accountId, url, events);
562
- if ("error" in result)
563
- return c.json(result, 400);
564
- return c.json({
565
- ...result,
566
- url,
567
- events,
568
- notice: "Save the secret. It's shown only once. Use it to verify the X-RogerThat-Signature header (HMAC-SHA256) on incoming events.",
569
- });
570
- });
571
- app.get("/api/account/webhooks", (c) => {
572
- const r = requireSession(c);
573
- if (r instanceof Response)
574
- return r;
575
- return c.json({ webhooks: listWebhooks(r.accountId) });
576
- });
577
- app.delete("/api/account/webhooks/:id", (c) => {
578
- const r = requireSession(c);
579
- if (r instanceof Response)
580
- return r;
581
- const id = c.req.param("id");
582
- const ok = deleteWebhook(r.accountId, id);
583
- if (!ok)
584
- return c.json({ error: "webhook not found or not yours" }, 404);
585
- return c.json({ ok: true });
586
- });
587
175
  app.get("/api/channels/:id", (c) => {
588
176
  const channelId = c.req.param("id");
589
177
  if (!channelExists(channelId)) {
@@ -597,7 +185,6 @@ export function createApp(opts) {
597
185
  channel_id: channelId,
598
186
  exists: true,
599
187
  retention: getChannelRetention(channelId),
600
- require_identity: getChannelRequireIdentity(channelId),
601
188
  trust_mode: getChannelTrustMode(channelId),
602
189
  has_owner_password: hasOwnerPassword(channelId),
603
190
  session_ttl_seconds: Math.round(getChannelSessionTtlMs(channelId) / 1000),
@@ -613,7 +200,6 @@ export function createApp(opts) {
613
200
  keepalive: `POST ${base}/keepalive`,
614
201
  stats: `GET ${base}/stats`,
615
202
  transcript: `GET ${base}/transcript`,
616
- webhooks: `POST ${base}/webhooks`,
617
203
  mcp: `${opts.publicOrigin}/mcp/${channelId}`,
618
204
  },
619
205
  auth: "All endpoints (except this one) require Authorization: Bearer <channel_token>. /send and /listen also require X-Session-Id from /join.",
@@ -674,26 +260,6 @@ export function createApp(opts) {
674
260
  if (info.retryAfter !== undefined)
675
261
  c.header("Retry-After", String(info.retryAfter));
676
262
  }
677
- // ─── Webhook fan-out helper ───
678
- function fanoutWebhooks(channelId, msg) {
679
- const payload = {
680
- channel_id: channelId,
681
- message: { id: msg.id, from: msg.from, to: msg.to, text: msg.text, at: msg.at },
682
- };
683
- // Account-scoped webhooks — only for DMs to identities owned by an account.
684
- if (msg.to !== "all") {
685
- const accountIds = getAccountIdsByIdentityCallsign(msg.to);
686
- for (const accountId of accountIds) {
687
- for (const hook of getActiveWebhooksForAccount(accountId, "message.received")) {
688
- deliver(hook, "message.received", payload);
689
- }
690
- }
691
- }
692
- // Channel-scoped webhooks — fire for EVERY message on this channel (DMs + broadcasts).
693
- for (const hook of getActiveWebhooksForChannel(channelId, "message.received")) {
694
- deliver(hook, "message.received", payload);
695
- }
696
- }
697
263
  // ─── REST API (MCP-free; for any CLI with shell access — Codex, Aider, scripts) ───
698
264
  function requireChannelBearer(c, channelId) {
699
265
  if (!channelExists(channelId))
@@ -736,23 +302,10 @@ export function createApp(opts) {
736
302
  catch {
737
303
  /* empty body ok */
738
304
  }
739
- const callsignArg = String(body.callsign ?? "");
740
- const identityKey = typeof body.identity_key === "string" ? body.identity_key : undefined;
305
+ const resolvedCallsign = String(body.callsign ?? "");
741
306
  const ownerPassword = typeof body.owner_password === "string" ? body.owner_password : "";
742
- let resolvedCallsign = callsignArg;
743
- let identitySource = null;
744
- if (identityKey) {
745
- const idRec = verifyIdentity(identityKey);
746
- if (!idRec)
747
- return c.json({ error: "invalid identity_key", code: "unauthorized" }, 401);
748
- resolvedCallsign = idRec.callsign;
749
- identitySource = idRec.account_id;
750
- }
751
- else if (getChannelRequireIdentity(channelId)) {
752
- return c.json({ error: "this channel requires identity_key (require_identity=true)", code: "unauthorized" }, 403);
753
- }
754
307
  if (!resolvedCallsign)
755
- return c.json({ error: "callsign or identity_key required", code: "invalid" }, 400);
308
+ return c.json({ error: "callsign required", code: "invalid" }, 400);
756
309
  const humanAuthorized = ownerPassword ? verifyOwnerPassword(channelId, ownerPassword) : false;
757
310
  if (ownerPassword && !humanAuthorized && hasOwnerPassword(channelId)) {
758
311
  return c.json({
@@ -784,7 +337,6 @@ export function createApp(opts) {
784
337
  return c.json({
785
338
  session_id: result.sessionId,
786
339
  callsign: resolvedCallsign,
787
- identity_account: identitySource,
788
340
  human_authorized: humanAuthorized,
789
341
  trust_mode: trustMode,
790
342
  trust_posture: trustPosture,
@@ -878,10 +430,9 @@ export function createApp(opts) {
878
430
  }
879
431
  const msg = channel.send(sessionId, to, message, priorityInput, suggestedReplies, attachments, kind);
880
432
  statsRecordMessage();
881
- // Status pings are ephemeral — keep them out of transcripts and webhooks.
433
+ // Status pings are ephemeral — keep them out of transcripts.
882
434
  if (msg.kind !== "status") {
883
435
  transcriptRecordMessage(channelId, getChannelRetention(channelId), msg);
884
- fanoutWebhooks(channelId, msg);
885
436
  }
886
437
  const queued = msg.kind !== "status" && msg.to !== "all" && !channel.isCallsignOnline(msg.to);
887
438
  return c.json({
@@ -1064,7 +615,6 @@ export function createApp(opts) {
1064
615
  return c.json({
1065
616
  channel_id: channelId,
1066
617
  retention: getChannelRetention(channelId),
1067
- require_identity: getChannelRequireIdentity(channelId),
1068
618
  trust_mode: getChannelTrustMode(channelId),
1069
619
  has_owner_password: hasOwnerPassword(channelId),
1070
620
  session_ttl_seconds: Math.floor(getChannelSessionTtlMs(channelId) / 1000),
@@ -1076,54 +626,6 @@ export function createApp(opts) {
1076
626
  last_activity_at: ch.lastActivityAt,
1077
627
  });
1078
628
  });
1079
- // ─── Channel-scoped webhooks (no account required, auth via channel token) ───
1080
- app.post("/api/channels/:id/webhooks", async (c) => {
1081
- const channelId = c.req.param("id");
1082
- const denied = requireChannelBearer(c, channelId);
1083
- if (denied)
1084
- return denied;
1085
- if (getChannelIsBand(channelId)) {
1086
- return c.json({ error: "webhooks cannot be subscribed on public bands (anyone could fire them)", code: "invalid" }, 400);
1087
- }
1088
- let body = {};
1089
- try {
1090
- const raw = await c.req.json();
1091
- if (raw && typeof raw === "object")
1092
- body = raw;
1093
- }
1094
- catch {
1095
- /* empty */
1096
- }
1097
- const url = String(body.url ?? "");
1098
- const events = Array.isArray(body.events) ? body.events.map(String) : ["message.received"];
1099
- const result = createChannelWebhook(channelId, url, events);
1100
- if ("error" in result)
1101
- return c.json(result, 400);
1102
- return c.json({
1103
- ...result,
1104
- url,
1105
- events,
1106
- channel_id: channelId,
1107
- notice: "Save the secret. It's shown only once. Use it to verify the X-RogerThat-Signature header (HMAC-SHA256) on incoming events.",
1108
- });
1109
- });
1110
- app.get("/api/channels/:id/webhooks", (c) => {
1111
- const channelId = c.req.param("id");
1112
- const denied = requireChannelBearer(c, channelId);
1113
- if (denied)
1114
- return denied;
1115
- return c.json({ webhooks: listChannelWebhooks(channelId) });
1116
- });
1117
- app.delete("/api/channels/:id/webhooks/:whId", (c) => {
1118
- const channelId = c.req.param("id");
1119
- const denied = requireChannelBearer(c, channelId);
1120
- if (denied)
1121
- return denied;
1122
- const ok = deleteChannelWebhook(channelId, c.req.param("whId"));
1123
- if (!ok)
1124
- return c.json({ error: "webhook not found on this channel" }, 404);
1125
- return c.json({ ok: true });
1126
- });
1127
629
  app.get("/api/channels/:id/roster", (c) => {
1128
630
  const channelId = c.req.param("id");
1129
631
  const denied = requireChannelBearer(c, channelId);
@@ -1173,7 +675,7 @@ export function createApp(opts) {
1173
675
  const denied = requireAdmin(c);
1174
676
  if (denied)
1175
677
  return denied;
1176
- return c.json({ channels: listActiveChannels(getChannelRetention, getChannelRequireIdentity, getChannelTrustMode) });
678
+ return c.json({ channels: listActiveChannels(getChannelRetention, getChannelTrustMode) });
1177
679
  });
1178
680
  async function mcpHandler(c, channelId) {
1179
681
  if (channelId !== null) {
@@ -1280,26 +782,6 @@ export function createApp(opts) {
1280
782
  ].join("\n");
1281
783
  return c.text(body, 200, { "Content-Type": "text/plain; charset=utf-8" });
1282
784
  });
1283
- function verifyResultPage(message, success) {
1284
- const color = success ? "#2d8a3e" : "#d6541f";
1285
- const icon = success ? "✓" : "✗";
1286
- return `<!doctype html><html><head><meta charset="utf-8" /><title>rogerthat</title>
1287
- <style>body{font-family:ui-monospace,Menlo,monospace;background:#f4ede0;color:#1a1a1a;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:24px;line-height:1.5}
1288
- .box{background:#fffaef;border:2px solid ${color};padding:32px;max-width:480px;text-align:center}
1289
- .icon{font-size:48px;color:${color};margin-bottom:12px}
1290
- a{color:#d6541f}</style></head><body>
1291
- <div class="box"><div class="icon">${icon}</div><p>${message}</p>
1292
- <p style="font-size:13px;color:#7a6f5f"><a href="/account">→ go to /account</a></p></div></body></html>`;
1293
- }
1294
- function recoveryAutoLoginPage(sessionToken) {
1295
- return `<!doctype html><html><head><meta charset="utf-8" /><title>rogerthat — recovered</title>
1296
- <style>body{font-family:ui-monospace,Menlo,monospace;background:#f4ede0;color:#1a1a1a;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;padding:24px;line-height:1.5}
1297
- .box{background:#fffaef;border:2px solid #2d8a3e;padding:32px;max-width:480px;text-align:center}
1298
- .icon{font-size:48px;color:#2d8a3e;margin-bottom:12px}</style></head><body>
1299
- <div class="box"><div class="icon">✓</div><p>Recovered. Signing you in…</p></div>
1300
- <script>sessionStorage.setItem('rogerthat_account_session', ${JSON.stringify(sessionToken)});setTimeout(function(){location.href='/account';}, 800);</script>
1301
- </body></html>`;
1302
- }
1303
785
  app.notFound((c) => c.text("not found", 404));
1304
786
  app.onError((errInstance, c) => {
1305
787
  console.error("[rogerthat] unhandled", errInstance);