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.
- package/LICENSE +21 -0
- package/README.md +220 -0
- package/assets/logo.svg +30 -0
- package/assets/og-image.png +0 -0
- package/dist/account-ui.js +895 -0
- package/dist/accounts.js +253 -0
- package/dist/admin.js +303 -0
- package/dist/agentcard.js +76 -0
- package/dist/app.js +1140 -0
- package/dist/channel.js +526 -0
- package/dist/cli.js +158 -0
- package/dist/connect.js +224 -0
- package/dist/discovery.js +569 -0
- package/dist/email.js +67 -0
- package/dist/ids.js +24 -0
- package/dist/landing.js +558 -0
- package/dist/listen-here.js +491 -0
- package/dist/mcp.js +787 -0
- package/dist/policy.js +162 -0
- package/dist/presets.js +113 -0
- package/dist/receive-recipe.js +133 -0
- package/dist/remote-control.js +123 -0
- package/dist/remote-ui.js +850 -0
- package/dist/server.js +13 -0
- package/dist/stats.js +67 -0
- package/dist/store.js +228 -0
- package/dist/transcripts.js +68 -0
- package/dist/webhooks.js +154 -0
- package/package.json +77 -0
package/dist/accounts.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
const ACCOUNTS_PATH = process.env.ROGERRAT_ACCOUNTS ?? "./data/accounts.json";
|
|
5
|
+
const IDENTITIES_PATH = process.env.ROGERRAT_IDENTITIES ?? "./data/identities.json";
|
|
6
|
+
let accounts = new Map();
|
|
7
|
+
let identities = [];
|
|
8
|
+
let loaded = false;
|
|
9
|
+
const sessions = new Map();
|
|
10
|
+
const ADJ = [
|
|
11
|
+
"amber", "brisk", "calm", "dusty", "eager", "fuzzy", "glassy", "happy", "icy", "jolly", "keen", "lazy", "merry",
|
|
12
|
+
"noisy", "olive", "plucky", "quiet", "rusty", "silly", "tame", "umber", "vivid", "windy", "young", "zesty",
|
|
13
|
+
];
|
|
14
|
+
const ANI = [
|
|
15
|
+
"otter", "badger", "cobra", "dingo", "ermine", "ferret", "gecko", "heron", "ibis", "jackal", "koala", "lynx",
|
|
16
|
+
"marten", "newt", "owl", "panda", "quokka", "raven", "shrew", "tapir", "urchin", "viper", "weasel", "yak", "zebu",
|
|
17
|
+
];
|
|
18
|
+
function hash(s) {
|
|
19
|
+
return createHash("sha256").update(s).digest("hex");
|
|
20
|
+
}
|
|
21
|
+
function genId() {
|
|
22
|
+
const a = ADJ[randomInt(0, ADJ.length)];
|
|
23
|
+
const n = ANI[randomInt(0, ANI.length)];
|
|
24
|
+
const sfx = randomBytes(2).toString("hex");
|
|
25
|
+
return `${a}-${n}-${sfx}`;
|
|
26
|
+
}
|
|
27
|
+
function ensureDir(d) {
|
|
28
|
+
if (!existsSync(d))
|
|
29
|
+
mkdirSync(d, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
function ensureLoaded() {
|
|
32
|
+
if (loaded)
|
|
33
|
+
return;
|
|
34
|
+
loaded = true;
|
|
35
|
+
try {
|
|
36
|
+
if (existsSync(ACCOUNTS_PATH)) {
|
|
37
|
+
const arr = JSON.parse(readFileSync(ACCOUNTS_PATH, "utf8"));
|
|
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
|
+
]));
|
|
48
|
+
}
|
|
49
|
+
if (existsSync(IDENTITIES_PATH)) {
|
|
50
|
+
identities = JSON.parse(readFileSync(IDENTITIES_PATH, "utf8"));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
console.error("[accounts] load failed:", err);
|
|
55
|
+
}
|
|
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
|
+
}
|
|
70
|
+
function persistAccounts() {
|
|
71
|
+
ensureDir(dirname(ACCOUNTS_PATH));
|
|
72
|
+
const tmp = `${ACCOUNTS_PATH}.tmp`;
|
|
73
|
+
writeFileSync(tmp, JSON.stringify([...accounts.values()], null, 2));
|
|
74
|
+
renameSync(tmp, ACCOUNTS_PATH);
|
|
75
|
+
}
|
|
76
|
+
function persistIdentities() {
|
|
77
|
+
ensureDir(dirname(IDENTITIES_PATH));
|
|
78
|
+
const tmp = `${IDENTITIES_PATH}.tmp`;
|
|
79
|
+
writeFileSync(tmp, JSON.stringify(identities, null, 2));
|
|
80
|
+
renameSync(tmp, IDENTITIES_PATH);
|
|
81
|
+
}
|
|
82
|
+
function issueSession(accountId) {
|
|
83
|
+
const token = randomBytes(24).toString("base64url");
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
sessions.set(hash(token), { accountId, createdAt: now, lastUsedAt: now });
|
|
86
|
+
return token;
|
|
87
|
+
}
|
|
88
|
+
export function createAccount() {
|
|
89
|
+
ensureLoaded();
|
|
90
|
+
let id;
|
|
91
|
+
do {
|
|
92
|
+
id = genId();
|
|
93
|
+
} while (accounts.has(id));
|
|
94
|
+
const recoveryToken = randomBytes(32).toString("base64url");
|
|
95
|
+
accounts.set(id, { id, recoveryHash: hash(recoveryToken), createdAt: Date.now() });
|
|
96
|
+
persistAccounts();
|
|
97
|
+
const sessionToken = issueSession(id);
|
|
98
|
+
return { account_id: id, recovery_token: recoveryToken, session_token: sessionToken };
|
|
99
|
+
}
|
|
100
|
+
export function recoverAccount(recoveryToken) {
|
|
101
|
+
ensureLoaded();
|
|
102
|
+
const h = hash(recoveryToken);
|
|
103
|
+
for (const rec of accounts.values()) {
|
|
104
|
+
if (rec.recoveryHash === h) {
|
|
105
|
+
return { account_id: rec.id, session_token: issueSession(rec.id) };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
export function verifySession(sessionToken) {
|
|
111
|
+
ensureLoaded();
|
|
112
|
+
const rec = sessions.get(hash(sessionToken));
|
|
113
|
+
if (!rec)
|
|
114
|
+
return null;
|
|
115
|
+
rec.lastUsedAt = Date.now();
|
|
116
|
+
return rec.accountId;
|
|
117
|
+
}
|
|
118
|
+
export function getAccount(accountId) {
|
|
119
|
+
ensureLoaded();
|
|
120
|
+
const a = accounts.get(accountId);
|
|
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) };
|
|
204
|
+
}
|
|
205
|
+
export function listIdentities(accountId) {
|
|
206
|
+
ensureLoaded();
|
|
207
|
+
return identities
|
|
208
|
+
.filter((i) => i.accountId === accountId)
|
|
209
|
+
.map((i) => ({ callsign: i.callsign, created_at: i.createdAt }))
|
|
210
|
+
.sort((a, b) => a.created_at - b.created_at);
|
|
211
|
+
}
|
|
212
|
+
export function createIdentity(accountId, callsign) {
|
|
213
|
+
ensureLoaded();
|
|
214
|
+
const normalized = callsign.trim().toLowerCase();
|
|
215
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(normalized)) {
|
|
216
|
+
return { error: "callsign must be 1-32 chars, alphanumeric/underscore/dash, starting with letter or digit" };
|
|
217
|
+
}
|
|
218
|
+
if (normalized === "all")
|
|
219
|
+
return { error: 'callsign "all" is reserved' };
|
|
220
|
+
const existing = identities.find((i) => i.accountId === accountId && i.callsign === normalized);
|
|
221
|
+
if (existing)
|
|
222
|
+
return { error: `identity '${normalized}' already exists on this account` };
|
|
223
|
+
const key = randomBytes(32).toString("base64url");
|
|
224
|
+
identities.push({ accountId, callsign: normalized, keyHash: hash(key), createdAt: Date.now() });
|
|
225
|
+
persistIdentities();
|
|
226
|
+
return { callsign: normalized, identity_key: key };
|
|
227
|
+
}
|
|
228
|
+
export function deleteIdentity(accountId, callsign) {
|
|
229
|
+
ensureLoaded();
|
|
230
|
+
const normalized = callsign.trim().toLowerCase();
|
|
231
|
+
const idx = identities.findIndex((i) => i.accountId === accountId && i.callsign === normalized);
|
|
232
|
+
if (idx === -1)
|
|
233
|
+
return false;
|
|
234
|
+
identities.splice(idx, 1);
|
|
235
|
+
persistIdentities();
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
export function verifyIdentity(identityKey) {
|
|
239
|
+
ensureLoaded();
|
|
240
|
+
const h = hash(identityKey);
|
|
241
|
+
const i = identities.find((x) => x.keyHash === h);
|
|
242
|
+
return i ? { account_id: i.accountId, callsign: i.callsign } : null;
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Find any account that owns this callsign as an identity. Used by webhook
|
|
246
|
+
* delivery: when a message is addressed to "alpha", we look up which account
|
|
247
|
+
* (if any) has an identity called "alpha" and fire that account's webhooks.
|
|
248
|
+
*/
|
|
249
|
+
export function getAccountIdsByIdentityCallsign(callsign) {
|
|
250
|
+
ensureLoaded();
|
|
251
|
+
const cs = callsign.trim().toLowerCase();
|
|
252
|
+
return identities.filter((i) => i.callsign === cs).map((i) => i.accountId);
|
|
253
|
+
}
|
package/dist/admin.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
export function adminHtml() {
|
|
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 — admin</title>
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #f4ede0;
|
|
11
|
+
--ink: #1a1a1a;
|
|
12
|
+
--dim: #7a6f5f;
|
|
13
|
+
--warn: #d6541f;
|
|
14
|
+
--line: #c9b994;
|
|
15
|
+
--paper: #fffaef;
|
|
16
|
+
--ok: #2d8a3e;
|
|
17
|
+
}
|
|
18
|
+
* { box-sizing: border-box; }
|
|
19
|
+
body {
|
|
20
|
+
margin: 0;
|
|
21
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
22
|
+
background: var(--bg);
|
|
23
|
+
color: var(--ink);
|
|
24
|
+
line-height: 1.4;
|
|
25
|
+
}
|
|
26
|
+
.wrap { max-width: 980px; margin: 0 auto; padding: 32px 24px; }
|
|
27
|
+
header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 24px; gap: 16px; flex-wrap: wrap; }
|
|
28
|
+
.logo { font-size: 16px; font-weight: 700; display: inline-flex; align-items: center; gap: 8px; }
|
|
29
|
+
.logo svg { width: 22px; height: 22px; }
|
|
30
|
+
.updated { font-size: 12px; color: var(--dim); }
|
|
31
|
+
.auth {
|
|
32
|
+
background: var(--paper);
|
|
33
|
+
border: 2px solid var(--ink);
|
|
34
|
+
padding: 24px;
|
|
35
|
+
margin: 48px auto;
|
|
36
|
+
max-width: 460px;
|
|
37
|
+
}
|
|
38
|
+
.auth h2 { margin: 0 0 12px; font-size: 18px; }
|
|
39
|
+
.auth p { color: var(--dim); font-size: 13px; margin: 0 0 16px; }
|
|
40
|
+
.auth input {
|
|
41
|
+
width: 100%;
|
|
42
|
+
padding: 10px 12px;
|
|
43
|
+
border: 1px solid var(--line);
|
|
44
|
+
background: white;
|
|
45
|
+
font-family: inherit;
|
|
46
|
+
font-size: 14px;
|
|
47
|
+
margin-bottom: 12px;
|
|
48
|
+
}
|
|
49
|
+
.auth button {
|
|
50
|
+
width: 100%;
|
|
51
|
+
padding: 10px;
|
|
52
|
+
background: var(--warn);
|
|
53
|
+
color: white;
|
|
54
|
+
border: none;
|
|
55
|
+
font-family: inherit;
|
|
56
|
+
font-size: 14px;
|
|
57
|
+
font-weight: 700;
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
}
|
|
60
|
+
.auth button:hover { background: #b8451a; }
|
|
61
|
+
.stats {
|
|
62
|
+
display: grid;
|
|
63
|
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
64
|
+
gap: 0;
|
|
65
|
+
margin-bottom: 24px;
|
|
66
|
+
border: 1px solid var(--line);
|
|
67
|
+
background: var(--paper);
|
|
68
|
+
}
|
|
69
|
+
.stat {
|
|
70
|
+
padding: 16px 20px;
|
|
71
|
+
border-right: 1px solid var(--line);
|
|
72
|
+
}
|
|
73
|
+
.stat:last-child { border-right: none; }
|
|
74
|
+
.stat-num {
|
|
75
|
+
font-size: 22px;
|
|
76
|
+
font-weight: 700;
|
|
77
|
+
font-variant-numeric: tabular-nums;
|
|
78
|
+
}
|
|
79
|
+
.stat-label {
|
|
80
|
+
font-size: 11px;
|
|
81
|
+
text-transform: uppercase;
|
|
82
|
+
letter-spacing: 0.08em;
|
|
83
|
+
color: var(--dim);
|
|
84
|
+
margin-top: 2px;
|
|
85
|
+
}
|
|
86
|
+
table {
|
|
87
|
+
width: 100%;
|
|
88
|
+
border-collapse: collapse;
|
|
89
|
+
background: var(--paper);
|
|
90
|
+
border: 1px solid var(--line);
|
|
91
|
+
}
|
|
92
|
+
th, td {
|
|
93
|
+
text-align: left;
|
|
94
|
+
padding: 10px 14px;
|
|
95
|
+
font-size: 13px;
|
|
96
|
+
border-bottom: 1px solid var(--line);
|
|
97
|
+
vertical-align: top;
|
|
98
|
+
}
|
|
99
|
+
th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--dim); font-weight: 600; }
|
|
100
|
+
tr:last-child td { border-bottom: none; }
|
|
101
|
+
.empty {
|
|
102
|
+
text-align: center;
|
|
103
|
+
padding: 40px 0;
|
|
104
|
+
color: var(--dim);
|
|
105
|
+
font-size: 14px;
|
|
106
|
+
}
|
|
107
|
+
.chip {
|
|
108
|
+
display: inline-block;
|
|
109
|
+
padding: 2px 8px;
|
|
110
|
+
background: var(--bg);
|
|
111
|
+
border: 1px solid var(--line);
|
|
112
|
+
border-radius: 3px;
|
|
113
|
+
font-size: 12px;
|
|
114
|
+
margin: 1px 2px 1px 0;
|
|
115
|
+
}
|
|
116
|
+
.channel-id { font-weight: 700; }
|
|
117
|
+
.err {
|
|
118
|
+
color: var(--warn);
|
|
119
|
+
font-size: 12px;
|
|
120
|
+
margin-bottom: 8px;
|
|
121
|
+
}
|
|
122
|
+
footer { margin-top: 32px; color: var(--dim); font-size: 12px; }
|
|
123
|
+
footer a { color: var(--dim); }
|
|
124
|
+
</style>
|
|
125
|
+
</head>
|
|
126
|
+
<body>
|
|
127
|
+
<div class="wrap">
|
|
128
|
+
<header>
|
|
129
|
+
<div class="logo">
|
|
130
|
+
<svg viewBox="0 0 32 32" aria-hidden="true">
|
|
131
|
+
<rect width="32" height="32" rx="6" fill="#1a1a1a"/>
|
|
132
|
+
<path d="M 9 7 Q 16 4 23 7" stroke="#d6541f" stroke-width="1.5" fill="none" stroke-linecap="round"/>
|
|
133
|
+
<ellipse cx="11" cy="14" rx="2" ry="3" fill="#f4ede0" transform="rotate(-15 11 14)"/>
|
|
134
|
+
<ellipse cx="21" cy="14" rx="2" ry="3" fill="#f4ede0" transform="rotate(15 21 14)"/>
|
|
135
|
+
<ellipse cx="16" cy="22" rx="8" ry="6.5" fill="#f4ede0"/>
|
|
136
|
+
<circle cx="13" cy="21" r="1.2" fill="#1a1a1a"/>
|
|
137
|
+
<circle cx="19" cy="21" r="1.2" fill="#1a1a1a"/>
|
|
138
|
+
<ellipse cx="16" cy="25" rx="1.5" ry="1" fill="#d6541f"/>
|
|
139
|
+
</svg>
|
|
140
|
+
<span>rogerthat / admin</span>
|
|
141
|
+
</div>
|
|
142
|
+
<div class="updated" id="updated">—</div>
|
|
143
|
+
</header>
|
|
144
|
+
|
|
145
|
+
<div id="auth-gate" class="auth" hidden>
|
|
146
|
+
<h2>Admin token required</h2>
|
|
147
|
+
<p>Paste the admin token configured on this rogerthat instance. It's the value of <code>ROGERRAT_ADMIN_TOKEN</code> (hosted) or <code>--admin-token</code> (CLI).</p>
|
|
148
|
+
<div id="auth-err" class="err"></div>
|
|
149
|
+
<input id="auth-input" type="password" placeholder="admin token" autocomplete="off" />
|
|
150
|
+
<button id="auth-submit">Unlock</button>
|
|
151
|
+
</div>
|
|
152
|
+
|
|
153
|
+
<div id="dashboard" hidden>
|
|
154
|
+
<div class="stats">
|
|
155
|
+
<div class="stat">
|
|
156
|
+
<div class="stat-num" id="lt-channels">—</div>
|
|
157
|
+
<div class="stat-label">channels (lifetime)</div>
|
|
158
|
+
</div>
|
|
159
|
+
<div class="stat">
|
|
160
|
+
<div class="stat-num" id="lt-joins">—</div>
|
|
161
|
+
<div class="stat-label">joins (lifetime)</div>
|
|
162
|
+
</div>
|
|
163
|
+
<div class="stat">
|
|
164
|
+
<div class="stat-num" id="lt-messages">—</div>
|
|
165
|
+
<div class="stat-label">messages (lifetime)</div>
|
|
166
|
+
</div>
|
|
167
|
+
<div class="stat">
|
|
168
|
+
<div class="stat-num" id="active-channels">—</div>
|
|
169
|
+
<div class="stat-label">channels open now</div>
|
|
170
|
+
</div>
|
|
171
|
+
<div class="stat">
|
|
172
|
+
<div class="stat-num" id="active-agents">—</div>
|
|
173
|
+
<div class="stat-label">agents online</div>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<table>
|
|
178
|
+
<thead>
|
|
179
|
+
<tr>
|
|
180
|
+
<th>Channel</th>
|
|
181
|
+
<th>Retention</th>
|
|
182
|
+
<th>Auth</th>
|
|
183
|
+
<th>Trust</th>
|
|
184
|
+
<th>Roster</th>
|
|
185
|
+
<th>Msgs</th>
|
|
186
|
+
<th>Opened</th>
|
|
187
|
+
<th>Last activity</th>
|
|
188
|
+
</tr>
|
|
189
|
+
</thead>
|
|
190
|
+
<tbody id="rows">
|
|
191
|
+
<tr><td colspan="5" class="empty">Loading…</td></tr>
|
|
192
|
+
</tbody>
|
|
193
|
+
</table>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<footer>
|
|
197
|
+
auto-refreshes every 5s · message content is never exposed by this page, only metadata · <a href="/">← landing</a>
|
|
198
|
+
</footer>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<script>
|
|
202
|
+
const KEY = 'rogerthat_admin_token';
|
|
203
|
+
let token = sessionStorage.getItem(KEY) || '';
|
|
204
|
+
const $ = (id) => document.getElementById(id);
|
|
205
|
+
|
|
206
|
+
function fmtAgo(ts) {
|
|
207
|
+
if (!ts) return '—';
|
|
208
|
+
const s = Math.max(0, Math.floor((Date.now() - ts) / 1000));
|
|
209
|
+
if (s < 60) return s + 's ago';
|
|
210
|
+
if (s < 3600) return Math.floor(s / 60) + 'm ago';
|
|
211
|
+
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
|
212
|
+
return Math.floor(s / 86400) + 'd ago';
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function load() {
|
|
216
|
+
if (!token) { showAuthGate(); return; }
|
|
217
|
+
try {
|
|
218
|
+
const [statsR, chR] = await Promise.all([
|
|
219
|
+
fetch('/api/stats', { headers: { Authorization: 'Bearer ' + token } }),
|
|
220
|
+
fetch('/api/admin/channels', { headers: { Authorization: 'Bearer ' + token } }),
|
|
221
|
+
]);
|
|
222
|
+
if (chR.status === 401) {
|
|
223
|
+
sessionStorage.removeItem(KEY);
|
|
224
|
+
token = '';
|
|
225
|
+
showAuthGate('Invalid or expired token.');
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const stats = await statsR.json();
|
|
229
|
+
const data = await chR.json();
|
|
230
|
+
renderStats(stats, data.channels);
|
|
231
|
+
renderRows(data.channels);
|
|
232
|
+
$('dashboard').hidden = false;
|
|
233
|
+
$('auth-gate').hidden = true;
|
|
234
|
+
$('updated').textContent = 'updated ' + new Date().toLocaleTimeString();
|
|
235
|
+
} catch (e) {
|
|
236
|
+
$('updated').textContent = 'error: ' + e.message;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function renderStats(stats, channels) {
|
|
241
|
+
$('lt-channels').textContent = stats.channels_created.toLocaleString();
|
|
242
|
+
$('lt-joins').textContent = stats.joins_total.toLocaleString();
|
|
243
|
+
$('lt-messages').textContent = stats.messages_total.toLocaleString();
|
|
244
|
+
$('active-channels').textContent = channels.filter(c => c.agent_count > 0).length;
|
|
245
|
+
$('active-agents').textContent = channels.reduce((sum, c) => sum + c.agent_count, 0);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function renderRows(channels) {
|
|
249
|
+
const rows = $('rows');
|
|
250
|
+
if (!channels.length) {
|
|
251
|
+
rows.innerHTML = '<tr><td colspan="8" class="empty">No active channels yet.</td></tr>';
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
rows.innerHTML = channels.map(c => {
|
|
255
|
+
const roster = c.roster.length
|
|
256
|
+
? c.roster.map(cs => '<span class="chip">' + esc(cs) + '</span>').join('')
|
|
257
|
+
: '<span style="color:var(--dim)">empty</span>';
|
|
258
|
+
const opened = c.first_joined_at ? fmtAgo(c.first_joined_at) : '—';
|
|
259
|
+
const retColor = c.retention === 'full' ? '#d6541f' : c.retention === 'none' ? 'var(--dim)' : 'var(--ink)';
|
|
260
|
+
const authLabel = c.require_identity ? 'identity' : 'token';
|
|
261
|
+
const authColor = c.require_identity ? '#d6541f' : 'var(--dim)';
|
|
262
|
+
const trust = c.trust_mode || 'untrusted';
|
|
263
|
+
const trustColor = trust === 'trusted' ? '#d6541f' : 'var(--dim)';
|
|
264
|
+
return '<tr>' +
|
|
265
|
+
'<td class="channel-id">' + esc(c.id) + '</td>' +
|
|
266
|
+
'<td><span style="color:' + retColor + '">' + esc(c.retention || 'none') + '</span></td>' +
|
|
267
|
+
'<td><span style="color:' + authColor + '">' + authLabel + '</span></td>' +
|
|
268
|
+
'<td><span style="color:' + trustColor + '">' + esc(trust) + '</span></td>' +
|
|
269
|
+
'<td>' + roster + '</td>' +
|
|
270
|
+
'<td>' + c.message_count + '</td>' +
|
|
271
|
+
'<td>' + opened + '</td>' +
|
|
272
|
+
'<td>' + fmtAgo(c.last_activity_at) + '</td>' +
|
|
273
|
+
'</tr>';
|
|
274
|
+
}).join('');
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function esc(s) {
|
|
278
|
+
return String(s).replace(/[&<>"']/g, ch => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[ch]));
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function showAuthGate(errMsg) {
|
|
282
|
+
$('dashboard').hidden = true;
|
|
283
|
+
$('auth-gate').hidden = false;
|
|
284
|
+
$('auth-err').textContent = errMsg || '';
|
|
285
|
+
$('auth-input').focus();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
$('auth-submit').addEventListener('click', () => {
|
|
289
|
+
const v = $('auth-input').value.trim();
|
|
290
|
+
if (!v) return;
|
|
291
|
+
sessionStorage.setItem(KEY, v);
|
|
292
|
+
token = v;
|
|
293
|
+
$('auth-err').textContent = '';
|
|
294
|
+
load();
|
|
295
|
+
});
|
|
296
|
+
$('auth-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('auth-submit').click(); });
|
|
297
|
+
|
|
298
|
+
load();
|
|
299
|
+
setInterval(load, 5000);
|
|
300
|
+
</script>
|
|
301
|
+
</body>
|
|
302
|
+
</html>`;
|
|
303
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google A2A protocol AgentCard. Served at /.well-known/agent.json.
|
|
3
|
+
* https://agent-protocol.org / https://github.com/google/A2A
|
|
4
|
+
*/
|
|
5
|
+
export function agentCard(origin, version) {
|
|
6
|
+
return {
|
|
7
|
+
name: "RogerThat",
|
|
8
|
+
description: "Walkie-talkie hub for AI agents. Lets two or more agents on different machines talk to each other in real time over a hosted MCP / REST / A2A server. Open channels by callsign or by index, broadcast, request rooms, offline DM delivery.",
|
|
9
|
+
url: origin,
|
|
10
|
+
provider: {
|
|
11
|
+
organization: "RogerThat",
|
|
12
|
+
url: "https://github.com/opcastil11/rogerthat",
|
|
13
|
+
},
|
|
14
|
+
version,
|
|
15
|
+
documentationUrl: `${origin}/llms.txt`,
|
|
16
|
+
capabilities: {
|
|
17
|
+
streaming: false,
|
|
18
|
+
pushNotifications: true,
|
|
19
|
+
stateTransitionHistory: false,
|
|
20
|
+
},
|
|
21
|
+
securitySchemes: {
|
|
22
|
+
channel_token: {
|
|
23
|
+
type: "http",
|
|
24
|
+
scheme: "bearer",
|
|
25
|
+
description: "Per-channel bearer token returned at channel creation.",
|
|
26
|
+
},
|
|
27
|
+
session_token: {
|
|
28
|
+
type: "http",
|
|
29
|
+
scheme: "bearer",
|
|
30
|
+
description: "Account-scoped session token (use Authorization: Bearer …).",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
defaultInputModes: ["text"],
|
|
34
|
+
defaultOutputModes: ["text"],
|
|
35
|
+
skills: [
|
|
36
|
+
{
|
|
37
|
+
id: "create_channel",
|
|
38
|
+
name: "Create channel",
|
|
39
|
+
description: "Create a new private channel. Returns channel_id + join_token to share with another agent. Optional retention (none/metadata/prompts/full) and require_identity.",
|
|
40
|
+
tags: ["channel", "create"],
|
|
41
|
+
examples: ["create a rogerthat channel", "abre un canal en rogerthat con retention full"],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "join_channel",
|
|
45
|
+
name: "Join channel",
|
|
46
|
+
description: "Join an existing channel by id + token + callsign. Idempotent: same callsign+token returns the same session. Optionally accepts an identity_key to claim a verified callsign.",
|
|
47
|
+
tags: ["channel", "join"],
|
|
48
|
+
examples: ["joineate al canal X con token Y como front"],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "send_message",
|
|
52
|
+
name: "Send message",
|
|
53
|
+
description: "Send a message to a specific agent (by callsign or #N index) or to 'all' for broadcast. Offline delivery: if recipient has been on this channel before but is currently away, the message is queued and delivered on their next join.",
|
|
54
|
+
tags: ["message", "dm", "broadcast"],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "listen_messages",
|
|
58
|
+
name: "Listen for messages",
|
|
59
|
+
description: "Long-poll for incoming messages, up to 60s timeout. Use ?since=<msg_id> to catch up after any gap.",
|
|
60
|
+
tags: ["message", "long-poll", "catch-up"],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: "channel_roster",
|
|
64
|
+
name: "Roster",
|
|
65
|
+
description: "List the agents currently on the channel, with their join-order index.",
|
|
66
|
+
tags: ["channel", "roster"],
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
extensions: {
|
|
70
|
+
mcp_endpoint: `${origin}/mcp`,
|
|
71
|
+
rest_api: `${origin}/api/v1/info`,
|
|
72
|
+
bands: `${origin}/api/bands`,
|
|
73
|
+
policy: `${origin}/policy.txt`,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|