cipher/public/js/api.js
King Omar 726d9d53ba Add read receipts, disappearing messages, and group invite links
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>
2026-07-22 18:32:24 +10:00

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