relaybrain 0.0.0-stage → 0.2.1
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 +434 -3
- package/backend/coordinator/client.js +364 -0
- package/backend/coordinator/lockTable.js +649 -0
- package/backend/coordinator/server.js +179 -0
- package/backend/lib/centralAuth.js +62 -0
- package/backend/lib/centralStore.js +112 -0
- package/backend/lib/centralSync.js +88 -0
- package/backend/lib/cloneRepo.js +108 -0
- package/backend/lib/collisionMetrics.js +231 -0
- package/backend/lib/conflicts.js +35 -0
- package/backend/lib/dashboard.js +53 -0
- package/backend/lib/depGraph.js +393 -0
- package/backend/lib/deps.js +46 -0
- package/backend/lib/dirtyFiles.js +33 -0
- package/backend/lib/editMatchers.js +55 -0
- package/backend/lib/falkor/analytics.js +228 -0
- package/backend/lib/falkor/bus.js +16 -0
- package/backend/lib/falkor/central.js +281 -0
- package/backend/lib/falkor/client.js +165 -0
- package/backend/lib/falkor/config.js +73 -0
- package/backend/lib/falkor/coord.js +299 -0
- package/backend/lib/falkor/cursor.js +58 -0
- package/backend/lib/falkor/demo.js +221 -0
- package/backend/lib/falkor/fixtures.js +85 -0
- package/backend/lib/falkor/index.js +146 -0
- package/backend/lib/falkor/ingest.js +233 -0
- package/backend/lib/falkor/intents.js +32 -0
- package/backend/lib/falkor/keys.js +102 -0
- package/backend/lib/falkor/migrations.js +56 -0
- package/backend/lib/falkor/project.js +307 -0
- package/backend/lib/falkor/query.js +344 -0
- package/backend/lib/falkor/redact.js +30 -0
- package/backend/lib/falkor/routes.js +125 -0
- package/backend/lib/falkor/schema.js +108 -0
- package/backend/lib/falkor/write.js +281 -0
- package/backend/lib/githubLogin.js +108 -0
- package/backend/lib/githubOAuth.js +42 -0
- package/backend/lib/githubRepo.js +117 -0
- package/backend/lib/harvest.js +85 -0
- package/backend/lib/httpServer.js +27 -0
- package/backend/lib/installAgentRelay.js +235 -0
- package/backend/lib/installHooks.js +309 -0
- package/backend/lib/lamport.js +14 -0
- package/backend/lib/localBrowse.js +109 -0
- package/backend/lib/memorySync.js +63 -0
- package/backend/lib/notices.js +150 -0
- package/backend/lib/ot.js +74 -0
- package/backend/lib/patches.js +192 -0
- package/backend/lib/paths.js +85 -0
- package/backend/lib/peerClash.js +64 -0
- package/backend/lib/presence.js +35 -0
- package/backend/lib/relayOsBlock.js +84 -0
- package/backend/lib/repoSnapshot.js +125 -0
- package/backend/lib/room.js +638 -0
- package/backend/lib/roomAsk.js +148 -0
- package/backend/lib/roomAuth.js +215 -0
- package/backend/lib/roomControl.js +226 -0
- package/backend/lib/roomLocks.js +201 -0
- package/backend/lib/roomSignal.js +265 -0
- package/backend/lib/roomSync.js +194 -0
- package/backend/lib/seed.js +107 -0
- package/backend/lib/sseHub.js +40 -0
- package/backend/lib/store.js +79 -0
- package/backend/lib/supergraph.js +46 -0
- package/backend/lib/transcripts/discover.js +437 -0
- package/backend/lib/transcripts/parse.js +558 -0
- package/backend/lib/transcripts/sessionId.js +36 -0
- package/backend/lib/transcripts/sync.js +291 -0
- package/backend/lib/transcripts/util.js +299 -0
- package/backend/lib/treeSitterImports.js +219 -0
- package/backend/lib/watchWorkspace.js +42 -0
- package/backend/mcp/README.md +193 -0
- package/backend/mcp/server.js +42 -0
- package/backend/mcp/tools.js +484 -0
- package/backend/server.js +3894 -0
- package/cli/graph.js +286 -0
- package/cli/relay.js +475 -0
- package/docker-compose.falkor.yml +17 -0
- package/docs/MEMORY-GRAPH.md +314 -0
- package/hooks/relay-antigravity-post-tool.js +2 -0
- package/hooks/relay-antigravity-pre-read.js +2 -0
- package/hooks/relay-antigravity-pre-tool.js +2 -0
- package/hooks/relay-antigravity-stop.js +2 -0
- package/hooks/relay-claude-post-tool.js +2 -0
- package/hooks/relay-claude-pre-read.js +2 -0
- package/hooks/relay-claude-pre-tool.js +2 -0
- package/hooks/relay-claude-stop.js +2 -0
- package/hooks/relay-codex-post-tool.js +2 -0
- package/hooks/relay-codex-pre-read.js +2 -0
- package/hooks/relay-codex-pre-tool.js +2 -0
- package/hooks/relay-codex-stop.js +2 -0
- package/hooks/relay-copilot-post-tool.js +2 -0
- package/hooks/relay-copilot-pre-read.js +2 -0
- package/hooks/relay-copilot-pre-tool.js +2 -0
- package/hooks/relay-copilot-stop.js +2 -0
- package/hooks/relay-cursor-post-tool.js +2 -0
- package/hooks/relay-cursor-pre-read.js +2 -0
- package/hooks/relay-cursor-pre-tool.js +2 -0
- package/hooks/relay-cursor-stop.js +2 -0
- package/hooks/relay-hook-lib.js +621 -0
- package/mission-control/out/404.html +1 -0
- package/mission-control/out/__next.__PAGE__.txt +26 -0
- package/mission-control/out/__next._full.txt +21 -0
- package/mission-control/out/__next._tree.txt +4 -0
- package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_buildManifest.js +11 -0
- package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_clientMiddlewareManifest.js +1 -0
- package/mission-control/out/_next/static/BuhfCxd0WQE65yIzZJCOM/_ssgManifest.js +1 -0
- package/mission-control/out/_next/static/chunks/0-3l04fnym95-.js +22 -0
- package/mission-control/out/_next/static/chunks/064ntycm-fx5v.js +1 -0
- package/mission-control/out/_next/static/chunks/0cz1d0mv5g_q7.js +1 -0
- package/mission-control/out/_next/static/chunks/0ehjiuuxbbhq9.js +1 -0
- package/mission-control/out/_next/static/chunks/1-dy52ls2ntb_.css +4 -0
- package/mission-control/out/_next/static/chunks/1q0cmt0kqufow.js +2 -0
- package/mission-control/out/_next/static/chunks/1ya1u0izct4f6.js +1 -0
- package/mission-control/out/_next/static/chunks/2n66mlnb34vjq.js +1 -0
- package/mission-control/out/_next/static/chunks/2xar70jzx8f1y.css +1 -0
- package/mission-control/out/_next/static/chunks/31cgqj_-l15yt.js +31 -0
- package/mission-control/out/_next/static/chunks/31qgr-p-oulpb.js +1 -0
- package/mission-control/out/_next/static/chunks/3vq9c4hivvgn_.js +1 -0
- package/mission-control/out/_next/static/chunks/turbopack-3hsllzpnzz12p.js +1 -0
- package/mission-control/out/_next/static/media/icon.27otmh_fv3buh.svg +4 -0
- package/mission-control/out/_not-found/__next._full.txt +18 -0
- package/mission-control/out/_not-found/__next._not-found/__PAGE__.txt +24 -0
- package/mission-control/out/_not-found/__next._tree.txt +3 -0
- package/mission-control/out/_not-found.html +1 -0
- package/mission-control/out/_not-found.txt +18 -0
- package/mission-control/out/agents/antigravity.png +0 -0
- package/mission-control/out/agents/claude.png +0 -0
- package/mission-control/out/agents/codex.png +0 -0
- package/mission-control/out/agents/copilot.png +0 -0
- package/mission-control/out/agents/cursor.png +0 -0
- package/mission-control/out/icon.svg +4 -0
- package/mission-control/out/index.html +1 -0
- package/mission-control/out/index.txt +21 -0
- package/mission-control/out/logo.png +0 -0
- package/mission-control/out/logo_transparent.png +0 -0
- package/package.json +76 -6
- package/scripts/falkor-rebuild.js +38 -0
- package/scripts/falkor-seed-demo.js +29 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Room brief for the `/relay ask` pseudo-command — what teammates' agents did recently.
|
|
3
|
+
* Agents fetch this via MCP `relay_room_brief`; humans do not run a CLI for it.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function sameLogin(a, b) {
|
|
7
|
+
return Boolean(a) && Boolean(b) && String(a).toLowerCase() === String(b).toLowerCase();
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function clip(text, max = 600) {
|
|
11
|
+
const s = String(text || "").trim();
|
|
12
|
+
if (!s) return "";
|
|
13
|
+
return s.length > max ? `${s.slice(0, max)}\n…` : s;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function formatTs(ts) {
|
|
17
|
+
const n = Number(ts);
|
|
18
|
+
if (!Number.isFinite(n) || n <= 0) return "";
|
|
19
|
+
try {
|
|
20
|
+
return new Date(n).toISOString().replace("T", " ").slice(0, 19);
|
|
21
|
+
} catch {
|
|
22
|
+
return "";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function peerRows(rows, login) {
|
|
27
|
+
return (rows || []).filter((row) => row && !row.mine && !sameLogin(row.ownerLogin, login));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function threadPeerMessages(chats, login, limit) {
|
|
31
|
+
const lines = [];
|
|
32
|
+
for (const thread of chats || []) {
|
|
33
|
+
if (sameLogin(thread.ownerLogin, login)) continue;
|
|
34
|
+
const who = thread.ownerLogin ? `@${thread.ownerLogin}` : thread.agent || "peer";
|
|
35
|
+
const agent = thread.agent ? ` (${thread.agent})` : "";
|
|
36
|
+
for (const msg of (thread.messages || []).slice(-6)) {
|
|
37
|
+
if (!msg?.text || msg.role === "system") continue;
|
|
38
|
+
const stamp = formatTs(msg.ts || thread.updatedAt);
|
|
39
|
+
lines.push({ ts: msg.ts || thread.updatedAt || 0, line: `- **${who}${agent}** ${stamp}: ${clip(msg.text, 500)}` });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return lines.sort((a, b) => (b.ts || 0) - (a.ts || 0)).slice(0, limit);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatLocks(locks) {
|
|
46
|
+
const live = (locks || []).filter((l) => l && !l.released);
|
|
47
|
+
if (!live.length) return "_No files locked right now._\n";
|
|
48
|
+
return (
|
|
49
|
+
live
|
|
50
|
+
.slice(0, 20)
|
|
51
|
+
.map((l) => {
|
|
52
|
+
const who = l.holder?.login ? `@${l.holder.login}` : l.holder?.label || l.agentId || "?";
|
|
53
|
+
return `- \`${l.filePath}\` — ${who} (${l.mode || "write"})`;
|
|
54
|
+
})
|
|
55
|
+
.join("\n") + "\n"
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {object} opts
|
|
61
|
+
* @param {object} opts.view — mergeRoomViews output
|
|
62
|
+
* @param {object|null} opts.room — loadRoom()
|
|
63
|
+
* @param {string} opts.login — current GitHub login
|
|
64
|
+
* @param {Array} opts.locks — live lock rows
|
|
65
|
+
* @param {number} opts.limit — max peer edit/chat lines
|
|
66
|
+
*/
|
|
67
|
+
function compileRoomAsk({ view, room, login, locks = [], limit = 30 }) {
|
|
68
|
+
const peerEdits = peerRows(view.edits, login)
|
|
69
|
+
.sort((a, b) => (b.ts || 0) - (a.ts || 0))
|
|
70
|
+
.slice(0, limit);
|
|
71
|
+
const peerActivity = peerRows(view.activity, login)
|
|
72
|
+
.sort((a, b) => (b.ts || 0) - (a.ts || 0))
|
|
73
|
+
.slice(0, limit);
|
|
74
|
+
const chatLines = threadPeerMessages(view.chats, login, limit);
|
|
75
|
+
const agents = (view.agents || []).filter((a) => a.status === "connected" && !sameLogin(a.ownerLogin, login));
|
|
76
|
+
|
|
77
|
+
const roomLine = room?.url
|
|
78
|
+
? `Room **${room.role}** · ${room.memberCount || view.peerLogins?.length || 0} member(s) · peers: ${(view.peerLogins || []).join(", ") || "none yet"}`
|
|
79
|
+
: "Not in a shared room — showing local activity only.";
|
|
80
|
+
|
|
81
|
+
const sections = [];
|
|
82
|
+
sections.push("# Relay ask — teammate activity\n");
|
|
83
|
+
sections.push(`Generated: ${new Date().toISOString()}`);
|
|
84
|
+
sections.push(`${roomLine}\n`);
|
|
85
|
+
sections.push("> Agents load this via MCP `relay_room_brief` when the user says `/relay ask`.\n");
|
|
86
|
+
|
|
87
|
+
sections.push("## Connected agents (other machines)\n");
|
|
88
|
+
if (!agents.length) sections.push("_No other connected agents right now._\n");
|
|
89
|
+
else {
|
|
90
|
+
for (const a of agents.slice(0, 12)) {
|
|
91
|
+
const owner = a.ownerLogin ? `@${a.ownerLogin}` : "";
|
|
92
|
+
sections.push(`- **${a.label || a.id}** ${owner}`.trim());
|
|
93
|
+
}
|
|
94
|
+
sections.push("");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
sections.push("## Recent teammate chat\n");
|
|
98
|
+
if (!chatLines.length) sections.push("_No peer chat synced yet — teammate needs `relay serve` + room join._\n");
|
|
99
|
+
else {
|
|
100
|
+
for (const row of chatLines) sections.push(row.line);
|
|
101
|
+
sections.push("");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
sections.push("## Recent teammate code edits\n");
|
|
105
|
+
if (!peerEdits.length) sections.push("_No peer edits in the room feed yet._\n");
|
|
106
|
+
else {
|
|
107
|
+
for (const e of peerEdits) {
|
|
108
|
+
const who = e.ownerLogin ? `@${e.ownerLogin}` : "peer";
|
|
109
|
+
const agent = e.agent ? ` · ${e.agent}` : "";
|
|
110
|
+
sections.push(`- \`${e.file || "?"}\` — ${who}${agent} · ${formatTs(e.ts)}`);
|
|
111
|
+
if (e.diff) sections.push(` \`\`\`diff\n${clip(e.diff, 400)}\n \`\`\``);
|
|
112
|
+
}
|
|
113
|
+
sections.push("");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
sections.push("## Teammate activity (tools)\n");
|
|
117
|
+
if (!peerActivity.length) sections.push("_No peer tool activity._\n");
|
|
118
|
+
else {
|
|
119
|
+
for (const a of peerActivity.slice(0, 15)) {
|
|
120
|
+
const who = a.ownerLogin ? `@${a.ownerLogin}` : "peer";
|
|
121
|
+
sections.push(`- ${who} · ${a.agent || "?"} · ${clip(a.text, 120)} · ${formatTs(a.ts)}`);
|
|
122
|
+
}
|
|
123
|
+
sections.push("");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
sections.push("## Live locks (whole room)\n");
|
|
127
|
+
sections.push(formatLocks(locks));
|
|
128
|
+
|
|
129
|
+
sections.push("## File sync note\n");
|
|
130
|
+
sections.push(
|
|
131
|
+
"Chat/activity above sync via the room transcript pipeline. **File contents** land on your disk only when post-tool hooks flush patches (`relay serve` + hooks on each machine). If a file is missing locally, ask the editor to re-save it or run `/api/flush-file` — not `relay push` after a git commit.\n"
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const markdown = sections.join("\n");
|
|
135
|
+
const json = {
|
|
136
|
+
room: room
|
|
137
|
+
? { role: room.role, url: room.url, peers: view.peerLogins || [], memberCount: room.memberCount }
|
|
138
|
+
: null,
|
|
139
|
+
peerEdits,
|
|
140
|
+
peerActivity: peerActivity.slice(0, 15),
|
|
141
|
+
chatLines: chatLines.map((r) => r.line),
|
|
142
|
+
agents: agents.slice(0, 12),
|
|
143
|
+
locks: (locks || []).filter((l) => !l.released).slice(0, 20),
|
|
144
|
+
};
|
|
145
|
+
return { markdown, json };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
module.exports = { compileRoomAsk, peerRows, sameLogin };
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
const crypto = require("crypto");
|
|
2
|
+
|
|
3
|
+
// A shared room is reachable by anyone who learns the tunnel URL, and it hands
|
|
4
|
+
// out the whole working tree, every agent transcript, and write access to the
|
|
5
|
+
// lock table. Membership therefore has to be proven, not assumed.
|
|
6
|
+
//
|
|
7
|
+
// Two credentials, with different lifetimes:
|
|
8
|
+
// invite code — issued by the host to one named person, single use, expires.
|
|
9
|
+
// member token — minted when an invite is redeemed, presented on every
|
|
10
|
+
// subsequent request for as long as the room lasts.
|
|
11
|
+
// Only hashes are ever written to room.json, so a leaked room file cannot be
|
|
12
|
+
// replayed to rejoin the room.
|
|
13
|
+
|
|
14
|
+
const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
15
|
+
const SECRET_BYTES = 24;
|
|
16
|
+
|
|
17
|
+
function hash(raw) {
|
|
18
|
+
return crypto.createHash("sha256").update(String(raw)).digest("hex");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function mint(prefix) {
|
|
22
|
+
return `${prefix}_${crypto.randomBytes(SECRET_BYTES).toString("hex")}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sameSecret(a, b) {
|
|
26
|
+
const left = Buffer.from(String(a || ""), "utf8");
|
|
27
|
+
const right = Buffer.from(String(b || ""), "utf8");
|
|
28
|
+
if (left.length !== right.length || !left.length) return false;
|
|
29
|
+
return crypto.timingSafeEqual(left, right);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normLogin(login) {
|
|
33
|
+
return String(login || "").trim().toLowerCase();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function invitesOf(room) {
|
|
37
|
+
return Array.isArray(room?.invites) ? room.invites : [];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function inviteState(invite, now = Date.now()) {
|
|
41
|
+
if (invite.revokedAt) return "revoked";
|
|
42
|
+
if (invite.acceptedAt) return "accepted";
|
|
43
|
+
if (invite.expiresAt && now > invite.expiresAt) return "expired";
|
|
44
|
+
return "pending";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function createInvite(room, { login, ttlMs, invitedBy } = {}) {
|
|
48
|
+
const target = normLogin(login);
|
|
49
|
+
if (!target) return { ok: false, error: "login_required" };
|
|
50
|
+
const code = mint("rli");
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const invite = {
|
|
53
|
+
id: `inv_${crypto.randomBytes(6).toString("hex")}`,
|
|
54
|
+
login: target,
|
|
55
|
+
codeHash: hash(code),
|
|
56
|
+
createdAt: now,
|
|
57
|
+
expiresAt: now + (Number(ttlMs) || INVITE_TTL_MS),
|
|
58
|
+
invitedBy: normLogin(invitedBy) || null,
|
|
59
|
+
acceptedAt: null,
|
|
60
|
+
revokedAt: null,
|
|
61
|
+
};
|
|
62
|
+
room.invites = [...invitesOf(room).filter((i) => inviteState(i) !== "expired"), invite];
|
|
63
|
+
return { ok: true, invite, code };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function revokeInvite(room, id) {
|
|
67
|
+
const invite = invitesOf(room).find((i) => i.id === id);
|
|
68
|
+
if (!invite) return { ok: false, error: "not_found" };
|
|
69
|
+
invite.revokedAt = Date.now();
|
|
70
|
+
return { ok: true, invite };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Safe to send to a client: never includes the code or its hash. */
|
|
74
|
+
function inviteView(room) {
|
|
75
|
+
return invitesOf(room).map((i) => ({
|
|
76
|
+
id: i.id,
|
|
77
|
+
login: i.login,
|
|
78
|
+
state: inviteState(i),
|
|
79
|
+
createdAt: i.createdAt,
|
|
80
|
+
expiresAt: i.expiresAt,
|
|
81
|
+
invitedBy: i.invitedBy,
|
|
82
|
+
acceptedAt: i.acceptedAt,
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function pendingInviteFor(room, login) {
|
|
87
|
+
const claimed = normLogin(login);
|
|
88
|
+
return invitesOf(room).find((i) => normLogin(i.login) === claimed && inviteState(i) === "pending") || null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function acceptedInviteFor(room, login) {
|
|
92
|
+
const claimed = normLogin(login);
|
|
93
|
+
return invitesOf(room).find((i) => normLogin(i.login) === claimed && inviteState(i) === "accepted") || null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Checks an invite code against the room. `login` is who is claiming it — an
|
|
98
|
+
* invite addressed to one person must not be usable by another, otherwise a
|
|
99
|
+
* forwarded link is as good as an open door. A code-less redeem is only safe
|
|
100
|
+
* after GitHub identity proof (see /api/room/redeem).
|
|
101
|
+
*/
|
|
102
|
+
function redeemInvite(room, code, login) {
|
|
103
|
+
const claimed = normLogin(login);
|
|
104
|
+
if (!claimed) return { ok: false, reason: "login_required" };
|
|
105
|
+
if (code) {
|
|
106
|
+
const hashed = hash(code);
|
|
107
|
+
const invite = invitesOf(room).find((i) => sameSecret(i.codeHash, hashed));
|
|
108
|
+
if (!invite) return { ok: false, reason: "invite_invalid" };
|
|
109
|
+
const state = inviteState(invite);
|
|
110
|
+
if (state === "revoked") return { ok: false, reason: "invite_revoked" };
|
|
111
|
+
if (state === "expired") return { ok: false, reason: "invite_expired" };
|
|
112
|
+
if (state === "accepted") return { ok: false, reason: "invite_already_used" };
|
|
113
|
+
if (normLogin(invite.login) !== claimed) {
|
|
114
|
+
return { ok: false, reason: "invite_wrong_user", invitedLogin: invite.login };
|
|
115
|
+
}
|
|
116
|
+
return { ok: true, invite };
|
|
117
|
+
}
|
|
118
|
+
// Code-less redeem is only safe after GitHub identity proof (see /api/room/redeem).
|
|
119
|
+
const pending = pendingInviteFor(room, claimed);
|
|
120
|
+
if (pending) return { ok: true, invite: pending };
|
|
121
|
+
const accepted = acceptedInviteFor(room, claimed);
|
|
122
|
+
if (accepted) return { ok: true, invite: accepted, rejoin: true };
|
|
123
|
+
return { ok: false, reason: "invite_required" };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function markAccepted(invite, login) {
|
|
127
|
+
invite.acceptedAt = Date.now();
|
|
128
|
+
invite.acceptedBy = normLogin(login);
|
|
129
|
+
return invite;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Mints the long-lived credential a member presents on every later request. */
|
|
133
|
+
function issueMemberToken(room, login) {
|
|
134
|
+
const target = normLogin(login);
|
|
135
|
+
const token = mint("rlm");
|
|
136
|
+
room.memberTokens = (Array.isArray(room.memberTokens) ? room.memberTokens : []).filter(
|
|
137
|
+
(t) => normLogin(t.login) !== target
|
|
138
|
+
);
|
|
139
|
+
room.memberTokens.push({ login: target, tokenHash: hash(token), issuedAt: Date.now() });
|
|
140
|
+
return token;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function authorizeMember(room, token) {
|
|
144
|
+
if (!token) return null;
|
|
145
|
+
const hashed = hash(token);
|
|
146
|
+
const row = (Array.isArray(room?.memberTokens) ? room.memberTokens : []).find((t) =>
|
|
147
|
+
sameSecret(t.tokenHash, hashed)
|
|
148
|
+
);
|
|
149
|
+
return row ? { login: row.login, issuedAt: row.issuedAt } : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function revokeMember(room, login) {
|
|
153
|
+
const target = normLogin(login);
|
|
154
|
+
room.memberTokens = (Array.isArray(room.memberTokens) ? room.memberTokens : []).filter(
|
|
155
|
+
(t) => normLogin(t.login) !== target
|
|
156
|
+
);
|
|
157
|
+
room.invites = invitesOf(room).map((i) =>
|
|
158
|
+
normLogin(i.login) === target && !i.revokedAt ? { ...i, revokedAt: Date.now() } : i
|
|
159
|
+
);
|
|
160
|
+
return room;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* The room as it is safe to hand to any client. /api/health and /api/room are
|
|
165
|
+
* both reachable through the tunnel without credentials, and the raw room
|
|
166
|
+
* carries invite and member token hashes.
|
|
167
|
+
*/
|
|
168
|
+
function publicRoom(room) {
|
|
169
|
+
if (!room) return null;
|
|
170
|
+
const { invites, memberTokens, members, memberToken, nonce, ...rest } = room;
|
|
171
|
+
return { ...rest, memberCount: Array.isArray(members) ? members.length : 0 };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Reads the member credential off a request. */
|
|
175
|
+
function roomToken(req) {
|
|
176
|
+
const header = req.headers?.["x-relay-room-token"];
|
|
177
|
+
if (header) return String(header);
|
|
178
|
+
const auth = String(req.headers?.authorization || "");
|
|
179
|
+
if (/^Room\s+/i.test(auth)) return auth.replace(/^Room\s+/i, "").trim();
|
|
180
|
+
return req.query?.token ? String(req.query.token) : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* True when the request arrived from somewhere other than this machine. ngrok
|
|
185
|
+
* (and any reverse proxy) stamps X-Forwarded-For, which a remote caller cannot
|
|
186
|
+
* strip. Local Mission Control and local hooks stay unauthenticated so adding
|
|
187
|
+
* room security never breaks solo use.
|
|
188
|
+
*/
|
|
189
|
+
function isRemoteRequest(req) {
|
|
190
|
+
if (req.headers?.["x-forwarded-for"] || req.headers?.["x-original-forwarded-for"]) return true;
|
|
191
|
+
const addr = String(req.socket?.remoteAddress || req.connection?.remoteAddress || "");
|
|
192
|
+
if (!addr) return false;
|
|
193
|
+
const bare = addr.replace(/^::ffff:/, "");
|
|
194
|
+
return bare !== "127.0.0.1" && bare !== "::1" && bare !== "localhost";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
module.exports = {
|
|
198
|
+
INVITE_TTL_MS,
|
|
199
|
+
hash,
|
|
200
|
+
normLogin,
|
|
201
|
+
createInvite,
|
|
202
|
+
revokeInvite,
|
|
203
|
+
inviteView,
|
|
204
|
+
inviteState,
|
|
205
|
+
invitesOf,
|
|
206
|
+
redeemInvite,
|
|
207
|
+
pendingInviteFor,
|
|
208
|
+
markAccepted,
|
|
209
|
+
issueMemberToken,
|
|
210
|
+
authorizeMember,
|
|
211
|
+
revokeMember,
|
|
212
|
+
publicRoom,
|
|
213
|
+
roomToken,
|
|
214
|
+
isRemoteRequest,
|
|
215
|
+
};
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
const { WebSocket } = require("ws");
|
|
2
|
+
|
|
3
|
+
// A guest that releases a lock while the tunnel is down must still be able to
|
|
4
|
+
// tell the host about it, or the file stays locked room-wide until the TTL runs
|
|
5
|
+
// out (up to MAX_TTL_MS, five minutes). The queue is bounded because an outage
|
|
6
|
+
// has no upper bound: past this many pending mutations the oldest are dropped,
|
|
7
|
+
// since a release that stale has been overtaken by TTL expiry anyway.
|
|
8
|
+
const MAX_QUEUED = 200;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Guest-side RPC over the dedicated control socket.
|
|
12
|
+
* Locks are game inputs: tiny, ordered, never queued behind chat snapshots.
|
|
13
|
+
*/
|
|
14
|
+
class RoomControl {
|
|
15
|
+
constructor() {
|
|
16
|
+
this.socket = null;
|
|
17
|
+
this.pending = new Map();
|
|
18
|
+
this.seq = 0;
|
|
19
|
+
this.onLocks = null;
|
|
20
|
+
// Mutations issued while `open` was false, replayed in order on reconnect.
|
|
21
|
+
this.queue = [];
|
|
22
|
+
this.queueSeq = 0;
|
|
23
|
+
this.onReplay = null;
|
|
24
|
+
this.flushing = null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
attach(socket) {
|
|
28
|
+
this.socket = socket;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get open() {
|
|
32
|
+
return Boolean(this.socket && this.socket.readyState === WebSocket.OPEN);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
handle(msg) {
|
|
36
|
+
if (!msg || typeof msg !== "object") return false;
|
|
37
|
+
if (msg.type === "locks" && this.onLocks) {
|
|
38
|
+
this.onLocks(msg);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
if ((msg.type === "rpc-ok" || msg.type === "rpc-err") && this.pending.has(msg.id)) {
|
|
42
|
+
const done = this.pending.get(msg.id);
|
|
43
|
+
this.pending.delete(msg.id);
|
|
44
|
+
let error = null;
|
|
45
|
+
if (msg.type === "rpc-err") {
|
|
46
|
+
error = new Error(msg.error || "rpc_failed");
|
|
47
|
+
// The host answered, and said no. Everything else that can reject an
|
|
48
|
+
// rpc — a dead socket, a timeout, a send that throws — is the tunnel
|
|
49
|
+
// failing rather than a verdict, and replay treats the two oppositely.
|
|
50
|
+
error.fromHost = true;
|
|
51
|
+
}
|
|
52
|
+
done(error, msg.result);
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
rpc(op, body, timeoutMs = 700) {
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
if (!this.open) {
|
|
61
|
+
reject(new Error("control_offline"));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const id = String(++this.seq);
|
|
65
|
+
const timer = setTimeout(() => {
|
|
66
|
+
this.pending.delete(id);
|
|
67
|
+
reject(new Error("control_timeout"));
|
|
68
|
+
}, timeoutMs);
|
|
69
|
+
this.pending.set(id, (err, result) => {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
if (err) reject(err);
|
|
72
|
+
else resolve(result);
|
|
73
|
+
});
|
|
74
|
+
try {
|
|
75
|
+
this.socket.send(JSON.stringify({ type: "rpc", op, id, body }));
|
|
76
|
+
} catch (err) {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
this.pending.delete(id);
|
|
79
|
+
reject(err);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Park a mutation that could not be sent, for replay on reconnect.
|
|
86
|
+
*
|
|
87
|
+
* Deliberately not used for `claim`: a claim is a question ("may I?"), and
|
|
88
|
+
* replaying it minutes later would answer a question nobody is still asking —
|
|
89
|
+
* the agent has long since been told no and moved on. Only the mutations that
|
|
90
|
+
* are statements of fact ("I am done with this file") are worth replaying.
|
|
91
|
+
*
|
|
92
|
+
* Deliberately not used for `heartbeat` either: a heartbeat is a liveness
|
|
93
|
+
* claim about *now*. Replaying a stale one would resurrect a lock the host has
|
|
94
|
+
* already expired, which is the opposite of what the sender meant.
|
|
95
|
+
*/
|
|
96
|
+
enqueue(op, body) {
|
|
97
|
+
if (op !== "release" && op !== "release-all") return false;
|
|
98
|
+
const key = dedupeKey(op, body);
|
|
99
|
+
const existing = this.queue.findIndex((item) => item.key === key);
|
|
100
|
+
if (existing !== -1) this.queue.splice(existing, 1);
|
|
101
|
+
this.queue.push({ key, op, body, seq: ++this.queueSeq, at: Date.now() });
|
|
102
|
+
while (this.queue.length > MAX_QUEUED) this.queue.shift();
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
get queued() {
|
|
107
|
+
return this.queue.length;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Replay the parked mutations, oldest first, and hand back what the host made
|
|
112
|
+
* of them.
|
|
113
|
+
*
|
|
114
|
+
* A host *verdict* (an `rpc-err`, tagged `fromHost`) is final and the entry is
|
|
115
|
+
* dropped, so a release the host keeps refusing is not retried forever. Any
|
|
116
|
+
* other rejection — dead socket, timeout, a send that throws — is the tunnel
|
|
117
|
+
* failing rather than an answer, so those entries go back on the queue for the
|
|
118
|
+
* next reconnect rather than being silently lost.
|
|
119
|
+
*/
|
|
120
|
+
flush() {
|
|
121
|
+
// Concurrent callers share one pass: a second flush would race the first for
|
|
122
|
+
// the same entries and could replay one twice. This is checked before the
|
|
123
|
+
// empty-queue guard because #flush drains the queue up front — by the time a
|
|
124
|
+
// second caller arrives the queue is already empty, and returning a fresh
|
|
125
|
+
// resolved promise there would report "done" while the replay is still in
|
|
126
|
+
// flight, which is exactly what settled() must not do.
|
|
127
|
+
if (this.flushing) return this.flushing;
|
|
128
|
+
if (!this.queue.length) return Promise.resolve({ replayed: [], rejected: [], requeued: [] });
|
|
129
|
+
this.flushing = this.#flush().finally(() => {
|
|
130
|
+
this.flushing = null;
|
|
131
|
+
});
|
|
132
|
+
return this.flushing;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Resolves once any in-flight replay has finished.
|
|
137
|
+
*
|
|
138
|
+
* Anything that claims a lock must wait on this. A queued release and a fresh
|
|
139
|
+
* claim for the same file both travel this socket, and `release` on the host
|
|
140
|
+
* only checks `sameOwner` — so if a re-claim lands first, the replayed release
|
|
141
|
+
* that follows deletes the lock the agent has just been granted and still
|
|
142
|
+
* believes it holds.
|
|
143
|
+
*/
|
|
144
|
+
settled() {
|
|
145
|
+
return this.flushing || Promise.resolve(null);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async #flush() {
|
|
149
|
+
const batch = this.queue.slice().sort((a, b) => a.seq - b.seq);
|
|
150
|
+
this.queue = [];
|
|
151
|
+
const replayed = [];
|
|
152
|
+
const rejected = [];
|
|
153
|
+
const requeued = [];
|
|
154
|
+
for (const item of batch) {
|
|
155
|
+
if (!this.open) {
|
|
156
|
+
this.#requeue(item);
|
|
157
|
+
requeued.push({ ...item, error: "control_offline" });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
try {
|
|
161
|
+
const result = await this.rpc(item.op, item.body);
|
|
162
|
+
replayed.push({ ...item, result });
|
|
163
|
+
} catch (err) {
|
|
164
|
+
const error = err?.message || String(err);
|
|
165
|
+
if (err?.fromHost) rejected.push({ ...item, error });
|
|
166
|
+
else {
|
|
167
|
+
this.#requeue(item);
|
|
168
|
+
requeued.push({ ...item, error });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const outcome = { replayed, rejected, requeued };
|
|
173
|
+
if (this.onReplay) {
|
|
174
|
+
try {
|
|
175
|
+
this.onReplay(outcome);
|
|
176
|
+
} catch {
|
|
177
|
+
/* a reporting failure must not break reconnection */
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return outcome;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Keeps the original seq, so a re-queued entry still replays ahead of
|
|
184
|
+
// mutations issued after it.
|
|
185
|
+
#requeue(item) {
|
|
186
|
+
if (this.queue.some((q) => q.key === item.key)) return;
|
|
187
|
+
this.queue.push(item);
|
|
188
|
+
while (this.queue.length > MAX_QUEUED) this.queue.shift();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
send(obj) {
|
|
192
|
+
if (!this.open) return false;
|
|
193
|
+
try {
|
|
194
|
+
this.socket.send(JSON.stringify(obj));
|
|
195
|
+
return true;
|
|
196
|
+
} catch {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// release-all is per agent, not per file, so it collapses to one entry per
|
|
203
|
+
// agent+workspace; a per-file release collapses to one entry per file.
|
|
204
|
+
function dedupeKey(op, body = {}) {
|
|
205
|
+
const ws = String(body.workspaceId || "");
|
|
206
|
+
if (op === "release-all") return `release-all::${ws}::${String(body.agentId || "")}`;
|
|
207
|
+
return `${op}::${ws}::${String(body.file || "")}::${String(body.agentId || "")}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sendSocket(socket, obj) {
|
|
211
|
+
if (!socket || socket.readyState !== 1) return false;
|
|
212
|
+
try {
|
|
213
|
+
socket.send(typeof obj === "string" ? obj : JSON.stringify(obj));
|
|
214
|
+
return true;
|
|
215
|
+
} catch {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function broadcast(wss, obj) {
|
|
221
|
+
if (!wss) return;
|
|
222
|
+
const msg = JSON.stringify(obj);
|
|
223
|
+
for (const client of wss.clients) sendSocket(client, msg);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
module.exports = { RoomControl, sendSocket, broadcast };
|