rogerthat 1.24.5 → 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/README.md +6 -5
- package/dist/admin.js +2 -2
- package/dist/agentcard.js +2 -2
- package/dist/app.js +9 -527
- package/dist/channel.js +1 -2
- package/dist/cli.js +2 -8
- package/dist/connect.js +1 -1
- package/dist/discovery.js +15 -226
- package/dist/landing.js +8 -290
- package/dist/listen-here.js +25 -0
- package/dist/mcp.js +36 -363
- package/dist/policy.js +5 -7
- package/dist/presets.js +6 -41
- package/dist/store.js +2 -93
- package/package.json +1 -1
- package/dist/account-ui.js +0 -895
- package/dist/accounts.js +0 -253
- package/dist/email.js +0 -67
- package/dist/remote-control.js +0 -174
- package/dist/remote-ui.js +0 -906
- package/dist/webhooks.js +0 -154
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
|
|
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,
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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);
|