@twinsearth/w2m-dsh-plugin 1.2.3 → 1.2.6
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +184 -0
- package/README.md +462 -462
- package/codex/mcp-servers.toml +1 -1
- package/package.json +14 -4
- package/plugin.json +1 -1
- package/src/agent/agent.mjs +162 -4
- package/src/agent/crypto.mjs +31 -0
- package/src/plugin/ask-store.mjs +167 -0
- package/src/plugin/schema-spec.mjs +33 -0
- package/src/plugin/task-graph.mjs +147 -0
- package/src/plugin/tools.mjs +7748 -7385
- package/src/plugin/turn.mjs +41 -0
- package/src/relay/fleet-members.mjs +78 -0
- package/src/relay/fleet-messages.mjs +241 -173
- package/src/relay/report.mjs +5 -0
- package/src/relay/server.mjs +1596 -1498
- package/src/relay/state.mjs +175 -4
- package/src/relay/task-deps.mjs +92 -0
- package/src/util/chat-crypto.mjs +98 -0
- package/src/util/dataflow.mjs +72 -0
- package/src/util/envelope-signature.mjs +69 -0
- package/src/util/policy-matrix.mjs +65 -0
- package/src/util/redact.mjs +70 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns inside a thread, derived from the messages themselves.
|
|
3
|
+
*
|
|
4
|
+
* The relay assigns `turn` when it stores a message, so the only thing this module does is check that the sequence it
|
|
5
|
+
* receives is usable: a reader that trusts client timestamps for ordering shows answers above questions. When a message has
|
|
6
|
+
* no turn, it inherits the previous one so a machine writing plain chat does not split every message into its own turn.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* @param {Array<{message_id?: string, thread_id?: string|null, turn?: number, at_ms?: number, text?: string}>} messages
|
|
10
|
+
* @returns {{threads: Array<{thread_id: string|null, turns: Array<{turn: number, messages: object[]}>}>}}
|
|
11
|
+
*/
|
|
12
|
+
export function deriveTurns(messages = {}) {
|
|
13
|
+
const list = Array.isArray(messages) ? messages : [];
|
|
14
|
+
const byThread = new Map();
|
|
15
|
+
let inferred = 0;
|
|
16
|
+
for (const message of list) {
|
|
17
|
+
const threadId = typeof message?.thread_id === 'string' && message.thread_id !== '' ? message.thread_id : null;
|
|
18
|
+
if (!byThread.has(threadId)) byThread.set(threadId, []);
|
|
19
|
+
let turn = Number.isInteger(message?.turn) ? message.turn : null;
|
|
20
|
+
if (turn === null) {
|
|
21
|
+
const history = byThread.get(threadId);
|
|
22
|
+
const last = history[history.length - 1] ?? null;
|
|
23
|
+
turn = last === null ? 1 : last.turn;
|
|
24
|
+
inferred += 1;
|
|
25
|
+
}
|
|
26
|
+
byThread.get(threadId).push({ message, turn });
|
|
27
|
+
}
|
|
28
|
+
const threads = [...byThread.entries()].map(([threadId, entries]) => {
|
|
29
|
+
const turns = [];
|
|
30
|
+
for (const entry of entries) {
|
|
31
|
+
const last = turns[turns.length - 1] ?? null;
|
|
32
|
+
if (last !== null && last.turn === entry.turn) { last.messages.push(entry.message); continue; }
|
|
33
|
+
turns.push({ turn: entry.turn, messages: [entry.message] });
|
|
34
|
+
}
|
|
35
|
+
// Turns are reported in the order the relay assigned them, never the order the list happened to arrive in.
|
|
36
|
+
turns.sort((a, b) => a.turn - b.turn);
|
|
37
|
+
return { thread_id: threadId, turns };
|
|
38
|
+
});
|
|
39
|
+
threads.sort((a, b) => String(a.thread_id ?? '').localeCompare(String(b.thread_id ?? '')));
|
|
40
|
+
return { threads, inferred_turns: inferred };
|
|
41
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fleet's member list: who is in this fleet, what role they hold, and when they joined.
|
|
3
|
+
*
|
|
4
|
+
* ## Why a role table and not a field on the device
|
|
5
|
+
*
|
|
6
|
+
* A device record is written by pairing and by reconnect; a role is an operator decision that must survive both. Keeping it in
|
|
7
|
+
* its own table also makes the default explicit: an unknown machine is a `worker`, never an `owner`, so a machine cannot gain
|
|
8
|
+
* authority by being re-paired.
|
|
9
|
+
*
|
|
10
|
+
* ## Why the refusals are values
|
|
11
|
+
*
|
|
12
|
+
* `role` is a closed set and the owner may only be changed by the operator token. Both are refused by name, because a member
|
|
13
|
+
* list that silently ignored a role change would be a board that disagrees with the fleet.
|
|
14
|
+
*/
|
|
15
|
+
export const MEMBER_ROLES = Object.freeze(['owner', 'worker', 'observer']);
|
|
16
|
+
|
|
17
|
+
export const MEMBERS_REFUSAL = Object.freeze({
|
|
18
|
+
UNKNOWN_MACHINE: 'MEMBERS_UNKNOWN_MACHINE',
|
|
19
|
+
BAD_ROLE: 'MEMBERS_BAD_ROLE',
|
|
20
|
+
LAST_OWNER: 'MEMBERS_LAST_OWNER',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
/** @param {unknown} role */
|
|
24
|
+
export function isMemberRole(role) {
|
|
25
|
+
return typeof role === 'string' && MEMBER_ROLES.includes(role);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {{devices?: Map<string, object>, roles?: Map<string, string>}} input
|
|
30
|
+
* @returns {Array<{machine_id: string, machine_name: string, role: string, joined_at_ms: number|null, online: boolean, last_seen_at_ms: number|null}>}
|
|
31
|
+
*/
|
|
32
|
+
export function buildMembers({ devices = new Map(), roles = new Map() } = {}) {
|
|
33
|
+
const list = [];
|
|
34
|
+
for (const [machineId, device] of devices.entries()) {
|
|
35
|
+
const role = roles.get(machineId);
|
|
36
|
+
list.push({
|
|
37
|
+
machine_id: machineId,
|
|
38
|
+
machine_name: typeof device?.machine_name === 'string' && device.machine_name !== '' ? device.machine_name : machineId,
|
|
39
|
+
role: isMemberRole(role) ? role : 'worker',
|
|
40
|
+
joined_at_ms: Number.isFinite(device?.paired_at_ms) ? device.paired_at_ms : null,
|
|
41
|
+
online: device?.online === true,
|
|
42
|
+
last_seen_at_ms: Number.isFinite(device?.last_seen_at_ms) ? device.last_seen_at_ms : null,
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
list.sort((a, b) => a.machine_id.localeCompare(b.machine_id));
|
|
46
|
+
return list;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {{machineId: string, role: unknown, devices: Map<string, object>, roles: Map<string, string>}} input
|
|
51
|
+
* @returns {{ok: true, member: object} | {ok: false, code: string, detail?: object}}
|
|
52
|
+
*/
|
|
53
|
+
export function setMemberRole({ machineId, role, devices = new Map(), roles = new Map() } = {}) {
|
|
54
|
+
if (typeof machineId !== 'string' || !devices.has(machineId)) {
|
|
55
|
+
return { ok: false, code: MEMBERS_REFUSAL.UNKNOWN_MACHINE, detail: { machine_id: machineId ?? null } };
|
|
56
|
+
}
|
|
57
|
+
if (!isMemberRole(role)) {
|
|
58
|
+
return { ok: false, code: MEMBERS_REFUSAL.BAD_ROLE, detail: { role: role ?? null, allowed: [...MEMBER_ROLES] } };
|
|
59
|
+
}
|
|
60
|
+
// The fleet must keep at least one owner: demoting the last one would leave nobody able to readmit a machine.
|
|
61
|
+
if (role !== 'owner') {
|
|
62
|
+
const owners = [...roles.entries()].filter(([id, held]) => held === 'owner' && id !== machineId);
|
|
63
|
+
const current = roles.get(machineId);
|
|
64
|
+
if (current === 'owner' && owners.length === 0) {
|
|
65
|
+
return { ok: false, code: MEMBERS_REFUSAL.LAST_OWNER, detail: { machine_id: machineId } };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
roles.set(machineId, role);
|
|
69
|
+
const device = devices.get(machineId);
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
member: {
|
|
73
|
+
machine_id: machineId,
|
|
74
|
+
machine_name: typeof device?.machine_name === 'string' ? device.machine_name : machineId,
|
|
75
|
+
role,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -1,173 +1,241 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Fleet chat: plain-text messages scoped to a SESSION, with a byte budget.
|
|
3
|
-
*
|
|
4
|
-
* ## Why the session is the scope
|
|
5
|
-
*
|
|
6
|
-
* The operator's decision: messages belong to the conversation, like a group chat, and disappear with it. A machine that
|
|
7
|
-
* joins or leaves a session changes who can read the flow, but never rewrites what was said -- and deleting the session
|
|
8
|
-
* deletes the history, because keeping it would be a second, silently-growing store nobody asked for.
|
|
9
|
-
*
|
|
10
|
-
* ## The quota, as specified
|
|
11
|
-
*
|
|
12
|
-
* 100 MB per fleet per 24 hours is the baseline; when that is exceeded, the 7-day retention figure is compared and the
|
|
13
|
-
* larger is kept, capped at 200 MB:
|
|
14
|
-
*
|
|
15
|
-
* limit = min(hardCap, max(quota24h, used7d))
|
|
16
|
-
*
|
|
17
|
-
* A message over the limit is REFUSED with the current usage attached -- never silently dropped, and never accepted by
|
|
18
|
-
* evicting somebody else's words. The relay is the only clock here: a client-chosen timestamp would let one machine
|
|
19
|
-
* claim its messages are older than they are.
|
|
20
|
-
*
|
|
21
|
-
* ## Sharding is not this file
|
|
22
|
-
*
|
|
23
|
-
* This keeps the authoritative list in memory for the relay to serve and to shard out; distributing encrypted shards
|
|
24
|
-
* across the fleet's machines is the next step and depends on this shape staying the same.
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
/** 100 MB per 24h per fleet (operator decision). */
|
|
28
|
-
export const QUOTA_24H_BYTES = 100 * 1024 * 1024;
|
|
29
|
-
/** Hard ceiling regardless of how much the 7-day window would allow. */
|
|
30
|
-
export const HARD_CAP_BYTES = 200 * 1024 * 1024;
|
|
31
|
-
/** Retention window used for the "keep the larger" comparison. */
|
|
32
|
-
export const RETENTION_DAYS = 7;
|
|
33
|
-
/** One message. Text is cheap; a 4 KiB cap keeps a single paste from eating the budget. */
|
|
34
|
-
export const MAX_MESSAGE_BYTES = 4 * 1024;
|
|
35
|
-
|
|
36
|
-
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* @param {{now?: () => number, quota24hBytes?: number, hardCapBytes?: number, retentionDays?: number,
|
|
40
|
-
* maxMessageBytes?: number}} [options]
|
|
41
|
-
*/
|
|
42
|
-
export function createFleetMessages(options = {}) {
|
|
43
|
-
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
44
|
-
const quota24hBytes = Number.isInteger(options.quota24hBytes) ? options.quota24hBytes : QUOTA_24H_BYTES;
|
|
45
|
-
const hardCapBytes = Number.isInteger(options.hardCapBytes) ? options.hardCapBytes : HARD_CAP_BYTES;
|
|
46
|
-
const retentionDays = Number.isInteger(options.retentionDays) ? options.retentionDays : RETENTION_DAYS;
|
|
47
|
-
const maxMessageBytes = Number.isInteger(options.maxMessageBytes) ? options.maxMessageBytes : MAX_MESSAGE_BYTES;
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Notification subscribers. An event carries NO text: the notification says "there is something new to read", and the
|
|
51
|
-
* reader fetches the text through the endpoint that already authenticates them. Putting the text in a broadcast would
|
|
52
|
-
* send every message to every connected listener regardless of session membership.
|
|
53
|
-
*/
|
|
54
|
-
const listeners = new Set();
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Fleet chat: plain-text messages scoped to a SESSION, with a byte budget.
|
|
3
|
+
*
|
|
4
|
+
* ## Why the session is the scope
|
|
5
|
+
*
|
|
6
|
+
* The operator's decision: messages belong to the conversation, like a group chat, and disappear with it. A machine that
|
|
7
|
+
* joins or leaves a session changes who can read the flow, but never rewrites what was said -- and deleting the session
|
|
8
|
+
* deletes the history, because keeping it would be a second, silently-growing store nobody asked for.
|
|
9
|
+
*
|
|
10
|
+
* ## The quota, as specified
|
|
11
|
+
*
|
|
12
|
+
* 100 MB per fleet per 24 hours is the baseline; when that is exceeded, the 7-day retention figure is compared and the
|
|
13
|
+
* larger is kept, capped at 200 MB:
|
|
14
|
+
*
|
|
15
|
+
* limit = min(hardCap, max(quota24h, used7d))
|
|
16
|
+
*
|
|
17
|
+
* A message over the limit is REFUSED with the current usage attached -- never silently dropped, and never accepted by
|
|
18
|
+
* evicting somebody else's words. The relay is the only clock here: a client-chosen timestamp would let one machine
|
|
19
|
+
* claim its messages are older than they are.
|
|
20
|
+
*
|
|
21
|
+
* ## Sharding is not this file
|
|
22
|
+
*
|
|
23
|
+
* This keeps the authoritative list in memory for the relay to serve and to shard out; distributing encrypted shards
|
|
24
|
+
* across the fleet's machines is the next step and depends on this shape staying the same.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** 100 MB per 24h per fleet (operator decision). */
|
|
28
|
+
export const QUOTA_24H_BYTES = 100 * 1024 * 1024;
|
|
29
|
+
/** Hard ceiling regardless of how much the 7-day window would allow. */
|
|
30
|
+
export const HARD_CAP_BYTES = 200 * 1024 * 1024;
|
|
31
|
+
/** Retention window used for the "keep the larger" comparison. */
|
|
32
|
+
export const RETENTION_DAYS = 7;
|
|
33
|
+
/** One message. Text is cheap; a 4 KiB cap keeps a single paste from eating the budget. */
|
|
34
|
+
export const MAX_MESSAGE_BYTES = 4 * 1024;
|
|
35
|
+
|
|
36
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {{now?: () => number, quota24hBytes?: number, hardCapBytes?: number, retentionDays?: number,
|
|
40
|
+
* maxMessageBytes?: number}} [options]
|
|
41
|
+
*/
|
|
42
|
+
export function createFleetMessages(options = {}) {
|
|
43
|
+
const now = typeof options.now === 'function' ? options.now : () => Date.now();
|
|
44
|
+
const quota24hBytes = Number.isInteger(options.quota24hBytes) ? options.quota24hBytes : QUOTA_24H_BYTES;
|
|
45
|
+
const hardCapBytes = Number.isInteger(options.hardCapBytes) ? options.hardCapBytes : HARD_CAP_BYTES;
|
|
46
|
+
const retentionDays = Number.isInteger(options.retentionDays) ? options.retentionDays : RETENTION_DAYS;
|
|
47
|
+
const maxMessageBytes = Number.isInteger(options.maxMessageBytes) ? options.maxMessageBytes : MAX_MESSAGE_BYTES;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Notification subscribers. An event carries NO text: the notification says "there is something new to read", and the
|
|
51
|
+
* reader fetches the text through the endpoint that already authenticates them. Putting the text in a broadcast would
|
|
52
|
+
* send every message to every connected listener regardless of session membership.
|
|
53
|
+
*/
|
|
54
|
+
const listeners = new Set();
|
|
55
|
+
/**
|
|
56
|
+
* v1.2.5 idempotency: (session, key) -> message_id. A sender that retries after a lost response must not post the text twice,
|
|
57
|
+
* and the answer to a replay is the FIRST message -- a second row would make the same sentence appear twice in the thread.
|
|
58
|
+
*/
|
|
59
|
+
const idempotency = new Map();
|
|
60
|
+
const MAX_IDEMPOTENCY_KEYS = 2000;
|
|
61
|
+
/** session_id -> [{ message_id, session_id, machine_id, text, at_ms, bytes }] */
|
|
62
|
+
const sessions = new Map();
|
|
63
|
+
let seq = 0;
|
|
64
|
+
|
|
65
|
+
const all = () => {
|
|
66
|
+
const out = [];
|
|
67
|
+
for (const rows of sessions.values()) out.push(...rows);
|
|
68
|
+
return out;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function usage() {
|
|
72
|
+
const t = now();
|
|
73
|
+
const day = all().filter((m) => t - m.at_ms <= DAY_MS);
|
|
74
|
+
const week = all().filter((m) => t - m.at_ms <= retentionDays * DAY_MS);
|
|
75
|
+
const sum = (rows) => rows.reduce((total, m) => total + m.bytes, 0);
|
|
76
|
+
return { used_24h_bytes: sum(day), used_retention_bytes: sum(week), messages: all().length };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function limitBytes() {
|
|
80
|
+
const { used_retention_bytes: week } = usage();
|
|
81
|
+
return Math.min(hardCapBytes, Math.max(quota24hBytes, week));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {{session_id: string, fleet_id?: string|null, machine_id: string, text: string}} input
|
|
86
|
+
* @returns {{ok: true, message: object} | {ok: false, code: string, detail?: object}}
|
|
87
|
+
*/
|
|
88
|
+
function post(input) {
|
|
89
|
+
const sessionId = typeof input?.session_id === 'string' ? input.session_id.trim() : '';
|
|
90
|
+
if (sessionId === '') return { ok: false, code: 'BAD_SESSION' };
|
|
91
|
+
const machineId = typeof input?.machine_id === 'string' ? input.machine_id.trim() : '';
|
|
92
|
+
if (machineId === '') return { ok: false, code: 'BAD_MACHINE' };
|
|
93
|
+
if (typeof input?.text !== 'string') return { ok: false, code: 'NOT_TEXT' };
|
|
94
|
+
const text = input.text;
|
|
95
|
+
if (text.trim() === '') return { ok: false, code: 'EMPTY_TEXT' };
|
|
96
|
+
// Reject control characters (except tab/newline): "plain text" is a contract, not a preference.
|
|
97
|
+
// eslint-disable-next-line no-control-regex
|
|
98
|
+
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(text)) return { ok: false, code: 'NOT_TEXT' };
|
|
99
|
+
const bytes = Buffer.byteLength(text, 'utf8');
|
|
100
|
+
if (bytes > maxMessageBytes) return { ok: false, code: 'MESSAGE_TOO_LARGE', detail: { bytes, maxBytes: maxMessageBytes } };
|
|
101
|
+
|
|
102
|
+
// Addressing: 'all' is the group, a machine id is one recipient. ONE row either way -- N copies for N recipients
|
|
103
|
+
// would multiply the byte budget by the fleet size for no extra meaning; visibility is a filter at read time.
|
|
104
|
+
const to = typeof input?.to === 'string' && input.to.trim() !== '' ? input.to.trim() : 'all';
|
|
105
|
+
if (to !== 'all' && (to.length > 64 || /\s/.test(to))) return { ok: false, code: 'BAD_ADDRESS' };
|
|
106
|
+
// Correlation: a thread groups a conversation, reply_to points at the message being answered, turn orders the
|
|
107
|
+
// exchange inside one thread. Plain fields, validated rather than trusted.
|
|
108
|
+
/**
|
|
109
|
+
* v1.2.6 end-to-end encryption: the relay carries the ciphertext and its metadata, and never a key. It validates the SHAPE
|
|
110
|
+
* because a malformed envelope would be stored and later fail on every reader; it cannot check the content, and that is the
|
|
111
|
+
* point -- which also means it cannot search messages or know their real length.
|
|
112
|
+
*/
|
|
113
|
+
const rawEnc = input?.enc;
|
|
114
|
+
let enc = null;
|
|
115
|
+
if (rawEnc !== undefined && rawEnc !== null) {
|
|
116
|
+
const ivOk = typeof rawEnc.iv === 'string' && rawEnc.iv.length > 0 && rawEnc.iv.length <= 64;
|
|
117
|
+
const tagOk = typeof rawEnc.tag === 'string' && rawEnc.tag.length > 0 && rawEnc.tag.length <= 64;
|
|
118
|
+
if (rawEnc.v !== 1 || rawEnc.alg !== 'aes-256-gcm' || !ivOk || !tagOk) return { ok: false, code: 'BAD_ENCRYPTION' };
|
|
119
|
+
enc = { v: 1, alg: 'aes-256-gcm', iv: rawEnc.iv, tag: rawEnc.tag };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// v1.2.5 idempotency: replay the first message instead of storing the text twice.
|
|
123
|
+
const idempotencyKey = typeof input?.idempotency_key === 'string' && input.idempotency_key.trim() !== ''
|
|
124
|
+
? input.idempotency_key.trim().slice(0, 64)
|
|
125
|
+
: null;
|
|
126
|
+
if (idempotencyKey !== null) {
|
|
127
|
+
const seenId = idempotency.get(sessionId + '\u0000' + idempotencyKey) ?? null;
|
|
128
|
+
if (seenId !== null) {
|
|
129
|
+
const known = (sessions.get(sessionId) ?? []).find((entry) => entry.message_id === seenId) ?? null;
|
|
130
|
+
if (known !== null) return { ok: true, message: { ...known }, deduped: true };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// v1.2.5 mentions: names to alert, validated as a small set. The message still has ONE row; a mention is a notification
|
|
135
|
+
// hint, not a delivery mechanism.
|
|
136
|
+
const rawMentions = input?.mentions;
|
|
137
|
+
let mentions = [];
|
|
138
|
+
if (rawMentions !== undefined && rawMentions !== null) {
|
|
139
|
+
if (!Array.isArray(rawMentions) || rawMentions.length > 10) return { ok: false, code: 'BAD_MENTIONS' };
|
|
140
|
+
for (const entry of rawMentions) {
|
|
141
|
+
if (typeof entry !== 'string' || entry === '' || entry.length > 64 || /\s/.test(entry)) return { ok: false, code: 'BAD_MENTIONS' };
|
|
142
|
+
}
|
|
143
|
+
mentions = [...new Set(rawMentions)];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const threadId = typeof input?.thread_id === 'string' && input.thread_id.trim() !== ''
|
|
147
|
+
? input.thread_id.trim().slice(0, 64)
|
|
148
|
+
: null;
|
|
149
|
+
const replyTo = typeof input?.reply_to === 'string' && input.reply_to.trim() !== '' ? input.reply_to.trim() : null;
|
|
150
|
+
|
|
151
|
+
const before = usage();
|
|
152
|
+
const limit = limitBytes();
|
|
153
|
+
if (before.used_24h_bytes + bytes > limit) {
|
|
154
|
+
return { ok: false, code: 'FLEET_QUOTA_EXCEEDED', detail: { used_24h_bytes: before.used_24h_bytes, limit_bytes: limit, bytes } };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A reply must point at something that exists in the SAME session: a dangling reply_to makes a thread unreadable.
|
|
158
|
+
const existing = sessions.get(sessionId) ?? [];
|
|
159
|
+
if (replyTo !== null && !existing.some((m) => m.message_id === replyTo)) {
|
|
160
|
+
return { ok: false, code: 'REPLY_TARGET_MISSING', detail: { reply_to: replyTo } };
|
|
161
|
+
}
|
|
162
|
+
const threadOf = threadId ?? (replyTo !== null ? (existing.find((m) => m.message_id === replyTo)?.thread_id ?? null) : null);
|
|
163
|
+
const turn = existing.filter((m) => (threadOf === null ? m.thread_id === null : m.thread_id === threadOf)).length + 1;
|
|
164
|
+
|
|
165
|
+
seq += 1;
|
|
166
|
+
const message = {
|
|
167
|
+
message_id: 'msg-' + String(seq).padStart(6, '0'),
|
|
168
|
+
session_id: sessionId,
|
|
169
|
+
fleet_id: typeof input?.fleet_id === 'string' ? input.fleet_id : null,
|
|
170
|
+
machine_id: machineId,
|
|
171
|
+
to,
|
|
172
|
+
thread_id: threadOf,
|
|
173
|
+
reply_to: replyTo,
|
|
174
|
+
turn,
|
|
175
|
+
text,
|
|
176
|
+
// Omitted when empty so a message without mentions keeps the pre-1.2.5 shape.
|
|
177
|
+
...(mentions.length === 0 ? {} : { mentions }),
|
|
178
|
+
// v1.2.6: ciphertext metadata travels beside the text so an old client can read it as opaque text and a new one can name
|
|
179
|
+
// the cipher it does not have a key for.
|
|
180
|
+
...(enc === null ? {} : { enc }),
|
|
181
|
+
...(idempotencyKey === null ? {} : { idempotency_key: idempotencyKey }),
|
|
182
|
+
at_ms: now(),
|
|
183
|
+
bytes,
|
|
184
|
+
};
|
|
185
|
+
if (!sessions.has(sessionId)) sessions.set(sessionId, []);
|
|
186
|
+
sessions.get(sessionId).push(message);
|
|
187
|
+
for (const listener of listeners) {
|
|
188
|
+
if (listener.sessionId !== null && listener.sessionId !== sessionId) continue;
|
|
189
|
+
try {
|
|
190
|
+
// The event carries addressing and mentions so a listener can say "you were named"; it never carries the text.
|
|
191
|
+
listener.fn({
|
|
192
|
+
session_id: sessionId,
|
|
193
|
+
message_id: message.message_id,
|
|
194
|
+
machine_id: message.machine_id,
|
|
195
|
+
at_ms: message.at_ms,
|
|
196
|
+
to: message.to,
|
|
197
|
+
...(mentions.length === 0 ? {} : { mentions }),
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
catch { /* a dead listener must not fail the write */ }
|
|
201
|
+
}
|
|
202
|
+
if (idempotencyKey !== null) {
|
|
203
|
+
idempotency.set(sessionId + '\u0000' + idempotencyKey, message.message_id);
|
|
204
|
+
if (idempotency.size > MAX_IDEMPOTENCY_KEYS) {
|
|
205
|
+
const oldest = idempotency.keys().next().value;
|
|
206
|
+
if (oldest !== undefined) idempotency.delete(oldest);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { ok: true, message };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Read one session's flow. \`since_ms\` is exclusive; the order is the order it was said. */
|
|
214
|
+
function list({ session_id: sessionId, since_ms: sinceMs = null, limit = 50, for_machine_id: forMachine = null,
|
|
215
|
+
thread_id: threadId = null, to: toFilter = null } = {}) {
|
|
216
|
+
const rows = sessions.get(sessionId) ?? [];
|
|
217
|
+
let filtered = Number.isFinite(sinceMs) ? rows.filter((m) => m.at_ms > sinceMs) : rows;
|
|
218
|
+
// A reader sees the group and what was addressed to it, never another machine's private message.
|
|
219
|
+
if (typeof forMachine === 'string' && forMachine !== '') {
|
|
220
|
+
filtered = filtered.filter((m) => (m.to ?? 'all') === 'all' || m.to === forMachine || m.machine_id === forMachine);
|
|
221
|
+
}
|
|
222
|
+
if (typeof threadId === 'string' && threadId !== '') filtered = filtered.filter((m) => m.thread_id === threadId);
|
|
223
|
+
if (typeof toFilter === 'string' && toFilter !== '') filtered = filtered.filter((m) => (m.to ?? 'all') === toFilter);
|
|
224
|
+
const capped = filtered.slice(-Math.max(1, Math.min(limit, 200)));
|
|
225
|
+
return { ok: true, messages: capped.map((m) => ({ ...m })), count: capped.length };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Deleting a session deletes its history -- the operator's rule, not a side effect. */
|
|
229
|
+
function deleteSession(sessionId) {
|
|
230
|
+
return sessions.delete(sessionId);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** @param {string|null} sessionId null subscribes to every session; returns the unsubscribe function. */
|
|
234
|
+
function subscribe(sessionId, fn) {
|
|
235
|
+
const listener = { sessionId: typeof sessionId === 'string' && sessionId !== '' ? sessionId : null, fn };
|
|
236
|
+
listeners.add(listener);
|
|
237
|
+
return () => listeners.delete(listener);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return { post, list, usage, limitBytes, deleteSession, subscribe, _sessions: sessions };
|
|
241
|
+
}
|
package/src/relay/report.mjs
CHANGED
|
@@ -148,6 +148,8 @@ export function aggregate(task, resultRecords = [], opts = {}) {
|
|
|
148
148
|
observing: lease.state === 'observing',
|
|
149
149
|
result: rec,
|
|
150
150
|
envelope: rec?.envelope ?? null,
|
|
151
|
+
// v1.2.6: the relay's own signature verdict (bound to the key registered at pairing), not a client claim.
|
|
152
|
+
signature_status: rec?.validation?.signature_status ?? null,
|
|
151
153
|
outcome: null,
|
|
152
154
|
reasons: [],
|
|
153
155
|
});
|
|
@@ -166,6 +168,7 @@ export function aggregate(task, resultRecords = [], opts = {}) {
|
|
|
166
168
|
gate_refused: false,
|
|
167
169
|
result: rec,
|
|
168
170
|
envelope: rec.envelope,
|
|
171
|
+
signature_status: rec?.validation?.signature_status ?? null,
|
|
169
172
|
outcome: null,
|
|
170
173
|
reasons: [],
|
|
171
174
|
});
|
|
@@ -487,6 +490,8 @@ export function aggregate(task, resultRecords = [], opts = {}) {
|
|
|
487
490
|
transport: m.envelope?.transport ?? null,
|
|
488
491
|
p2p: m.envelope?.p2p ?? null,
|
|
489
492
|
reasons: m.reasons,
|
|
493
|
+
// v1.2.6: the relay's verdict, bound to the key registered at pairing.
|
|
494
|
+
signature_status: m.signature_status ?? null,
|
|
490
495
|
})),
|
|
491
496
|
steps,
|
|
492
497
|
differences: step3.differences ?? [],
|