Receipts: messages now carry a shared client id (mid); recipients send delivered/read receipts over the E2E relay, rendered as ✓/✓✓ ticks (blue when read). Groups aggregate — ticks turn on once every member acks. Disappearing messages: per-chat timer (Off..1 week) stored locally and synced to peers via an encrypted config control message; messages carry a ttl and a client-side sweeper deletes them from both sides on schedule. Group invite links: new group_invites table + /groups/:id/invite (mint/ revoke) and /groups/join routes. Group info gains "Add people" and "Invite via link" (shareable URL + QR); opening a #join=<token> link joins the group and re-syncs every member's roster over the relay. store.js v3 adds a mid index + getByMid/put/sweepExpired; SW cache v9. Verified: 12 live server tests (invite/join/add/revoke/leave) + 11 store unit tests (receipts, sweep, meta) all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
130 lines
4.4 KiB
JavaScript
130 lines
4.4 KiB
JavaScript
// Thin client for the Cipher Worker API + the mailbox WebSocket.
|
|
|
|
let token = null;
|
|
export function setToken(t) {
|
|
token = t;
|
|
}
|
|
|
|
async function req(method, path, body, raw = false) {
|
|
const headers = {};
|
|
if (token) headers["Authorization"] = "Bearer " + token;
|
|
const opts = { method, headers };
|
|
if (body !== undefined) {
|
|
if (raw) {
|
|
opts.body = body; // ArrayBuffer / Blob
|
|
} else {
|
|
headers["Content-Type"] = "application/json";
|
|
opts.body = JSON.stringify(body);
|
|
}
|
|
}
|
|
const res = await fetch(path, opts);
|
|
if (!res.ok) {
|
|
let msg = res.statusText;
|
|
try {
|
|
msg = (await res.json()).error || msg;
|
|
} catch {}
|
|
throw new Error(msg);
|
|
}
|
|
const ct = res.headers.get("Content-Type") || "";
|
|
return ct.includes("application/json") ? res.json() : res;
|
|
}
|
|
|
|
export const api = {
|
|
register: (payload) => req("POST", "/api/auth/register", payload),
|
|
login: (payload) => req("POST", "/api/auth/login", payload),
|
|
lookup: (username) => req("GET", "/api/users/" + encodeURIComponent(username)),
|
|
bundle: (userId) => req("GET", "/api/keys/bundle/" + userId),
|
|
keyCount: () => req("GET", "/api/keys/count"),
|
|
addPreKeys: (oneTimePreKeys) => req("POST", "/api/keys/prekeys", { oneTimePreKeys }),
|
|
resetKeys: (signedPreKey, oneTimePreKeys) =>
|
|
req("POST", "/api/keys/reset", { signedPreKey, oneTimePreKeys }),
|
|
send: (toUserId, messages) => req("POST", "/api/messages/send", { toUserId, messages }),
|
|
|
|
// recovery backup (server stores/serves only ciphertext)
|
|
putBackup: (payload) => req("POST", "/api/backup", { payload }),
|
|
getBackup: () => req("GET", "/api/backup"),
|
|
backupExists: () => req("GET", "/api/backup/exists"),
|
|
|
|
// QR provisioning relay
|
|
provisionNew: (ephemeralPub) => req("POST", "/api/provision/new", { ephemeralPub }),
|
|
provisionGet: (id) => req("GET", "/api/provision/" + id),
|
|
provisionComplete: (id, payload) => req("POST", "/api/provision/" + id + "/complete", { payload }),
|
|
|
|
// groups (metadata only; messages are E2E fan-out)
|
|
createGroup: (name, memberUserIds) => req("POST", "/api/groups", { name, memberUserIds }),
|
|
listGroups: () => req("GET", "/api/groups"),
|
|
getGroup: (id) => req("GET", "/api/groups/" + id),
|
|
addGroupMember: (id, userId) => req("POST", "/api/groups/" + id + "/members", { userId }),
|
|
leaveGroup: (id) => req("POST", "/api/groups/" + id + "/leave", {}),
|
|
groupInvite: (id) => req("POST", "/api/groups/" + id + "/invite", {}),
|
|
revokeGroupInvite: (id) => req("DELETE", "/api/groups/" + id + "/invite"),
|
|
joinGroup: (token) => req("POST", "/api/groups/join", { token }),
|
|
uploadMedia: async (ciphertext) => {
|
|
const res = await req("POST", "/api/media", ciphertext, true);
|
|
return res.json ? res.json() : res;
|
|
},
|
|
fetchMedia: async (key) => {
|
|
const res = await req("GET", "/api/media/" + key);
|
|
return res.arrayBuffer();
|
|
},
|
|
};
|
|
|
|
// ---- WebSocket with auto-reconnect + ack ----
|
|
export class MailboxSocket {
|
|
constructor(tok, onEnvelope) {
|
|
this.tok = tok;
|
|
this.onEnvelope = onEnvelope;
|
|
this.ws = null;
|
|
this.backoff = 1000;
|
|
this.closed = false;
|
|
this.pending = []; // envelope ids awaiting ack batch
|
|
}
|
|
|
|
connect() {
|
|
const proto = location.protocol === "https:" ? "wss:" : "ws:";
|
|
this.ws = new WebSocket(`${proto}//${location.host}/api/ws?token=${encodeURIComponent(this.tok)}`);
|
|
this.ws.onopen = () => {
|
|
this.backoff = 1000;
|
|
this.ws.send(JSON.stringify({ t: "pull" }));
|
|
};
|
|
this.ws.onmessage = async (ev) => {
|
|
let msg;
|
|
try {
|
|
msg = JSON.parse(ev.data);
|
|
} catch {
|
|
return;
|
|
}
|
|
if (msg.t === "message" && msg.envelope) {
|
|
try {
|
|
await this.onEnvelope(msg.envelope);
|
|
this.ack(msg.envelope.id);
|
|
} catch (e) {
|
|
console.error("envelope handling failed", e);
|
|
}
|
|
}
|
|
};
|
|
this.ws.onclose = () => {
|
|
if (this.closed) return;
|
|
setTimeout(() => this.connect(), this.backoff);
|
|
this.backoff = Math.min(this.backoff * 2, 30000);
|
|
};
|
|
this.ws.onerror = () => this.ws && this.ws.close();
|
|
}
|
|
|
|
ack(id) {
|
|
// Batch acks briefly to reduce chatter.
|
|
this.pending.push(id);
|
|
clearTimeout(this._ackTimer);
|
|
this._ackTimer = setTimeout(() => {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN && this.pending.length) {
|
|
this.ws.send(JSON.stringify({ t: "ack", ids: this.pending }));
|
|
this.pending = [];
|
|
}
|
|
}, 150);
|
|
}
|
|
|
|
close() {
|
|
this.closed = true;
|
|
if (this.ws) this.ws.close();
|
|
}
|
|
}
|