1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
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 | // In-memory stand-in for the offline mailbox service (same API shapes as the
// production node behind signal.cautpartener.ro): open mint, per-room ring
// with SHA-256 dedup, zero-padded ordered seqs, and /__test controls.
// Importable (identity.e2e.mjs) or standalone: node e2e/mailbox-stub.mjs [port]
import { createServer } from "node:http";
import { createHash } from "node:crypto";
const MAX_MESSAGE_KB = 64;
export function startStub(port) {
const rooms = new Map(); // roomId → { messages: [{seq,id,ts,payload}], byHash: Map }
let deposits = 0; // every non-deduped 201, across rooms
// like the real node, seqs stay monotonic even across a stub restart —
// clients keep their replay cursors through our down/up scenarios
let seqCounter = Date.now() * 1000;
const room = (id) => {
let r = rooms.get(id);
if (!r) rooms.set(id, (r = { messages: [], byHash: new Map() }));
return r;
};
const pad = (n) => String(n).padStart(20, "0");
const server = createServer((req, res) => {
const cors = {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, OPTIONS",
"access-control-allow-headers": "authorization, content-type",
};
const json = (code, body) => {
res.writeHead(code, { ...cors, "content-type": "application/json" });
res.end(JSON.stringify(body));
};
if (req.method === "OPTIONS") {
res.writeHead(204, cors);
return res.end();
}
const url = new URL(req.url, "http://x");
// open mint (the real node also checks Origin; the stub doesn't need to).
// Like the real node (verified 2026-08-22): `room` omitted ⇒ an app-scoped
// token valid for EVERY room; `room` given ⇒ a room-scoped token.
if (req.method === "GET" && url.pathname === "/mailbox-credentials") {
const r = url.searchParams.get("room") ?? "";
const ttl = Math.min(Number(url.searchParams.get("ttl") ?? 3600), 86400);
const token = r ? `stub:${r}` : "stub:*";
const body = { token, ttl, expires_at: Math.floor(Date.now() / 1000) + ttl };
return json(200, r ? { ...body, room: r } : body);
}
// test controls
if (url.pathname === "/__test/stats") {
const out = {};
for (const [id, r] of rooms) {
out[id] = { count: r.messages.length, oldest: r.messages[0]?.seq ?? null, latest: r.messages.at(-1)?.seq ?? null };
}
return json(200, { deposits, rooms: out });
}
if (req.method === "POST" && url.pathname === "/__test/truncate") {
const id = url.searchParams.get("room");
const keep = Number(url.searchParams.get("keep") ?? 0);
const targets = id ? [room(id)] : [...rooms.values()];
for (const r of targets) {
const dropped = r.messages.splice(0, Math.max(0, r.messages.length - keep));
for (const m of dropped) for (const [h, mm] of r.byHash) if (mm === m) r.byHash.delete(h);
}
return json(200, { ok: true });
}
const m = url.pathname.match(/^\/mailbox\/v1\/rooms\/([^/]+)\/messages$/);
if (!m) return json(404, { error: "not_found" });
const roomId = decodeURIComponent(m[1]);
const auth = req.headers.authorization ?? "";
if (!auth.startsWith("Bearer ")) return json(401, { error: "missing_token" });
const tok = auth.slice(7);
if (tok !== "stub:*" && tok !== `stub:${roomId}`) return json(403, { error: "token_out_of_scope" });
const r = room(roomId);
if (req.method === "POST") {
const chunks = [];
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const body = Buffer.concat(chunks);
if (body.length > MAX_MESSAGE_KB * 1024) return json(413, { error: "message_too_large" });
const hash = createHash("sha256").update(body).digest("hex");
const dup = r.byHash.get(hash);
if (dup) return json(200, { deduped: true, id: dup.id, seq: dup.seq, expires_at: 0 });
const msg = { seq: pad(++seqCounter), id: hash.slice(0, 32), ts: Date.now(), payload: body.toString("base64") };
r.messages.push(msg);
r.byHash.set(hash, msg);
deposits++;
return json(201, { deduped: false, id: msg.id, seq: msg.seq, expires_at: 0 });
});
return;
}
if (req.method === "GET") {
const after = url.searchParams.get("after");
const before = url.searchParams.get("before"); // null = param absent
const limit = Math.min(Number(url.searchParams.get("limit") ?? 100), 100);
const meta = {
oldest_seq: r.messages[0]?.seq ?? null,
latest_seq: r.messages.at(-1)?.seq ?? null,
};
if (before) {
// backward paging (public-plane replay, PROTOCOL §6 — mirrors the
// real node, verified 2026-08-22): the NEWEST `limit` messages with
// seq < before, ascending; has_more = OLDER messages remain beyond
// the window. Clients start with before=18446744073709551615 (max
// uint64, 20 digits) — our seqs are zero-padded to 20 chars and start
// with "0", so the plain lexical compare below is numerically correct
// for that constant too.
const pool = r.messages.filter((x) => x.seq < before);
const page = pool.slice(-limit);
return json(200, { messages: page, has_more: pool.length > limit, ...meta });
}
// forward replay (like the real node): absent or EMPTY `before=` falls
// through here — the OLDEST window forward, NOT the newest;
// has_more = NEWER messages remain past the window.
const start = after ? r.messages.findLastIndex((x) => x.seq <= after) + 1 : 0;
const page = r.messages.slice(start, start + limit);
return json(200, {
messages: page,
has_more: start + limit < r.messages.length,
...meta,
});
}
return json(405, { error: "method_not_allowed" });
});
return new Promise((resolve) => {
server.listen(port, () =>
resolve({
server,
port,
close: () => new Promise((done) => server.close(done)),
}),
);
});
}
if (import.meta.url === `file://${process.argv[1]}`) {
const port = Number(process.argv[2] ?? 8788);
await startStub(port);
console.log(`mailbox stub up on :${port}`);
}
|