Oregami
Repositories/oxedyne/daimond

oxedyne/daimond/www/js/group.js

64.0 KiB, 1 run

created by r2519314175:1373, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1/* ============================================================
2 Daimond — groups (group.js)
3 ------------------------------------------------------------
4 A GROUP IS A MEMBERSHIP LIST AND THERE IS NO GROUP KEY.
5
6 A message to a group is one envelope sealed once per member,
7 which is the recipient-slot list in post.js doing the work it
8 was reserved for. Membership changes need no re-keying because
9 there is no key to change: a message is sealed to whoever is a
10 member at the moment it is sent. That single decision removes
11 group-key rotation, which is the part of group cryptography
12 that is hard.
13
14 ── THE THREE THINGS THAT MUST BE ON SCREEN ─────────────────
15
16 Each is a consequence of the design and none is a defect, so
17 each is said in the interface rather than left in a comment
18 where the only person who reads it is the next person to
19 write the code.
20
21 1. JOINING SHOWS NOTHING EARLIER. A new member cannot read
22 what was sent before they joined, because those envelopes
23 were never sealed to them. Drawn on the invitation, before
24 the Join press, not after it.
25
26 2. REMOVING SOMEBODY RETRACTS NOTHING. They keep every
27 message already delivered to them. The control therefore
28 says "stop sending to", never "remove", and the sentence
29 is beside it before the press. Anything else would be a
30 promise this design cannot keep.
31
32 3. CLOSING IS FINAL, AND IT DESTROYS NOTHING. A group is
33 closed by its creator writing a roster that names NOBODY
34 -- because a group IS its membership list, so a list with
35 nobody on it is the group being over. Everybody keeps
36 every message; nobody can write to it again, the creator
37 included. Said in one confirmation dialogue before the
38 press, because it cannot be undone.
39
40 ── WHERE THE LIST LIVES, AND WHAT THE RELAY LEARNS ─────────
41
42 In the clients, and nowhere else. A roster travels as an
43 ORDINARY SEALED MESSAGE through the relay that already
44 exists: the gateway gains no group record, no group endpoint
45 and no group table, and `gateway/src/handlers/post.rs` is
46 unchanged by this file.
47
48 What the relay learns is therefore what it already learned by
49 routing: N envelopes sharing one `addr`, landing in N boxes
50 at one instant, which is a correlation anybody reading the
51 store can make. It does NOT learn the group's name, its id,
52 who created it, or that these deliveries are a group rather
53 than a broadcast. §12.6 says the member list is visible to
54 the relay and that is true — but by INFERENCE and not by
55 record, which is the better of the two positions: the blinded
56 mailbox ids §12.7 defers would remove the inference outright,
57 whereas a stored roster would have to be designed away.
58
59 ── WHO MAY ADD AND REMOVE, AND WHY IT IS NOT A POLICY ──────
60
61 The creator, and only the creator. That is §12.6's first cut,
62 and here it is not a rule a client could decline to enforce —
63 it is an identity:
64
65 gid = SHA-256("daimond.group.id.v1" ‖ creatorPub ‖ salt)
66
67 A roster is obeyed only where the id recomputed from the
68 op's OWN author key and its OWN salt equals the `to` the
69 signature covers. Nobody but the holder of that signing key
70 can produce a roster for that group, so "creator only" costs
71 no membership-operation schema, no signed-op ordering and no
72 story about two people editing membership at once — which is
73 the convergence problem §12.6 declines to open.
74
75 It also makes the invitation self-authenticating: a device
76 that has never heard of a group can check the first roster it
77 is sent, because everything needed to check it is inside it.
78
79 Attaches one global, `window.DaimondGroup`.
80 ============================================================ */
81(function () {
82 'use strict';
83
84 // ── Saying things ──────────────────────────────────────────
85
86 function t(k, v) { return window.DaimondI18n ? DaimondI18n.t(k, v) : k; }
87
88 /// A string from the table, or the English written at the call site where the
89 /// table has no entry for it yet. post.js's `tOr`, and for the same reason.
90 function tOr(k, fallback, v) {
91 var s = t(k, v);
92 if (s !== k) return s;
93 if (!v) return fallback;
94 return String(fallback).replace(/\{(\w+)\}/g, function (whole, name) {
95 return v[name] != null ? String(v[name]) : whole;
96 });
97 }
98
99 function log(/* ...args */) {
100 try {
101 if (!window.DAIMOND_DEBUG) return;
102 console.log.apply(console, ['[group]'].concat([].slice.call(arguments)));
103 } catch (e) { /* no console */ }
104 }
105
106 // ── The op ─────────────────────────────────────────────────
107 //
108 // A roster is a `daimond/post/0` payload like any other, because a new
109 // schema would mean fe2o3_sbj, the wasm write side and a rebuilt bundle for
110 // a record with five fields in it. What marks it is the first line of the
111 // body, and post.js refuses to SEND a body whose first line is this — so no
112 // message a person wrote can be mistaken for a roster.
113 //
114 // The marker is not a security boundary and is not asked to be one. A
115 // hostile client forging a roster is refused by the id derivation, which is
116 // where the authorisation actually is; the marker only keeps an honest
117 // person's prose out of the roster path.
118
119 /// The first line of every roster body.
120 var MARK = 'daimond.group.op.v1';
121
122 /// The domain the group id is derived in. Not a prefix of any other tag.
123 var ID_INFO = 'daimond.group.id.v1';
124
125 /// The salt's exact width, in bytes.
126 var SALT = 16;
127
128 /// The most members a roster may name.
129 ///
130 /// The slot count in post.js's envelope is ONE BYTE, so 255 is the hard
131 /// ceiling of the seal itself and not a policy. See the fan-out arithmetic
132 /// in post.js, which reaches its practical wall well before this.
133 var MEMBERS_MAX = 255;
134
135 /// The most a group name may carry, in characters. A name is a label its
136 /// author chose, drawn beside the id for the same reason a card's label is
137 /// drawn beside a fingerprint.
138 var NAME_MAX = 64;
139
140 // ── Encoding ───────────────────────────────────────────────
141
142 function utf8(s) { return new TextEncoder().encode(String(s)); }
143
144 function hex(bytes) {
145 var s = '';
146 for (var i = 0; i < bytes.length; i++) s += ('0' + bytes[i].toString(16)).slice(-2);
147 return s;
148 }
149
150 function unhex(s) {
151 var str = String(s || '');
152 var out = new Uint8Array(str.length >> 1);
153 for (var i = 0; i < out.length; i++) out[i] = parseInt(str.substr(i * 2, 2), 16);
154 return out;
155 }
156
157 /// Whether a string is exactly `n` bytes of lowercase hexadecimal.
158 function isHex(s, n) {
159 return typeof s === 'string' && s.length === n * 2 && /^[0-9a-f]+$/.test(s);
160 }
161
162 /// A millisecond timestamp, kept whole. `| 0` would coerce it to a signed
163 /// 32-bit integer, and `Date.now()` is about 1.79e12 -- see post.js's `ms`,
164 /// which says what that costs and why it is invisible for weeks at a time.
165 function ms(v) {
166 var n = Number(v);
167 return isFinite(n) ? Math.trunc(n) : 0;
168 }
169
170 function cat(parts) {
171 var n = 0, i;
172 for (i = 0; i < parts.length; i++) n += parts[i].length;
173 var out = new Uint8Array(n), at = 0;
174 for (i = 0; i < parts.length; i++) { out.set(parts[i], at); at += parts[i].length; }
175 return out;
176 }
177
178 function b64enc(buf) {
179 var b = (buf instanceof Uint8Array) ? buf : new Uint8Array(buf);
180 var bin = '';
181 for (var i = 0; i < b.length; i++) bin += String.fromCharCode(b[i]);
182 return btoa(bin);
183 }
184
185 /// Standard base64 to the base64url the gateway binds an account by.
186 function b64url(b64) {
187 return String(b64).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
188 }
189
190 /// A signing key in hex, as the base64url spelling the relay addresses by.
191 function pubOf(keyHex) { return b64url(b64enc(unhex(keyHex))); }
192
193 // ── The identity of a group ────────────────────────────────
194
195 /// Derive a group's id from its creator's signing key and its salt.
196 ///
197 /// This is the whole of the authorisation model. The id is what the payload's
198 /// signed `to` carries, so a roster verifies for exactly one signing key and
199 /// nobody else can mint one for the same group without finding a SHA-256
200 /// preimage. It also means a device seeing a group for the first time can
201 /// check the roster that introduces it without knowing anything beforehand.
202 async function deriveId(creatorHex, saltHex) {
203 if (!isHex(creatorHex, 32) || !isHex(saltHex, SALT)) return '';
204 var bits = await crypto.subtle.digest('SHA-256',
205 cat([utf8(ID_INFO), unhex(creatorHex), unhex(saltHex)]));
206 return hex(new Uint8Array(bits));
207 }
208
209 // ── The store, which is post.js's ──────────────────────────
210 //
211 // Groups live in the message record and not in one of their own, and that is
212 // deliberate rather than convenient. post.js already wraps that record under
213 // the identity key at rest, re-seals it through `DaimondRekey` on a
214 // passphrase change, hangs it on the sync parcel and merges an arriving one.
215 // A second store would be a second thing to wrap, a second thing to re-seal
216 // and a second section for sync.js to carry — and the roster and the
217 // messages sealed under it are one account state, so they must merge
218 // together or a device can hold a message for a group it does not know it is
219 // in.
220
221 /// The groups map, or null while the identity is locked.
222 async function all() {
223 if (!window.DaimondPost || !DaimondPost.groups) return null;
224 return await DaimondPost.groups();
225 }
226
227 /// One group's record, or null.
228 async function get(gid) {
229 var g = await all();
230 return (g && g[String(gid)]) || null;
231 }
232
233 /// Write one group's record back and save.
234 async function put(gid, rec) {
235 if (!window.DaimondPost || !DaimondPost.putGroup) return false;
236 return await DaimondPost.putGroup(String(gid), rec);
237 }
238
239 // ── A record ───────────────────────────────────────────────
240 //
241 // TWO CLOCKS, EACH WITH ONE WRITER, which is what makes the merge in
242 // post.js `adopt` converge without any ordering machinery:
243 //
244 // * the ROSTER half -- `at`, `salt`, `name`, `members` -- is the
245 // creator's, and only the creator writes it, so the higher `at` wins;
246 // * the LOCAL half -- `state`, `stateAt` -- is this account's own
247 // decision, and only this account writes it, so the higher `stateAt`
248 // wins.
249 //
250 // Neither clock is compared against the other and neither is a Lamport
251 // counter pretending to be a timestamp. A record is:
252 //
253 // { gid, creator, salt, name, at, addr, members: [ {k, e, n} ],
254 // state: 'invited' | 'joined' | 'left', stateAt }
255
256 /// The three states a group is in on THIS device.
257 var STATE = { INVITED: 'invited', JOINED: 'joined', LEFT: 'left' };
258
259 /// Whether a state may compose to the group.
260 function canSend(st) { return st === STATE.JOINED; }
261
262 /// Why nothing more can be sent to a closed group.
263 ///
264 /// One place, because three callers refuse for it -- `sealTo`, `roster` and
265 /// `close` -- and a sentence written out at each of them is three wordings
266 /// waiting for two of them to stop being read. The same argument that makes
267 /// `DaimondPost.shortfall` the single authority one level up.
268 function closedWhy() {
269 return tOr('group.err_closed',
270 'This group has been closed, so nothing more can be sent to it. '
271 + 'Every message already here stays where it is.');
272 }
273
274 /// Has this group been CLOSED? Its roster names nobody.
275 ///
276 /// CLOSED IS NOT A FOURTH FIELD, it is the membership list being empty, and
277 /// that is the whole reason it works. Three things fall out of the machinery
278 /// that already exists rather than having to be built:
279 ///
280 /// - IT IS AUTHORISED. Only the holder of the creator's signing key can
281 /// produce a roster for this id, so only they can produce the empty one.
282 /// - IT TRAVELS BOTH ROADS. `members` is a field `post.js` `adopt` already
283 /// copies on the roster half, so a second device of the same account
284 /// converges over the sync parcel; every member converges over the relay
285 /// through `consume`. A `closed` flag of its own would travel the relay and
286 /// NOT the parcel, and the creator's other phone is the one device the
287 /// relay never delivers their own roster to.
288 /// - IT ENFORCES ITSELF AT THE READERS. `accepts` walks the roster looking
289 /// for the author; an empty roster contains nobody, so a message to a
290 /// closed group is refused by every reader for the same reason a removed
291 /// member's is. There is no group key to rotate and the relay knows
292 /// nothing, so the readers are the only place it could be.
293 function isClosed(rec) {
294 return !!rec && Array.isArray(rec.members) && rec.members.length === 0;
295 }
296
297 /// AND THE CASE THE THREE STATES DID NOT HAVE: this account wrote the roster.
298 ///
299 /// All three are answers to somebody else's invitation, and an author has no
300 /// invitation to answer -- writing the roster IS the answer. Until this was
301 /// read, `create` applied its own roster through `consume`, was filed
302 /// INVITED like any stranger's, and `sealTo` then refused the creator from a
303 /// group they had just made with "Join this group before writing to it."
304 /// Nothing in the app ever joined them: the panel's Make branch reports how
305 /// many people were told and stops. So the state machine was not missing a
306 /// FOURTH state, it was missing a rule about the three it has --
307 ///
308 /// FOR A GROUP THIS ACCOUNT CREATED, THE STATE IS ALWAYS `joined`.
309 ///
310 /// -- which holds because the id is derived from the creator's own signing key
311 /// and `roster` puts them in every roster it writes, so there is no roster of
312 /// theirs that does not name them. `invited` is unreachable for an author and
313 /// so is `left`: both are contradicted by their own next roster.
314 ///
315 /// WITH EXACTLY ONE EXCEPTION, and it is the one this rule's own premise names:
316 /// THE CLOSING ROSTER, which names nobody at all (see `isClosed`). It is the
317 /// one roster of the creator's that does not name them, so it is the one that
318 /// puts an author in `left` -- and it has to, because "nobody can write to it
319 /// again" includes them. `consume` reaches it before this branch: an empty
320 /// roster fails the membership test first, and the chain below is ordered
321 /// membership, then authorship, then invitation for that reason.
322 ///
323 /// It is read HERE, at the one door a roster becomes a record, and not in
324 /// `create`, because `create` is one of four ways in. The other three are
325 /// `setMembers`, the creator's own copy arriving back off the relay, and a
326 /// second device of the same account reading the same bytes -- and a repair
327 /// bolted onto `create` would leave that last one filing the account's own
328 /// group as an invitation on its owner's other phone.
329 function authoredBy(rec, mineHex) {
330 return !!mineHex && !!rec && String(rec.creator).toLowerCase() === mineHex;
331 }
332
333 /// This device's signing key in hex, or '' while the identity is unreadable.
334 ///
335 /// Four places asked this question with four copies of the same try/catch, and
336 /// '' is the answer that makes each of them fail closed: no key compares equal
337 /// to it, so an unreadable identity is nobody rather than everybody.
338 async function whoAmI() {
339 try {
340 var me = await DaimondIdentity.publicKeyRaw();
341 return me ? hex(me) : '';
342 } catch (e) { return ''; }
343 }
344
345 // ── Reading a roster op ────────────────────────────────────
346
347 /// Whether a message body is a roster op rather than something somebody wrote.
348 function looksLikeOp(body) {
349 var s = String(body == null ? '' : body);
350 return s.slice(0, MARK.length) === MARK
351 && (s.length === MARK.length || s.charAt(MARK.length) === '\n');
352 }
353
354 /// Parse a roster op out of a body, or null. Shape only; the id derivation
355 /// below is what decides whether it may be obeyed.
356 function parseOp(body) {
357 if (!looksLikeOp(body)) return null;
358 var j = null;
359 try { j = JSON.parse(String(body).slice(MARK.length + 1)); }
360 catch (e) { return null; }
361 if (!j || typeof j !== 'object' || j.op !== 'roster') return null;
362 if (!isHex(j.salt, SALT)) return null;
363 // AN EMPTY ROSTER IS LEGAL AND MEANS CLOSED. This read `|| !j.members.length`
364 // and refused one, so the shape a close travels in was rejected at the door
365 // by the same function that lets every other roster through. It is still a
366 // list, still signed, and still verified by the id derivation -- so nobody
367 // but the creator can produce one. See `isClosed`.
368 if (!Array.isArray(j.members) || j.members.length > MEMBERS_MAX) return null;
369 var out = [], seen = {}, i;
370 for (i = 0; i < j.members.length; i++) {
371 var m = j.members[i];
372 if (!m || !isHex(m.k, 32) || !isHex(m.e, 32)) return null;
373 // A roster naming one key twice would let a member be counted twice in
374 // a fan-out and, worse, would let one entry's sealing key disagree with
375 // another's for the same person.
376 if (seen[m.k]) return null;
377 seen[m.k] = 1;
378 out.push({ k: m.k, e: m.e, n: String(m.n || '').slice(0, NAME_MAX) });
379 }
380 return { salt: j.salt, name: String(j.name || '').slice(0, NAME_MAX), members: out };
381 }
382
383 /// Build the body of a roster op.
384 function opBody(salt, name, members) {
385 return MARK + '\n' + JSON.stringify({
386 op: 'roster',
387 salt: salt,
388 name: String(name || '').slice(0, NAME_MAX),
389 members: members.map(function (m) { return { k: m.k, e: m.e, n: m.n || '' }; }),
390 });
391 }
392
393 // ── What post.js asks this file ────────────────────────────
394
395 /// Whether an artefact addressed to `toHex` may be opened by this device.
396 ///
397 /// post.js calls this when a message's signed `to` is not this account's own
398 /// signing key, which is the ONE place a group message differs from any
399 /// other. Two answers are yes, and each carries its own reason:
400 ///
401 /// - THE INVITATION. The artefact is a roster op whose id, recomputed from
402 /// its own author and its own salt, is the `to` it was signed under. It
403 /// needs no prior knowledge, which is what makes a first roster carriable
404 /// over the ordinary relay with nothing arranged beforehand.
405 ///
406 /// - AN ORDINARY GROUP MESSAGE. There is a known group at that id, this
407 /// device is in it, and THE AUTHOR IS IN ITS CURRENT ROSTER.
408 ///
409 /// That last clause is where a removal is enforced, and it is the only place
410 /// it can be. There is no group key to rotate and the relay knows nothing, so
411 /// a member who has been dropped is refused BY EVERY READER rather than by
412 /// the transport. A client that skipped it would keep drawing messages from
413 /// somebody the creator removed, which is the one thing "stop sending to"
414 /// must actually mean.
415 async function accepts(toHex, got) {
416 var want = String(toHex || '').toLowerCase();
417 if (!isHex(want, 32)) return null;
418
419 // The invitation, checked against itself.
420 var op = parseOp(got && got.post && got.post.body);
421 if (op) {
422 var derived = await deriveId(String(got.author || '').toLowerCase(), op.salt);
423 if (derived && derived === want) return { op: true, gid: want };
424 // A body that reads as a roster and does not verify is refused
425 // outright rather than falling through to be drawn as prose. A reader
426 // shown JSON in a message bubble has been shown a failure as content.
427 return null;
428 }
429
430 var rec = await get(want);
431 if (!rec || rec.state === STATE.LEFT) return null;
432 var who = String(got && got.author || '').toLowerCase();
433 var i;
434 // AND A CLOSED GROUP IS REFUSED BY THIS SAME WALK, with no line of its own.
435 // A closed group's roster names nobody (see `isClosed`), so nobody is found
436 // in it and every message to it is refused for the reason a removed
437 // member's is. An explicit `isClosed` guard was written above this loop
438 // first and then deleted: it could not be made to fail, because disabling
439 // it left the walk refusing exactly the same envelopes. A check that cannot
440 // go red is not a check, and the mechanism it was standing in front of is
441 // the one worth naming here.
442 //
443 // It matters that this holds WITHOUT the local half being right. The two
444 // halves of a record merge on separate clocks, and the local half only
445 // moves when the arriving `stateAt` is the higher -- so a member who
446 // pressed Join on a device whose clock runs ahead of the creator's can
447 // adopt the empty roster onto a record still saying `joined`. Clock skew
448 // between two people is not a fault either can see; the roster is.
449 for (i = 0; i < rec.members.length; i++) {
450 if (rec.members[i].k === who) {
451 return { op: false, gid: want, name: rec.name, state: rec.state };
452 }
453 }
454 return null;
455 }
456
457 /// Take one roster op and answer whether it moved this device.
458 ///
459 /// The op is NOT re-checked against the creator here: `accepts` has already
460 /// recomputed the id from the artefact's own author and salt, and calling
461 /// this with an artefact that did not pass it is a caller error rather than
462 /// a state to defend against. It is the same arrangement `openEnvelope` uses
463 /// for a signature.
464 async function consume(got) {
465 var op = parseOp(got && got.post && got.post.body);
466 if (!op) return false;
467 var gid = String(got.post.to).toLowerCase();
468 var at = ms(got.time);
469 var rec = await get(gid);
470
471 // A CLOSED GROUP CANNOT BE REOPENED, AND IT IS THE READERS THAT SAY SO.
472 // This is what makes the confirmation dialogue's "it cannot be undone" a
473 // property rather than a promise: a later roster from the creator naming
474 // everybody again is refused HERE, on every device, so a client that
475 // offered to reopen a group would find nobody obeying it. Before the `at`
476 // comparison, because a reopening roster is by definition the newer one.
477 if (isClosed(rec)) return false;
478
479 // A roster no newer than the one held changes nothing. This is what makes
480 // a replay inert: an old op re-delivered by anybody is simply older.
481 if (rec && (at < ms(rec.at)
482 || (at === ms(rec.at) && String(got.address) <= String(rec.addr || '')))) {
483 return false;
484 }
485
486 var mineHex = await whoAmI();
487
488 var inIt = false, i;
489 for (i = 0; i < op.members.length; i++) {
490 if (op.members[i].k === mineHex) { inIt = true; break; }
491 }
492
493 var next = {
494 gid: gid,
495 creator: String(got.author).toLowerCase(),
496 salt: op.salt,
497 name: op.name,
498 at: at,
499 addr: String(got.address || ''),
500 members: op.members,
501 state: rec ? rec.state : STATE.INVITED,
502 stateAt: rec ? ms(rec.stateAt) : 0,
503 };
504 // THE LOCAL HALF, decided in one chain so that the precedence is on the
505 // page rather than in the order two independent `if`s happen to sit in.
506 // Membership first, then authorship, then the invitation.
507 if (!inIt) {
508 // A roster that no longer names this device is the removal, arriving. It
509 // is sent to the dropped member deliberately (see `setMembers`), because
510 // a removal nobody is told about leaves somebody composing into a group
511 // that will refuse every word of it.
512 //
513 // NOTHING IS DELETED. Every message already collected stays exactly
514 // where it is, which is the second sentence this file exists to keep
515 // true.
516 if (next.state !== STATE.LEFT) {
517 next.state = STATE.LEFT;
518 next.stateAt = at;
519 }
520 } else if (authoredBy(next, mineHex)) {
521 // THE AUTHOR IS IN THEIR OWN GROUP AND IS NOT ASKED. See `authoredBy`:
522 // there is no invitation to answer, so there is no state but `joined`
523 // for a roster this account wrote and named itself in. Written with the
524 // ROSTER'S OWN STAMP rather than `Date.now()`, so the local half still
525 // merges on a real millisecond clock and this account's other devices
526 // converge on the same answer instead of on whichever read the bytes
527 // last.
528 if (next.state !== STATE.JOINED) {
529 next.state = STATE.JOINED;
530 next.stateAt = at;
531 }
532 } else if (next.state === STATE.LEFT) {
533 // And a roster that names this device again after a removal is a fresh
534 // invitation rather than a silent rejoin: joining is an act.
535 next.state = STATE.INVITED;
536 next.stateAt = at;
537 }
538 // The ANSWER IS THE WRITE, not the decision to write. A roster that was
539 // understood and could not be stored -- the identity locked between the
540 // two -- has moved nothing, and a caller told otherwise would count it as
541 // applied and never look at it again.
542 var wrote = await put(gid, next);
543 if (wrote) log('roster', gid.slice(0, 8), next.state, next.members.length, 'member(s)');
544 return wrote;
545 }
546
547 // ── Sealing to a group ─────────────────────────────────────
548
549 /// Who a message to this group is sealed to, and who was left out.
550 ///
551 /// Answers `{ ok, why, gid, enc, to, skipped, name }`:
552 /// - `enc` is the sealing keys the envelope gets a slot for;
553 /// - `to` is the base64url signing keys the envelope is DELIVERED to;
554 /// - `skipped` is `[{ label, why }]`, and it is drawn, never swallowed.
555 ///
556 /// THE KEY-CHANGE RULE, which is the whole reason this is not a one-liner.
557 /// trust.js holds a state per key — `matched`, `new`, `changed`, `blocked` —
558 /// and a group message sealed to a CHANGED key is a group message sealed to
559 /// somebody who may not be the same person. Of the three things that could be
560 /// done about that:
561 ///
562 /// * seal to it anyway — hands the words to whoever now holds the key, and
563 /// tells the sender nothing;
564 /// * refuse the whole send — one member's rotated key silences a group of
565 /// forty for a reason nobody can see;
566 /// * seal to the rest and SAY WHO WAS LEFT OUT — the sender's screen names
567 /// them, and the message reaches everybody it safely can.
568 ///
569 /// The third. The first is the only one that is unsafe and the second is the
570 /// only one that is useless, so this is not a close call; what it costs is
571 /// that the sender must be shown the list, which is why `skipped` is a
572 /// returned value and not a log line.
573 ///
574 /// A roster's `e` is THE CREATOR'S CLAIM about a member's sealing key. Where
575 /// trust.js holds a card for the same signing key, that card wins and a
576 /// disagreement is treated exactly as a key change is — because that is what
577 /// it is, seen from the group's side.
578 async function sealTo(gid) {
579 var rec = await get(gid);
580 if (!rec) {
581 return { ok: false, why: tOr('group.err_unknown',
582 'This device does not know that group.') };
583 }
584 // CLOSED IS ASKED FIRST, and the order is the whole of why the answer is
585 // worth anything. A closed group leaves this account in `left`, so without
586 // this line the sender is told "You are no longer in this group" -- true of
587 // the record and wrong about what happened, and wrong in the one direction
588 // that matters: it reads as something done to them, when it is a group
589 // nobody is in any more. The creator gets that same sentence about a group
590 // they closed themselves.
591 if (isClosed(rec)) {
592 return { ok: false, gid: gid, name: rec.name, why: closedWhy() };
593 }
594 if (!canSend(rec.state)) {
595 return { ok: false, why: rec.state === STATE.LEFT
596 ? tOr('group.err_left',
597 'You are no longer in this group, so nothing can be sent to it. '
598 + 'The messages already here stay where they are.')
599 : tOr('group.err_not_joined',
600 'Join this group before writing to it.') };
601 }
602
603 var dir = {};
604 try {
605 if (window.DaimondTrust && DaimondTrust.people) {
606 (await DaimondTrust.people() || []).forEach(function (p) {
607 if (p && p.key) dir[String(p.key).toLowerCase()] = p;
608 });
609 }
610 } catch (e) { log('people projection failed', e); }
611
612 var mineHex = await whoAmI();
613
614 var enc = [], to = [], skipped = [], i;
615 for (i = 0; i < rec.members.length; i++) {
616 var m = rec.members[i];
617 var name = m.n || (dir[m.k] && dir[m.k].label) || m.k.slice(0, 8);
618 if (m.k === mineHex) {
619 // This device's own slot is post.js's business, and it adds one.
620 // Adding a second here would be a duplicate slot in every envelope.
621 continue;
622 }
623 var p = dir[m.k] || null;
624 if (p && p.state === 'blocked') {
625 skipped.push({ label: name, why: tOr('group.skip_blocked',
626 'you blocked this key') });
627 continue;
628 }
629 if (p && p.state === 'changed') {
630 skipped.push({ label: name, why: tOr('group.skip_changed',
631 'their key changed and you have not matched the new one') });
632 continue;
633 }
634 if (p && p.enc && String(p.enc).toLowerCase() !== m.e) {
635 // The roster and this device's own card disagree about a sealing
636 // key. That IS a key change, arriving by another road.
637 skipped.push({ label: name, why: tOr('group.skip_disagree',
638 'the group\'s key for them is not the one you hold') });
639 continue;
640 }
641 enc.push(unhex(m.e));
642 to.push(pubOf(m.k));
643 }
644 if (!enc.length) {
645 return { ok: false, gid: gid, name: rec.name, skipped: skipped,
646 why: tOr('group.err_nobody',
647 'There is nobody in this group this device can seal to.') };
648 }
649 return { ok: true, gid: gid, name: rec.name, enc: enc, to: to, skipped: skipped };
650 }
651
652 // ── Making and changing a group ────────────────────────────
653
654 /// Start a group, and send its first roster.
655 ///
656 /// The creator is a member of their own group and does not have to be named:
657 /// a group of one that the creator is not in is a group nobody can write to.
658 ///
659 /// `keys` are member signing keys in hex. Their sealing keys come from
660 /// trust.js, because that is the only place this app holds a key it has
661 /// re-verified; a member with no card cannot be added, and saying so is
662 /// better than adding somebody nothing can be sealed to.
663 async function create(name, keys) {
664 if (!window.DaimondIdentity || !DaimondIdentity.isUnlocked()) {
665 return { ok: false, why: tOr('group.err_locked',
666 'Unlock Daimond to make a group: its roster is signed with your own key.') };
667 }
668 var salt = hex(crypto.getRandomValues(new Uint8Array(SALT)));
669 var mine = await DaimondIdentity.publicKeyRaw();
670 var gid = await deriveId(hex(mine), salt);
671 if (!gid) return { ok: false, why: tOr('group.err_no_id',
672 'This device could not name a group.') };
673 return await roster(gid, salt, name, keys, []);
674 }
675
676 /// Replace a group's membership and send the new roster.
677 ///
678 /// `keys` is the WHOLE membership, not a delta. A snapshot converges with no
679 /// ordering machinery because there is exactly one writer — the creator — so
680 /// the higher `at` is simply the later roster. A delta stream would need gap
681 /// detection and a story about a missing op, for a group that changes twice a
682 /// year.
683 async function setMembers(gid, name, keys) {
684 var rec = await get(gid);
685 if (!rec) return { ok: false, why: tOr('group.err_unknown',
686 'This device does not know that group.') };
687 var mine = await DaimondIdentity.publicKeyRaw();
688 if (!mine || hex(mine) !== rec.creator) {
689 return { ok: false, why: tOr('group.err_not_creator',
690 'Only the person who made a group can change who is in it.') };
691 }
692 var keep = {}, i;
693 for (i = 0; i < keys.length; i++) keep[String(keys[i]).toLowerCase()] = 1;
694 var dropped = rec.members.filter(function (m) { return !keep[m.k]; })
695 .map(function (m) { return m.k; });
696 return await roster(gid, rec.salt, name == null ? rec.name : name, keys, dropped);
697 }
698
699 /// Compose one roster, apply it here, and send it.
700 ///
701 /// `dropped` are members the new roster does not name. THEY ARE SENT IT TOO,
702 /// and that is the one place this file spends an envelope on somebody who is
703 /// not a member. A removal nobody is told about leaves a person composing
704 /// into a group where every reader will refuse them, with nothing on their
705 /// screen to explain it. The copy they get proves the second sentence rather
706 /// than only asserting it: they can read the roster that does not name them,
707 /// and every message they already hold is still there.
708 ///
709 /// `closing` writes the ONE roster that names nobody -- not even its author --
710 /// which is how a group is closed. See `isClosed` and `close`.
711 async function roster(gid, salt, name, keys, dropped, closing) {
712 // A CLOSED GROUP TAKES NO FURTHER ROSTER, asked at the door and not only in
713 // `close`: `setMembers` comes through here too, and a membership change to a
714 // group that no longer has a membership is a roster no reader would obey
715 // (see `consume`). Refusing it here is what turns that silence into a
716 // sentence.
717 //
718 // AND IT IS ONLY THE SENTENCE. Measured, by deleting these five lines: the
719 // call still fails, because `consume` refuses the read-back and `roster`
720 // refuses any roster it could not apply -- so what moves is the reason, from
721 // "this device could not apply the roster it just wrote" to one a reader can
722 // act on. Worth having and worth being honest about the size of.
723 var was = await get(gid);
724 if (isClosed(was)) return { ok: false, why: closedWhy() };
725 var mine = await DaimondIdentity.publicKeyRaw();
726 var mineHex = hex(mine);
727 var dir = {};
728 try {
729 if (window.DaimondTrust && DaimondTrust.people) {
730 (await DaimondTrust.people() || []).forEach(function (p) {
731 if (p && p.key && p.enc) dir[String(p.key).toLowerCase()] = p;
732 });
733 }
734 } catch (e) { /* nobody */ }
735
736 keys = keys || [];
737 var members = [], seen = {}, missing = [], bad = [], dupes = 0, i;
738 // The creator first, always, and from this device's own keys rather than
739 // from a card: asking a directory about a key this device holds the other
740 // half of is how the two spellings of one key get out of step.
741 var myEnc = DaimondIdentity.sealingKeyRaw();
742 if (!myEnc) return { ok: false, why: tOr('group.err_no_sealing_key',
743 'This device has no sealing key yet, so it cannot make a group.') };
744 // EXCEPT WHEN CLOSING, which is the one roster the creator leaves themselves
745 // out of. The sealing key is still required: `compose` gives the author
746 // their own slot whatever the roster says, so the closing roster is
747 // re-readable on this account's other devices exactly as every other is.
748 if (!closing) {
749 members.push({ k: mineHex, e: hex(myEnc),
750 n: (window.DaimondIdentity.displayName && DaimondIdentity.displayName()) || '' });
751 seen[mineHex] = 1;
752 }
753
754 for (i = 0; i < keys.length; i++) {
755 var k = String(keys[i]).toLowerCase();
756 // A KEY THAT IS NOT A KEY IS NAMED, NEVER SKIPPED. This line read
757 // `if (!isHex(k, 32) || seen[k]) continue;` and dropped both cases on the
758 // floor without adding either to `missing`, so a caller who mis-spelled
759 // one key was answered `ok:true, members:1, sent:0` -- a group of one,
760 // made silently, which is exactly what happened the first time anybody
761 // called this by hand. The spelling is carried out in `bad` rather than
762 // counted, because a count cannot be corrected and a spelling can.
763 if (!isHex(k, 32)) { bad.push(k.slice(0, 72)); continue; }
764 // A DUPLICATE IS A DIFFERENT CASE AND IS NOT AN ERROR. Naming somebody
765 // twice, or naming yourself, asks for a roster this one already is: the
766 // caller gets the membership they asked for, so it is counted and
767 // reported and refuses nothing. Reporting it as a fault would refuse a
768 // group over a request that was already granted.
769 if (seen[k]) { dupes++; continue; }
770 var p = dir[k];
771 if (!p || !p.enc) { missing.push(k); continue; }
772 seen[k] = 1;
773 members.push({ k: k, e: String(p.enc).toLowerCase(), n: String(p.label || '') });
774 }
775 if (members.length > MEMBERS_MAX) {
776 return { ok: false, why: tOr('group.err_too_many',
777 'A group can hold at most {n} people.', { n: MEMBERS_MAX }) };
778 }
779 // A KEY THAT COULD NOT GO IN REFUSES THE ROSTER, and both arrays come back
780 // so the caller can tell the two faults apart: `missing` is somebody real
781 // whose code has not been scanned, `bad` is a spelling that is not a key at
782 // all.
783 //
784 // TWO SENTENCES, BECAUSE THERE ARE TWO REPAIRS -- correct the spelling, or
785 // scan a code -- which is `shortfall`'s own rule about two lists in one
786 // answer. It was one sentence, `group.err_no_card` counting
787 // `bad.length + missing.length`, and that arithmetic made the sentence
788 // FALSE: one mis-typed key and one uncarded person read as "no sealing key
789 // for 2 of the people chosen", sending the reader to scan a code for a
790 // typing mistake. `{n}` is now the number the sentence is actually about.
791 //
792 // `group.err_bad_keys` IS PLURAL BECAUSE `bad` IS AN ARRAY. The singular
793 // `group.err_bad_key` with its one `{k}` could not say what happened --
794 // `create('x', ['ABC…zz', 'not-a-key'])` refuses with two entries -- so it
795 // is gone rather than kept for a caller that does not exist. `{who}` is a
796 // joined list in the register `post.group_refused` already uses, and it
797 // carries the SPELLINGS, because a spelling is the thing that can be
798 // corrected and a count is not.
799 if (bad.length || missing.length) {
800 if (bad.length) log('keys that are not keys, refused', bad);
801 var why = '';
802 if (bad.length) {
803 why += tOr('group.err_bad_keys', 'These are not keys: {who}.',
804 { who: bad.join(', ') });
805 }
806 if (missing.length) {
807 why += (why ? ' ' : '') + tOr('group.err_no_card',
808 'There is no sealing key for {n} of the people chosen, so they cannot '
809 + 'be added. Scan their code first.', { n: missing.length });
810 }
811 return { ok: false, bad: bad, missing: missing, why: why };
812 }
813
814 // Everybody the roster is sent to: the new membership, plus anybody it
815 // drops. The creator's own copy is post.js's Sent slot and is not a
816 // delivery.
817 var reach = members.filter(function (m) { return m.k !== mineHex; })
818 .map(function (m) { return { k: m.k, e: m.e }; });
819 for (i = 0; i < dropped.length; i++) {
820 var was = null, j;
821 for (j = 0; j < members.length; j++) { if (members[j].k === dropped[i]) was = 1; }
822 if (was) continue;
823 var old = dir[dropped[i]];
824 if (old && old.enc) reach.push({ k: dropped[i], e: String(old.enc).toLowerCase() });
825 }
826
827 var body = opBody(salt, name, members);
828 var made;
829 try {
830 made = await DaimondPost.compose({
831 body: body,
832 group: { id: unhex(gid), enc: reach.map(function (r) { return unhex(r.e); }) },
833 });
834 } catch (e) { return { ok: false, why: String(e && e.message || e) }; }
835
836 // APPLIED HERE THROUGH THE SAME DOOR AN ARRIVING ONE TAKES. The creator's
837 // own copy comes back off the relay eventually, but a group that did not
838 // exist until a round trip completed would be a group a person could
839 // press twice. Reading the artefact back rather than writing the record
840 // straight means there is one path that turns a roster into a record, and
841 // a bug in it shows up for the creator first.
842 //
843 // AND A ROSTER THIS DEVICE DID NOT APPLY IS NOT SENT. The answer used to be
844 // a log line, so a read-back that failed left the caller holding `ok:true`
845 // for a group that is in nobody's record -- the same shape as the malformed
846 // key above, one function further down. `consume` answering false is the
847 // same failure as it throwing: either way this device would be fanning out
848 // a roster it does not itself hold, and would then refuse every message
849 // sent to the group it had just announced.
850 try {
851 var got = JSON.parse(DaimondPost.bridgeRead(made.artefact));
852 got.address = got.address || made.addr;
853 if (!await consume(got)) {
854 throw new Error('this device could not apply the roster it just wrote,'
855 + ' so it was not sent');
856 }
857 } catch (e) {
858 log('the creator could not read back their own roster', e);
859 return { ok: false, why: String(e && e.message || e) };
860 }
861
862 var sent = await DaimondPost.fanout(made, reach.map(function (r) { return pubOf(r.k); }));
863 await draw();
864 // The composed roster comes back with the answer. A caller carrying it by
865 // hand -- a verifier proving this works between three devices with no
866 // server in the path -- must carry the SAME bytes the fan-out sent, not a
867 // second composition of the same roster.
868 // `refused` is drawn by the caller through `shortfallWords`, and `dupes`
869 // counts the keys that were already in the roster -- a request that was
870 // granted rather than a fault, so it is reported and refuses nothing.
871 return { ok: true, gid: gid, sent: sent.sent, refused: sent.refused,
872 dupes: dupes, members: members.length, addr: made.addr,
873 envelope: made.envelope };
874 }
875
876 /// Close a group for everybody, and tell everybody who was in it.
877 ///
878 /// THE CREATOR'S ACT, AND THE ONLY IRREVERSIBLE ONE IN THIS FILE. It writes the
879 /// roster that names nobody, which every reader obeys and no later roster can
880 /// undo (see `isClosed` and `consume`). Nothing is deleted anywhere: every
881 /// member keeps every message they hold, and the record stays on the list so the
882 /// transcript still has a name over it.
883 ///
884 /// EVERYBODY WHO WAS IN IT IS SENT IT, through the same door a removal takes:
885 /// the whole old membership is handed to `roster` as `dropped`, so each of them
886 /// gets the roster that closes the group rather than finding out by writing into
887 /// it and having every reader refuse them. A member whose card this device no
888 /// longer holds cannot be sealed to and so is not told -- the same limit the
889 /// removal path has, and for the same reason.
890 async function close(gid) {
891 var rec = await get(gid);
892 if (!rec) return { ok: false, why: tOr('group.err_unknown',
893 'This device does not know that group.') };
894 if (isClosed(rec)) return { ok: false, why: closedWhy() };
895 var mine = await DaimondIdentity.publicKeyRaw();
896 if (!mine || hex(mine) !== rec.creator) {
897 // Its own sentence rather than `group.err_not_creator`, which is about
898 // changing who is in a group. Closing one is not a membership change and
899 // being told it is sends the reader looking for a control that is not the
900 // one they pressed.
901 return { ok: false, why: tOr('group.err_close_not_creator',
902 'Only the person who made a group can close it.') };
903 }
904 var told = rec.members.map(function (m) { return m.k; });
905 return await roster(gid, rec.salt, rec.name, [], told, true);
906 }
907
908 /// Accept an invitation. Nothing earlier arrives with it, and the row said so.
909 ///
910 /// A CLOSED GROUP CANNOT BE JOINED, and the guard is at the door rather than
911 /// only on the control: the panel draws no Join on a closed row, but a panel is
912 /// one caller, and a device that adopted the empty roster with an older
913 /// `stateAt` (see `accepts`) would otherwise be one press from `joined` on a
914 /// group nothing can be sent to.
915 async function join(gid) {
916 var rec = await get(gid);
917 if (!rec) return false;
918 if (isClosed(rec)) return false;
919 if (rec.state === STATE.JOINED) return true;
920 rec.state = STATE.JOINED;
921 rec.stateAt = Date.now();
922 await put(gid, rec);
923 // Messages that arrived while the invitation was open were sealed to this
924 // device and are its own; the tray was holding them until the invitation
925 // was answered, exactly as it holds a stranger's first message.
926 try { if (window.DaimondPost && DaimondPost.untrayGroup) await DaimondPost.untrayGroup(gid); }
927 catch (e) { /* no post module */ }
928 await draw();
929 return true;
930 }
931
932 /// Leave, or decline. Local, and it tells nobody: the same reasoning Ignore
933 /// is silent under. Nothing already held is deleted.
934 ///
935 /// A GROUP THIS ACCOUNT MADE CANNOT BE LEFT, and the guard is here rather than
936 /// only on the control that draws it. `left` is a state the creator's own next
937 /// roster contradicts -- `roster` names them in everything it writes and
938 /// `consume` reads authorship -- so the press would appear to work and the
939 /// next membership change would silently undo it. The invariant is worth more
940 /// at the door than in the panel: a panel is one caller.
941 ///
942 /// AND A CLOSED GROUP CANNOT BE LEFT EITHER, because there is nothing left to
943 /// leave: the closing roster already put this account out of it, on every device
944 /// that holds the roster. A press that wrote `left` again would move a stamp and
945 /// nothing else, and answering true for it would tell the caller an act happened.
946 async function leave(gid) {
947 var rec = await get(gid);
948 if (!rec) return false;
949 if (isClosed(rec)) return false;
950 if (authoredBy(rec, await whoAmI())) return false;
951 rec.state = STATE.LEFT;
952 rec.stateAt = Date.now();
953 await put(gid, rec);
954 await draw();
955 return true;
956 }
957
958 // ── The panel ──────────────────────────────────────────────
959 //
960 // Drawn INSIDE post.js's own region, because the Social panel's views belong
961 // to improve.js and a third view would be an edit to a file this lane does
962 // not own. post.js calls `draw` once from its `render`, so a language change
963 // and a collect both reach this without a second surface registration.
964
965 /// Where post.js parks this section, set by `mount`.
966 var _host = null;
967
968 function elt(tag, cls, text) {
969 var e = document.createElement(tag);
970 if (cls) e.className = cls;
971 if (text != null) e.textContent = String(text);
972 return e;
973 }
974
975 /// A group's name, drawn as the claim it is: the creator chose it, so it is
976 /// shown beside the first eight characters of the id, which nobody chose.
977 function title(rec) {
978 var name = rec.name || tOr('group.unnamed', 'A group');
979 return name + ' · ' + String(rec.gid).slice(0, 8);
980 }
981
982 /// THE FIRST OF THE TWO SENTENCES, and it is on the invitation rather than
983 /// after the press, because after the press it is an explanation and before
984 /// it is a fact somebody can act on.
985 function joiningSentence() {
986 return tOr('group.joining_shows_nothing',
987 'Joining shows you nothing that was sent before you join. Those messages '
988 + 'were never sealed to your key, so no device can open them for you.');
989 }
990
991 /// THE SECOND, beside the control that drops somebody, before it is pressed.
992 /// The control itself says "stop sending to" and never "remove", because
993 /// "remove" reads as though something is taken back and nothing is.
994 function removingSentence() {
995 return tOr('group.removing_retracts_nothing',
996 'Taking somebody out takes nothing back. They keep every message already '
997 + 'sent to them; they will not receive anything sent from now on.');
998 }
999
1000 /// THE THIRD, beside the control that closes a group, before it is pressed --
1001 /// and again inside the confirmation dialogue, because a sentence read once on
1002 /// the way past is not consent for something that cannot be undone.
1003 function closingSentence() {
1004 return tOr('group.close_note',
1005 'Closing a group closes it for everybody. Nobody can write to it again, '
1006 + 'you included; every message already sent stays where it is. It cannot '
1007 + 'be undone.');
1008 }
1009
1010 /// Draw the whole section into the host post.js gave this file.
1011 async function draw() {
1012 if (!_host) return 0;
1013 _host.textContent = '';
1014 var g = await all();
1015 if (!g) return 0; // locked; post.js has already said so
1016
1017 // Whether this account made a group is asked ONCE, here, rather than
1018 // inside a row: the answer is a key comparison and a row that asked it
1019 // would be a row that has to be asynchronous to draw.
1020 var mineHex = await whoAmI();
1021
1022 var rows = Object.keys(g).map(function (k) { return g[k]; })
1023 .sort(function (a, b) { return ms(b.at) - ms(a.at); });
1024 // CLOSED IS ASKED BEFORE THE STATE, so a closed group cannot be drawn as an
1025 // invitation or as a group somebody may write to whatever the local half
1026 // says. `consume` moves that half to `left` when the closing roster lands,
1027 // but the two halves merge on separate clocks (see `accepts`) and the
1028 // roster is the half that carries the closing.
1029 var closed = rows.filter(isClosed);
1030 var open = rows.filter(function (r) { return !isClosed(r); });
1031
1032 // AND THE LOCAL HALF IS SETTLED WHERE IT DISAGREES, because ONE READER OF
1033 // THIS RECORD IS NOT IN THIS FILE: `post.js` `joinedGroups` builds the
1034 // recipient picker from `state === 'joined'` alone, so a record left saying
1035 // `joined` over an empty roster offers a destination that `sealTo` then
1036 // refuses -- a control that cannot do what it says, which this panel
1037 // deliberately does not draw anywhere else. Writing it here rather than in
1038 // `adopt` keeps the repair in the file that owns what a group record means.
1039 // The picker is drawn in the same pass as this, so it goes right on the NEXT
1040 // render; the sentence in between is `group.err_closed`, which is the true
1041 // one either way.
1042 var settle = closed.filter(function (r) { return r.state !== STATE.LEFT; });
1043 for (var s = 0; s < settle.length; s++) {
1044 settle[s].state = STATE.LEFT;
1045 settle[s].stateAt = ms(settle[s].at);
1046 await put(settle[s].gid, settle[s]);
1047 }
1048 // After the settle, because `put` strips this flag off the record it is
1049 // handed and a row drawn without it is a row whose author sees nothing.
1050 rows.forEach(function (r) { r.iAmCreator = authoredBy(r, mineHex); });
1051 var invites = open.filter(function (r) { return r.state === STATE.INVITED; });
1052 var joined = open.filter(function (r) { return r.state === STATE.JOINED; });
1053 var left = open.filter(function (r) { return r.state === STATE.LEFT; });
1054
1055 if (invites.length) {
1056 var isec = elt('section', 'post-tray');
1057 isec.id = 'group-invites';
1058 isec.appendChild(elt('h3', null, tOr('group.invites_head', 'Group invitations')));
1059 invites.forEach(function (r) { isec.appendChild(drawInvite(r)); });
1060 _host.appendChild(isec);
1061 }
1062
1063 // TWO CLASSES, both of them already in improve.css: `post-list` for the
1064 // padding and `post-tray` for the one rule that styles a section heading.
1065 // A third class of its own would be a rule to add to a stylesheet this
1066 // lane does not own, for a heading that already has one.
1067 var lsec = elt('section', 'post-list post-tray');
1068 lsec.id = 'group-list';
1069 lsec.appendChild(elt('h3', null, tOr('group.head', 'Groups')));
1070 if (!joined.length && !left.length && !closed.length) {
1071 // Two sentences, because "no groups yet" over a pending invitation is
1072 // a screen arguing with the row above it.
1073 lsec.appendChild(elt('p', 'post-empty', invites.length
1074 ? tOr('group.none_joined',
1075 'None joined yet. Answer the invitation above, or make one below.')
1076 : tOr('group.none',
1077 'No groups yet. A group is a list of people a message is sealed to '
1078 + 'one by one — there is no shared key, and nothing is kept on the relay.')));
1079 }
1080 joined.forEach(function (r) { lsec.appendChild(drawGroup(r)); });
1081 left.forEach(function (r) { lsec.appendChild(drawLeft(r)); });
1082 // A CLOSED GROUP KEEPS ITS PLACE ON THE LIST, and that is not a cosmetic
1083 // choice: `post.js` `drawMsg` puts the group's NAME over every message of
1084 // it, read out of this record (`groupRec`). Take the record away and the
1085 // transcript the feature promises to keep loses the one thing that says
1086 // which group it was. So closing is not removing, and removing is not
1087 // offered here -- see the note on `drawClosed`.
1088 closed.forEach(function (r) { lsec.appendChild(drawClosed(r)); });
1089 lsec.appendChild(drawMake());
1090 _host.appendChild(lsec);
1091 return rows.length;
1092 }
1093
1094 /// An invitation, with the first sentence on it.
1095 function drawInvite(rec) {
1096 var row = elt('article', 'post-req');
1097 row.dataset.gid = rec.gid;
1098 var who = elt('div', 'post-who');
1099 who.appendChild(elt('span', 'post-name', title(rec)));
1100 row.appendChild(who);
1101 row.appendChild(elt('p', 'post-body', tOr('group.invited_by',
1102 '{n} people, invited by the person who made it.', { n: rec.members.length })));
1103 row.appendChild(elt('p', 'post-audience', joiningSentence()));
1104 var acts = elt('div', 'post-acts');
1105 [['group-join', tOr('group.join', 'Join')],
1106 ['group-decline', tOr('group.decline', 'Not now')]].forEach(function (p) {
1107 var b = elt('button', 'post-btn', p[1]);
1108 b.type = 'button';
1109 b.dataset.act = p[0];
1110 acts.appendChild(b);
1111 });
1112 row.appendChild(acts);
1113 return row;
1114 }
1115
1116 /// A group this device is in.
1117 function drawGroup(rec) {
1118 var row = elt('article', 'post-msg');
1119 row.dataset.gid = rec.gid;
1120 var who = elt('div', 'post-who');
1121 who.appendChild(elt('span', 'post-name', title(rec)));
1122 who.appendChild(elt('span', 'post-fp', tOr('group.count',
1123 '{n} people', { n: rec.members.length })));
1124 row.appendChild(who);
1125
1126 // The roster, on one line. A name is the creator's claim and the eight
1127 // characters beside it are not, which is the same pairing a card's label
1128 // and fingerprint are drawn in.
1129 row.appendChild(elt('p', 'post-body', rec.members.map(function (m) {
1130 return (m.n || tOr('post.someone', 'Someone new')) + ' · ' + m.k.slice(0, 8);
1131 }).join('\n')));
1132
1133 // The creator's own controls. Everybody else sees the roster and no
1134 // buttons over it, because a control that always refuses explains less
1135 // than its absence.
1136 if (rec.iAmCreator) {
1137 row.appendChild(elt('p', 'post-audience', removingSentence()));
1138 var acts = elt('div', 'post-acts');
1139 rec.members.forEach(function (m) {
1140 if (m.k === rec.creator) return;
1141 var b = elt('button', 'post-btn', tOr('group.stop_sending',
1142 'Stop sending to {who}', { who: m.n || m.k.slice(0, 8) }));
1143 b.type = 'button';
1144 b.dataset.act = 'group-drop';
1145 b.dataset.key = m.k;
1146 acts.appendChild(b);
1147 });
1148 row.appendChild(acts);
1149
1150 // AND THE ONE ACT THAT ENDS IT, which is the creator's alone for the same
1151 // reason changing the membership is: the id is derived from their signing
1152 // key, so nobody else can write the roster that closes it. Its own row of
1153 // controls, under its own sentence, and BELOW the per-member controls --
1154 // an irreversible act does not sit next to a reversible one.
1155 row.appendChild(elt('p', 'post-audience', closingSentence()));
1156 var end = elt('div', 'post-acts');
1157 var cl = elt('button', 'post-btn', tOr('group.close', 'Close this group'));
1158 cl.type = 'button';
1159 cl.dataset.act = 'group-close';
1160 end.appendChild(cl);
1161 row.appendChild(end);
1162 }
1163
1164 // NOT OFFERED TO THE CREATOR, because `leave` refuses them and would be
1165 // right to: see its own note. This is the same argument the drop controls
1166 // above are absent under -- a control that cannot do what it says explains
1167 // less than its absence -- except that this one is worse than one that
1168 // always refuses, since it would appear to work until the next roster.
1169 if (!rec.iAmCreator) {
1170 var mineActs = elt('div', 'post-acts');
1171 var lv = elt('button', 'post-btn', tOr('group.leave', 'Leave this group'));
1172 lv.type = 'button';
1173 lv.dataset.act = 'group-leave';
1174 mineActs.appendChild(lv);
1175 row.appendChild(mineActs);
1176 }
1177 return row;
1178 }
1179
1180 /// A group this device is no longer in. It stays on the list, with its
1181 /// messages, because that is the whole of the second sentence: leaving and
1182 /// being taken out both retract exactly nothing.
1183 function drawLeft(rec) {
1184 var row = elt('article', 'post-msg');
1185 row.dataset.gid = rec.gid;
1186 var who = elt('div', 'post-who');
1187 who.appendChild(elt('span', 'post-name', title(rec)));
1188 row.appendChild(who);
1189 row.appendChild(elt('p', 'post-body', tOr('group.gone',
1190 'You are no longer in this group. Nothing has been taken away: every '
1191 + 'message already here stays, and nothing new will arrive.')));
1192 return row;
1193 }
1194
1195 /// A group that has been closed. It keeps its place, with its messages, and it
1196 /// carries NO CONTROLS AT ALL -- not for the creator either.
1197 ///
1198 /// COULD IT BE TAKEN OFF THE LIST? Not by this act, and the reason is a fact
1199 /// about another file rather than a preference: `post.js` `drawMsg` puts the
1200 /// group's name over every message of it, read out of this record. Delete the
1201 /// record and a transcript the whole feature exists to preserve is left under
1202 /// "A group · 3f2a91c4", which is deletion wearing the words of a tidy-up.
1203 ///
1204 /// So it would be a SECOND act, local to one device, in the register of
1205 /// `post.js`'s Ignore -- hide the row, keep the record -- and it is not built
1206 /// here. Closing and hiding answer to different people: closing is the
1207 /// creator's and reaches everybody, hiding is each reader's own and reaches
1208 /// nobody. Putting them on one button would let one press mean either.
1209 function drawClosed(rec) {
1210 var row = elt('article', 'post-msg');
1211 row.dataset.gid = rec.gid;
1212 row.dataset.closed = '1';
1213 var who = elt('div', 'post-who');
1214 who.appendChild(elt('span', 'post-name', title(rec)));
1215 row.appendChild(who);
1216 row.appendChild(elt('p', 'post-body', tOr('group.closed',
1217 'This group has been closed by the person who made it. Nothing has been '
1218 + 'taken away: every message already here stays, and nobody can write to '
1219 + 'it again.')));
1220 return row;
1221 }
1222
1223 /// The box that makes one. A name and a set of people this device holds cards
1224 /// for, because a member with no card is a member nothing can be sealed to.
1225 function drawMake() {
1226 var box = elt('form', 'post-write');
1227 box.id = 'group-make';
1228 var who = [];
1229 try { who = (window.DaimondPost && DaimondPost.people) ? DaimondPost.people() : []; }
1230 catch (e) { who = []; }
1231 if (!who.length) {
1232 box.appendChild(elt('p', 'post-nobody', tOr('group.nobody',
1233 'There is nobody to put in a group yet. Exchange codes with somebody '
1234 + 'in People, and they will be here.')));
1235 return box;
1236 }
1237 var name = elt('input', 'post-to');
1238 name.id = 'group-name';
1239 name.type = 'text';
1240 name.maxLength = NAME_MAX;
1241 name.placeholder = tOr('group.name_ph', 'What to call this group');
1242 name.setAttribute('aria-label', tOr('group.name_label', 'The group\'s name'));
1243 box.appendChild(name);
1244
1245 var pick = elt('select', 'post-to');
1246 pick.id = 'group-members';
1247 pick.multiple = true;
1248 pick.size = Math.min(6, who.length);
1249 pick.setAttribute('aria-label', tOr('group.members_label', 'Who is in this group'));
1250 who.forEach(function (p) {
1251 var o = elt('option', null, p.label || p.keyHex.slice(0, 8));
1252 o.value = p.keyHex;
1253 pick.appendChild(o);
1254 });
1255 box.appendChild(pick);
1256
1257 box.appendChild(elt('p', 'post-audience', joiningSentence()));
1258 var mk = elt('button', 'post-btn post-send', tOr('group.make', 'Make this group'));
1259 mk.type = 'submit';
1260 mk.dataset.act = 'group-make';
1261 box.appendChild(mk);
1262 var note = elt('p', 'post-note');
1263 note.id = 'group-note';
1264 box.appendChild(note);
1265 return box;
1266 }
1267
1268 function say(text) {
1269 var n = document.getElementById('group-note');
1270 if (n) n.textContent = String(text || '');
1271 }
1272
1273 /// Everything a roster did not do, borrowed from post.js so that there is one
1274 /// voice for it.
1275 ///
1276 /// A ROSTER'S REFUSALS ARE PEOPLE WHO DO NOT KNOW THE GROUP EXISTS, which is
1277 /// why they cannot be counted and dropped: "Made, and 5 people have been told"
1278 /// over a fan-out that reached one is a sentence about four people who will
1279 /// never see a message sent to them. It is the same fault post.js had one
1280 /// function along -- an `ok:true` beside a count of what failed, and no caller
1281 /// reading the count -- so it takes the same remedy rather than a second one.
1282 function shortfallWords(r) {
1283 try {
1284 if (window.DaimondPost && DaimondPost.shortfall) return DaimondPost.shortfall(r);
1285 } catch (e) { /* no post module */ }
1286 return '';
1287 }
1288
1289 /// Ask, once, before closing a group. Answers whether they said yes.
1290 ///
1291 /// THE APP'S OWN DIALOGUE FRAME, `DaimondCore.confirm`, which is
1292 /// `daimond.js`'s one modal: `share.js` asks about a stranger's code through
1293 /// it and `trust.js` borrows `pairing.js`'s overlay rather than writing a
1294 /// second. A third frame for a third question is how an app ends up with three
1295 /// ways of asking the same thing and only two of them trapping focus.
1296 ///
1297 /// NO FRAME, NO CLOSE. The answer to a missing dialogue is `false`, not "go
1298 /// ahead": the one act in this file that cannot be undone must not happen
1299 /// because a script did not load. That is the opposite of `share.js`'s `tell`,
1300 /// which may fail quietly -- it only says something, and nothing turns on it.
1301 async function askClose(rec) {
1302 var body = closingSentence() + '\n\n' + tOr('group.close_ask',
1303 'This closes “{name}” for everybody in it.',
1304 { name: rec.name || tOr('group.unnamed', 'A group') });
1305 try {
1306 if (window.DaimondCore && typeof DaimondCore.confirm === 'function') {
1307 return !!await DaimondCore.confirm(body,
1308 tOr('group.close_ok', 'Close it for everybody'), {
1309 title: tOr('group.close_title', 'Close this group for everybody?'),
1310 danger: true,
1311 });
1312 }
1313 } catch (e) { log('the confirmation dialogue would not open', e); }
1314 log('no dialogue frame, so the group was not closed');
1315 return false;
1316 }
1317
1318 /// Take the region post.js gives this file, and draw into it.
1319 function mount(host) {
1320 _host = host || null;
1321 return draw();
1322 }
1323
1324 // ── Wiring ─────────────────────────────────────────────────
1325
1326 document.addEventListener('click', function (e) {
1327 var b = e.target && e.target.closest ? e.target.closest('[data-act]') : null;
1328 if (!b || String(b.dataset.act).slice(0, 6) !== 'group-') return;
1329 if (!_host || !_host.contains(b)) return;
1330 var row = b.closest('[data-gid]');
1331 var gid = row ? row.dataset.gid : '';
1332 var act = b.dataset.act;
1333 e.preventDefault();
1334
1335 if (act === 'group-join') { join(gid); return; }
1336 if (act === 'group-decline') { leave(gid); return; }
1337 if (act === 'group-leave') { leave(gid); return; }
1338 if (act === 'group-drop') {
1339 get(gid).then(function (rec) {
1340 if (!rec) return;
1341 var keep = rec.members.filter(function (m) {
1342 return m.k !== rec.creator && m.k !== b.dataset.key;
1343 }).map(function (m) { return m.k; });
1344 say(tOr('group.sending_roster', 'Telling everybody…'));
1345 return setMembers(gid, rec.name, keep).then(function (r) {
1346 say((r.ok ? tOr('group.roster_sent', 'Done.') : r.why)
1347 + shortfallWords(r));
1348 });
1349 });
1350 return;
1351 }
1352 if (act === 'group-close') {
1353 // ONE DIALOGUE, AND THE ACT IS INSIDE ITS ANSWER. Not asked and then done
1354 // anyway: `askClose` resolving false is the whole of the refusal, so a
1355 // dismissed dialogue leaves the group exactly as it was.
1356 get(gid).then(function (rec) {
1357 if (!rec) return;
1358 return askClose(rec).then(function (yes) {
1359 if (!yes) return;
1360 say(tOr('group.sending_roster', 'Telling everybody…'));
1361 return close(gid).then(function (r) {
1362 say((r.ok
1363 ? tOr('group.closed_said',
1364 'Closed, and {n} people have been told.', { n: r.sent | 0 })
1365 : r.why) + shortfallWords(r));
1366 });
1367 });
1368 });
1369 return;
1370 }
1371 if (act === 'group-make') {
1372 var name = document.getElementById('group-name');
1373 var pick = document.getElementById('group-members');
1374 var keys = pick ? [].slice.call(pick.selectedOptions || [])
1375 .map(function (o) { return o.value; }) : [];
1376 if (!keys.length) {
1377 say(tOr('group.err_pick', 'Choose at least one person.'));
1378 return;
1379 }
1380 say(tOr('group.making', 'Making the group…'));
1381 create(name ? name.value : '', keys).then(function (r) {
1382 say((r.ok
1383 ? tOr('group.made', 'Made, and {n} people have been told.', { n: r.sent | 0 })
1384 : r.why) + shortfallWords(r));
1385 });
1386 return;
1387 }
1388 });
1389
1390 // ── Public surface ─────────────────────────────────────────
1391 window.DaimondGroup = {
1392 /// The marker post.js refuses to let a person send.
1393 MARK: MARK,
1394 STATE: STATE,
1395 /// The identity of a group, and the whole of its authorisation model.
1396 deriveId: deriveId,
1397 /// What post.js asks about an artefact whose `to` is not this key.
1398 accepts: accepts,
1399 consume: consume,
1400 looksLikeOp: looksLikeOp,
1401 /// Who a message to a group is sealed to and delivered to, and who was
1402 /// left out. `skipped` is drawn by the caller, never swallowed.
1403 sealTo: sealTo,
1404 /// The acts.
1405 create: create,
1406 setMembers: setMembers,
1407 join: join,
1408 leave: leave,
1409 /// The one that cannot be undone. Its confirmation is on the control, not
1410 /// in here: a caller reaching this has already asked or is a verifier.
1411 close: close,
1412 /// Whether a group's roster names nobody, which is what closed IS.
1413 isClosed: isClosed,
1414 /// What is held, for a panel and for a verifier.
1415 list: async function () {
1416 var g = await all();
1417 if (!g) return [];
1418 return Object.keys(g).map(function (k) { return g[k]; });
1419 },
1420 get: get,
1421 /// The panel, hosted inside post.js's own region.
1422 mount: mount,
1423 draw: draw,
1424 /// The three sentences, published so a verifier asserts the WORDS that are
1425 /// drawn rather than a class name that could be drawn empty.
1426 joiningSentence: joiningSentence,
1427 removingSentence: removingSentence,
1428 closingSentence: closingSentence,
1429 };
1430})();