cautpartener / client / e2e / activities.e2e.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
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
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
// P1 activities e2e — activities with time ranges, joinable, public or
// contacts-restricted, over PLAIN mailbox replay (no mesh):
//   A (contact of B via rendezvous) publishes a PUBLIC activity in
//   jocuri|bucuresti through the composer; B sees it in the Activități
//   section and JOINS (LWW join doc, joiner's stripe); A sees B going with
//   B's CONTACT name in the attendee sheet; A edits the time window (same
//   aid, seq bump — LWW) → B sees the update with the join preserved; B
//   LEAVES → the count drops (join LWW, unlike immutable likes). A publishes
//   a CONTACTS-ONLY activity (the implicit auto-maintained group): member B
//   unseals desc+loc, stranger C sees title + lock chip and NO plaintext;
//   C joins the PUBLIC activity without being anyone's contact. A cancels
//   the public activity → cancelled badge everywhere. An activity with a
//   past window publishes (end > now−7d) but stays OUT of the upcoming
//   index. Permalink #/a/<shard>/<org>/<aid> resolves on a stranger.
//   Profile page shows the "Activități viitoare" strip (cancelled + expired
//   excluded; restricted shown with a lock).
//   Finally: an attacker re-deposits A's OLD (pre-cancel) activity doc at the
//   room head and pads the window with filler — a fresh stranger's permalink
//   resolve must still show the CANCELLED newest revision (stale-doc replay
//   resilience: bounded extra paging past the first match).
// Run: node e2e/activities.e2e.mjs        (HEADED=1 to watch)
// NEVER put a seed (or any part of one) into a fail message — it is logged.
import { decode as cborDecode } from "@ipld/dag-cbor";
import { CID } from "multiformats/cid";
import { randomBytes } from "node:crypto";
import {
  acceptInvite,
  closeSheet,
  createHarness,
  createIdentity as createIdentityFlow,
  createInvite,
  fail,
  ok,
  roomIdOf,
  sleep,
  waitAllRoomsDrained,
  waitChatReady,
  waitContactsReady,
} from "./helpers.mjs";

const PORT = 5193;
const STUB_PORT = 8801;
const SHARD = "jocuri|bucuresti";

const harness = createHarness({ port: PORT, stubPort: STUB_PORT });
const BASE = harness.base;
const createIdentity = (page) => createIdentityFlow(page, BASE);

const TITLE1 = "Seară de board games la cafenea";
const LOC1 = "Cafeneaua Meeple, Piața Romană";
const TITLE2 = "Cină doar pentru contacte";
const SECRET_DESC = "Adresa exactă: str. Ascunsă 7, interfon 42";
const SECRET_LOC = "La mine acasă";
const TITLE_EXPIRED = "Tura de ieri prin parc";

const myPubOf = (page) => page.evaluate(() => window.__cp.identity.identity.publicKeyB64);
const actCard = (aid) => `[data-testid="act-card"][data-aid="${aid}"]`;
const cardWithTitle = (title) => `[data-testid="act-card"]:has-text("${title}")`;

/** Local "YYYY-MM-DDTHH:MM" for <input type="datetime-local"> (same TZ as the browser). */
function toLocalInput(ms) {
  const d = new Date(ms);
  const p = (n) => String(n).padStart(2, "0");
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
}

/** Enter the jocuri|bucuresti feed (via the picker), then the Activități section. */
async function enterActs(page) {
  await page.click('[data-testid="tab-feed"]');
  await page.waitForSelector('[data-testid="feed-frame"]', { timeout: 20000 });
  if ((await page.getAttribute('[data-testid="feed-frame"]', "data-shard")) !== "jocuri|bucuresti") {
    await page.selectOption('[data-testid="feed-county"]', "bucuresti");
    await page.waitForSelector('[data-testid="feed-frame"][data-shard$="|bucuresti"]', { timeout: 20000 });
    await page.click('[data-testid="feed-cat"][data-cat="jocuri"]');
    await page.waitForSelector('[data-testid="feed-frame"][data-shard="jocuri|bucuresti"]', { timeout: 20000 });
  }
  await page.waitForFunction(() => !document.querySelector('[data-testid="feed-loading"]'), undefined, {
    timeout: 30000,
  });
  await page.click('[data-testid="feed-seg-acts"]');
  await page.waitForSelector('[data-testid="act-card"], [data-testid="act-empty"]', { timeout: 20000 });
}

