rogerrat 0.8.1 → 0.9.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.
@@ -95,6 +95,14 @@ export function accountHtml() {
95
95
  <h3 style="margin:32px 0 8px;font-size:13px;color:var(--dim);text-transform:uppercase;letter-spacing:0.06em">New account</h3>
96
96
  <p class="sub">Generates a recovery_token + session_token. Save the recovery_token in your password manager — it's shown only once and is the only way to recover this account.</p>
97
97
  <button id="create-btn">Create account</button>
98
+
99
+ <h3 style="margin:32px 0 8px;font-size:13px;color:var(--dim);text-transform:uppercase;letter-spacing:0.06em">Lost your recovery_token? Use email</h3>
100
+ <p class="sub">If you attached a verified email to your account, we can send a one-time recovery link.</p>
101
+ <p id="recover-msg" class="err"></p>
102
+ <div class="row">
103
+ <input id="recover-email" type="email" autocomplete="email" placeholder="email@example.com" />
104
+ <button id="recover-btn" class="ghost">Send recovery link</button>
105
+ </div>
98
106
  </div>
99
107
 
100
108
  <div id="dashboard" hidden>
@@ -114,6 +122,24 @@ export function accountHtml() {
114
122
  </div>
115
123
  </div>
116
124
 
125
+ <div class="card">
126
+ <h2 style="margin-top:0">Email (optional recovery)</h2>
127
+ <p class="sub">Attach a verified email so you can recover the account if you lose your recovery_token. We only send: verification links + recovery links. We never send marketing.</p>
128
+ <div id="email-status"></div>
129
+ <p id="email-err" class="err"></p>
130
+ <div id="email-form" hidden>
131
+ <div class="row">
132
+ <input id="new-email" type="email" autocomplete="email" placeholder="email@example.com" />
133
+ <button id="attach-email">Send verification</button>
134
+ </div>
135
+ </div>
136
+ <div id="email-current" hidden style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
137
+ <code id="email-shown"></code>
138
+ <span id="email-badge" style="font-size:11px;padding:2px 8px;border-radius:3px"></span>
139
+ <button id="remove-email" class="danger" style="margin-left:auto">Remove</button>
140
+ </div>
141
+ </div>
142
+
117
143
  <div class="card">
118
144
  <h2 style="margin-top:0">Identities</h2>
119
145
  <p class="sub">An identity is a stable callsign you can use to join channels with <code>require_identity=true</code>. Each identity has a persistent <code>identity_key</code> (shown once on creation) that proves "you on this account, using this callsign". Treat the key like a password.</p>
@@ -168,6 +194,7 @@ export function accountHtml() {
168
194
  $('account-id').textContent = account.id;
169
195
  $('account-created').textContent = fmtDate(account.created_at);
170
196
  $('account-extra').textContent = account.identities ? '(' + account.identities.length + ' identit' + (account.identities.length === 1 ? 'y' : 'ies') + ')' : '';
197
+ renderEmail(account);
171
198
  renderIdentities(account.identities || []);
172
199
  if (justCreated) {
173
200
  const text = [
@@ -213,6 +240,31 @@ export function accountHtml() {
213
240
  });
214
241
  });
215
242
 
243
+ function renderEmail(account) {
244
+ const form = $('email-form');
245
+ const current = $('email-current');
246
+ if (account.email) {
247
+ form.hidden = true;
248
+ current.hidden = false;
249
+ current.style.display = 'flex';
250
+ $('email-shown').textContent = account.email;
251
+ const badge = $('email-badge');
252
+ if (account.email_verified) {
253
+ badge.textContent = 'verified';
254
+ badge.style.background = '#2d8a3e';
255
+ badge.style.color = 'white';
256
+ } else {
257
+ badge.textContent = 'pending verification — check inbox';
258
+ badge.style.background = 'var(--bg)';
259
+ badge.style.color = 'var(--warn)';
260
+ }
261
+ } else {
262
+ form.hidden = false;
263
+ current.hidden = true;
264
+ current.style.display = 'none';
265
+ }
266
+ }
267
+
216
268
  function renderIdentities(idents) {
217
269
  const tbody = $('ident-rows');
218
270
  if (!idents.length) {
@@ -282,6 +334,59 @@ export function accountHtml() {
282
334
 
283
335
  $('login-token').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('login-btn').click(); });
284
336
 
337
+ $('recover-btn').addEventListener('click', async () => {
338
+ const email = $('recover-email').value.trim();
339
+ if (!email) return;
340
+ $('recover-msg').style.color = 'var(--dim)';
341
+ $('recover-msg').textContent = 'Sending…';
342
+ try {
343
+ const r = await fetch('/api/account/email-recover', {
344
+ method: 'POST',
345
+ headers: { 'Content-Type': 'application/json' },
346
+ body: JSON.stringify({ email }),
347
+ });
348
+ const data = await r.json();
349
+ $('recover-msg').textContent = data.message || 'If this email is registered and verified, a recovery link was sent.';
350
+ } catch (e) {
351
+ $('recover-msg').style.color = 'var(--warn)';
352
+ $('recover-msg').textContent = 'Error: ' + e.message;
353
+ }
354
+ });
355
+
356
+ $('attach-email').addEventListener('click', async () => {
357
+ const email = $('new-email').value.trim();
358
+ if (!email) return;
359
+ $('email-err').textContent = '';
360
+ try {
361
+ const r = await fetch('/api/account/email', {
362
+ method: 'POST',
363
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + session },
364
+ body: JSON.stringify({ email }),
365
+ });
366
+ const data = await r.json();
367
+ if (!r.ok) { $('email-err').textContent = data.error || ('HTTP ' + r.status); return; }
368
+ $('new-email').value = '';
369
+ loadAccount();
370
+ } catch (e) {
371
+ $('email-err').textContent = 'Error: ' + e.message;
372
+ }
373
+ });
374
+
375
+ $('remove-email').addEventListener('click', async () => {
376
+ if (!confirm('Remove the email from this account? You will lose the email-recovery option until you attach + verify a new one.')) return;
377
+ $('email-err').textContent = '';
378
+ try {
379
+ const r = await fetch('/api/account/email', {
380
+ method: 'DELETE',
381
+ headers: { Authorization: 'Bearer ' + session },
382
+ });
383
+ if (!r.ok) { $('email-err').textContent = 'Failed: HTTP ' + r.status; return; }
384
+ loadAccount();
385
+ } catch (e) {
386
+ $('email-err').textContent = 'Error: ' + e.message;
387
+ }
388
+ });
389
+
285
390
  $('create-btn').addEventListener('click', async () => {
286
391
  try {
287
392
  const r = await fetch('/api/account', { method: 'POST' });
package/dist/accounts.js CHANGED
@@ -35,7 +35,16 @@ function ensureLoaded() {
35
35
  try {
36
36
  if (existsSync(ACCOUNTS_PATH)) {
37
37
  const arr = JSON.parse(readFileSync(ACCOUNTS_PATH, "utf8"));
38
- accounts = new Map(arr.map((r) => [r.id, r]));
38
+ accounts = new Map(arr.map((r) => [
39
+ r.id,
40
+ {
41
+ id: r.id,
42
+ recoveryHash: r.recoveryHash,
43
+ createdAt: r.createdAt,
44
+ email: typeof r.email === "string" ? r.email : undefined,
45
+ emailVerifiedAt: typeof r.emailVerifiedAt === "number" ? r.emailVerifiedAt : undefined,
46
+ },
47
+ ]));
39
48
  }
40
49
  if (existsSync(IDENTITIES_PATH)) {
41
50
  identities = JSON.parse(readFileSync(IDENTITIES_PATH, "utf8"));
@@ -45,6 +54,19 @@ function ensureLoaded() {
45
54
  console.error("[accounts] load failed:", err);
46
55
  }
47
56
  }
57
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
58
+ const CODE_TTL_MS = 60 * 60 * 1000; // 1 hour
59
+ const pendingVerifications = new Map();
60
+ const pendingRecoveries = new Map();
61
+ function gcPending() {
62
+ const now = Date.now();
63
+ for (const [k, v] of pendingVerifications)
64
+ if (v.expiresAt < now)
65
+ pendingVerifications.delete(k);
66
+ for (const [k, v] of pendingRecoveries)
67
+ if (v.expiresAt < now)
68
+ pendingRecoveries.delete(k);
69
+ }
48
70
  function persistAccounts() {
49
71
  ensureDir(dirname(ACCOUNTS_PATH));
50
72
  const tmp = `${ACCOUNTS_PATH}.tmp`;
@@ -96,7 +118,89 @@ export function verifySession(sessionToken) {
96
118
  export function getAccount(accountId) {
97
119
  ensureLoaded();
98
120
  const a = accounts.get(accountId);
99
- return a ? { id: a.id, created_at: a.createdAt } : null;
121
+ if (!a)
122
+ return null;
123
+ return {
124
+ id: a.id,
125
+ created_at: a.createdAt,
126
+ email: a.email ?? null,
127
+ email_verified: !!a.emailVerifiedAt,
128
+ };
129
+ }
130
+ export function attachEmail(accountId, rawEmail) {
131
+ ensureLoaded();
132
+ const email = rawEmail.trim().toLowerCase();
133
+ if (!EMAIL_RE.test(email) || email.length > 254) {
134
+ return { error: "invalid email format" };
135
+ }
136
+ const account = accounts.get(accountId);
137
+ if (!account)
138
+ return { error: "account not found" };
139
+ account.email = email;
140
+ account.emailVerifiedAt = undefined;
141
+ persistAccounts();
142
+ const code = randomBytes(32).toString("base64url");
143
+ pendingVerifications.set(hash(code), { accountId, email, expiresAt: Date.now() + CODE_TTL_MS });
144
+ return { code, email };
145
+ }
146
+ export function verifyEmailCode(code) {
147
+ ensureLoaded();
148
+ gcPending();
149
+ const rec = pendingVerifications.get(hash(code));
150
+ if (!rec)
151
+ return { error: "invalid or expired verification link" };
152
+ const account = accounts.get(rec.accountId);
153
+ if (!account)
154
+ return { error: "account not found" };
155
+ for (const other of accounts.values()) {
156
+ if (other.id !== rec.accountId && other.email === rec.email && other.emailVerifiedAt) {
157
+ pendingVerifications.delete(hash(code));
158
+ account.email = undefined;
159
+ persistAccounts();
160
+ return { error: "this email is already verified on another account" };
161
+ }
162
+ }
163
+ account.email = rec.email;
164
+ account.emailVerifiedAt = Date.now();
165
+ persistAccounts();
166
+ pendingVerifications.delete(hash(code));
167
+ return { accountId: rec.accountId, email: rec.email };
168
+ }
169
+ export function removeEmail(accountId) {
170
+ ensureLoaded();
171
+ const account = accounts.get(accountId);
172
+ if (!account)
173
+ return false;
174
+ account.email = undefined;
175
+ account.emailVerifiedAt = undefined;
176
+ persistAccounts();
177
+ return true;
178
+ }
179
+ export function requestEmailRecovery(rawEmail) {
180
+ ensureLoaded();
181
+ const email = rawEmail.trim().toLowerCase();
182
+ if (!EMAIL_RE.test(email))
183
+ return null;
184
+ for (const a of accounts.values()) {
185
+ if (a.email === email && a.emailVerifiedAt) {
186
+ const code = randomBytes(32).toString("base64url");
187
+ pendingRecoveries.set(hash(code), { accountId: a.id, expiresAt: Date.now() + CODE_TTL_MS });
188
+ return { code, accountId: a.id };
189
+ }
190
+ }
191
+ return null;
192
+ }
193
+ export function confirmEmailRecovery(code) {
194
+ ensureLoaded();
195
+ gcPending();
196
+ const rec = pendingRecoveries.get(hash(code));
197
+ if (!rec)
198
+ return null;
199
+ const account = accounts.get(rec.accountId);
200
+ if (!account)
201
+ return null;
202
+ pendingRecoveries.delete(hash(code));
203
+ return { accountId: rec.accountId, session_token: issueSession(rec.accountId) };
100
204
  }
101
205
  export function listIdentities(accountId) {
102
206
  ensureLoaded();
package/dist/app.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { Hono } from "hono";
3
- import { createAccount, createIdentity, deleteIdentity, getAccount, listIdentities, recoverAccount, verifyIdentity, verifySession, } from "./accounts.js";
3
+ import { attachEmail, confirmEmailRecovery, createAccount, createIdentity, deleteIdentity, getAccount, listIdentities, recoverAccount, removeEmail, requestEmailRecovery, verifyEmailCode, verifyIdentity, verifySession, } from "./accounts.js";
4
+ import { buildRecoveryEmail, buildVerifyEmail, emailEnabled, sendEmail } from "./email.js";
4
5
  import { accountHtml } from "./account-ui.js";
5
6
  import { adminHtml } from "./admin.js";
6
7
  import { getOrCreateChannel, listActiveChannels } from "./channel.js";
@@ -119,6 +120,110 @@ export function createApp(opts) {
119
120
  return c.json({ error: "identity not found" }, 404);
120
121
  return c.json({ ok: true });
121
122
  });
123
+ // ─── Email recovery (optional channel for "I lost my recovery_token") ───
124
+ const recoveryHits = new Map();
125
+ const RECOVERY_WINDOW_MS = 10 * 60 * 1000;
126
+ const RECOVERY_MAX_PER_WINDOW = 3;
127
+ function rateLimitRecover(c) {
128
+ const ip = c.req.header("cf-connecting-ip") ??
129
+ c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
130
+ c.req.header("x-real-ip") ??
131
+ "unknown";
132
+ const now = Date.now();
133
+ const bucket = recoveryHits.get(ip);
134
+ if (!bucket || now - bucket.windowStart > RECOVERY_WINDOW_MS) {
135
+ recoveryHits.set(ip, { count: 1, windowStart: now });
136
+ return true;
137
+ }
138
+ if (bucket.count >= RECOVERY_MAX_PER_WINDOW)
139
+ return false;
140
+ bucket.count++;
141
+ return true;
142
+ }
143
+ app.post("/api/account/email", async (c) => {
144
+ const r = requireSession(c);
145
+ if (r instanceof Response)
146
+ return r;
147
+ if (!emailEnabled())
148
+ return c.json({ error: "email is not configured on this instance" }, 503);
149
+ let body = {};
150
+ try {
151
+ const raw = await c.req.json();
152
+ if (raw && typeof raw === "object")
153
+ body = raw;
154
+ }
155
+ catch {
156
+ /* empty */
157
+ }
158
+ const email = String(body.email ?? "");
159
+ if (!email)
160
+ return c.json({ error: "email required" }, 400);
161
+ const result = attachEmail(r.accountId, email);
162
+ if ("error" in result)
163
+ return c.json(result, 400);
164
+ try {
165
+ const msg = buildVerifyEmail(opts.publicOrigin, result.code, r.accountId);
166
+ await sendEmail(result.email, msg.subject, msg.text, msg.html);
167
+ }
168
+ catch (e) {
169
+ return c.json({ error: "failed to send verification email: " + e.message }, 502);
170
+ }
171
+ return c.json({ ok: true, email: result.email, verification_sent: true });
172
+ });
173
+ app.delete("/api/account/email", (c) => {
174
+ const r = requireSession(c);
175
+ if (r instanceof Response)
176
+ return r;
177
+ removeEmail(r.accountId);
178
+ return c.json({ ok: true });
179
+ });
180
+ app.get("/api/account/email-verify", (c) => {
181
+ const code = c.req.query("code") ?? "";
182
+ if (!code)
183
+ return c.html(verifyResultPage("Missing code parameter.", false));
184
+ const result = verifyEmailCode(code);
185
+ if ("error" in result)
186
+ return c.html(verifyResultPage(result.error, false));
187
+ return c.html(verifyResultPage(`Verified ${result.email} for account ${result.accountId}.`, true));
188
+ });
189
+ app.post("/api/account/email-recover", async (c) => {
190
+ if (!emailEnabled())
191
+ return c.json({ error: "email is not configured on this instance" }, 503);
192
+ if (!rateLimitRecover(c))
193
+ return c.json({ error: "too many requests, try again later" }, 429);
194
+ let body = {};
195
+ try {
196
+ const raw = await c.req.json();
197
+ if (raw && typeof raw === "object")
198
+ body = raw;
199
+ }
200
+ catch {
201
+ /* empty */
202
+ }
203
+ const email = String(body.email ?? "");
204
+ if (email) {
205
+ const result = requestEmailRecovery(email);
206
+ if (result) {
207
+ try {
208
+ const msg = buildRecoveryEmail(opts.publicOrigin, result.code);
209
+ await sendEmail(email, msg.subject, msg.text, msg.html);
210
+ }
211
+ catch (e) {
212
+ console.error("[recover] failed to send email:", e);
213
+ }
214
+ }
215
+ }
216
+ return c.json({ ok: true, message: "If this email is registered and verified, a recovery link was sent." });
217
+ });
218
+ app.get("/api/account/email-recover-confirm", (c) => {
219
+ const code = c.req.query("code") ?? "";
220
+ if (!code)
221
+ return c.html(verifyResultPage("Missing code parameter.", false));
222
+ const result = confirmEmailRecovery(code);
223
+ if (!result)
224
+ return c.html(verifyResultPage("Invalid or expired recovery link.", false));
225
+ return c.html(recoveryAutoLoginPage(result.session_token));
226
+ });
122
227
  app.post("/api/channels", async (c) => {
123
228
  let body = {};
124
229
  try {
@@ -368,6 +473,26 @@ export function createApp(opts) {
368
473
  app.post("/mcp/:channelId", (c) => mcpHandler(c, c.req.param("channelId")));
369
474
  app.get("/mcp", (c) => c.json({ jsonrpc: "2.0", id: null, error: { code: -32000, message: "method not allowed; use POST" } }, 405));
370
475
  app.get("/mcp/:channelId", (c) => c.json({ jsonrpc: "2.0", id: null, error: { code: -32000, message: "method not allowed; use POST" } }, 405));
476
+ function verifyResultPage(message, success) {
477
+ const color = success ? "#2d8a3e" : "#d6541f";
478
+ const icon = success ? "✓" : "✗";
479
+ return `<!doctype html><html><head><meta charset="utf-8" /><title>rogerrat</title>
480
+ <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}
481
+ .box{background:#fffaef;border:2px solid ${color};padding:32px;max-width:480px;text-align:center}
482
+ .icon{font-size:48px;color:${color};margin-bottom:12px}
483
+ a{color:#d6541f}</style></head><body>
484
+ <div class="box"><div class="icon">${icon}</div><p>${message}</p>
485
+ <p style="font-size:13px;color:#7a6f5f"><a href="/account">→ go to /account</a></p></div></body></html>`;
486
+ }
487
+ function recoveryAutoLoginPage(sessionToken) {
488
+ return `<!doctype html><html><head><meta charset="utf-8" /><title>rogerrat — recovered</title>
489
+ <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}
490
+ .box{background:#fffaef;border:2px solid #2d8a3e;padding:32px;max-width:480px;text-align:center}
491
+ .icon{font-size:48px;color:#2d8a3e;margin-bottom:12px}</style></head><body>
492
+ <div class="box"><div class="icon">✓</div><p>Recovered. Signing you in…</p></div>
493
+ <script>sessionStorage.setItem('rogerrat_account_session', ${JSON.stringify(sessionToken)});setTimeout(function(){location.href='/account';}, 800);</script>
494
+ </body></html>`;
495
+ }
371
496
  app.notFound((c) => c.text("not found", 404));
372
497
  app.onError((errInstance, c) => {
373
498
  console.error("[rogerrat] unhandled", errInstance);
package/dist/discovery.js CHANGED
@@ -1,4 +1,4 @@
1
- const VERSION = "0.8.1";
1
+ const VERSION = "0.9.0";
2
2
  export function llmsText(origin) {
3
3
  return `# RogerRat
4
4
 
package/dist/email.js ADDED
@@ -0,0 +1,67 @@
1
+ const RESEND_API_KEY = process.env.RESEND_API_KEY;
2
+ const RESEND_FROM = process.env.RESEND_FROM ?? "RogerRat <no-reply@rogerrat.chat>";
3
+ export function emailEnabled() {
4
+ return !!RESEND_API_KEY;
5
+ }
6
+ export async function sendEmail(to, subject, text, html) {
7
+ if (!RESEND_API_KEY)
8
+ throw new Error("email is disabled on this instance (no RESEND_API_KEY)");
9
+ const r = await fetch("https://api.resend.com/emails", {
10
+ method: "POST",
11
+ headers: {
12
+ Authorization: `Bearer ${RESEND_API_KEY}`,
13
+ "Content-Type": "application/json",
14
+ },
15
+ body: JSON.stringify({
16
+ from: RESEND_FROM,
17
+ to: [to],
18
+ subject,
19
+ text,
20
+ ...(html ? { html } : {}),
21
+ }),
22
+ });
23
+ if (!r.ok) {
24
+ const body = await r.text();
25
+ throw new Error(`Resend ${r.status}: ${body}`);
26
+ }
27
+ return (await r.json());
28
+ }
29
+ export function buildVerifyEmail(origin, code, accountId) {
30
+ const url = `${origin}/api/account/email-verify?code=${encodeURIComponent(code)}`;
31
+ return {
32
+ subject: "Verify your email for RogerRat",
33
+ text: `Hello,
34
+
35
+ You (or someone with your session_token) attached this email to RogerRat account ${accountId}.
36
+
37
+ Click the link below to verify your email:
38
+ ${url}
39
+
40
+ The link expires in 1 hour. If you didn't request this, you can ignore the email — nothing happens unless you click.
41
+
42
+ — RogerRat`,
43
+ html: `<p>Hello,</p>
44
+ <p>You (or someone with your session_token) attached this email to RogerRat account <strong>${accountId}</strong>.</p>
45
+ <p><a href="${url}">Click here to verify your email</a></p>
46
+ <p style="color:#7a6f5f;font-size:13px">The link expires in 1 hour. If you didn't request this, ignore this email — nothing happens unless you click.</p>
47
+ <p style="color:#7a6f5f;font-size:13px">— RogerRat</p>`,
48
+ };
49
+ }
50
+ export function buildRecoveryEmail(origin, code) {
51
+ const url = `${origin}/api/account/email-recover-confirm?code=${encodeURIComponent(code)}`;
52
+ return {
53
+ subject: "RogerRat account recovery",
54
+ text: `Someone requested account recovery for this email on RogerRat.
55
+
56
+ If it was you, click here to get a fresh session_token:
57
+ ${url}
58
+
59
+ The link expires in 1 hour and can only be used once. If it wasn't you, ignore this email — nothing happens unless you click.
60
+
61
+ — RogerRat`,
62
+ html: `<p>Someone requested account recovery for this email on RogerRat.</p>
63
+ <p>If it was you, <a href="${url}">click here to get a fresh session_token</a>.</p>
64
+ <p style="color:#7a6f5f;font-size:13px">The link expires in 1 hour and can only be used once. If it wasn't you, ignore this email — nothing happens unless you click.</p>
65
+ <p style="color:#7a6f5f;font-size:13px">— RogerRat</p>`,
66
+ };
67
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rogerrat",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Walkie-talkie MCP server for AI coding agents. Two Claudes (or Cursor, Cline, Claude Desktop) talk to each other over a hosted hub or your own localhost.",
5
5
  "keywords": [
6
6
  "mcp",