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
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322 | /**
* Contact requests over public inbox rooms (docs/PROTOCOL.md §2/§3).
*
* Requester: "Cere contact" on a stranger's profile signs a creq (name + msg
* + X25519 cert) and deposits it into THEIR inboxRoom; a localStorage
* sent-marker rate-limits duplicates per target. The requester then polls the
* OWN inboxRoom for a cack from that pub — cert verified, mandatory
* fingerprint confirmation, then the contact is stored (TOFU rules apply via
* ContactsService.addContact).
*
* Owner: boot + Contacte-tab polls replay the OWN inboxRoom, verify creq docs
* (to = own pub), dedupe per requester pub (newest wins) and surface them in
* the queued-requests UI. Accepting runs the fingerprint sheet, stores the
* contact and deposits a cack into the REQUESTER's inboxRoom.
*/
import { nsKey } from "../config";
import { myXIdentity } from "../core/contacts";
import { Session } from "../core/session";
import { fromB64, type Bytes } from "../lib/crypto";
import type { ReplayMessage } from "../lib/mailbox";
import { finishWrapIngest, ingestWrapPayload, prepareWrapIngest, ENV_WRAP } from "./match-service";
import { parseEnvelope, publicRoom, ENV_DOC } from "./mailbox-io";
import { sanitizeText, signDoc, verifyDoc, CREQ_MSG_MAX, type CackDoc, type CreqDoc } from "./public-doc";
import { inboxRoom } from "./public-rooms";
// ---- sent markers (requester side; rate-limit + cack watch list) ---------------
const SENT_KEY = nsKey("creq-sent");
type SentMap = Record<string, { ts: number }>;
function loadSent(): SentMap {
try {
const raw = localStorage.getItem(SENT_KEY);
return raw ? (JSON.parse(raw) as SentMap) : {};
} catch {
return {};
}
}
function saveSent(map: SentMap): void {
try {
localStorage.setItem(SENT_KEY, JSON.stringify(map));
} catch {}
}
export function creqSentTo(pub: string): boolean {
return pub in loadSent();
}
export function clearCreqSent(pub: string): void {
const map = loadSent();
if (pub in map) {
delete map[pub];
saveSent(map);
}
}
// ---- dismissed incoming requests (owner side) ----------------------------------
const DISMISSED_KEY = nsKey("creq-dismissed");
function loadDismissed(): string[] {
try {
const raw = localStorage.getItem(DISMISSED_KEY);
const list = raw ? (JSON.parse(raw) as unknown) : [];
return Array.isArray(list) ? list.filter((x): x is string => typeof x === "string").slice(-200) : [];
} catch {
return [];
}
}
export function dismissRequest(pub: string, xPub: string): void {
const list = loadDismissed();
const id = `${pub}|${xPub}`;
if (!list.includes(id)) list.push(id);
try {
localStorage.setItem(DISMISSED_KEY, JSON.stringify(list.slice(-200)));
} catch {}
}
export function isRequestDismissed(pub: string, xPub: string): boolean {
return loadDismissed().includes(`${pub}|${xPub}`);
}
// ---- requester side -------------------------------------------------------------
export type CreqOutcome = { ok: true } | { ok: false; reason: "invalid" | "offline" | "deposit" | "duplicate" };
/**
* Sign + deposit a contact request into `targetPub`'s inboxRoom. `name` is
* the sender's display name (goes inside the signed §3 cert). Duplicate
* requests to the same target are refused via the local sent-marker.
*/
export async function sendCreq(targetPub: string, name: string, msg: string): Promise<CreqOutcome> {
const id = Session.identity;
if (!id) return { ok: false, reason: "offline" };
if (targetPub === id.identity.publicKeyB64) return { ok: false, reason: "invalid" };
if (creqSentTo(targetPub)) return { ok: false, reason: "duplicate" };
const x = await myXIdentity(id.identity, id.seed, name);
if (!x.name) return { ok: false, reason: "invalid" };
let room;
try {
room = publicRoom(await inboxRoom(targetPub));
} catch {
return { ok: false, reason: "invalid" };
}
if (!room) return { ok: false, reason: "offline" };
const doc: CreqDoc = {
ts: Date.now(),
to: targetPub,
name: x.name,
msg: sanitizeText(msg, CREQ_MSG_MAX),
xPub: x.xPub,
xSig: x.xSig,
};
const signed = await signDoc(id.identity, "creq", doc);
const r = await room.depositDoc(signed);
if (!r.ok) return { ok: false, reason: "deposit" };
const map = loadSent();
map[targetPub] = { ts: Date.now() };
saveSent(map);
return { ok: true };
}
/** Deposit a cack into the requester's inboxRoom (owner accepted). */
export async function sendCack(requesterPub: string, myName: string): Promise<boolean> {
const id = Session.identity;
if (!id) return false;
const x = await myXIdentity(id.identity, id.seed, myName);
if (!x.name) return false;
let room;
try {
room = publicRoom(await inboxRoom(requesterPub));
} catch {
return false;
}
if (!room) return false;
const doc: CackDoc = { ts: Date.now(), to: requesterPub, name: x.name, xPub: x.xPub, xSig: x.xSig };
const signed = await signDoc(id.identity, "cack", doc);
return (await room.depositDoc(signed)).ok;
}
// ---- own-inbox poll (both directions in one walk) -------------------------------
export interface IncomingRequest {
pub: string;
name: string;
msg: string;
xPub: string;
xSig: string;
ts: number;
}
export interface IncomingAck {
pub: string;
name: string;
xPub: string;
xSig: string;
ts: number;
}
/** Spam bound: at most this many docs are signature-verified per poll. */
const VERIFY_MAX_PER_POLL = 200;
/** At most this many request rows are surfaced per poll (newest first). */
const REQUESTS_MAX = 50;
/**
* Persisted backward-sweep cursor, per inbox room (starvation defense): on a
* busy inbox the newest-first budget may never reach old pages, so each poll
* continues ONE extra page from where the previous poll's sweep stopped —
* the whole history is eventually covered across polls.
*/
const sweepKey = (roomId: string): string => nsKey(`inbox-sweep:${roomId}`);
export interface InboxView {
reached: boolean;
/** creq docs addressed to me, one per requester pub, newest first (≤50). */
requests: IncomingRequest[];
/** cack docs addressed to me FROM pubs I sent a creq to (newest wins). */
acks: IncomingAck[];
/** The verify budget or the row cap was hit — more requests may exist. */
capped: boolean;
}
/**
* Replay the OWN inboxRoom backward and split it into pending contact
* requests (owner role) and acks for requests I sent (requester role) — and
* (P2) open any 0x12 sealed dead-drops addressed to me (matching likes),
* which share the same walk and the same spam budget. Already-active
* same-key contacts and dismissed requests are filtered out. Spam bounds: at
* most 200 envelopes are verified/unwrapped per poll and at most 50 request
* rows surface (newest per pub first) — `capped` says the view may be
* partial so the UI can stay honest about it. A persisted per-room backward
* cursor adds one older page per poll, so history the budget never reaches is
* still swept eventually (§6).
*/
export async function pollOwnInbox(): Promise<InboxView> {
const id = Session.identity;
const svc = Session.contacts;
if (!id || !svc) return { reached: false, requests: [], acks: [], capped: false };
const myPub = id.identity.publicKeyB64;
let room;
try {
room = publicRoom(await inboxRoom(myPub));
} catch {
return { reached: false, requests: [], acks: [], capped: false };
}
if (!room) return { reached: false, requests: [], acks: [], capped: false };
const sent = loadSent();
const requests = new Map<string, IncomingRequest>();
const acks = new Map<string, IncomingAck>();
let verified = 0;
const wrapCtx = await prepareWrapIngest();
/** Oldest message actually PROCESSED — a sweep resumes strictly below it. */
let oldestCovered: string | null = null;
const visit = async (m: ReplayMessage): Promise<boolean | void> => {
const env = parseEnvelope(m.payload);
if (!env) {
// P2: a 0x12 sealed like (or junk) — unwrap-or-drop under the SAME
// budget as doc verification; junk never surfaces anywhere.
let raw: Bytes | null = null;
try {
raw = fromB64(m.payload);
} catch {}
if (wrapCtx && raw && raw.length >= 2 && raw[0] === ENV_WRAP) {
if (verified >= VERIFY_MAX_PER_POLL) return true;
verified++;
await ingestWrapPayload(raw, wrapCtx);
}
oldestCovered = m.seq;
return;
}
if (env.type !== ENV_DOC) {
oldestCovered = m.seq;
return;
}
if (verified >= VERIFY_MAX_PER_POLL) return true; // budget spent — stop the walk
verified++;
const v = await verifyDoc(env.body, { kinds: ["creq", "cack"], to: myPub, cid: env.cid });
oldestCovered = m.seq;
if (!v || v.pub === myPub) return;
if (v.kind === "creq") {
const d = v.doc as CreqDoc;
const prev = requests.get(v.pub);
if (!prev || d.ts > prev.ts)
requests.set(v.pub, { pub: v.pub, name: d.name, msg: d.msg, xPub: d.xPub, xSig: d.xSig, ts: d.ts });
} else if (v.kind === "cack") {
if (!(v.pub in sent)) return; // an ack for a request this device never sent
const d = v.doc as CackDoc;
const prev = acks.get(v.pub);
if (!prev || d.ts > prev.ts)
acks.set(v.pub, { pub: v.pub, name: d.name, xPub: d.xPub, xSig: d.xSig, ts: d.ts });
}
};
const walk = await room.walkBackward(visit);
// Slow sweep (starvation defense): the walk above always spends its budget
// newest-first, so on a flooded inbox the OLDEST deposits could stay unseen
// forever. When the walk did not cover full history, continue one page per
// poll from the persisted older-cursor (initialized at the point this
// poll's walk stopped); the cursor clears when the sweep reaches the oldest
// page — or when a later walk covers everything — and re-arms afresh.
if (walk.reached) {
const key = sweepKey(room.roomId);
if (walk.complete && !walk.stopped) {
try {
localStorage.removeItem(key);
} catch {}
} else {
let cursor: string | null = null;
try {
cursor = localStorage.getItem(key);
} catch {}
cursor ??= oldestCovered;
if (cursor) {
verified = 0; // the sweep page gets its own (page-bounded) budget
const page = await room.pageBefore(cursor);
if (page) {
let stoppedEarly = false;
for (let i = page.messages.length - 1; i >= 0; i--) {
if ((await visit(page.messages[i])) === true) {
stoppedEarly = true;
break;
}
}
try {
if (page.messages.length > 0 && (page.hasMore || stoppedEarly))
localStorage.setItem(key, oldestCovered ?? page.messages[0].seq);
else localStorage.removeItem(key);
} catch {}
}
}
}
}
if (wrapCtx) finishWrapIngest(wrapCtx); // fan out new-match detection
const state = svc.state;
const pending = [...requests.values()]
.filter((r) => {
if (isRequestDismissed(r.pub, r.xPub)) return false;
const existing = state.contacts.get(r.pub);
return !(existing?.status === "active" && existing.xPub === r.xPub);
})
.sort((a, b) => b.ts - a.ts); // newest per pub first
const usableAcks = [...acks.values()].filter((a) => {
const existing = state.contacts.get(a.pub);
return !(existing?.status === "active" && existing.xPub === a.xPub);
});
return {
reached: walk.reached,
requests: pending.slice(0, REQUESTS_MAX),
acks: usableAcks,
// walk.stopped ⇒ the verify budget cut the walk short — unseen docs remain
capped: walk.stopped || pending.length > REQUESTS_MAX,
};
}
|