@twinsearth/w2m-dsh-plugin 1.1.15 → 1.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
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
+ /** session_id -> [{ message_id, session_id, machine_id, text, at_ms, bytes }] */
56
+ const sessions = new Map();
57
+ let seq = 0;
58
+
59
+ const all = () => {
60
+ const out = [];
61
+ for (const rows of sessions.values()) out.push(...rows);
62
+ return out;
63
+ };
64
+
65
+ function usage() {
66
+ const t = now();
67
+ const day = all().filter((m) => t - m.at_ms <= DAY_MS);
68
+ const week = all().filter((m) => t - m.at_ms <= retentionDays * DAY_MS);
69
+ const sum = (rows) => rows.reduce((total, m) => total + m.bytes, 0);
70
+ return { used_24h_bytes: sum(day), used_retention_bytes: sum(week), messages: all().length };
71
+ }
72
+
73
+ function limitBytes() {
74
+ const { used_retention_bytes: week } = usage();
75
+ return Math.min(hardCapBytes, Math.max(quota24hBytes, week));
76
+ }
77
+
78
+ /**
79
+ * @param {{session_id: string, fleet_id?: string|null, machine_id: string, text: string}} input
80
+ * @returns {{ok: true, message: object} | {ok: false, code: string, detail?: object}}
81
+ */
82
+ function post(input) {
83
+ const sessionId = typeof input?.session_id === 'string' ? input.session_id.trim() : '';
84
+ if (sessionId === '') return { ok: false, code: 'BAD_SESSION' };
85
+ const machineId = typeof input?.machine_id === 'string' ? input.machine_id.trim() : '';
86
+ if (machineId === '') return { ok: false, code: 'BAD_MACHINE' };
87
+ if (typeof input?.text !== 'string') return { ok: false, code: 'NOT_TEXT' };
88
+ const text = input.text;
89
+ if (text.trim() === '') return { ok: false, code: 'EMPTY_TEXT' };
90
+ // Reject control characters (except tab/newline): "plain text" is a contract, not a preference.
91
+ // Control characters are the thing being rejected here, so the rule that forbids them in a pattern
92
+ // does not apply -- the escape IS the contract.
93
+ // eslint-disable-next-line no-control-regex
94
+ if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F]/.test(text)) return { ok: false, code: 'NOT_TEXT' };
95
+ const bytes = Buffer.byteLength(text, 'utf8');
96
+ if (bytes > maxMessageBytes) return { ok: false, code: 'MESSAGE_TOO_LARGE', detail: { bytes, maxBytes: maxMessageBytes } };
97
+
98
+ const before = usage();
99
+ const limit = limitBytes();
100
+ if (before.used_24h_bytes + bytes > limit) {
101
+ return { ok: false, code: 'FLEET_QUOTA_EXCEEDED', detail: { used_24h_bytes: before.used_24h_bytes, limit_bytes: limit, bytes } };
102
+ }
103
+
104
+ seq += 1;
105
+ const message = {
106
+ message_id: 'msg-' + String(seq).padStart(6, '0'),
107
+ session_id: sessionId,
108
+ fleet_id: typeof input?.fleet_id === 'string' ? input.fleet_id : null,
109
+ machine_id: machineId,
110
+ text,
111
+ at_ms: now(),
112
+ bytes,
113
+ };
114
+ if (!sessions.has(sessionId)) sessions.set(sessionId, []);
115
+ sessions.get(sessionId).push(message);
116
+ for (const listener of listeners) {
117
+ if (listener.sessionId !== null && listener.sessionId !== sessionId) continue;
118
+ try { listener.fn({ session_id: sessionId, message_id: message.message_id, machine_id: message.machine_id, at_ms: message.at_ms }); }
119
+ catch { /* a dead listener must not fail the write */ }
120
+ }
121
+ return { ok: true, message };
122
+ }
123
+
124
+ /** Read one session's flow. \`since_ms\` is exclusive; the order is the order it was said. */
125
+ function list({ session_id: sessionId, since_ms: sinceMs = null, limit = 50 } = {}) {
126
+ const rows = sessions.get(sessionId) ?? [];
127
+ const filtered = Number.isFinite(sinceMs) ? rows.filter((m) => m.at_ms > sinceMs) : rows;
128
+ const capped = filtered.slice(-Math.max(1, Math.min(limit, 200)));
129
+ return { ok: true, messages: capped.map((m) => ({ ...m })), count: capped.length };
130
+ }
131
+
132
+ /** Deleting a session deletes its history -- the operator's rule, not a side effect. */
133
+ function deleteSession(sessionId) {
134
+ return sessions.delete(sessionId);
135
+ }
136
+
137
+ /** @param {string|null} sessionId null subscribes to every session; returns the unsubscribe function. */
138
+ function subscribe(sessionId, fn) {
139
+ const listener = { sessionId: typeof sessionId === 'string' && sessionId !== '' ? sessionId : null, fn };
140
+ listeners.add(listener);
141
+ return () => listeners.delete(listener);
142
+ }
143
+
144
+ return { post, list, usage, limitBytes, deleteSession, subscribe, _sessions: sessions };
145
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Chat history, encrypted and split across the fleet's own machines.
3
+ *
4
+ * ## The three rules that keep "distributed storage" from becoming "lost data"
5
+ *
6
+ * 1. **Rebuildable.** Any single message must be recoverable from the shards that exist, and the metadata says where they
7
+ * are: \`{machine_id, part, total, sha256}\` per shard. A reader never has to guess who holds what.
8
+ * 2. **Offline tolerated.** Each part is assigned to **two distinct** machines. One machine being down is the normal
9
+ * case, not the failure case, and a single copy would make it a data-loss case.
10
+ * 3. **Integrity.** Every shard carries sha256 of its own bytes. A shard that does not match is corrupt and is named;
11
+ * it is never silently decrypted into garbage. Missing shards are named too -- "some data is gone" without saying
12
+ * which part is gone is not a report, it is a shrug.
13
+ *
14
+ * ## Encryption
15
+ *
16
+ * AES-256-GCM with a key derived from the fleet key (the same secret that already authorises joining, so no new
17
+ * long-lived secret appears). The IV is generated per day-batch and travels in the header; the auth tag is verified before
18
+ * any plaintext is returned, so a wrong key or a tampered ciphertext fails loudly.
19
+ *
20
+ * ## What this file does NOT do
21
+ *
22
+ * It does not decide where the machines are or how bytes reach them. It produces the shards and reassembles them; the
23
+ * relay owns transport. Keeping the split here is what makes it testable without a fleet.
24
+ */
25
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
26
+
27
+ /** Default slice size for one shard: small enough to distribute, large enough not to explode the shard count. */
28
+ export const DEFAULT_SHARD_BYTES = 256 * 1024;
29
+ /** Two copies, per rule 2. */
30
+ export const DEFAULT_REPLICAS = 2;
31
+
32
+ const sha256 = (buffer) => createHash('sha256').update(buffer).digest('hex');
33
+ const dayKey = (atMs) => new Date(atMs).toISOString().slice(0, 10);
34
+
35
+ /** Derive the encryption key from the fleet key. Same secret, different purpose, separated label. */
36
+ export function deriveShardKey(fleetKey) {
37
+ if (typeof fleetKey !== 'string' || fleetKey === '') throw new TypeError('fleetKey must be a non-empty string');
38
+ return createHash('sha256').update('w2m-chat-shards-v1').update('\u0000').update(fleetKey).digest();
39
+ }
40
+
41
+ /**
42
+ * Split one day's messages into encrypted shards and assign each part to replica machines.
43
+ *
44
+ * @param {{messages: object[], fleetKey: string, machines: string[], shardBytes?: number, replicas?: number,
45
+ * at_ms?: number}} input
46
+ * @returns {{ok: true, day: string, shards: object[], header: object} | {ok: false, code: string}}
47
+ */
48
+ export function buildShards({ messages, fleetKey, machines, shardBytes = DEFAULT_SHARD_BYTES, replicas = DEFAULT_REPLICAS, at_ms }) {
49
+ if (!Array.isArray(messages) || messages.length === 0) return { ok: false, code: 'NOTHING_TO_SHARD' };
50
+ if (!Array.isArray(machines) || machines.length === 0) return { ok: false, code: 'NO_MACHINES' };
51
+ if (replicas > machines.length) return { ok: false, code: 'NOT_ENOUGH_MACHINES', detail: { machines: machines.length, replicas } };
52
+ const key = deriveShardKey(fleetKey);
53
+ const at = Number.isFinite(at_ms) ? at_ms : messages[0].at_ms ?? Date.now();
54
+ const day = dayKey(at);
55
+ const plaintext = Buffer.from(JSON.stringify(messages), 'utf8');
56
+
57
+ const iv = randomBytes(12);
58
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
59
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
60
+ const tag = cipher.getAuthTag();
61
+ const sealed = Buffer.concat([ciphertext, tag]);
62
+
63
+ const total = Math.max(1, Math.ceil(sealed.length / shardBytes));
64
+ const shards = [];
65
+ for (let part = 0; part < total; part += 1) {
66
+ const body = sealed.subarray(part * shardBytes, (part + 1) * shardBytes);
67
+ const digest = sha256(body);
68
+ // Rule 2: distinct machines per part, rotating so the copies are not all on the same pair.
69
+ const holders = [];
70
+ for (let r = 0; r < replicas; r += 1) holders.push(machines[(part + r) % machines.length]);
71
+ for (const machineId of holders) {
72
+ shards.push({
73
+ day, part, total, machine_id: machineId, sha256: digest,
74
+ bytes: body.length, body: body.toString('base64'),
75
+ });
76
+ }
77
+ }
78
+ return { ok: true, day, shards, header: { iv: iv.toString('base64'), algorithm: 'aes-256-gcm', total, day } };
79
+ }
80
+
81
+ /**
82
+ * Reassemble the day's messages from whatever shards are available.
83
+ *
84
+ * Every failure is named: a missing part, a corrupt part, and a failed authentication are three different operational
85
+ * facts and a caller that cannot tell them apart cannot decide whether to retry another replica.
86
+ *
87
+ * @param {{shards: object[], header: object, fleetKey: string}} input
88
+ * @returns {{ok: true, messages: object[]} | {ok: false, code: string, detail?: object}}
89
+ */
90
+ export function assembleShards({ shards, header, fleetKey }) {
91
+ if (!Array.isArray(shards) || shards.length === 0) return { ok: false, code: 'NO_SHARDS' };
92
+ if (header === null || typeof header !== 'object') return { ok: false, code: 'NO_HEADER' };
93
+ const total = header.total;
94
+ const byPart = new Map();
95
+ for (const shard of shards) {
96
+ if (sha256(Buffer.from(shard.body, 'base64')) !== shard.sha256) {
97
+ return { ok: false, code: 'SHARD_CORRUPT', detail: { part: shard.part, machine_id: shard.machine_id } };
98
+ }
99
+ if (!byPart.has(shard.part)) byPart.set(shard.part, shard);
100
+ }
101
+ const missing = [];
102
+ for (let part = 0; part < total; part += 1) if (!byPart.has(part)) missing.push(part);
103
+ if (missing.length > 0) return { ok: false, code: 'MISSING_SHARDS', detail: { missing } };
104
+
105
+ const ordered = [];
106
+ for (let part = 0; part < total; part += 1) ordered.push(Buffer.from(byPart.get(part).body, 'base64'));
107
+ const sealed = Buffer.concat(ordered);
108
+ const tag = sealed.subarray(sealed.length - 16);
109
+ const ciphertext = sealed.subarray(0, sealed.length - 16);
110
+ try {
111
+ const decipher = createDecipheriv('aes-256-gcm', deriveShardKey(fleetKey), Buffer.from(header.iv, 'base64'));
112
+ decipher.setAuthTag(tag);
113
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
114
+ return { ok: true, messages: JSON.parse(plaintext.toString('utf8')) };
115
+ } catch {
116
+ // Wrong key or tampered ciphertext: never return partial plaintext.
117
+ return { ok: false, code: 'AUTH_FAILED' };
118
+ }
119
+ }
120
+
121
+ /** Which shards does a reader hold for one machine? Transport uses this; it does not read the bodies. */
122
+ export function shardsForMachine(shards, machineId) {
123
+ return shards.filter((shard) => shard.machine_id === machineId).map(({ body, ...meta }) => meta);
124
+ }
@@ -21,7 +21,8 @@ import {
21
21
  ProtocolError,
22
22
  RabbitState,
23
23
  rfc3339,
24
- } from './state.mjs';
24
+ } from './state.mjs';
25
+ import { createFleetMessages } from './fleet-messages.mjs';
25
26
  import { createJoinChallenges } from './join-challenge.mjs';
26
27
  import { createAdmission } from './admission.mjs';
27
28
  import { Persistence } from './persistence.mjs';
@@ -1037,6 +1038,75 @@ export class RelayServer {
1037
1038
  return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), ...result });
1038
1039
  }
1039
1040
 
1041
+ /* ---- v1.1.17: fleet chat ---- */
1042
+ //
1043
+ // Both endpoints sit AFTER the device gate on purpose: a message must be attributable to a paired machine, and
1044
+ // "who said this" is exactly the machine_id the token resolves to -- never a body field, which would let one machine
1045
+ // speak as another. The refusal codes from the store are passed through unchanged (FLEET_QUOTA_EXCEEDED included)
1046
+ // because a caller that cannot tell "too big" from "over quota" from "not text" cannot tell the operator what to do.
1047
+ this.fleetMessages ??= createFleetMessages({ now: () => this.state.nowMs() });
1048
+
1049
+ if (method === 'POST' && path === '/v1/fleet/messages') {
1050
+ const body = takeJson();
1051
+ const posted = this.fleetMessages.post({
1052
+ session_id: body.session_id,
1053
+ fleet_id: typeof body.fleet_id === 'string' ? body.fleet_id : null,
1054
+ machine_id: device.machine_id,
1055
+ text: body.text,
1056
+ });
1057
+ if (posted.ok !== true) {
1058
+ throw new ProtocolError('BAD_REQUEST', 'message refused: ' + posted.code, { code: posted.code, ...(posted.detail ?? {}) });
1059
+ }
1060
+ return sendJson(res, 200, { protocol_version: PROTOCOL_VERSION, rabbit_time: this.state.nowIso(), message: posted.message });
1061
+ }
1062
+
1063
+ if (method === 'GET' && path === '/v1/fleet/messages/stream') {
1064
+ /**
1065
+ * v1.1.17: a notification channel, not a message channel.
1066
+ *
1067
+ * The event carries the id and the session -- never the text -- so a client that connects learns "something was
1068
+ * said" and then fetches it through GET /v1/fleet/messages, which is where membership is actually checked. A
1069
+ * broadcast carrying text would hand every message to every connected listener and make the check decorative.
1070
+ *
1071
+ * The stream sits behind the device gate: an unpaired client cannot listen.
1072
+ */
1073
+ const sessionId = url.searchParams.get('session_id');
1074
+ res.writeHead(200, {
1075
+ 'content-type': 'text/event-stream; charset=utf-8',
1076
+ 'cache-control': 'no-store',
1077
+ connection: 'keep-alive',
1078
+ 'x-accel-buffering': 'no',
1079
+ });
1080
+ res.write(': connected\n\n');
1081
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
1082
+ const keepalive = setInterval(() => { try { res.write(': keepalive\n\n'); } catch { /* closed */ } }, 15_000);
1083
+ if (typeof keepalive.unref === 'function') keepalive.unref();
1084
+ const unsubscribe = this.fleetMessages.subscribe(sessionId, (event) => {
1085
+ try {
1086
+ res.write('event: new_message\ndata: ' + JSON.stringify(event) + '\n\n');
1087
+ } catch { /* the reader went away; close() below cleans up */ }
1088
+ });
1089
+ const done = () => { clearInterval(keepalive); unsubscribe(); try { res.end(); } catch { /* already closed */ } };
1090
+ req.on('close', done);
1091
+ return undefined;
1092
+ }
1093
+
1094
+ if (method === 'GET' && path === '/v1/fleet/messages') {
1095
+ const sinceRaw = url.searchParams.get('since_ms');
1096
+ const limitRaw = url.searchParams.get('limit');
1097
+ const listed = this.fleetMessages.list({
1098
+ session_id: url.searchParams.get('session_id') ?? '',
1099
+ since_ms: sinceRaw === null ? null : Number(sinceRaw),
1100
+ limit: limitRaw === null ? 50 : Number(limitRaw),
1101
+ });
1102
+ return sendJson(res, 200, {
1103
+ protocol_version: PROTOCOL_VERSION,
1104
+ rabbit_time: this.state.nowIso(),
1105
+ ...listed,
1106
+ usage: this.fleetMessages.usage(), // shown with every read so a client can warn before the quota refuses a post
1107
+ });
1108
+ }
1109
+
1040
1110
  throw new ProtocolError('NOT_FOUND', `no route for ${method} ${path}`, { method, path });
1041
1111
  }
1042
1112