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.
- package/dist/account-ui.js +176 -9
- package/dist/accounts.js +106 -2
- package/dist/app.js +126 -1
- package/dist/discovery.js +1 -1
- package/dist/email.js +67 -0
- package/package.json +1 -1
package/dist/account-ui.js
CHANGED
|
@@ -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
|
|
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
|
|
157
|
-
'
|
|
158
|
-
'
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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) => [
|
|
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
|
-
|
|
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
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