rogerthat 1.21.2

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.
@@ -0,0 +1,895 @@
1
+ export function accountHtml() {
2
+ return `<!doctype html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7
+ <title>rogerthat — account</title>
8
+ <!-- Loaded for the "Pair phone" QR modal. Stays out of every other page; failure
9
+ is recoverable — the URL itself is still shown and can be opened directly. -->
10
+ <script src="https://unpkg.com/qrcode@1.5.4/build/qrcode.min.js" defer></script>
11
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%231a1a1a'/><path d='M 9 7 Q 16 4 23 7' stroke='%23d6541f' stroke-width='1.5' fill='none' stroke-linecap='round'/><ellipse cx='11' cy='14' rx='2' ry='3' fill='%23f4ede0' transform='rotate(-15 11 14)'/><ellipse cx='21' cy='14' rx='2' ry='3' fill='%23f4ede0' transform='rotate(15 21 14)'/><ellipse cx='16' cy='22' rx='8' ry='6.5' fill='%23f4ede0'/><circle cx='13' cy='21' r='1.2' fill='%231a1a1a'/><circle cx='19' cy='21' r='1.2' fill='%231a1a1a'/><ellipse cx='16' cy='25' rx='1.5' ry='1' fill='%23d6541f'/></svg>" />
12
+ <style>
13
+ :root {
14
+ --bg: #f4ede0; --ink: #1a1a1a; --dim: #7a6f5f; --warn: #d6541f;
15
+ --line: #c9b994; --paper: #fffaef; --ok: #2d8a3e;
16
+ }
17
+ * { box-sizing: border-box; }
18
+ body { margin: 0; font-family: ui-monospace, Menlo, monospace; background: var(--bg); color: var(--ink); line-height: 1.5; }
19
+ .wrap { max-width: 720px; margin: 0 auto; padding: 32px 24px 96px; }
20
+ header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 32px; gap: 12px; flex-wrap: wrap; }
21
+ .logo { font-size: 16px; font-weight: 700; display: inline-flex; align-items: center; gap: 8px; }
22
+ .logo svg { width: 22px; height: 22px; }
23
+ .nav a { color: var(--dim); text-decoration: none; font-size: 13px; margin-left: 14px; }
24
+ .nav a:hover { color: var(--ink); }
25
+ h1 { font-size: 28px; letter-spacing: -0.02em; margin: 0 0 6px; }
26
+ h2 { font-size: 16px; margin: 32px 0 12px; }
27
+ p.sub { color: var(--dim); font-size: 14px; margin: 0 0 24px; }
28
+ .card { background: var(--paper); border: 1px solid var(--line); padding: 24px; margin-bottom: 24px; }
29
+ .card.gate { border: 2px solid var(--ink); }
30
+ label { font-size: 13px; color: var(--dim); display: block; margin-bottom: 6px; }
31
+ .row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
32
+ input[type=text], input[type=password] {
33
+ flex: 1; min-width: 220px; padding: 10px 12px; border: 1px solid var(--line); background: white;
34
+ font-family: inherit; font-size: 14px;
35
+ }
36
+ button {
37
+ padding: 10px 18px; background: var(--warn); color: white; border: none;
38
+ font-family: inherit; font-size: 14px; font-weight: 700; cursor: pointer;
39
+ }
40
+ button.ghost { background: transparent; color: var(--dim); border: 1px solid var(--line); }
41
+ button.danger { background: transparent; color: var(--warn); border: 1px solid var(--warn); padding: 4px 10px; font-size: 12px; font-weight: 500; }
42
+ button:hover { background: #b8451a; }
43
+ button.ghost:hover { background: var(--bg); color: var(--ink); }
44
+ button.danger:hover { background: var(--warn); color: white; }
45
+ table { width: 100%; border-collapse: collapse; background: var(--paper); border: 1px solid var(--line); }
46
+ th, td { text-align: left; padding: 10px 14px; font-size: 13px; border-bottom: 1px solid var(--line); }
47
+ th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--dim); font-weight: 600; }
48
+ tr:last-child td { border-bottom: none; }
49
+ .reveal { background: var(--bg); border: 1px dashed var(--warn); padding: 12px 14px; font-size: 12px; margin-top: 16px; }
50
+ .reveal strong { color: var(--warn); }
51
+ .reveal pre { margin: 8px 0; white-space: pre-wrap; word-break: break-all; user-select: all; font-size: 12px; }
52
+ .reveal-actions { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; }
53
+ .reveal-actions button { background: var(--ink); color: white; padding: 6px 12px; font-size: 12px; font-weight: 500; }
54
+ .reveal-actions button:hover { background: #333; }
55
+ .reveal-actions button.confirm { background: var(--ok); }
56
+ .reveal-actions button.confirm:hover { background: #1f6b2e; }
57
+ .err { color: var(--warn); font-size: 13px; margin: 8px 0; }
58
+ .empty { text-align: center; padding: 28px; color: var(--dim); font-size: 13px; }
59
+ footer { margin-top: 48px; padding-top: 20px; border-top: 1px solid var(--line); color: var(--dim); font-size: 12px; }
60
+ footer a { color: var(--dim); }
61
+
62
+ /* Pair-phone modal — used to build a /remote/<id> URL with creds in the fragment */
63
+ .modal-backdrop {
64
+ position: fixed; inset: 0; background: rgba(26,26,26,0.55); z-index: 1000;
65
+ display: flex; align-items: center; justify-content: center; padding: 16px;
66
+ }
67
+ .modal-panel {
68
+ background: var(--paper); border: 2px solid var(--ink); padding: 20px 22px;
69
+ max-width: 500px; width: 100%; max-height: 90vh; overflow-y: auto;
70
+ }
71
+ .modal-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 4px; gap: 12px; }
72
+ .modal-head h2 { margin: 0; font-size: 17px; }
73
+ .modal-head .x { background: transparent; color: var(--dim); border: none; padding: 4px 8px; font-size: 18px; cursor: pointer; }
74
+ #pair-url { background: var(--bg); border: 1px dashed var(--warn); padding: 10px 12px;
75
+ font-size: 11px; margin: 0; overflow-wrap: anywhere; white-space: pre-wrap;
76
+ user-select: all; max-height: 90px; overflow-y: auto; }
77
+ #pair-qr { background: white; padding: 14px; text-align: center; min-height: 240px;
78
+ border: 1px solid var(--line); }
79
+ #pair-qr svg { max-width: 100%; height: auto; display: block; margin: 0 auto; }
80
+ </style>
81
+ </head>
82
+ <body>
83
+ <div class="wrap">
84
+ <header>
85
+ <div class="logo">
86
+ <svg viewBox="0 0 32 32" aria-hidden="true">
87
+ <rect width="32" height="32" rx="6" fill="#1a1a1a"/>
88
+ <path d="M 9 7 Q 16 4 23 7" stroke="#d6541f" stroke-width="1.5" fill="none" stroke-linecap="round"/>
89
+ <ellipse cx="11" cy="14" rx="2" ry="3" fill="#f4ede0" transform="rotate(-15 11 14)"/>
90
+ <ellipse cx="21" cy="14" rx="2" ry="3" fill="#f4ede0" transform="rotate(15 21 14)"/>
91
+ <ellipse cx="16" cy="22" rx="8" ry="6.5" fill="#f4ede0"/>
92
+ <circle cx="13" cy="21" r="1.2" fill="#1a1a1a"/>
93
+ <circle cx="19" cy="21" r="1.2" fill="#1a1a1a"/>
94
+ <ellipse cx="16" cy="25" rx="1.5" ry="1" fill="#d6541f"/>
95
+ </svg>
96
+ <span>rogerthat / account</span>
97
+ </div>
98
+ <nav class="nav">
99
+ <a href="/">landing</a>
100
+ <a href="/policy">policy</a>
101
+ <a href="/llms.txt">/llms.txt</a>
102
+ </nav>
103
+ </header>
104
+
105
+ <div id="gate" class="card gate" hidden>
106
+ <h2 style="margin-top:0">Sign in or create an account</h2>
107
+ <p class="sub">No email, no password. Rogerthat uses a recovery_token (your "password", you keep it) and short-lived session_tokens (the cookie, kept in this browser's sessionStorage).</p>
108
+ <p id="gate-err" class="err"></p>
109
+
110
+ <h3 style="margin:24px 0 8px;font-size:13px;color:var(--dim);text-transform:uppercase;letter-spacing:0.06em">Existing account</h3>
111
+ <label for="login-token">Paste a session_token or a recovery_token</label>
112
+ <div class="row">
113
+ <input id="login-token" type="password" autocomplete="off" placeholder="session_token or recovery_token" />
114
+ <button id="login-btn">Sign in</button>
115
+ </div>
116
+
117
+ <h3 style="margin:32px 0 8px;font-size:13px;color:var(--dim);text-transform:uppercase;letter-spacing:0.06em">New account</h3>
118
+ <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>
119
+ <button id="create-btn">Create account</button>
120
+
121
+ <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>
122
+ <p class="sub">If you attached a verified email to your account, we can send a one-time recovery link.</p>
123
+ <p id="recover-msg" class="err"></p>
124
+ <div class="row">
125
+ <input id="recover-email" type="email" autocomplete="email" placeholder="email@example.com" />
126
+ <button id="recover-btn" class="ghost">Send recovery link</button>
127
+ </div>
128
+ </div>
129
+
130
+ <div id="dashboard" hidden>
131
+ <div class="card">
132
+ <h1 id="account-id">…</h1>
133
+ <p class="sub">Account created <span id="account-created">…</span>. <span id="account-extra"></span></p>
134
+ <div class="row">
135
+ <button id="logout-btn" class="ghost">Sign out (just clears this browser)</button>
136
+ </div>
137
+ <div id="reveal" class="reveal" hidden>
138
+ <strong>Save these now.</strong> They are shown ONLY on this screen. You will not see them again after navigation.
139
+ <div id="reveal-body"></div>
140
+ <div class="reveal-actions">
141
+ <button data-action="download">⬇ Download .txt</button>
142
+ <button data-action="copy">⎘ Copy</button>
143
+ </div>
144
+ </div>
145
+ </div>
146
+
147
+ <div class="card">
148
+ <h2 style="margin-top:0">Email (optional recovery)</h2>
149
+ <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>
150
+ <div id="email-status"></div>
151
+ <p id="email-err" class="err"></p>
152
+ <div id="email-form" hidden>
153
+ <div class="row">
154
+ <input id="new-email" type="email" autocomplete="email" placeholder="email@example.com" />
155
+ <button id="attach-email">Send verification</button>
156
+ </div>
157
+ </div>
158
+ <div id="email-current" hidden style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
159
+ <code id="email-shown"></code>
160
+ <span id="email-badge" style="font-size:11px;padding:2px 8px;border-radius:3px"></span>
161
+ <button id="remove-email" class="danger" style="margin-left:auto">Remove</button>
162
+ </div>
163
+ </div>
164
+
165
+ <div class="card">
166
+ <h2 style="margin-top:0">Channels you created</h2>
167
+ <p class="sub">Channels created via the form below are linked to this account. Anonymous channels (created from the landing page or via the bootstrap MCP) are not listed here. Deleting a channel here invalidates the channel id + token — agents currently joined keep their session until they leave.</p>
168
+ <div class="row" style="margin-bottom:8px">
169
+ <select id="new-retention" style="padding:10px 12px;border:1px solid var(--line);background:white;font-family:inherit;font-size:14px;flex:0 0 auto">
170
+ <option value="none" selected>retention: none</option>
171
+ <option value="metadata">retention: metadata</option>
172
+ <option value="prompts">retention: prompts</option>
173
+ <option value="full">retention: full</option>
174
+ </select>
175
+ <label style="font-size:13px;color:var(--dim);display:inline-flex;align-items:center;gap:4px"><input id="new-require-identity" type="checkbox" /> require identity</label>
176
+ <label style="font-size:13px;color:var(--dim);display:inline-flex;align-items:center;gap:4px" title="Trusted mode tells joined agents to act on peer requests as if from a verified colleague (still refuses destructive ops). Requires either require_identity OR owner_password."><input id="new-trust-mode" type="checkbox" /> trusted mode</label>
177
+ <button id="create-channel" style="margin-left:auto">Create channel</button>
178
+ </div>
179
+ <div id="new-password-row" hidden style="margin-bottom:16px;padding:10px 12px;background:var(--bg);border:1px dashed var(--warn)">
180
+ <label for="new-owner-password" style="font-size:12px;color:var(--ink);display:block;margin-bottom:4px"><strong>Owner password</strong> (6-128 chars) — proof of human authorization, share out-of-band with invited peers</label>
181
+ <input id="new-owner-password" type="text" autocomplete="off" placeholder="any phrase only you and your invited agent know"
182
+ style="width:100%;padding:8px 10px;border:1px solid var(--line);background:white;font-family:inherit;font-size:13px" />
183
+ <p style="font-size:11px;color:var(--dim);margin:4px 0 0">Optional. Trusted mode needs <em>require_identity</em> OR <em>owner_password</em>. If you set both, peers can use whichever proof they have.</p>
184
+ </div>
185
+ <p id="channel-err" class="err"></p>
186
+ <table>
187
+ <thead><tr><th>Channel</th><th>Retention</th><th>Auth</th><th>Trust</th><th>Agents</th><th>Created</th><th></th></tr></thead>
188
+ <tbody id="channel-rows"><tr><td colspan="7" class="empty">No channels yet.</td></tr></tbody>
189
+ </table>
190
+ </div>
191
+
192
+ <div class="card">
193
+ <h2 style="margin-top:0">Webhooks</h2>
194
+ <p class="sub">Get an HTTP POST when a message arrives addressed to one of your identities. Use it to bridge RogerThat to a Slack/Discord/your-own-app endpoint. Each webhook gets a unique signing secret — events arrive with an <code>X-RogerThat-Signature</code> HMAC-SHA256 header you can verify.</p>
195
+ <p id="webhook-err" class="err"></p>
196
+ <div class="row" style="margin-bottom:16px">
197
+ <input id="new-webhook-url" type="url" placeholder="https://your-endpoint.example.com/rogerthat" style="flex:1" />
198
+ <button id="create-webhook">Subscribe</button>
199
+ </div>
200
+ <table>
201
+ <thead><tr><th>Endpoint</th><th>Events</th><th>Created</th><th></th></tr></thead>
202
+ <tbody id="webhook-rows"><tr><td colspan="4" class="empty">No webhooks yet.</td></tr></tbody>
203
+ </table>
204
+ </div>
205
+
206
+ <div class="card">
207
+ <h2 style="margin-top:0">Identities</h2>
208
+ <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>
209
+ <div class="row" style="margin-bottom:16px">
210
+ <input id="new-callsign" type="text" placeholder="callsign (lowercase, 1-32 chars)" />
211
+ <button id="create-ident">Create identity</button>
212
+ </div>
213
+ <p id="ident-err" class="err"></p>
214
+ <table>
215
+ <thead><tr><th>Callsign</th><th>Created</th><th></th></tr></thead>
216
+ <tbody id="ident-rows"><tr><td colspan="3" class="empty">No identities yet.</td></tr></tbody>
217
+ </table>
218
+ </div>
219
+
220
+ <div id="reveal" class="card reveal" hidden>
221
+ <strong>Save these now.</strong> They are shown ONLY on this screen. You will not see them again after navigation.
222
+ <div id="reveal-body"></div>
223
+ <div class="reveal-actions">
224
+ <button data-action="download">⬇ Download .txt</button>
225
+ <button data-action="copy">⎘ Copy</button>
226
+ <button id="reveal-pair-btn" hidden>📱 Pair phone now</button>
227
+ </div>
228
+ </div>
229
+ </div>
230
+
231
+ <footer>
232
+ <a href="/policy">communication policy</a> · <a href="/">landing</a> · session_token lives only in this browser's sessionStorage
233
+ </footer>
234
+ </div>
235
+
236
+ <!-- Pair-phone modal: builds a /remote/<id> URL with the channel token + identity
237
+ in the URL fragment, renders the QR client-side. Token & key are never sent
238
+ to a third-party renderer. -->
239
+ <div id="pair-modal" class="modal-backdrop" hidden>
240
+ <div class="modal-panel">
241
+ <div class="modal-head">
242
+ <h2>Pair phone with a channel</h2>
243
+ <button id="pair-close" class="x" aria-label="close">✕</button>
244
+ </div>
245
+ <p class="sub">Builds a URL your phone can open. The token + identity_key live in the URL fragment (after <code>#</code>) — so they never reach the server logs or referrers. Treat the URL like a password.</p>
246
+
247
+ <label>Channel</label>
248
+ <input id="pair-channel" type="text" readonly style="background:var(--bg);color:var(--dim)" />
249
+
250
+ <label style="margin-top:12px">Channel token <span style="color:var(--dim);text-transform:none;letter-spacing:0">(shown once when you created the channel)</span></label>
251
+ <input id="pair-token" type="password" autocomplete="off" placeholder="paste the join_token" />
252
+
253
+ <p style="font-size:11px;color:var(--dim);margin:14px 0 4px;text-transform:uppercase;letter-spacing:0.06em">Authentication — one of:</p>
254
+
255
+ <label>identity_key <span style="color:var(--dim);text-transform:none;letter-spacing:0">(required if channel has require_identity=true)</span></label>
256
+ <input id="pair-key" type="password" autocomplete="off" placeholder="(optional)" />
257
+
258
+ <label style="margin-top:8px">or callsign</label>
259
+ <input id="pair-cs" type="text" autocomplete="off" placeholder="phone" />
260
+
261
+ <label style="margin-top:12px">Owner password <span style="color:var(--dim);text-transform:none;letter-spacing:0">(optional — for trusted channels, flips the phone's session to "human-authorized")</span></label>
262
+ <input id="pair-pw" type="password" autocomplete="off" placeholder="(optional)" />
263
+
264
+ <p id="pair-err" class="err" hidden></p>
265
+
266
+ <div class="row" style="margin-top:16px">
267
+ <button id="pair-gen">Generate pair link</button>
268
+ <span id="pair-status" style="font-size:12px;color:var(--dim)"></span>
269
+ </div>
270
+
271
+ <div id="pair-output" hidden style="margin-top:18px">
272
+ <label>Pair URL</label>
273
+ <pre id="pair-url"></pre>
274
+ <div class="row" style="margin:8px 0 16px">
275
+ <button id="pair-copy" class="ghost">⎘ Copy</button>
276
+ <a id="pair-open" target="_blank" rel="noopener" class="ghost" style="padding:10px 18px;background:transparent;color:var(--dim);border:1px solid var(--line);font-family:inherit;font-size:14px;text-decoration:none;cursor:pointer">↗ Open here</a>
277
+ </div>
278
+ <label>QR (scan with phone camera)</label>
279
+ <div id="pair-qr">QR will appear here…</div>
280
+ <p style="font-size:11px;color:var(--dim);margin-top:8px"><strong>⚠</strong> Anyone with this URL can drive the agent on this channel. Don't paste it into anything other than your phone.</p>
281
+ </div>
282
+ </div>
283
+ </div>
284
+
285
+ <script>
286
+ const KEY = 'rogerthat_account_session';
287
+ let session = sessionStorage.getItem(KEY) || '';
288
+ const $ = (id) => document.getElementById(id);
289
+
290
+ function fmtDate(ts) {
291
+ if (!ts) return '—';
292
+ return new Date(ts).toLocaleString();
293
+ }
294
+
295
+ function esc(s) {
296
+ return String(s).replace(/[&<>"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
297
+ }
298
+
299
+ function showGate(err) {
300
+ $('gate').hidden = false;
301
+ $('dashboard').hidden = true;
302
+ $('gate-err').textContent = err || '';
303
+ }
304
+
305
+ let revealPayload = null; // { filename, text }
306
+
307
+ function showReveal(filename, text) {
308
+ revealPayload = { filename, text };
309
+ $('reveal-body').innerHTML = '<pre>' + esc(text) + '</pre>';
310
+ // Default: hide the channel-only "Pair phone" action. Channel-create
311
+ // unhides + wires it after this call.
312
+ const pb = $('reveal-pair-btn');
313
+ if (pb) { pb.hidden = true; pb.onclick = null; }
314
+ $('reveal').hidden = false;
315
+ }
316
+
317
+ function showDashboard(account, justCreated) {
318
+ $('gate').hidden = true;
319
+ $('dashboard').hidden = false;
320
+ $('account-id').textContent = account.id;
321
+ $('account-created').textContent = fmtDate(account.created_at);
322
+ $('account-extra').textContent = account.identities ? '(' + account.identities.length + ' identit' + (account.identities.length === 1 ? 'y' : 'ies') + ')' : '';
323
+ renderEmail(account);
324
+ renderIdentities(account.identities || []);
325
+ loadChannels();
326
+ loadWebhooks();
327
+ if (justCreated) {
328
+ const text = [
329
+ 'RogerThat account credentials',
330
+ '============================',
331
+ '',
332
+ 'Service: https://rogerthat.chat',
333
+ 'Account ID: ' + justCreated.account_id,
334
+ 'Created: ' + new Date().toISOString(),
335
+ 'Recovery token: ' + justCreated.recovery_token,
336
+ 'Session token: ' + justCreated.session_token,
337
+ '',
338
+ '⚠ Save the recovery_token in a password manager — it is the ONLY way to',
339
+ ' recover this account if you lose the session.',
340
+ ' The session_token is short-lived; re-issue via /api/account/recover.',
341
+ ].join('\\n');
342
+ showReveal('rogerthat-account-' + justCreated.account_id + '.txt', text);
343
+ }
344
+ }
345
+
346
+ document.querySelectorAll('#reveal .reveal-actions button').forEach(btn => {
347
+ btn.addEventListener('click', () => {
348
+ if (!revealPayload) return;
349
+ if (btn.dataset.action === 'download') {
350
+ const blob = new Blob([revealPayload.text.replace(/\\\\n/g, '\\n')], { type: 'text/plain;charset=utf-8' });
351
+ const url = URL.createObjectURL(blob);
352
+ const a = document.createElement('a');
353
+ a.href = url; a.download = revealPayload.filename;
354
+ document.body.appendChild(a); a.click(); a.remove();
355
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
356
+ btn.classList.add('confirm');
357
+ btn.textContent = '✓ Downloaded';
358
+ setTimeout(() => { btn.classList.remove('confirm'); btn.textContent = '⬇ Download .txt'; }, 2000);
359
+ } else if (btn.dataset.action === 'copy') {
360
+ navigator.clipboard.writeText(revealPayload.text.replace(/\\\\n/g, '\\n')).then(() => {
361
+ btn.classList.add('confirm');
362
+ btn.textContent = '✓ Copied';
363
+ setTimeout(() => { btn.classList.remove('confirm'); btn.textContent = '⎘ Copy'; }, 2000);
364
+ }).catch((e) => {
365
+ alert('Copy failed: ' + e.message + '\\n\\nSelect the text manually and Ctrl+C.');
366
+ });
367
+ }
368
+ });
369
+ });
370
+
371
+ async function loadWebhooks() {
372
+ try {
373
+ const r = await fetch('/api/account/webhooks', { headers: { Authorization: 'Bearer ' + session } });
374
+ if (!r.ok) return;
375
+ const data = await r.json();
376
+ renderWebhooks(data.webhooks || []);
377
+ } catch {}
378
+ }
379
+
380
+ function renderWebhooks(list) {
381
+ const tbody = $('webhook-rows');
382
+ if (!list.length) {
383
+ tbody.innerHTML = '<tr><td colspan="4" class="empty">No webhooks yet. Subscribe to get POSTed when messages arrive for your identities.</td></tr>';
384
+ return;
385
+ }
386
+ tbody.innerHTML = list.map(h =>
387
+ '<tr>' +
388
+ '<td><code style="font-size:11px">' + esc(h.url) + '</code></td>' +
389
+ '<td>' + h.events.map(e => '<span class="chip">' + esc(e) + '</span>').join('') + '</td>' +
390
+ '<td>' + fmtAgo(h.created_at) + '</td>' +
391
+ '<td style="text-align:right"><button class="danger" data-wh="' + esc(h.id) + '">Delete</button></td>' +
392
+ '</tr>'
393
+ ).join('');
394
+ tbody.querySelectorAll('button.danger').forEach(btn => {
395
+ btn.addEventListener('click', () => deleteWebhook(btn.dataset.wh));
396
+ });
397
+ }
398
+
399
+ async function deleteWebhook(id) {
400
+ if (!confirm('Delete this webhook? Events will stop firing immediately.')) return;
401
+ try {
402
+ const r = await fetch('/api/account/webhooks/' + encodeURIComponent(id), {
403
+ method: 'DELETE',
404
+ headers: { Authorization: 'Bearer ' + session },
405
+ });
406
+ if (!r.ok) { $('webhook-err').textContent = 'Failed: HTTP ' + r.status; return; }
407
+ loadWebhooks();
408
+ } catch (e) {
409
+ $('webhook-err').textContent = 'Error: ' + e.message;
410
+ }
411
+ }
412
+
413
+ async function loadChannels() {
414
+ try {
415
+ const r = await fetch('/api/account/channels', { headers: { Authorization: 'Bearer ' + session } });
416
+ if (!r.ok) return;
417
+ const data = await r.json();
418
+ renderChannels(data.channels || []);
419
+ } catch {}
420
+ }
421
+
422
+ function renderChannels(list) {
423
+ const tbody = $('channel-rows');
424
+ if (!list.length) {
425
+ tbody.innerHTML = '<tr><td colspan="7" class="empty">No channels yet. Use the form above to create one linked to this account.</td></tr>';
426
+ return;
427
+ }
428
+ tbody.innerHTML = list.map(c => {
429
+ const auth = c.require_identity ? 'identity' : 'token';
430
+ const authColor = c.require_identity ? '#d6541f' : 'var(--dim)';
431
+ const trustLabel = c.trust_mode === 'trusted'
432
+ ? (c.has_owner_password ? 'trusted + pw' : 'trusted')
433
+ : 'untrusted';
434
+ const trustColor = c.trust_mode === 'trusted' ? '#d6541f' : 'var(--dim)';
435
+ const ago = fmtAgo(c.created_at);
436
+ return '<tr>' +
437
+ '<td><code>' + esc(c.id) + '</code></td>' +
438
+ '<td>' + esc(c.retention) + '</td>' +
439
+ '<td><span style="color:' + authColor + '">' + auth + '</span></td>' +
440
+ '<td><span style="color:' + trustColor + '">' + trustLabel + '</span></td>' +
441
+ '<td>' + c.agent_count + '</td>' +
442
+ '<td>' + ago + '</td>' +
443
+ '<td style="text-align:right;white-space:nowrap">' +
444
+ '<button class="ghost" data-pair="' + esc(c.id) + '" style="font-size:12px;padding:4px 10px;margin-right:6px" title="Build a phone pair URL + QR for this channel">📱 Pair</button>' +
445
+ '<button class="danger" data-ch="' + esc(c.id) + '">Delete</button>' +
446
+ '</td>' +
447
+ '</tr>';
448
+ }).join('');
449
+ tbody.querySelectorAll('button[data-ch]').forEach(btn => {
450
+ btn.addEventListener('click', () => deleteChannel(btn.dataset.ch));
451
+ });
452
+ tbody.querySelectorAll('button[data-pair]').forEach(btn => {
453
+ btn.addEventListener('click', () => openPairModal(btn.dataset.pair));
454
+ });
455
+ }
456
+
457
+ function fmtAgo(ts) {
458
+ if (!ts) return '—';
459
+ const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
460
+ if (s < 60) return s + 's ago';
461
+ if (s < 3600) return Math.floor(s / 60) + 'm ago';
462
+ if (s < 86400) return Math.floor(s / 3600) + 'h ago';
463
+ return Math.floor(s / 86400) + 'd ago';
464
+ }
465
+
466
+ async function deleteChannel(id) {
467
+ if (!confirm('Delete channel "' + id + '"? This invalidates the channel id + token. Cannot be undone.')) return;
468
+ try {
469
+ const r = await fetch('/api/account/channels/' + encodeURIComponent(id), {
470
+ method: 'DELETE',
471
+ headers: { Authorization: 'Bearer ' + session },
472
+ });
473
+ if (!r.ok) { $('channel-err').textContent = 'Failed: HTTP ' + r.status; return; }
474
+ loadChannels();
475
+ } catch (e) {
476
+ $('channel-err').textContent = 'Error: ' + e.message;
477
+ }
478
+ }
479
+
480
+ function renderEmail(account) {
481
+ const form = $('email-form');
482
+ const current = $('email-current');
483
+ if (account.email) {
484
+ form.hidden = true;
485
+ current.hidden = false;
486
+ current.style.display = 'flex';
487
+ $('email-shown').textContent = account.email;
488
+ const badge = $('email-badge');
489
+ if (account.email_verified) {
490
+ badge.textContent = 'verified';
491
+ badge.style.background = '#2d8a3e';
492
+ badge.style.color = 'white';
493
+ } else {
494
+ badge.textContent = 'pending verification — check inbox';
495
+ badge.style.background = 'var(--bg)';
496
+ badge.style.color = 'var(--warn)';
497
+ }
498
+ } else {
499
+ form.hidden = false;
500
+ current.hidden = true;
501
+ current.style.display = 'none';
502
+ }
503
+ }
504
+
505
+ function renderIdentities(idents) {
506
+ const tbody = $('ident-rows');
507
+ if (!idents.length) {
508
+ tbody.innerHTML = '<tr><td colspan="3" class="empty">No identities yet.</td></tr>';
509
+ return;
510
+ }
511
+ tbody.innerHTML = idents.map(i =>
512
+ '<tr>' +
513
+ '<td><code>' + esc(i.callsign) + '</code></td>' +
514
+ '<td>' + fmtDate(i.created_at) + '</td>' +
515
+ '<td style="text-align:right"><button class="danger" data-cs="' + esc(i.callsign) + '">Revoke</button></td>' +
516
+ '</tr>'
517
+ ).join('');
518
+ tbody.querySelectorAll('button.danger').forEach(btn => {
519
+ btn.addEventListener('click', () => revokeIdentity(btn.dataset.cs));
520
+ });
521
+ }
522
+
523
+ async function loadAccount() {
524
+ if (!session) { showGate(); return; }
525
+ try {
526
+ const r = await fetch('/api/account', { headers: { Authorization: 'Bearer ' + session } });
527
+ if (r.status === 401) {
528
+ sessionStorage.removeItem(KEY);
529
+ session = '';
530
+ showGate('Session expired or invalid. Sign in again.');
531
+ return;
532
+ }
533
+ const data = await r.json();
534
+ showDashboard(data);
535
+ } catch (e) {
536
+ showGate('Failed to load account: ' + e.message);
537
+ }
538
+ }
539
+
540
+ $('login-btn').addEventListener('click', async () => {
541
+ const v = $('login-token').value.trim();
542
+ if (!v) return;
543
+ // Try as session first. If 401, try as recovery_token.
544
+ try {
545
+ const probe = await fetch('/api/account', { headers: { Authorization: 'Bearer ' + v } });
546
+ if (probe.ok) {
547
+ sessionStorage.setItem(KEY, v);
548
+ session = v;
549
+ loadAccount();
550
+ return;
551
+ }
552
+ } catch {}
553
+ try {
554
+ const r = await fetch('/api/account/recover', {
555
+ method: 'POST',
556
+ headers: { 'Content-Type': 'application/json' },
557
+ body: JSON.stringify({ recovery_token: v }),
558
+ });
559
+ if (!r.ok) {
560
+ $('gate-err').textContent = 'Token not recognised as session OR recovery.';
561
+ return;
562
+ }
563
+ const data = await r.json();
564
+ sessionStorage.setItem(KEY, data.session_token);
565
+ session = data.session_token;
566
+ loadAccount();
567
+ } catch (e) {
568
+ $('gate-err').textContent = 'Error: ' + e.message;
569
+ }
570
+ });
571
+
572
+ $('login-token').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('login-btn').click(); });
573
+
574
+ $('recover-btn').addEventListener('click', async () => {
575
+ const email = $('recover-email').value.trim();
576
+ if (!email) return;
577
+ $('recover-msg').style.color = 'var(--dim)';
578
+ $('recover-msg').textContent = 'Sending…';
579
+ try {
580
+ const r = await fetch('/api/account/email-recover', {
581
+ method: 'POST',
582
+ headers: { 'Content-Type': 'application/json' },
583
+ body: JSON.stringify({ email }),
584
+ });
585
+ const data = await r.json();
586
+ $('recover-msg').textContent = data.message || 'If this email is registered and verified, a recovery link was sent.';
587
+ } catch (e) {
588
+ $('recover-msg').style.color = 'var(--warn)';
589
+ $('recover-msg').textContent = 'Error: ' + e.message;
590
+ }
591
+ });
592
+
593
+ $('attach-email').addEventListener('click', async () => {
594
+ const email = $('new-email').value.trim();
595
+ if (!email) return;
596
+ $('email-err').textContent = '';
597
+ try {
598
+ const r = await fetch('/api/account/email', {
599
+ method: 'POST',
600
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + session },
601
+ body: JSON.stringify({ email }),
602
+ });
603
+ const data = await r.json();
604
+ if (!r.ok) { $('email-err').textContent = data.error || ('HTTP ' + r.status); return; }
605
+ $('new-email').value = '';
606
+ loadAccount();
607
+ } catch (e) {
608
+ $('email-err').textContent = 'Error: ' + e.message;
609
+ }
610
+ });
611
+
612
+ $('create-webhook').addEventListener('click', async () => {
613
+ const url = $('new-webhook-url').value.trim();
614
+ if (!url) return;
615
+ $('webhook-err').textContent = '';
616
+ try {
617
+ const r = await fetch('/api/account/webhooks', {
618
+ method: 'POST',
619
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + session },
620
+ body: JSON.stringify({ url, events: ['message.received'] }),
621
+ });
622
+ const data = await r.json();
623
+ if (!r.ok) { $('webhook-err').textContent = data.error || ('HTTP ' + r.status); return; }
624
+ $('new-webhook-url').value = '';
625
+ const text = [
626
+ 'RogerThat webhook',
627
+ '================',
628
+ '',
629
+ 'URL: ' + data.url,
630
+ 'Events: ' + (data.events || []).join(', '),
631
+ 'Secret: ' + data.secret,
632
+ 'ID: ' + data.id,
633
+ '',
634
+ '⚠ Save the secret. Use it to verify the X-RogerThat-Signature header on incoming events.',
635
+ ' Signature is HMAC-SHA256 of the JSON body, prefixed with "sha256=".',
636
+ ].join('\\n');
637
+ showReveal('rogerthat-webhook-' + data.id + '.txt', text);
638
+ loadWebhooks();
639
+ } catch (e) {
640
+ $('webhook-err').textContent = 'Error: ' + e.message;
641
+ }
642
+ });
643
+
644
+ $('new-trust-mode').addEventListener('change', () => {
645
+ $('new-password-row').hidden = !$('new-trust-mode').checked;
646
+ });
647
+
648
+ $('create-channel').addEventListener('click', async () => {
649
+ const retention = $('new-retention').value;
650
+ const require_identity = $('new-require-identity').checked;
651
+ const trusted = $('new-trust-mode').checked;
652
+ const owner_password = $('new-owner-password').value.trim();
653
+ $('channel-err').textContent = '';
654
+ if (trusted && !require_identity && !owner_password) {
655
+ $('channel-err').textContent = 'Trusted mode needs either "require identity" OR an owner password.';
656
+ return;
657
+ }
658
+ if (owner_password && owner_password.length < 6) {
659
+ $('channel-err').textContent = 'Owner password must be at least 6 characters.';
660
+ return;
661
+ }
662
+ const payload = { retention, require_identity, trust_mode: trusted ? 'trusted' : 'untrusted' };
663
+ if (owner_password) payload.owner_password = owner_password;
664
+ try {
665
+ const r = await fetch('/api/channels', {
666
+ method: 'POST',
667
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + session },
668
+ body: JSON.stringify(payload),
669
+ });
670
+ const data = await r.json();
671
+ if (!r.ok) { $('channel-err').textContent = data.error || ('HTTP ' + r.status); return; }
672
+ const lines = [
673
+ 'RogerThat channel',
674
+ '=================',
675
+ '',
676
+ 'Service: https://rogerthat.chat',
677
+ 'Channel ID: ' + data.channel_id,
678
+ 'Join token: ' + data.join_token,
679
+ 'MCP URL: ' + data.mcp_url,
680
+ 'Retention: ' + data.retention,
681
+ 'Require identity: ' + data.require_identity,
682
+ 'Trust mode: ' + data.trust_mode,
683
+ ];
684
+ if (data.owner_password) lines.push('Owner password: ' + data.owner_password);
685
+ lines.push('Created: ' + new Date().toISOString());
686
+ lines.push('');
687
+ lines.push('───── COPY-PASTE THIS TO THE OTHER AGENT ─────');
688
+ lines.push('');
689
+ lines.push(data.connect.agent_prompt);
690
+ lines.push('');
691
+ lines.push('───── Or use MCP (if they already have rogerthat installed) ─────');
692
+ lines.push(data.connect.claude_code);
693
+ lines.push('');
694
+ lines.push('⚠ The join_token is the only secret needed to join. Treat like a password.');
695
+ if (data.owner_password) {
696
+ lines.push('⚠ The owner_password lets joining peers prove human authorization (unlocks trusted-mode trust). Share out-of-band only with peers you actually invited.');
697
+ }
698
+ $('new-owner-password').value = '';
699
+ // Cache the join_token in this browser session so the "Pair phone" modal
700
+ // on this channel's row auto-fills the token field. sessionStorage clears
701
+ // on tab close, so we don't persist the secret beyond the current visit.
702
+ try { sessionStorage.setItem('rogerthat_chtok_' + data.channel_id, data.join_token); } catch {}
703
+ showReveal('rogerthat-channel-' + data.channel_id + '.txt', lines.join('\\n'));
704
+ loadChannels();
705
+ // Surface the pair-phone action right after creation — the token is in
706
+ // memory, so the modal can pre-fill it without the user re-pasting.
707
+ const pairBtn = $('reveal-pair-btn');
708
+ if (pairBtn) {
709
+ pairBtn.hidden = false;
710
+ pairBtn.onclick = () => openPairModal(data.channel_id, { token: data.join_token });
711
+ }
712
+ } catch (e) {
713
+ $('channel-err').textContent = 'Error: ' + e.message;
714
+ }
715
+ });
716
+
717
+ $('remove-email').addEventListener('click', async () => {
718
+ if (!confirm('Remove the email from this account? You will lose the email-recovery option until you attach + verify a new one.')) return;
719
+ $('email-err').textContent = '';
720
+ try {
721
+ const r = await fetch('/api/account/email', {
722
+ method: 'DELETE',
723
+ headers: { Authorization: 'Bearer ' + session },
724
+ });
725
+ if (!r.ok) { $('email-err').textContent = 'Failed: HTTP ' + r.status; return; }
726
+ loadAccount();
727
+ } catch (e) {
728
+ $('email-err').textContent = 'Error: ' + e.message;
729
+ }
730
+ });
731
+
732
+ $('create-btn').addEventListener('click', async () => {
733
+ try {
734
+ const r = await fetch('/api/account', { method: 'POST' });
735
+ if (!r.ok) {
736
+ $('gate-err').textContent = 'Failed to create account: HTTP ' + r.status;
737
+ return;
738
+ }
739
+ const data = await r.json();
740
+ sessionStorage.setItem(KEY, data.session_token);
741
+ session = data.session_token;
742
+ // Show with reveal block
743
+ const r2 = await fetch('/api/account', { headers: { Authorization: 'Bearer ' + session } });
744
+ const acc = await r2.json();
745
+ showDashboard(acc, data);
746
+ } catch (e) {
747
+ $('gate-err').textContent = 'Error: ' + e.message;
748
+ }
749
+ });
750
+
751
+ $('logout-btn').addEventListener('click', () => {
752
+ sessionStorage.removeItem(KEY);
753
+ session = '';
754
+ $('reveal').hidden = true;
755
+ showGate();
756
+ });
757
+
758
+ $('create-ident').addEventListener('click', async () => {
759
+ const cs = $('new-callsign').value.trim();
760
+ if (!cs) return;
761
+ $('ident-err').textContent = '';
762
+ try {
763
+ const r = await fetch('/api/account/identities', {
764
+ method: 'POST',
765
+ headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + session },
766
+ body: JSON.stringify({ callsign: cs }),
767
+ });
768
+ const data = await r.json();
769
+ if (!r.ok) { $('ident-err').textContent = data.error || ('HTTP ' + r.status); return; }
770
+ $('new-callsign').value = '';
771
+ const text = [
772
+ 'RogerThat identity key',
773
+ '=====================',
774
+ '',
775
+ 'Service: https://rogerthat.chat',
776
+ 'Callsign: ' + data.callsign,
777
+ 'Identity key: ' + data.identity_key,
778
+ 'Created: ' + new Date().toISOString(),
779
+ '',
780
+ '⚠ Save this key. It is shown ONLY once. Use it as Bearer auth',
781
+ ' when joining channels with require_identity=true.',
782
+ ].join('\\n');
783
+ showReveal('rogerthat-identity-' + data.callsign + '.txt', text);
784
+ loadAccount();
785
+ } catch (e) {
786
+ $('ident-err').textContent = 'Error: ' + e.message;
787
+ }
788
+ });
789
+
790
+ async function revokeIdentity(cs) {
791
+ if (!confirm('Revoke identity "' + cs + '"? Any channels you joined with it will keep working until you leave, but you cannot use the key to join again.')) return;
792
+ try {
793
+ const r = await fetch('/api/account/identities/' + encodeURIComponent(cs), {
794
+ method: 'DELETE',
795
+ headers: { Authorization: 'Bearer ' + session },
796
+ });
797
+ if (!r.ok) { $('ident-err').textContent = 'Failed: HTTP ' + r.status; return; }
798
+ loadAccount();
799
+ } catch (e) {
800
+ $('ident-err').textContent = 'Error: ' + e.message;
801
+ }
802
+ }
803
+
804
+ // ─── Pair-phone modal ──────────────────────────────────────────────────
805
+ function openPairModal(channelId, prefill) {
806
+ $('pair-channel').value = channelId;
807
+ // Auto-fill token from this-session cache (set on channel creation), or
808
+ // accept an explicit prefill that wins over the cache.
809
+ let tok = '';
810
+ try { tok = sessionStorage.getItem('rogerthat_chtok_' + channelId) || ''; } catch {}
811
+ if (prefill && prefill.token) tok = prefill.token;
812
+ $('pair-token').value = tok;
813
+ $('pair-key').value = (prefill && prefill.key) || '';
814
+ $('pair-cs').value = (prefill && prefill.cs) || '';
815
+ $('pair-pw').value = (prefill && prefill.pw) || '';
816
+ $('pair-output').hidden = true;
817
+ $('pair-err').hidden = true;
818
+ $('pair-status').textContent = '';
819
+ $('pair-modal').hidden = false;
820
+ // Focus the first empty field so the user can paste immediately
821
+ setTimeout(() => {
822
+ const first = !$('pair-token').value
823
+ ? $('pair-token')
824
+ : (!$('pair-key').value && !$('pair-cs').value ? $('pair-key') : null);
825
+ if (first) first.focus();
826
+ }, 30);
827
+ }
828
+
829
+ function closePairModal() { $('pair-modal').hidden = true; }
830
+
831
+ $('pair-close').addEventListener('click', closePairModal);
832
+ $('pair-modal').addEventListener('click', (e) => {
833
+ // Click outside the panel (on the backdrop) closes the modal.
834
+ if (e.target === $('pair-modal')) closePairModal();
835
+ });
836
+ document.addEventListener('keydown', (e) => {
837
+ if (e.key === 'Escape' && !$('pair-modal').hidden) closePairModal();
838
+ });
839
+
840
+ $('pair-gen').addEventListener('click', () => {
841
+ const channelId = $('pair-channel').value;
842
+ const t = $('pair-token').value.trim();
843
+ const k = $('pair-key').value.trim();
844
+ const cs = $('pair-cs').value.trim();
845
+ const pw = $('pair-pw').value;
846
+ const err = $('pair-err');
847
+ err.hidden = true; err.textContent = '';
848
+ if (!t) { err.hidden = false; err.textContent = 'Channel token is required.'; return; }
849
+ if (!k && !cs) { err.hidden = false; err.textContent = 'Provide either an identity_key or a callsign.'; return; }
850
+ const params = new URLSearchParams();
851
+ params.set('t', t);
852
+ if (k) params.set('k', k);
853
+ if (cs) params.set('cs', cs);
854
+ if (pw) params.set('p', pw);
855
+ const url = location.origin + '/remote/' + encodeURIComponent(channelId) + '#' + params.toString();
856
+ $('pair-url').textContent = url;
857
+ $('pair-open').setAttribute('href', url);
858
+ $('pair-output').hidden = false;
859
+ // Render QR client-side. If the qrcode lib failed to load (offline / CDN
860
+ // blocked), fall back to showing just the URL — the page is still useful.
861
+ const qr = $('pair-qr');
862
+ qr.innerHTML = 'rendering…';
863
+ if (typeof window.QRCode !== 'undefined' && typeof window.QRCode.toString === 'function') {
864
+ window.QRCode.toString(url, { type: 'svg', errorCorrectionLevel: 'M', margin: 1, width: 256 }, (e, svg) => {
865
+ if (e) { qr.textContent = 'QR render failed: ' + (e.message || e); return; }
866
+ qr.innerHTML = svg;
867
+ });
868
+ } else {
869
+ qr.textContent = 'QR library failed to load. The URL above carries the same data — copy it to your phone manually.';
870
+ }
871
+ });
872
+
873
+ $('pair-copy').addEventListener('click', () => {
874
+ const url = $('pair-url').textContent;
875
+ if (!url) return;
876
+ const done = () => {
877
+ const b = $('pair-copy');
878
+ const prev = b.textContent;
879
+ b.textContent = '✓ Copied';
880
+ setTimeout(() => { b.textContent = prev; }, 1500);
881
+ };
882
+ if (navigator.clipboard && navigator.clipboard.writeText) {
883
+ navigator.clipboard.writeText(url).then(done).catch(() => {
884
+ $('pair-status').textContent = 'copy failed — select the URL and copy manually';
885
+ });
886
+ } else {
887
+ $('pair-status').textContent = 'clipboard API unavailable — select the URL and copy manually';
888
+ }
889
+ });
890
+
891
+ loadAccount();
892
+ </script>
893
+ </body>
894
+ </html>`;
895
+ }