- Hono Worker: auth, key directory (X3DH prekey bundles), message relay, R2 media - Mailbox Durable Object: WebSocket delivery + offline ciphertext queue - PWA client: libsignal (vendored), IndexedDB key store, chat UI, media E2E - Server only ever holds public keys + ciphertext; private keys stay on device Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
78 lines
2.7 KiB
JavaScript
78 lines
2.7 KiB
JavaScript
// Message relay. The Worker never inspects ciphertext — it just routes an
|
|
// encrypted envelope to the recipient's Mailbox Durable Object, which delivers
|
|
// live over WebSocket or queues until the recipient reconnects.
|
|
|
|
import { Hono } from "hono";
|
|
import { requireAuth } from "../lib/auth.js";
|
|
import { now, uuid } from "../lib/util.js";
|
|
|
|
const app = new Hono();
|
|
|
|
function mailboxStub(env, userId) {
|
|
const id = env.MAILBOX.idFromName(userId);
|
|
return env.MAILBOX.get(id);
|
|
}
|
|
|
|
// POST /api/messages/send
|
|
// body: { toUserId, messages: [{ toDeviceId, type, body }] } // one entry per recipient device
|
|
// `body` is Signal ciphertext (base64); `type` is the Signal message type (1=whisper,3=prekey).
|
|
app.post("/messages/send", requireAuth(), async (c) => {
|
|
const fromUserId = c.get("userId");
|
|
const fromDeviceId = c.get("deviceId");
|
|
const payload = await c.req.json().catch(() => null);
|
|
if (!payload?.toUserId || !Array.isArray(payload.messages))
|
|
return c.json({ error: "bad request" }, 400);
|
|
|
|
const ts = now();
|
|
const results = [];
|
|
for (const m of payload.messages) {
|
|
const envelope = {
|
|
id: uuid(),
|
|
fromUserId,
|
|
fromDeviceId,
|
|
toDeviceId: m.toDeviceId,
|
|
type: m.type,
|
|
body: m.body, // ciphertext — opaque to the server
|
|
sentAt: ts,
|
|
};
|
|
const stub = mailboxStub(c.env, payload.toUserId);
|
|
const res = await stub.fetch("https://mailbox/deliver", {
|
|
method: "POST",
|
|
body: JSON.stringify(envelope),
|
|
});
|
|
results.push({ toDeviceId: m.toDeviceId, delivered: res.ok });
|
|
}
|
|
|
|
// Best-effort push wake-up (payloadless — client fetches the real ciphertext over WS).
|
|
c.executionCtx.waitUntil(wakeRecipient(c.env, payload.toUserId));
|
|
|
|
return c.json({ ok: true, id: uuid(), sentAt: ts, results });
|
|
});
|
|
|
|
async function wakeRecipient(env, toUserId) {
|
|
// Placeholder for Web Push fan-out; wired up once VAPID keys are configured.
|
|
// Left intentionally minimal so message send never blocks on push.
|
|
return;
|
|
}
|
|
|
|
// GET /api/ws — upgrade to WebSocket, routed to the caller's own Mailbox DO.
|
|
app.get("/ws", async (c) => {
|
|
// Auth via query token (browsers can't set headers on WebSocket handshakes).
|
|
const token = c.req.query("token");
|
|
if (!token) return c.json({ error: "unauthorized" }, 401);
|
|
const sess = await c.env.DB.prepare(
|
|
"SELECT user_id, device_id, expires_at FROM sessions WHERE token = ?"
|
|
)
|
|
.bind(token)
|
|
.first();
|
|
if (!sess || sess.expires_at < now()) return c.json({ error: "unauthorized" }, 401);
|
|
|
|
const stub = mailboxStub(c.env, sess.user_id);
|
|
const url = new URL("https://mailbox/connect");
|
|
url.searchParams.set("deviceId", sess.device_id);
|
|
return stub.fetch(url.toString(), {
|
|
headers: { Upgrade: "websocket", Connection: "Upgrade" },
|
|
});
|
|
});
|
|
|
|
export default app;
|