rogerrat 0.8.0 → 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.
@@ -45,7 +45,12 @@ export function accountHtml() {
45
45
  tr:last-child td { border-bottom: none; }
46
46
  .reveal { background: var(--bg); border: 1px dashed var(--warn); padding: 12px 14px; font-size: 12px; margin-top: 16px; }
47
47
  .reveal strong { color: var(--warn); }
48
- .reveal pre { margin: 8px 0 0; white-space: pre-wrap; word-break: break-all; user-select: all; font-size: 12px; }
48
+ .reveal pre { margin: 8px 0; white-space: pre-wrap; word-break: break-all; user-select: all; font-size: 12px; }
49
+ .reveal-actions { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
50
+ .reveal-actions button { background: var(--ink); color: white; padding: 6px 12px; font-size: 12px; font-weight: 500; }
51
+ .reveal-actions button:hover { background: #333; }
52
+ .reveal-actions button.confirm { background: var(--ok); }
53
+ .reveal-actions button.confirm:hover { background: #1f6b2e; }
49
54
  .err { color: var(--warn); font-size: 13px; margin: 8px 0; }
50
55
  .empty { text-align: center; padding: 28px; color: var(--dim); font-size: 13px; }
51
56
  footer { margin-top: 48px; padding-top: 20px; border-top: 1px solid var(--line); color: var(--dim); font-size: 12px; }
@@ -90,6 +95,14 @@ export function accountHtml() {
90
95
  <h3 style="margin:32px 0 8px;font-size:13px;color:var(--dim);text-transform:uppercase;letter-spacing:0.06em">New account</h3>
91
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>
92
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>
93
106
  </div>
94
107
 
95
108
  <div id="dashboard" hidden>
@@ -102,6 +115,28 @@ export function accountHtml() {
102
115
  <div id="reveal" class="reveal" hidden>
103
116
  <strong>Save these now.</strong> They are shown ONLY on this screen. You will not see them again after navigation.
104
117
  <div id="reveal-body"></div>
118
+ <div class="reveal-actions">
119
+ <button data-action="download">⬇ Download .txt</button>
120
+ <button data-action="copy">⎘ Copy</button>
121
+ </div>
122
+ </div>
123
+ </div>
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>
105
140
  </div>
106
141
  </div>
107
142
 
@@ -145,19 +180,88 @@ export function accountHtml() {
145
180
  $('gate-err').textContent = err || '';
146
181
  }
147
182
 
183
+ let revealPayload = null; // { filename, text }
184
+
185
+ function showReveal(filename, text) {
186
+ revealPayload = { filename, text };
187
+ $('reveal-body').innerHTML = '<pre>' + esc(text) + '</pre>';
188
+ $('reveal').hidden = false;
189
+ }
190
+
148
191
  function showDashboard(account, justCreated) {
149
192
  $('gate').hidden = true;
150
193
  $('dashboard').hidden = false;
151
194
  $('account-id').textContent = account.id;
152
195
  $('account-created').textContent = fmtDate(account.created_at);
153
196
  $('account-extra').textContent = account.identities ? '(' + account.identities.length + ' identit' + (account.identities.length === 1 ? 'y' : 'ies') + ')' : '';
197
+ renderEmail(account);
154
198
  renderIdentities(account.identities || []);
155
199
  if (justCreated) {
156
- const html = '<pre>account_id: ' + esc(justCreated.account_id) + '\\n' +
157
- 'recovery_token: ' + esc(justCreated.recovery_token) + '\\n' +
158
- 'session_token: ' + esc(justCreated.session_token) + '</pre>';
159
- $('reveal-body').innerHTML = html;
160
- $('reveal').hidden = false;
200
+ const text = [
201
+ 'RogerRat account credentials',
202
+ '============================',
203
+ '',
204
+ 'Service: https://rogerrat.chat',
205
+ 'Account ID: ' + justCreated.account_id,
206
+ 'Created: ' + new Date().toISOString(),
207
+ 'Recovery token: ' + justCreated.recovery_token,
208
+ 'Session token: ' + justCreated.session_token,
209
+ '',
210
+ '⚠ Save the recovery_token in a password manager — it is the ONLY way to',
211
+ ' recover this account if you lose the session.',
212
+ ' The session_token is short-lived; re-issue via /api/account/recover.',
213
+ ].join('\\n');
214
+ showReveal('rogerrat-account-' + justCreated.account_id + '.txt', text);
215
+ }
216
+ }
217
+
218
+ document.querySelectorAll('#reveal .reveal-actions button').forEach(btn => {
219
+ btn.addEventListener('click', () => {
220
+ if (!revealPayload) return;
221
+ if (btn.dataset.action === 'download') {
222
+ const blob = new Blob([revealPayload.text.replace(/\\\\n/g, '\\n')], { type: 'text/plain;charset=utf-8' });
223
+ const url = URL.createObjectURL(blob);
224
+ const a = document.createElement('a');
225
+ a.href = url; a.download = revealPayload.filename;
226
+ document.body.appendChild(a); a.click(); a.remove();
227
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
228
+ btn.classList.add('confirm');
229
+ btn.textContent = '✓ Downloaded';
230
+ setTimeout(() => { btn.classList.remove('confirm'); btn.textContent = '⬇ Download .txt'; }, 2000);
231
+ } else if (btn.dataset.action === 'copy') {
232
+ navigator.clipboard.writeText(revealPayload.text.replace(/\\\\n/g, '\\n')).then(() => {
233
+ btn.classList.add('confirm');
234
+ btn.textContent = '✓ Copied';
235
+ setTimeout(() => { btn.classList.remove('confirm'); btn.textContent = '⎘ Copy'; }, 2000);
236
+ }).catch((e) => {
237
+ alert('Copy failed: ' + e.message + '\\n\\nSelect the text manually and Ctrl+C.');
238
+ });
239
+ }
240
+ });
241
+ });
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';
161
265
  }
162
266
  }
163
267
 
@@ -230,6 +334,59 @@ export function accountHtml() {
230
334
 
231
335
  $('login-token').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('login-btn').click(); });
232
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
+
233
390
  $('create-btn').addEventListener('click', async () => {
234
391
  try {
235
392
  const r = await fetch('/api/account', { method: 'POST' });
@@ -269,9 +426,19 @@ export function accountHtml() {
269
426
  const data = await r.json();
270
427
  if (!r.ok) { $('ident-err').textContent = data.error || ('HTTP ' + r.status); return; }
271
428
  $('new-callsign').value = '';
272
- // Show the one-time key
273
- $('reveal-body').innerHTML = '<pre>callsign: ' + esc(data.callsign) + '\\nidentity_key: ' + esc(data.identity_key) + '</pre>';
274
- $('reveal').hidden = false;
429
+ const text = [
430
+ 'RogerRat identity key',
431
+ '=====================',
432
+ '',
433
+ 'Service: https://rogerrat.chat',
434
+ 'Callsign: ' + data.callsign,
435
+ 'Identity key: ' + data.identity_key,
436
+ 'Created: ' + new Date().toISOString(),
437
+ '',
438
+ '⚠ Save this key. It is shown ONLY once. Use it as Bearer auth',
439
+ ' when joining channels with require_identity=true.',
440
+ ].join('\\n');
441
+ showReveal('rogerrat-identity-' + data.callsign + '.txt', text);
275
442
  loadAccount();
276
443
  } catch (e) {
277
444
  $('ident-err').textContent = 'Error: ' + e.message;
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.0";
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.0",
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",