/** Confirm queued rendezvous joins until every pub is an active contact. */
async function confirmAll(page, pubs) {
  const deadline = Date.now() + 120000;
  for (;;) {
    const allActive = await page.evaluate(
      (ps) => ps.every((p) => window.__cp?.contacts?.state.contacts.get(p)?.status === "active"),
      pubs,
    );
    if (allActive) return;
    if (Date.now() > deadline) fail("contacts were not all confirmed in time");
    const btn = await page.$('[data-testid="invite-confirm-yes"]');
    if (btn) await btn.click().catch(() => {});
    await sleep(400);
  }
}

const waitRgrantEpoch = (page, epoch) =>
  page.waitForFunction(
    (e) => {
      const s = window.__cp?.selfroom?.state;
      return !!s && [...s.rgrants.values()].some((g) => g.epoch >= e);
    },
    epoch,
    { timeout: 120000 },
  );

const goingCount = (page, aid) =>
  page.$eval(`${actCard(aid)} [data-testid="act-going-count"]`, (el) => el.dataset.n);

// ---- scenario ----------------------------------------------------------------

async function main() {
  await harness.start();

  const dirA = harness.newProfileDir("cp-act-a-");
  const dirB = harness.newProfileDir("cp-act-b-");
  const dirC = harness.newProfileDir("cp-act-c-");

  // --- A: identity + profile + invite for B ------------------------------------
  let A = await harness.openDevice("A", dirA);
  await createIdentity(A.page);
  await waitContactsReady(A.page);
  const pubA = await myPubOf(A.page);

  await A.page.click('[data-testid="tab-profile"]');
  await A.page.waitForSelector('[data-testid="pf-name"]', { timeout: 30000 });
  await A.page.fill('[data-testid="pf-name"]', "Ana");
  await A.page.selectOption('[data-testid="pf-county"]', "bucuresti");
  await A.page.click('[data-testid="pf-cat"][data-cat="jocuri"]');
  await A.page.click('[data-testid="pf-publish"]');
  await A.page.waitForFunction(
    () => document.querySelector('[data-testid="pf-status"]')?.textContent.includes("publicat:"),
    undefined,
    { timeout: 30000 },
  );
  const { token: tokB } = await createInvite(A.page, "Ana");
  {
    const room = await roomIdOf(A.page, tokB.split(".")[0]);
    let count = 0;
    for (let i = 0; i < 40 && count < 1; i++) {
      count = await harness.roomCount(room);
      if (count < 1) await sleep(500);
    }
    if (count < 1) fail("A's invite card did not reach the rendezvous room");
  }

  // --- A: public activity through the composer ---------------------------------
  const start1 = Date.now() + 24 * 3600_000;
  const end1 = start1 + 3 * 3600_000;
  await enterActs(A.page);
  await A.page.click('[data-testid="feed-fab"]');
  await A.page.waitForSelector('[data-testid="act-compose-sheet"]');
  await A.page.fill('[data-testid="act-title-input"]', TITLE1);
  await A.page.fill('[data-testid="act-desc-input"]', "Aducem Catan și Azul, veniți cu poftă de joc.");
  await A.page.fill('[data-testid="act-start"]', toLocalInput(start1));
  await A.page.fill('[data-testid="act-end"]', toLocalInput(end1));
  await A.page.fill('[data-testid="act-loc"]', LOC1);
  await A.page.click('[data-testid="act-publish"]');
  await A.page.waitForSelector(cardWithTitle(TITLE1), { timeout: 30000 });
  const aid1 = await A.page.getAttribute(cardWithTitle(TITLE1), "data-aid");
  if (!aid1 || !/^[A-Za-z0-9_-]{22}$/.test(aid1)) fail("published activity has no valid aid");
  // the card renders time range, location and the empty going state
  await A.page.waitForSelector(`${actCard(aid1)} [data-testid="act-when"]`, { timeout: 10000 });
  const where = (await A.page.textContent(`${actCard(aid1)} [data-testid="act-where"]`)).trim();
  if (!where.includes("Meeple")) fail(`location renders as "${where}"`);
  if ((await goingCount(A.page, aid1)) !== "0") fail("fresh activity does not start at 0 going");
  await waitAllRoomsDrained(A.page);
  await A.close();
  ok("A: profile + invite + PUBLIC activity published (title, window, location render; 0 going)");

  // --- B: accepts the invite, sees the activity, JOINS -------------------------
  let B = await harness.openDevice("B", dirB);
  await createIdentity(B.page);
  await waitContactsReady(B.page);
  const pubB = await myPubOf(B.page);
  await acceptInvite(B.page, BASE, tokB, "Bogdan");
  await B.page.goto(`${BASE}/#/`);
  await enterActs(B.page);
  await B.page.waitForSelector(actCard(aid1), { timeout: 30000 });
  await B.page.click(`${actCard(aid1)} [data-testid="act-join"]`);
  await B.page.waitForSelector(`${actCard(aid1)} [data-testid="act-leave"]`, { timeout: 30000 });
  if ((await goingCount(B.page, aid1)) !== "1") fail("B's join did not raise the going count to 1");
  await waitAllRoomsDrained(B.page);
  await B.close();
  ok("B: cold-load renders A's activity in Activități; join lands (count 1, Leave shown)");

  // --- A: confirms B, sees B going BY NAME, edits the window, restricts --------
  A = await harness.openDevice("A2", dirA);
  await A.page.goto(BASE);
  await waitContactsReady(A.page);
  await confirmAll(A.page, [pubB]);
  await waitChatReady(A.page);
  await enterActs(A.page);
  await A.page.waitForSelector(actCard(aid1), { timeout: 30000 });
  if ((await goingCount(A.page, aid1)) !== "1") fail("A does not see B's join (count ≠ 1)");
  await A.page.click(`${actCard(aid1)} [data-testid="act-going-count"]`);
  await A.page.waitForSelector('[data-testid="act-detail"]', { timeout: 10000 });
  const attendee = await A.page.textContent(`[data-testid="act-going-item"][data-pub="${pubB}"]`);
  if (!attendee.includes("Bogdan")) fail("attendee is not resolved to the contact name");
  await closeSheet(A.page);
  await A.page.waitForFunction(() => !document.querySelector('[data-testid="act-detail"]'), undefined, {
    timeout: 10000,
  });
  ok("A: sees B going — resolved to the CONTACT name in the attendee sheet");

  // edit: shift the window by a day — same aid, seq bumps to 2 (LWW)
  const start1b = start1 + 24 * 3600_000;
  const end1b = end1 + 24 * 3600_000;
  await A.page.click(`${actCard(aid1)} [data-testid="act-edit"]`);
  await A.page.waitForSelector('[data-testid="act-compose-sheet"]');
  await A.page.fill('[data-testid="act-start"]', toLocalInput(start1b));
  await A.page.fill('[data-testid="act-end"]', toLocalInput(end1b));
  await A.page.click('[data-testid="act-publish"]');
  await A.page.waitForSelector(`${actCard(aid1)}[data-seq="2"]`, { timeout: 30000 });
  if ((await goingCount(A.page, aid1)) !== "1") fail("the edit lost B's join on A's view");
  ok("A: time edit republished the SAME aid with seq 2 — B's join preserved through the edit");

  // contacts-only activity: the implicit auto-group seals desc+loc, title public
  const start2 = Date.now() + 48 * 3600_000;
  await A.page.click('[data-testid="feed-fab"]');
  await A.page.waitForSelector('[data-testid="act-compose-sheet"]');
  await A.page.fill('[data-testid="act-title-input"]', TITLE2);
  await A.page.fill('[data-testid="act-desc-input"]', SECRET_DESC);
  await A.page.fill('[data-testid="act-start"]', toLocalInput(start2));
  await A.page.fill('[data-testid="act-end"]', toLocalInput(start2 + 2 * 3600_000));
  await A.page.fill('[data-testid="act-loc"]', SECRET_LOC);
  await A.page.selectOption('[data-testid="act-vis"]', "__contacts__");
  await A.page.waitForSelector('[data-testid="act-vis-note"]', { timeout: 5000 });
  await A.page.click('[data-testid="act-publish"]');
  await A.page.waitForSelector(cardWithTitle(TITLE2), { timeout: 30000 });
  const aid2 = await A.page.getAttribute(cardWithTitle(TITLE2), "data-aid");
  const ownBadge = (await A.page.textContent(`${actCard(aid2)} [data-testid="act-aud-badge"]`)).trim();
  if (!ownBadge.includes("Toate contactele")) fail(`owner badge reads "${ownBadge}"`);
  // the auto-group exists, holds B, and minted its first epoch key
  const autoGroup = await A.page.evaluate(() => {
    const s = window.__cp.selfroom.state;
    const g = s.groups.get("toate-contactele");
    const keys = [...s.gkeys.values()].filter((k) => k.gid === "toate-contactele");
    return { active: g?.status === "active", epochs: keys.map((k) => k.epoch) };
  });
  if (!autoGroup.active || autoGroup.epochs.join(",") !== "1") {
    fail(`implicit contacts group state wrong: ${JSON.stringify(autoGroup)}`);
  }
  const memberB = await A.page.evaluate(
    (p) => window.__cp.selfroom.state.gmembers.get(`toate-contactele|${p}`)?.on === true,
    pubB,
  );
  if (!memberB) fail("B was not auto-enrolled into the implicit contacts group");
  ok("A: contacts-only activity published — implicit group minted (epoch 1), B auto-enrolled, owner badge shown");

  // an activity whose window already passed: publishes (end > now−7d) but must
  // stay OUT of the upcoming index
  const expired = await A.page.evaluate(
    async ([sh, title]) => {
      const fs = await import("/src/public/feed-service.ts");
      return await fs.publishActivity({
        shard: sh,
        title,
        desc: "s-a încheiat deja",
        start: Date.now() - 26 * 3600_000,
        end: Date.now() - 25 * 3600_000,
      });
    },
    [SHARD, TITLE_EXPIRED],
  );
  if (!expired.ok) fail("expired-window activity publish was rejected");
  await A.page.click('[data-testid="feed-refresh"]');
  await sleep(1500);
  if (await A.page.$(cardWithTitle(TITLE_EXPIRED))) fail("an expired activity shows in the upcoming index");
  ok("expired: past-window activity publishes (within the 7 d edit grace) but is absent from upcoming");
  await sleep(2000); // audience grant appends are hook-async
  await waitAllRoomsDrained(A.page);
  await A.close();

  // --- B: member unseals; sees the edit with join preserved; LEAVES ------------
  B = await harness.openDevice("B2", dirB);
  await B.page.goto(BASE);
  await waitChatReady(B.page);
  await waitRgrantEpoch(B.page, 1);
  await enterActs(B.page);
  await B.page.waitForSelector(`${actCard(aid1)}[data-seq="2"]`, { timeout: 30000 });
  await B.page.waitForSelector(`${actCard(aid1)} [data-testid="act-leave"]`, { timeout: 10000 });
  if ((await goingCount(B.page, aid1)) !== "1") fail("B's join did not survive A's edit");
  ok("B: sees the edited window (seq 2) with the OWN join preserved (LWW by (pub,org,aid))");

  // restricted activity: B (member) unseals desc + loc, generic member badge
  await B.page.waitForSelector(actCard(aid2), { timeout: 30000 });
  await B.page.waitForSelector(`${actCard(aid2)} [data-testid="act-aud-badge"]`, { timeout: 30000 });
  const bDesc = (await B.page.textContent(`${actCard(aid2)} [data-testid="act-desc"]`)).trim();
  const bWhere = (await B.page.textContent(`${actCard(aid2)} [data-testid="act-where"]`)).trim();
  if (bDesc !== SECRET_DESC) fail(`B's unsealed desc reads "${bDesc}"`);
  if (!bWhere.includes(SECRET_LOC)) fail(`B's unsealed loc reads "${bWhere}"`);
  await B.page.waitForSelector(`${actCard(aid2)} [data-testid="act-join-note"]`, { timeout: 10000 });
  ok("B: contacts-only activity unsealed (desc + loc) via the auto-group grant; joins-public note shown");

  // leave: the LWW join flips to "out" — the count drops
  await B.page.click(`${actCard(aid1)} [data-testid="act-leave"]`);
  await B.page.waitForFunction(
    (sel) => document.querySelector(`${sel} [data-testid="act-going-count"]`)?.dataset.n === "0",
    actCard(aid1),
    { timeout: 30000 },
  );
  ok("B: leave drops the going count to 0 (joins are LWW-mutable, unlike likes)");
  await waitAllRoomsDrained(B.page);
  await B.close();

  // --- C (stranger): lock chip on restricted; joins the PUBLIC one -------------
  const C = await harness.openDevice("C", dirC);
  await createIdentity(C.page);
  await waitContactsReady(C.page);
  await enterActs(C.page);
  await C.page.waitForSelector(actCard(aid2), { timeout: 30000 });
  await C.page.waitForSelector(`${actCard(aid2)} [data-testid="act-locked"]`, { timeout: 10000 });
  const title2C = (await C.page.textContent(`${actCard(aid2)} [data-testid="act-title"]`)).trim();
  if (title2C !== TITLE2) fail("the PUBLIC title of the restricted activity is not rendered for C");
  {
    const body = await C.page.evaluate(() => document.body.innerText);
    if (body.includes(SECRET_DESC) || body.includes(SECRET_LOC)) fail("restricted plaintext leaked to stranger C");
  }
  ok("C: restricted activity shows PUBLIC title + lock chip — desc/loc never reach the DOM");

  // C joins the PUBLIC activity — no contact relationship needed
  if ((await goingCount(C.page, aid1)) !== "0") fail("C does not see the post-leave count 0");
  await C.page.click(`${actCard(aid1)} [data-testid="act-join"]`);
  await C.page.waitForSelector(`${actCard(aid1)} [data-testid="act-leave"]`, { timeout: 30000 });
  if ((await goingCount(C.page, aid1)) !== "1") fail("C's join did not land");
  ok("C: joined the PUBLIC activity as a stranger (no contact needed)");

  // permalink resolve on the stranger: #/a/<shard>/<org>/<aid>
  await C.page.goto(`${BASE}/#/a/${encodeURIComponent(SHARD)}/${pubA}/${aid1}`);
  await C.page.waitForSelector(`[data-testid="av-view"] ${actCard(aid1)}`, { timeout: 30000 });
  const pTitle = (await C.page.textContent(`[data-testid="av-view"] [data-testid="act-title"]`)).trim();
  if (pTitle !== TITLE1) fail(`permalink resolves to "${pTitle}"`);
  ok("C: #/a/<shard>/<org>/<aid> permalink resolves via the shard walk");
  await waitAllRoomsDrained(C.page);
  await C.close();

  // --- A: sees C going (fingerprint, not a name), CANCELS ----------------------
  A = await harness.openDevice("A3", dirA);
  await A.page.goto(BASE);
  await waitContactsReady(A.page);
  await enterActs(A.page);
  await A.page.waitForSelector(actCard(aid1), { timeout: 30000 });
  if ((await goingCount(A.page, aid1)) !== "1") fail("A does not see C's join");
  await A.page.click(`${actCard(aid1)} [data-testid="act-going-count"]`);
  await A.page.waitForSelector('[data-testid="act-detail"]', { timeout: 10000 });
  const pubC = await A.page.getAttribute('[data-testid="act-going-item"]', "data-pub");
  if (pubC === pubB) fail("attendee list still shows B after the leave");
  const strangerRow = (await A.page.textContent(`[data-testid="act-going-item"][data-pub="${pubC}"]`)).trim();
  if (!strangerRow.includes(`${pubC.slice(0, 10)}…`)) fail("non-contact attendee is not shown as short pub + fingerprint");
  await closeSheet(A.page);
  await A.page.waitForFunction(() => !document.querySelector('[data-testid="act-detail"]'), undefined, {
    timeout: 10000,
  });
  ok("A: C's join shows as short pub + fingerprint (not a contact — no name to resolve)");

  await A.page.click(`${actCard(aid1)} [data-testid="act-cancel"]`);
  await A.page.waitForSelector('[data-testid="act-cancel-sheet"]');
  await A.page.click('[data-testid="act-cancel-yes"]');
  await A.page.waitForSelector(`${actCard(aid1)} [data-testid="act-cancelled"]`, { timeout: 30000 });
  const seqAfterCancel = await A.page.getAttribute(actCard(aid1), "data-seq");
  if (seqAfterCancel !== "3") fail(`cancel produced seq ${seqAfterCancel}, expected 3`);
  await waitAllRoomsDrained(A.page);
  await A.close();
  ok("A: cancel republished aid1 with cancelled:true (seq 3) — badge on the own card");

  // --- B: sees the cancelled badge + the profile activities strip --------------
  B = await harness.openDevice("B3", dirB);
  await B.page.goto(BASE);
  await waitChatReady(B.page);
  await enterActs(B.page);
  await B.page.waitForSelector(`${actCard(aid1)} [data-testid="act-cancelled"]`, { timeout: 30000 });
  if (await B.page.$(cardWithTitle(TITLE_EXPIRED))) fail("the expired activity leaked into B's upcoming index");
  ok("B: cancelled badge propagated via LWW; expired activity still absent");

  // profile strip: upcoming only — aid2 (restricted, locked glyph); aid1 is
  // cancelled and the backdated one expired, so exactly one row remains
  await B.page.goto(`${BASE}/#/u/${pubA}`);
  await B.page.waitForSelector('[data-testid="up-acts-list"]', { timeout: 30000 });
  const rows = await B.page.$$eval('[data-testid="up-act-row"]', (els) =>
    els.map((el) => ({ aid: el.dataset.aid, text: el.textContent })),
  );
  if (rows.length !== 1) fail(`profile strip shows ${rows.length} rows, expected 1 (upcoming, not cancelled)`);
  if (rows[0].aid !== aid2) fail("profile strip row is not the restricted upcoming activity");
  if (!rows[0].text.includes("🔒") || !rows[0].text.includes(TITLE2)) fail("strip row lacks the lock + public title");
  ok("profile: 'Activități viitoare' strip shows exactly the upcoming activity (lock + public title)");
  await B.close();

  // --- stale-doc replay attack: OLD revision re-deposited at head + filler -----
  // An attacker holding A's OLD (pre-cancel) SIGNED activity doc re-deposits
  // it at the room head and pads the window with filler, so a first-match
  // resolver would stop on the stale revision and hide the cancellation. The
  // permalink resolver must keep paging (bounded) past the first match until
  // no higher-seq revision surfaces.
  const D = await harness.openDevice("D", harness.newProfileDir("cp-act-d-"));
  await createIdentity(D.page);
  await waitContactsReady(D.page);
  const feedRoomId = await D.page.evaluate(async ([sh, pub]) => {
    const { feedRoom, stripe } = await import("/src/public/public-rooms.ts");
    return await feedRoom(sh, stripe(pub));
  }, [SHARD, pubA]);
  const stubBase = `http://localhost:${STUB_PORT}`;
  const auth = { authorization: `Bearer stub:${feedRoomId}` };
  const roomUrl = `${stubBase}/mailbox/v1/rooms/${encodeURIComponent(feedRoomId)}/messages`;

  // read the WHOLE room forward (A's stripe: all aid1/aid2 activity docs)
  const msgs = [];
  for (let after = null; ; ) {
    const q = after ? `?limit=100&after=${encodeURIComponent(after)}` : "?limit=100";
    const r = await (await fetch(roomUrl + q, { headers: auth })).json();
    msgs.push(...r.messages);
    if (!r.has_more || r.messages.length === 0) break;
    after = r.messages.at(-1).seq;
  }
  const aid1Revs = [];
  for (const m of msgs) {
    try {
      const raw = Buffer.from(m.payload, "base64");
      if (raw[0] !== 0x10) continue; // 0x10 = signed public doc envelope
      const [, rest] = CID.decodeFirst(raw.subarray(1));
      const env = cborDecode(rest);
      const d = env?.d;
      if (!d || d.aid !== aid1) continue;
      if (Buffer.from(env.p).toString("base64url") !== pubA) continue;
      aid1Revs.push({ payload: m.payload, docSeq: d.seq, cancelled: d.cancelled === true });
    } catch {}
  }
  if (aid1Revs.length < 2) fail(`expected ≥2 aid1 revisions in A's stripe room, found ${aid1Revs.length}`);
  const oldRev = aid1Revs.reduce((a, b) => (a.docSeq < b.docSeq ? a : b));
  const newestRev = aid1Revs.reduce((a, b) => (a.docSeq > b.docSeq ? a : b));
  if (!newestRev.cancelled || newestRev.docSeq !== 3) fail("newest aid1 revision is not the cancelled seq-3 doc");

  // rebuild the room: originals (minus the old revision) → filler past the
  // resolver's INITIAL page budget (3 pages × 100) → the OLD revision LAST,
  // i.e. at the head. Truncating first makes the stub's dedup forget the old
  // copy (in production the TTL expiry does this for the attacker).
  await fetch(`${stubBase}/__test/truncate?room=${encodeURIComponent(feedRoomId)}&keep=0`, { method: "POST" });
  const post = (body) => fetch(roomUrl, { method: "POST", headers: auth, body });
  for (const m of msgs) {
    if (m.payload === oldRev.payload) continue;
    const r = await post(Buffer.from(m.payload, "base64"));
    if (r.status !== 201) fail(`re-deposit of an original message failed (${r.status})`);
  }
  const FILLER = 340; // > 300 ⇒ the newest revision sits below the initial window
  for (let i = 0; i < FILLER; i += 20) {
    const rs = await Promise.all(
      Array.from({ length: Math.min(20, FILLER - i) }, () => {
        const junk = randomBytes(40);
        junk[0] = 0; // never a valid envelope — pure page filler
        return post(junk);
      }),
    );
    if (rs.some((r) => r.status !== 201)) fail("a filler deposit failed");
  }
  const rHead = await post(Buffer.from(oldRev.payload, "base64"));
  if (rHead.status !== 201) fail(`re-deposit of the stale revision at the head failed (${rHead.status})`);

  // a cold stranger resolve must STILL land on the cancelled newest revision
  await D.page.goto(`${BASE}/#/a/${encodeURIComponent(SHARD)}/${pubA}/${aid1}`);
  await D.page.waitForSelector(`[data-testid="av-view"] ${actCard(aid1)}`, { timeout: 30000 });
  await D.page.waitForSelector('[data-testid="av-view"] [data-testid="act-cancelled"]', { timeout: 30000 });
  const seqD = await D.page.getAttribute(`[data-testid="av-view"] ${actCard(aid1)}`, "data-seq");
  if (seqD !== "3") fail(`stale-head replay resolved to seq ${seqD}, expected the newest (3)`);
  ok("permalink: stale revision re-deposited at head + filler — resolver still shows the CANCELLED seq-3 doc");
  await D.close();
}

let exitCode = 0;
try {
  await main();
  console.log("\n🎉 activities: publish + join/leave LWW + edit + cancel + contacts-only sealing + stranger lock + permalink + expiry + stale-head replay resilience passed");
} catch (err) {
  console.error("❌ " + (err?.message ?? err));
  exitCode = 1;
} finally {
  await harness.shutdown();
}
process.exit(exitCode);

static mirror of HEAD · about · clone: git clone https://cod-sursa.cautpartener.ro/cautpartener.git