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.
- package/dist/account-ui.js +105 -0
- 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
|
@@ -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) => [
|
|
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