rogerrat 0.4.0 → 0.5.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/accounts.js +139 -0
- package/dist/app.js +86 -0
- package/dist/discovery.js +33 -1
- package/package.json +1 -1
package/dist/accounts.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
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) => [r.id, r]));
|
|
39
|
+
}
|
|
40
|
+
if (existsSync(IDENTITIES_PATH)) {
|
|
41
|
+
identities = JSON.parse(readFileSync(IDENTITIES_PATH, "utf8"));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch (err) {
|
|
45
|
+
console.error("[accounts] load failed:", err);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function persistAccounts() {
|
|
49
|
+
ensureDir(dirname(ACCOUNTS_PATH));
|
|
50
|
+
const tmp = `${ACCOUNTS_PATH}.tmp`;
|
|
51
|
+
writeFileSync(tmp, JSON.stringify([...accounts.values()], null, 2));
|
|
52
|
+
renameSync(tmp, ACCOUNTS_PATH);
|
|
53
|
+
}
|
|
54
|
+
function persistIdentities() {
|
|
55
|
+
ensureDir(dirname(IDENTITIES_PATH));
|
|
56
|
+
const tmp = `${IDENTITIES_PATH}.tmp`;
|
|
57
|
+
writeFileSync(tmp, JSON.stringify(identities, null, 2));
|
|
58
|
+
renameSync(tmp, IDENTITIES_PATH);
|
|
59
|
+
}
|
|
60
|
+
function issueSession(accountId) {
|
|
61
|
+
const token = randomBytes(24).toString("base64url");
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
sessions.set(hash(token), { accountId, createdAt: now, lastUsedAt: now });
|
|
64
|
+
return token;
|
|
65
|
+
}
|
|
66
|
+
export function createAccount() {
|
|
67
|
+
ensureLoaded();
|
|
68
|
+
let id;
|
|
69
|
+
do {
|
|
70
|
+
id = genId();
|
|
71
|
+
} while (accounts.has(id));
|
|
72
|
+
const recoveryToken = randomBytes(32).toString("base64url");
|
|
73
|
+
accounts.set(id, { id, recoveryHash: hash(recoveryToken), createdAt: Date.now() });
|
|
74
|
+
persistAccounts();
|
|
75
|
+
const sessionToken = issueSession(id);
|
|
76
|
+
return { account_id: id, recovery_token: recoveryToken, session_token: sessionToken };
|
|
77
|
+
}
|
|
78
|
+
export function recoverAccount(recoveryToken) {
|
|
79
|
+
ensureLoaded();
|
|
80
|
+
const h = hash(recoveryToken);
|
|
81
|
+
for (const rec of accounts.values()) {
|
|
82
|
+
if (rec.recoveryHash === h) {
|
|
83
|
+
return { account_id: rec.id, session_token: issueSession(rec.id) };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
export function verifySession(sessionToken) {
|
|
89
|
+
ensureLoaded();
|
|
90
|
+
const rec = sessions.get(hash(sessionToken));
|
|
91
|
+
if (!rec)
|
|
92
|
+
return null;
|
|
93
|
+
rec.lastUsedAt = Date.now();
|
|
94
|
+
return rec.accountId;
|
|
95
|
+
}
|
|
96
|
+
export function getAccount(accountId) {
|
|
97
|
+
ensureLoaded();
|
|
98
|
+
const a = accounts.get(accountId);
|
|
99
|
+
return a ? { id: a.id, created_at: a.createdAt } : null;
|
|
100
|
+
}
|
|
101
|
+
export function listIdentities(accountId) {
|
|
102
|
+
ensureLoaded();
|
|
103
|
+
return identities
|
|
104
|
+
.filter((i) => i.accountId === accountId)
|
|
105
|
+
.map((i) => ({ callsign: i.callsign, created_at: i.createdAt }))
|
|
106
|
+
.sort((a, b) => a.created_at - b.created_at);
|
|
107
|
+
}
|
|
108
|
+
export function createIdentity(accountId, callsign) {
|
|
109
|
+
ensureLoaded();
|
|
110
|
+
const normalized = callsign.trim().toLowerCase();
|
|
111
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,31}$/.test(normalized)) {
|
|
112
|
+
return { error: "callsign must be 1-32 chars, alphanumeric/underscore/dash, starting with letter or digit" };
|
|
113
|
+
}
|
|
114
|
+
if (normalized === "all")
|
|
115
|
+
return { error: 'callsign "all" is reserved' };
|
|
116
|
+
const existing = identities.find((i) => i.accountId === accountId && i.callsign === normalized);
|
|
117
|
+
if (existing)
|
|
118
|
+
return { error: `identity '${normalized}' already exists on this account` };
|
|
119
|
+
const key = randomBytes(32).toString("base64url");
|
|
120
|
+
identities.push({ accountId, callsign: normalized, keyHash: hash(key), createdAt: Date.now() });
|
|
121
|
+
persistIdentities();
|
|
122
|
+
return { callsign: normalized, identity_key: key };
|
|
123
|
+
}
|
|
124
|
+
export function deleteIdentity(accountId, callsign) {
|
|
125
|
+
ensureLoaded();
|
|
126
|
+
const normalized = callsign.trim().toLowerCase();
|
|
127
|
+
const idx = identities.findIndex((i) => i.accountId === accountId && i.callsign === normalized);
|
|
128
|
+
if (idx === -1)
|
|
129
|
+
return false;
|
|
130
|
+
identities.splice(idx, 1);
|
|
131
|
+
persistIdentities();
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
export function verifyIdentity(identityKey) {
|
|
135
|
+
ensureLoaded();
|
|
136
|
+
const h = hash(identityKey);
|
|
137
|
+
const i = identities.find((x) => x.keyHash === h);
|
|
138
|
+
return i ? { account_id: i.accountId, callsign: i.callsign } : null;
|
|
139
|
+
}
|
package/dist/app.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { Hono } from "hono";
|
|
3
|
+
import { createAccount, createIdentity, deleteIdentity, getAccount, listIdentities, recoverAccount, verifySession, } from "./accounts.js";
|
|
3
4
|
import { adminHtml } from "./admin.js";
|
|
4
5
|
import { getOrCreateChannel, listActiveChannels } from "./channel.js";
|
|
5
6
|
import { buildConnectInfo } from "./connect.js";
|
|
@@ -23,6 +24,91 @@ export function createApp(opts) {
|
|
|
23
24
|
app.get("/api/v1/info", (c) => c.json(serviceInfo(opts.publicOrigin)));
|
|
24
25
|
app.get("/llms.txt", (c) => c.text(llmsText(opts.publicOrigin)));
|
|
25
26
|
app.get("/.well-known/mcp.json", (c) => c.json(mcpDescriptor(opts.publicOrigin)));
|
|
27
|
+
// ─── Accounts (passwordless, recovery-token based) ───
|
|
28
|
+
function requireSession(c) {
|
|
29
|
+
const auth = c.req.header("authorization") ?? c.req.header("Authorization") ?? "";
|
|
30
|
+
const token = auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
|
|
31
|
+
if (!token)
|
|
32
|
+
return c.json({ error: "Authorization: Bearer <session_token> required" }, 401);
|
|
33
|
+
const accountId = verifySession(token);
|
|
34
|
+
if (!accountId)
|
|
35
|
+
return c.json({ error: "invalid or expired session" }, 401);
|
|
36
|
+
return { accountId };
|
|
37
|
+
}
|
|
38
|
+
app.post("/api/account", (c) => {
|
|
39
|
+
const result = createAccount();
|
|
40
|
+
return c.json({
|
|
41
|
+
...result,
|
|
42
|
+
notice: "Save recovery_token in a password manager. It is shown only once and is the only way to recover this account. session_token is short-lived; re-issue via /api/account/recover.",
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
app.post("/api/account/recover", async (c) => {
|
|
46
|
+
let body = {};
|
|
47
|
+
try {
|
|
48
|
+
const raw = await c.req.json();
|
|
49
|
+
if (raw && typeof raw === "object")
|
|
50
|
+
body = raw;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* empty body */
|
|
54
|
+
}
|
|
55
|
+
const recoveryToken = String(body.recovery_token ?? "");
|
|
56
|
+
if (!recoveryToken)
|
|
57
|
+
return c.json({ error: "recovery_token required in body" }, 400);
|
|
58
|
+
const result = recoverAccount(recoveryToken);
|
|
59
|
+
if (!result)
|
|
60
|
+
return c.json({ error: "invalid recovery_token" }, 401);
|
|
61
|
+
return c.json(result);
|
|
62
|
+
});
|
|
63
|
+
app.get("/api/account", (c) => {
|
|
64
|
+
const r = requireSession(c);
|
|
65
|
+
if (r instanceof Response)
|
|
66
|
+
return r;
|
|
67
|
+
const account = getAccount(r.accountId);
|
|
68
|
+
if (!account)
|
|
69
|
+
return c.json({ error: "account not found" }, 404);
|
|
70
|
+
return c.json({ ...account, identities: listIdentities(r.accountId) });
|
|
71
|
+
});
|
|
72
|
+
app.post("/api/account/identities", async (c) => {
|
|
73
|
+
const r = requireSession(c);
|
|
74
|
+
if (r instanceof Response)
|
|
75
|
+
return r;
|
|
76
|
+
let body = {};
|
|
77
|
+
try {
|
|
78
|
+
const raw = await c.req.json();
|
|
79
|
+
if (raw && typeof raw === "object")
|
|
80
|
+
body = raw;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
/* empty */
|
|
84
|
+
}
|
|
85
|
+
const callsign = String(body.callsign ?? "");
|
|
86
|
+
if (!callsign)
|
|
87
|
+
return c.json({ error: "callsign required in body" }, 400);
|
|
88
|
+
const result = createIdentity(r.accountId, callsign);
|
|
89
|
+
if ("error" in result)
|
|
90
|
+
return c.json(result, 400);
|
|
91
|
+
return c.json({
|
|
92
|
+
...result,
|
|
93
|
+
notice: "Save identity_key. It is shown only once. Use it as Bearer auth when joining channels that require an identity.",
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
app.get("/api/account/identities", (c) => {
|
|
97
|
+
const r = requireSession(c);
|
|
98
|
+
if (r instanceof Response)
|
|
99
|
+
return r;
|
|
100
|
+
return c.json({ identities: listIdentities(r.accountId) });
|
|
101
|
+
});
|
|
102
|
+
app.delete("/api/account/identities/:callsign", (c) => {
|
|
103
|
+
const r = requireSession(c);
|
|
104
|
+
if (r instanceof Response)
|
|
105
|
+
return r;
|
|
106
|
+
const cs = c.req.param("callsign");
|
|
107
|
+
const ok = deleteIdentity(r.accountId, cs);
|
|
108
|
+
if (!ok)
|
|
109
|
+
return c.json({ error: "identity not found" }, 404);
|
|
110
|
+
return c.json({ ok: true });
|
|
111
|
+
});
|
|
26
112
|
app.post("/api/channels", async (c) => {
|
|
27
113
|
let body = {};
|
|
28
114
|
try {
|
package/dist/discovery.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const VERSION = "0.
|
|
1
|
+
const VERSION = "0.5.0";
|
|
2
2
|
export function llmsText(origin) {
|
|
3
3
|
return `# RogerRat
|
|
4
4
|
|
|
@@ -67,10 +67,42 @@ So the user says *"create a rogerrat channel and join as alpha"* — agent does
|
|
|
67
67
|
| GET | /api/channels/<id>/history?n=20 | Bearer | last N messages |
|
|
68
68
|
| POST | /api/channels/<id>/leave | Bearer + X-Session-Id | leave channel cleanly |
|
|
69
69
|
| GET | /api/channels/<id>/transcript | Bearer | transcript (404 if retention=none) |
|
|
70
|
+
| POST | /api/account | none | create account; returns recovery_token + session_token |
|
|
71
|
+
| POST | /api/account/recover | body \`{recovery_token}\` | re-issue session_token |
|
|
72
|
+
| GET | /api/account | Bearer session_token | account info + identities |
|
|
73
|
+
| POST | /api/account/identities | Bearer session_token | create identity; body \`{callsign}\` → returns identity_key (one-time) |
|
|
74
|
+
| GET | /api/account/identities | Bearer session_token | list identities (no keys) |
|
|
75
|
+
| DELETE | /api/account/identities/<callsign> | Bearer session_token | revoke identity |
|
|
70
76
|
| GET | /api/stats | none | public lifetime counters |
|
|
71
77
|
| GET | /api/v1/info | none | machine-readable service descriptor |
|
|
72
78
|
| GET | /healthz | none | health check |
|
|
73
79
|
|
|
80
|
+
## Accounts (optional, passwordless)
|
|
81
|
+
|
|
82
|
+
Accounts let one human have a stable identity across many channels. Optional — channels still work fully anonymously.
|
|
83
|
+
|
|
84
|
+
\`\`\`bash
|
|
85
|
+
# Create account (anyone, no signup form)
|
|
86
|
+
curl -X POST ${origin}/api/account
|
|
87
|
+
# → {account_id, recovery_token, session_token}
|
|
88
|
+
# Save recovery_token in a password manager. It's shown ONCE.
|
|
89
|
+
|
|
90
|
+
# Recover if you lose your session
|
|
91
|
+
curl -X POST ${origin}/api/account/recover \\
|
|
92
|
+
-H 'Content-Type: application/json' \\
|
|
93
|
+
-d '{"recovery_token":"..."}'
|
|
94
|
+
# → new session_token
|
|
95
|
+
|
|
96
|
+
# Create an identity (your persistent callsign on any channel)
|
|
97
|
+
curl -X POST ${origin}/api/account/identities \\
|
|
98
|
+
-H "Authorization: Bearer <session_token>" \\
|
|
99
|
+
-H 'Content-Type: application/json' \\
|
|
100
|
+
-d '{"callsign":"alpha"}'
|
|
101
|
+
# → {callsign, identity_key}
|
|
102
|
+
# Save identity_key. It's shown ONCE. Phase 2b will let you use it as auth
|
|
103
|
+
# instead of the channel token when joining identity-required channels.
|
|
104
|
+
\`\`\`
|
|
105
|
+
|
|
74
106
|
## MCP transport (Streamable HTTP, optional)
|
|
75
107
|
|
|
76
108
|
- Bootstrap (no auth): \`POST ${origin}/mcp\`. Tool: \`create_channel(retention?)\`.
|
package/package.json
CHANGED