chat / client / e2e / mailbox-stub.mjs
  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
// In-memory stand-in for the ird offline mailbox (protocol.md shapes verified
// against the live service 2026-08-21): open mint, per-room ring with SHA-256
// dedup, zero-padded ordered seqs, and /__test controls for truncation.
// Importable (mailbox.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)
    if (req.method === "GET" && url.pathname === "/mailbox-credentials") {
      const r = url.searchParams.get("room") ?? "";
      if (!r) return json(400, { error: "missing_room" });
      const ttl = Math.min(Number(url.searchParams.get("ttl") ?? 3600), 86400);
      return json(200, { token: `stub:${r}`, ttl, expires_at: Math.floor(Date.now() / 1000) + ttl, room: r });
    }

    // 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" });
    if (auth.slice(7) !== `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 limit = Math.min(Number(url.searchParams.get("limit") ?? 100), 100);
      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,
        oldest_seq: r.messages[0]?.seq ?? null,
        latest_seq: r.messages.at(-1)?.seq ?? null,
      });
    }

    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}`);
}

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/chat.git