source: Klonkt/src/services/guardianship/handshake.js@ abbb72a

main
Last change on this file since abbb72a was abbb72a, checked in by Robin <roboburr@…>, 4 weeks ago

De markering bereikte de mede-guardian nooit (shaer-lgo)

Gevonden door met de ECHTE relatie te kijken in plaats van met een test.
dev.klonkt.com is guardian van @mee@…, en zij heeft er
nog een: boiert.eu. In ap_help_state staan vier afgehandelde hulpvragen van haar
-- en elke markering is van dev zelf. Nooit een van boiert, en dev heeft er ook
nooit een naartoe gestuurd.

De oorzaak stond in de markeerroute:

listGuardians(wardUri.replace(/.*\/ap\/users\, ))

De staart van de URI als slug. listGuardians kent alleen relaties van LOKALE
sites, dus voor een ward elders was dat altijd een lege lijst. Nagemeten op dev:
de regex geeft "mee", er is geen site "mee", de lijst is leeg. De markering ging
dus alleen naar het kind.

En juist die ward is het hele punt: een ward op je eigen instance heeft geen
federatie nodig. Dit is precies de faalstand waar deze bead voor bestaat --
iedereen denkt dat de ander het oppakt -- en hij was stil, want er komt geen
fout uit een lege lijst.

Erger nog: had er toevallig een lokale site met die naam bestaan, dan waren het
DIENS guardians geweest.

existingGuardiansOf in handshake.js kende de goede weg al: lokaal opzoeken, en
anders shaer:guardians uit de actor van de ward. Die stond alleen niet aan deze
route vast. Nu geexporteerd en aangesloten -- geen tweede afleiding erbij, de
bestaande gebruikt.

Drie tests, met de oude fout als eerste erin vastgelegd zodat hij niet
terugsluipt. De actor wordt met een nepversie opgehaald: anders test het of de
testmachine internet heeft.

Co-Authored-By: Claude Opus 5 <noreply@…>

  • Property mode set to 100644
File size: 33.1 KB
Line 
1/**
2 * Guardianship (FEP-633c §3) — the adoption handshake, multi-party and
3 * distributed across instances.
4 *
5 * The candidate Offers a Relationship{subject: ward, object: candidate},
6 * addressed to the ward AND every existing guardian of the ward. Each party
7 * (ward, existing guardians, and finally the candidate) Accepts, addressed to
8 * all the others, so every instance's copy of the tally converges. The
9 * candidate's Accept is the LAST one and carries the escalation handle in
10 * `result`: that return is the atomic commit (§3.1.3). Only then does the
11 * ward gain the guardian in shaer:guardians and the guardian gain the ward.
12 * A single Reject from any party voids the offer (§3.2).
13 *
14 * The state machine lives in offers.js (a faithful port of the Shaer test
15 * daemon); this module wires it onto Klonkt's C2S/S2S plumbing. AP helpers
16 * arrive once via wireHandshake(deps); nothing here imports ActivityPubService.
17 */
18import { isGuardianRelationship, GUARDIAN_RELATIONSHIP_COMPACT, carriesGuardians } from './context.js';
19import * as offers from './offers.js';
20import * as relations from './relations.js';
21import * as gated from './gated.js';
22import * as availability from './availability.js';
23
24let deps = null;
25export function wireHandshake(d) { deps = d; }
26
27const idOf = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.id === 'string' ? v.id : null));
28const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
29
30/**
31 * FEP-633c §3.2/§3.3 — ending a guardianship.
32 *
33 * "After commit, either side MAY end the relationship with `Undo` of the
34 * `Relationship`. An `Undo` from a guardian, or from the ward co-signed by an
35 * existing guardian, removes the guardian from `shaer:guardians`."
36 *
37 * §3.3 bounds it: this is how ONE guardian goes while others remain. Removing
38 * the last one empties `shaer:guardians` and that is emancipation (§3.4), which
39 * has its own flow and is explicitly not a single party's call. So an Undo that
40 * would leave a ward with nobody is refused here rather than quietly performed.
41 */
42export function parseUndoRelationship(activity) {
43 const type = Array.isArray(activity && activity.type) ? activity.type[0] : (activity && activity.type);
44 if (type !== 'Undo') return null;
45 return parseRelationship(activity && activity.object);
46}
47
48/**
49 * De overgebleven guardians opnieuw vertellen of ZIJ nu de doorslag geven
50 * (shaer-8vt, Barts correctie 8-8).
51 *
52 * "Doorslaggevend" is geen eigenschap van een moment maar van een STAND: zodra
53 * er nog een stem nodig is, is iedereen die nog moet antwoorden het. Eenmalig
54 * berekenen bij het doorsturen bevriest een antwoord dat verandert.
55 *
56 * Nooit dragend: lukt de update niet, dan blijft de oude waarde staan. Die is
57 * dan te voorzichtig of te stil -- en juist daarom staat de FAALSTAND aan de
58 * kant van waarschuwen (isDecisive leest onbekend als "ja, jij beslist").
59 */
60function herzieDoorslag(site, offerId, gsOffer, laatsteStem) {
61 try {
62 const p = gated.gatedProgress(site.slug, gsOffer.feature);
63 if (!gated.isDecisive(p.votes, p.need)) return; // nog niets veranderd
64 const me = deps.selfId(site.slug);
65 const gestemd = new Set([gsOffer.proposer, laatsteStem].filter(Boolean));
66 for (const g of relations.listGuardians(site.slug).map((x) => x.other_uri)) {
67 if (gestemd.has(g)) continue;
68 deps.deliverTo(site, g, {
69 id: offerId, type: 'Offer', actor: me, to: [g],
70 object: { type: 'shaer:GatedSetting', 'shaer:ward': me, 'shaer:feature': gsOffer.feature, 'shaer:value': !!gsOffer.value },
71 'shaer:proposer': gsOffer.proposer || undefined,
72 'shaer:decisive': true,
73 }).catch(() => { /* de bezorgwachtrij probeert opnieuw */ });
74 }
75 } catch { /* nooit dragend */ }
76}
77
78/** Parse a Relationship object into {ward, candidate} or null. */
79export function parseRelationship(rel) {
80 if (!rel || typeof rel !== 'object') return null;
81 const type = Array.isArray(rel.type) ? rel.type[0] : rel.type;
82 if (type !== 'Relationship') return null;
83 if (!isGuardianRelationship(String(rel.relationship || ''))) return null;
84 const ward = idOf(rel.subject);
85 const candidate = idOf(rel.object);
86 return ward && candidate ? { ward, candidate } : null;
87}
88
89/**
90 * The existing guardians of a ward: local list, or the remote actor's
91 * shaer:guardians.
92 *
93 * Geexporteerd sinds shaer-lgo: de markeerroute had zijn EIGEN afleiding, en
94 * die was fout voor precies het geval dat telt (zie routes/guardian.js).
95 */
96export async function existingGuardiansOf(wardUri) {
97 const local = deps.localSlug(wardUri);
98 if (local) return relations.listGuardians(local).map((r) => r.other_uri);
99 const doc = await deps.fetchActor(wardUri).catch(() => null);
100 const g = doc && doc['shaer:guardians'];
101 return Array.isArray(g) ? g.filter((x) => typeof x === 'string') : [];
102}
103
104function offerActivity(offerId, ward, candidate, recipients) {
105 return {
106 id: offerId, type: 'Offer', actor: candidate, to: recipients,
107 object: { type: 'Relationship', subject: ward, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: candidate },
108 };
109}
110
111/** Deliver `activity` to every uri in `recipients` (skipping the local self). */
112async function fanout(site, recipients, activity) {
113 let anyDelivered = false;
114 for (const uri of [...new Set(recipients)]) {
115 const r = await deps.deliverTo(site, uri, activity).catch(() => ({ delivered: false }));
116 if (r && r.delivered !== false) anyDelivered = true;
117 }
118 return anyDelivered;
119}
120
121/**
122 * §5.6, the closing of the loop: a settled gated decision answers the Offer
123 * that opened it. Accept when it settled on the proposed value, Reject when on
124 * the opposite. Without this the proposer's screen can only ever say
125 * "waiting", forever, whatever actually happened: the tally lives on the
126 * ward's server and nobody else may read it, so the ward's server must speak.
127 */
128function answerGatedProposer(site, offerId, r) {
129 const o = gated.recallGatedOffer(offerId);
130 if (!o || !o.proposer) return;
131 const me = deps.selfId(site.slug);
132 if (o.proposer === me) return; // the ward proposed to itself: nothing to write home
133 const agreed = r.value === !!o.value;
134 deps.deliverTo(site, o.proposer, {
135 id: `${me}#gatedanswer-${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`,
136 type: agreed ? 'Accept' : 'Reject',
137 actor: me, to: [o.proposer], object: offerId,
138 }).catch(() => { /* the delivery queue retries */ });
139}
140
141/** Apply the local side of a commit: the ward writes its guardian, the
142 * candidate writes its ward. Each instance writes only what it hosts.
143 * other_handle is the human @handle for display (from the offer); the FEP
144 * escalation handle (candidate inbox) lives on the offer row, not here. */
145function applyCommitLocally(offer) {
146 const wardSlug = deps.localSlug(offer.ward_uri);
147 const candSlug = deps.localSlug(offer.candidate_uri);
148 if (wardSlug) relations.commitGuardianForWard(wardSlug, offer.candidate_uri, { handle: offer.candidate_handle, offerId: offer.offer_id });
149 if (candSlug) relations.commitWardForGuardian(candSlug, offer.ward_uri, { handle: offer.ward_handle, offerId: offer.offer_id });
150}
151
152/**
153 * FEP-633c §4.2 — is this candidate fit to be a guardian at all?
154 *
155 * A guardian MUST be free of guardians (§1). Checked here and not at the Offer,
156 * because guardianship state can change in between: a candidate that was free
157 * when it offered may have been adopted before the ward accepted. So the check
158 * runs against a freshly dereferenced actor document, at the moment the
159 * relationship would become real.
160 *
161 * Three answers, and the third is not a failure of this check but a failure to
162 * perform it:
163 * 'ok' — free of guardians, may serve
164 * 'malformed' — carries shaer:guardians; a teapot (§4)
165 * 'unverified' — the actor could not be read at all
166 */
167async function candidateFitness(candidateUri) {
168 // A candidate on this instance needs no dereference: our own tables are the
169 // document, and fresher than anything we could fetch from ourselves. This is
170 // also the co-located case (ward and guardian on one Klonkt), where there is
171 // no network to be unreachable on.
172 const local = deps.localSlug(candidateUri);
173 if (local) return relations.listGuardians(local).length > 0 ? 'malformed' : 'ok';
174
175 const doc = await deps.fetchActor(candidateUri).catch(() => null);
176 if (!doc) return 'unverified';
177 return carriesGuardians(doc) ? 'malformed' : 'ok';
178}
179
180/** Commit this local copy of the offer when the tally is complete (ward +
181 * candidate + ≥1 existing guardian, §3.1.2). The handle is the candidate's
182 * inbox (§6 minimum); the commit is order-independent, so whichever accept
183 * lands last triggers it on every copy. */
184async function maybeCommit(slug, offerId) {
185 const offer = offers.getOffer(slug, offerId);
186 if (!offer || !offers.readyToCommit(offer)) return { done: null, refused: null };
187
188 const fitness = await candidateFitness(offer.candidate_uri);
189
190 // §4.2: unlike the soft skip at delivery (§4.1), this refusal is loud. A
191 // handshake concerns exactly one candidate, so there is no remaining
192 // well-formed target to continue to; committing anyway would leave the ward
193 // counting a guardian whose escalations get dropped. Voiding is all this
194 // function does; saying so on the wire belongs to whoever was acting.
195 if (fitness === 'malformed') {
196 offers.recordReject(slug, offerId, offer.ward_uri); // voids this copy (§3.2)
197 notify(slug, {
198 kind: 'offer_rejected', offer: offerId,
199 reason: 'not_a_teapot', candidate: offer.candidate_uri,
200 });
201 return { done: null, refused: 'not_a_teapot', offer };
202 }
203
204 // Could not read the candidate: neither commit nor void. Refusing outright
205 // would let a momentary outage destroy a multi-party adoption; committing
206 // would record a guardian nobody checked. The offer stays pending and the
207 // next accept retries.
208 if (fitness === 'unverified') return { done: null, refused: null };
209
210 const done = offers.commit(slug, offerId, `${offer.candidate_uri}/inbox`);
211 if (done) { applyCommitLocally(done); notify(slug, { kind: 'committed', ward: done.ward_uri, guardian: done.candidate_uri }); }
212 return { done, refused: null };
213}
214
215/**
216 * End a guardianship from the local guardian's side and let it travel (§3.2).
217 *
218 * One path for both callers: the button in the Guardian PWA and an `Undo` a
219 * Guardian app POSTs to its own outbox. Addressed like the Offer that started
220 * it (§3.1.1): the ward, and every other guardian, so no copy is left behind
221 * believing the relation still stands.
222 */
223export async function endGuardianship(site, wardUri) {
224 const me = deps.selfId(site.slug);
225 if (!relations.getRelation(site.slug, 'guardian', wardUri)) return { status: 404, error: 'not_my_ward' };
226 const set = await existingGuardiansOf(wardUri);
227 const others = set.filter((g) => g !== me);
228 // Only a set we actually read counts as proof. A remote ward whose server is
229 // down reads as an empty set; refusing on that would trap the guardian, and
230 // the ward's server checks again on arrival anyway.
231 if (set.length && others.length === 0) return { status: 409, error: 'would_emancipate' };
232 const recipients = [wardUri, ...others];
233 const undo = {
234 id: `${me}/undo/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`,
235 type: 'Undo', actor: me, to: recipients,
236 object: { type: 'Relationship', subject: wardUri, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: me },
237 };
238 const delivered = await fanout(site, recipients, undo);
239 relations.removeRelation(site.slug, 'guardian', wardUri);
240 // A ward we host ourselves never receives its own delivery: an inbox on this
241 // machine is not reachable over HTTP from this machine (and should not be).
242 // The commit path has the same shape and solves it the same way — each
243 // instance writes what it hosts (applyCommitLocally).
244 const wardSlug = deps.localSlug(wardUri);
245 if (wardSlug) dropGuardianFromWard(wardSlug, deps.selfId(site.slug));
246 notify(site.slug, { kind: 'guardianship_ended', ward: wardUri, delivered });
247 return { status: 202, delivered, guardiansLeft: others.length };
248}
249
250/**
251 * The ward's side of an ended guardianship: drop that guardian, unless doing so
252 * would empty the set. §3.3 only permits this while more than one remains;
253 * emptying it is emancipation (§3.4) and no single party decides that.
254 */
255function dropGuardianFromWard(wardSlug, guardianUri) {
256 const set = relations.listGuardians(wardSlug).map((r) => r.other_uri);
257 if (!set.includes(guardianUri)) return false; // already gone: an Undo is idempotent
258 if (set.length <= 1) {
259 notify(wardSlug, { kind: 'guardianship_end_refused', guardian: guardianUri, reason: 'would_emancipate' });
260 return false;
261 }
262 relations.removeRelation(wardSlug, 'ward', guardianUri);
263 notify(wardSlug, { kind: 'guardian_left', guardian: guardianUri });
264 return true;
265}
266
267/** The receiving side of that Undo. Returns true when consumed. */
268function applyInboundUndo(site, activity) {
269 const rel = parseUndoRelationship(activity);
270 if (!rel) return false;
271 const me = deps.selfId(site.slug);
272 const actor = idOf(activity.actor);
273 const ward = rel.ward;
274 const guardian = rel.candidate; // in an Undo the Relationship's object is the leaving guardian
275
276 if (ward === me) {
277 // I am the ward. Only the guardian itself may end its own relation here;
278 // the ward-co-signed variant of §3.2 needs a second signature and is not
279 // built, so it is refused rather than half-honoured.
280 if (actor !== guardian) return false;
281 dropGuardianFromWard(site.slug, guardian);
282 return true;
283 }
284
285 // I am one of the other guardians: nothing of mine changes, but being left
286 // as one of fewer is exactly the kind of thing a guardian should hear about.
287 if (relations.getRelation(site.slug, 'guardian', ward)) {
288 notify(site.slug, { kind: 'coguardian_left', ward, guardian });
289 return true;
290 }
291 return false;
292}
293
294// ── C2S: a LOCAL party acts (PWA, Berichten, or the Shaer app outbox) ──────
295
296/**
297 * Handle a guardianship activity POSTed to the local outbox. Returns null when
298 * it is not ours, else {status, ...} for the route.
299 */
300export async function handleOutbox(site, activity) {
301 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
302 if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return null;
303 const me = deps.selfId(site.slug);
304 // One answer restores everything (§3.6): any C2S activity from this actor
305 // is that answer, for every local ward it guards. Runs before anything is
306 // even looked at, so the target of a running lapse cancels it by doing
307 // anything at all — including trying to vote on it.
308 try { availability.oneAnswer(me, Date.now()); } catch { /* never load-bearing */ }
309
310 // ── Undo: a guardian ends its own guardianship (§3.2). Same path as the
311 // button in the Guardian PWA, so an app and the dashboard cannot drift.
312 if (type === 'Undo') {
313 const rel = parseUndoRelationship(activity);
314 if (!rel) return null;
315 if (rel.candidate !== me) return { status: 403, error: 'not_your_relation' };
316 return endGuardianship(site, rel.ward);
317 }
318
319 // ── Offer: the local site is the guardian-candidate. ───────────────────
320 if (type === 'Offer') {
321 // §3.6.3 over C2S: a guardian here proposes releasing a dormant
322 // co-guardian. A ward we host opens locally; a remote ward gets the
323 // proposal delivered, because the ward's server is the one that tallies
324 // and enforces (the §5.6 line: a guardian next door must not have more
325 // say than one far away).
326 const lp = availability.parseLapse(activity.object);
327 if (lp) {
328 // ONE path (Robins regel, 29-7): the ward's server opens, tallies and
329 // enforces, wherever it lives. A local ward is reached by the same
330 // deliverTo, which loops back into the inbox handler; co-location is a
331 // transport detail and never a shortcut past the decision.
332 const id = `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
333 const offer = { id, type: 'Offer', actor: me, to: [lp.ward], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } };
334 const delivered = await fanout(site, [lp.ward], offer);
335 return { status: 202, id, url: id, delivered };
336 }
337 const rel = parseRelationship(activity.object);
338 if (!rel) return null;
339 if (rel.candidate !== me) return { status: 403, error: 'only_the_candidate_offers' }; // fixed initiator (§3.1)
340 if (relations.listGuardians(site.slug).length) return { status: 403, error: 'a_ward_cannot_guard' }; // §1
341 const existing = await existingGuardiansOf(rel.ward);
342 const offerId = `${me}/offers/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
343 offers.start(site.slug, {
344 offerId, ward: rel.ward, candidate: me, existingGuardians: existing,
345 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(me),
346 });
347 // The Offer IS the candidate's agreement to serve: record it as the
348 // candidate's accept. So a FREE ward commits on its own single accept (no
349 // second guardian to co-approve yet); once it IS a ward, adding another
350 // guardian still needs an existing guardian to co-accept.
351 offers.recordAccept(site.slug, offerId, me);
352 // Addressed to the ward AND every existing guardian (§3.1.1).
353 const recipients = [rel.ward, ...existing];
354 const delivered = await fanout(site, recipients, offerActivity(offerId, rel.ward, me, recipients));
355 notify(site.slug, { kind: 'offer_sent', ward: rel.ward });
356 return { status: 202, id: offerId, url: offerId, delivered };
357 }
358
359 // ── Accept / Reject: the local site is a party answering an offer. ─────
360 const offerId = idOf(activity.object);
361 if (!offerId) return { status: 400, error: 'missing_offer' };
362 // A lapse vote over C2S (§3.6.3): the same Accept/Reject wire the offers
363 // and gated follows use, which is exactly why the Shaer clients need no
364 // new verbs for it.
365 if (availability.getLapse(offerId)) {
366 const r = availability.lapseVote(offerId, me, type === 'Accept', Date.now());
367 if (r && r.error) return { status: r.error === 'not_in_set' ? 403 : 409, error: r.error };
368 return { status: 202, id: offerId, url: offerId, 'shaer:outcome': 'open', 'shaer:accepts': r.accepts, 'shaer:threshold': r.threshold };
369 }
370 let offer = offers.getOffer(site.slug, offerId);
371 if (!offer) return { status: 404, error: 'no_such_offer' };
372 const others = offers.parties(offer).filter((p) => p !== me);
373
374 if (type === 'Reject') {
375 offers.recordReject(site.slug, offerId, me);
376 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Reject', actor: me, to: others, object: offerId });
377 notify(site.slug, { kind: 'offer_rejected', offer: offerId });
378 return { status: 202, id: offerId, url: offerId };
379 }
380
381 // §1 is flat in BOTH directions. The Offer path above bars a ward from
382 // offering to guard; this is the mirror: an actor that already guards wards
383 // must not become a ward itself. Only the ward's own accept can create that
384 // state, so the candidate and the existing guardians pass through untouched.
385 //
386 // Without it the inconsistency would also be invisible. actorProps() picks
387 // one role with an if/else and would publish shaer:guardians while dropping
388 // shaer:isGuardian, so this account keeps routing its wards' escalations
389 // locally while every remote §4 check reads it as malformed and drops it —
390 // a ward believing it is watched over when it is not, silent on both sides.
391 if (me === offer.ward_uri && relations.listWards(site.slug).length) {
392 return { status: 403, error: 'a_guardian_cannot_be_guarded' };
393 }
394
395 // Accept: record my accept, broadcast it to the other parties, and commit
396 // this copy if the tally is now complete (order-independent, §3.1.3).
397 offers.recordAccept(site.slug, offerId, me);
398 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId });
399 const { done, refused, offer: voided } = await maybeCommit(site.slug, offerId);
400 if (refused) {
401 // §4.2: the refusal travels as a `Reject` of the Offer (§3.2), which an
402 // implementation unaware of §4 still handles correctly. Who is told WHY is
403 // not uniform, and deliberately so.
404 const answer = (to, withReason) => ({
405 id: `${me}/answers/${Date.now().toString(36)}`,
406 type: 'Reject', actor: me, to, object: offerId,
407 ...(withReason ? { 'shaer:notATeapot': true } : {}),
408 });
409 const candidate = voided && voided.candidate_uri;
410 // The ward and its existing guardians MUST learn the reason: they are
411 // parties, the condition is public data (§2.1), and a bare void would
412 // leave a ward believing an adoption completed that did not.
413 const family = others.filter((u) => u !== candidate);
414 if (family.length) await fanout(site, family, answer(family, true));
415 // The candidate gets a BARE Reject. Commit is the last step of §3.1, so a
416 // refusal that names itself technical also discloses that every human
417 // party already accepted and only the protocol objected — which, where a
418 // guardianship is contested, is not theirs to learn. The kind path for an
419 // merely misconfigured candidate is the check on the Offer, before anyone
420 // has consented to anything.
421 if (candidate && others.includes(candidate)) await fanout(site, [candidate], answer([candidate], false));
422 return { status: 202, id: offerId, url: offerId, committed: false, refused };
423 }
424 return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) };
425}
426
427// ── S2S: a REMOTE party's activity arrives in a local inbox ────────────────
428
429/**
430 * Handle an inbound guardianship activity for the local site `site` (the inbox
431 * owner). Returns true when consumed.
432 */
433export async function handleInbox(site, activity) {
434 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
435 if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return false;
436 if (type === 'Undo') return applyInboundUndo(site, activity);
437 const me = deps.selfId(site.slug);
438 const actor = idOf(activity.actor);
439
440 // §5.6: a guardian proposes a gated setting for THIS ward. The ward's server
441 // tallies and enforces, so the decision lands here, not on the proposer.
442 if (type === 'Offer') {
443 const gs = gated.parseGatedSetting(activity.object);
444 if (gs) {
445 const offerId = idOf(activity);
446 // ── I am the WARD: record, tally, and forward to the other guardians.
447 if (gs.ward === me) {
448 gated.rememberGatedOffer(offerId, site.slug, gs.feature, gs.value, actor);
449 // The proposer's Offer carries its own agreement (§3.1's one-step clause).
450 const r = gated.recordGatedVote(site.slug, gs.feature, actor, gs.value);
451 // The forward is the leg that was missing. A proposal addressed to the
452 // ward's server reaches only the proposer and the ward; the other
453 // guardians never learn it exists, so a threshold of two can never be
454 // met and every proposal expires unanswered. The ward's server is the
455 // one that knows the authoritative guardian list, which is exactly why
456 // §5.3 forwards a gated follow from here too.
457 if (r.state === 'open') {
458 for (const g of relations.listGuardians(site.slug).map((x) => x.other_uri)) {
459 if (g === actor) continue; // the proposer already answered
460 // The forward goes out AS THE WARD, because the ward's key signs
461 // it. Keeping the proposer in `actor` made every receiver answer
462 // 401 signer mismatch, and rightly so: the body claimed one author
463 // and the signature proved another. §5.3 forwards a gated follow
464 // the same way. Who proposed it rides along separately, for the
465 // guardian's screen.
466 // Zou DIT antwoord het besluit afmaken (shaer-8vt)? De telling loopt
467 // hier, op de server van het kind, en nergens anders -- zonder dit
468 // veld kan een guardian elders onmogelijk weten dat hij de doorslag
469 // geeft. Een ja/nee en geen getal: zie isDecisive.
470 const p = gated.gatedProgress(site.slug, gs.feature);
471 deps.deliverTo(site, g, {
472 id: offerId, type: 'Offer', actor: me, to: [g], object: activity.object,
473 'shaer:proposer': actor,
474 'shaer:decisive': gated.isDecisive(p.votes, p.need),
475 }).catch(() => { /* the delivery queue retries */ });
476 }
477 } else {
478 gated.clearGatedReviews(offerId); // settled at once: nothing left to ask
479 answerGatedProposer(site, offerId, r);
480 }
481 notify(site.slug, { kind: 'gated_setting', feature: gs.feature, value: gs.value, state: r.state });
482 return true;
483 }
484 // ── I am one of the GUARDIANS: the forwarded copy. Store it so this
485 // guardian can answer; the answer goes back to the ward, which tallies.
486 if (relations.getRelation(site.slug, 'guardian', gs.ward)) {
487 const wardDoc = await deps.fetchActor(gs.ward).catch(() => null);
488 gated.recordGatedReview(site.slug, {
489 id: offerId, wardUri: gs.ward, wardInbox: wardDoc && wardDoc.inbox,
490 // A forward is signed by the ward, so `actor` is the ward; the
491 // guardian who opened it travels in shaer:proposer.
492 proposer: (typeof activity['shaer:proposer'] === 'string' ? activity['shaer:proposer'] : actor),
493 feature: gs.feature, value: gs.value,
494 // Ontbreekt het veld (een oudere server), dan WAARSCHUWEN we: niets
495 // zeggen terwijl je beslist is de gevaarlijke kant (shaer-8vt).
496 decisive: activity['shaer:decisive'] !== false,
497 });
498 notify(site.slug, { kind: 'gated_review', feature: gs.feature, value: gs.value, ward: gs.ward });
499 return true;
500 }
501 return false; // not our ward, and not a ward we guard
502 }
503 // §3.6.3: a co-guardian proposes releasing a dormant guardian of THIS
504 // ward. The ward's server opens, tallies and (after the full window)
505 // executes, exactly as it does for the gated settings above.
506 const lp = availability.parseLapse(activity.object);
507 if (lp) {
508 if (lp.ward !== me) return false; // not our ward
509 const id = idOf(activity) || `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
510 const r = availability.openLapse({ id, wardSlug: site.slug, wardUri: me, target: lp.target, openedBy: actor, now: Date.now() });
511 if (r.error) {
512 notify(site.slug, { kind: 'lapse_refused', reason: r.error, target: lp.target });
513 return true; // consumed: the refusal is the answer
514 }
515 // The target is notified like any dormancy marking (§3.6.2): in
516 // protocol (a copy of the Offer, so one answer can cancel it) AND the
517 // §6 handle, which for a committed guardian is its inbox — the same
518 // door this delivery knocks on.
519 deps.deliverTo(site, lp.target, activity).catch(() => { /* best-effort */ });
520 notify(site.slug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set });
521 return true;
522 }
523 const rel = parseRelationship(activity.object);
524 if (!rel) return false;
525 // I must be a party: the ward, or one of the existing guardians in `to`.
526 const recipients = arr(activity.to);
527 const existing = recipients.filter((u) => u !== rel.ward);
528 if (rel.ward !== me && !existing.includes(me)) return false;
529 // §4.2: check the candidate here too, and refuse before anyone accepts.
530 // At this point no party has consented, so saying why discloses nothing
531 // about anyone's position, and a candidate that is merely misconfigured
532 // can find that out and fix it. The commit-time check stays REQUIRED as
533 // the backstop for a candidate whose state changes in between.
534 if (await candidateFitness(rel.candidate) === 'malformed') {
535 notify(site.slug, { kind: 'offer_refused', offer: idOf(activity), reason: 'not_a_teapot', candidate: rel.candidate });
536 await fanout(site, [rel.candidate], {
537 id: `${me}/answers/${Date.now().toString(36)}`,
538 type: 'Reject', actor: me, to: [rel.candidate], object: idOf(activity), 'shaer:notATeapot': true,
539 });
540 return true;
541 }
542 offers.start(site.slug, {
543 offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing,
544 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(rel.candidate),
545 });
546 // The Offer carries the candidate's agreement (see the C2S side): record it
547 // so this copy's tally matches — a free ward then commits on its own accept.
548 offers.recordAccept(site.slug, idOf(activity), rel.candidate);
549 notify(site.slug, { kind: rel.ward === me ? 'offer_received' : 'offer_for_ward', ward: rel.ward, candidate: rel.candidate });
550 return true;
551 }
552
553 // Accept / Reject of an offer we (also) track.
554 const offerId = idOf(activity.object);
555 // §5.6, the answer coming HOME: the ward's server settled a decision we
556 // proposed and answers our Offer. Accept = it settled on what we proposed,
557 // Reject = on the opposite. Only the ward may say so: the answer must come
558 // from the ward the proposal was about, or anyone could close our books.
559 const sent = gated.recallSent(offerId);
560 if (sent && sent.guardian_slug === site.slug) {
561 if (actor !== sent.ward_uri) return false; // not the ward's voice: not an outcome
562 const outcome = type === 'Accept' ? 'accepted' : 'rejected';
563 gated.settleSent(offerId, outcome);
564 notify(site.slug, { kind: 'gated_outcome', feature: sent.feature, value: !!sent.value, outcome, ward: sent.ward_uri });
565 return true;
566 }
567 // §5.6: a fellow guardian answering a gated-setting proposal. The Accept only
568 // references the offer, so the value comes from the proposal we stored. A
569 // Reject is a vote for the opposite, not a shrug: it is still an answer.
570 const gsOffer = gated.recallGatedOffer(offerId);
571 if (gsOffer && gsOffer.slug === site.slug) {
572 const value = type === 'Accept' ? !!gsOffer.value : !gsOffer.value;
573 const r = gated.recordGatedVote(site.slug, gsOffer.feature, actor, value);
574 if (r.state === 'settled') answerGatedProposer(site, offerId, r);
575 // DOORSLAGGEVEND SCHUIFT MEE (Barts correctie, 8-8). Ik berekende dit een
576 // keer bij het doorsturen en bevroor het. Bij vijf guardians staat er dan
577 // "je beslist niets" -- en zodra er een ja bij komt IS elk van de anderen de
578 // doorslag. Dat is precies de stille kant: het scherm zwijgt op het moment
579 // dat het moet spreken.
580 //
581 // Dus na elke stem die het open laat: de overgeblevenen opnieuw vertellen
582 // waar ze staan. Alleen wie NOG NIET geantwoord heeft, en alleen als het
583 // antwoord verandert -- anders is dit een bericht per stem per guardian.
584 else herzieDoorslag(site, offerId, gsOffer, actor);
585 notify(site.slug, { kind: 'gated_setting', feature: gsOffer.feature, value, state: r.state });
586 return true;
587 }
588 // §3.6.3: a set member answering a running lapse. Irreversible, so even a
589 // full tally leaves it open until the window closes (§3.5); the completion
590 // happens lazily on reads (queues) once the window has run.
591 if (availability.getLapse(offerId)) {
592 const r = availability.lapseVote(offerId, actor, type === 'Accept', Date.now());
593 notify(site.slug, { kind: 'lapse_vote', lapse: offerId, by: actor, state: r && !r.error ? 'recorded' : (r && r.error) || 'refused' });
594 return true;
595 }
596 let offer = offers.getOffer(site.slug, offerId);
597 if (!offer) return false;
598 if (!offers.isParty(offer, actor)) return false;
599
600 if (type === 'Reject') {
601 offers.recordReject(site.slug, offerId, actor);
602 notify(site.slug, { kind: 'offer_rejected', offer: offerId });
603 return true;
604 }
605
606 offers.recordAccept(site.slug, offerId, actor);
607 await maybeCommit(site.slug, offerId); // commits this copy once the tally is complete (§4.2 may refuse)
608 return true;
609}
610
611/**
612 * §4.2 SHOULD: retry the dereference for handshakes left deferred because the
613 * candidate could not be read.
614 *
615 * Waiting for a further activity from a party is not enough: the commit is
616 * triggered by the LAST `Accept`, so if that one has already arrived nothing
617 * will ever poke it again and the handshake would sit until its window closed.
618 * The ward's dashboard polling its own offers queue is this instance's
619 * schedule, exactly as a read settles a lapse (§3.6.3).
620 *
621 * Deliberately not awaited by the read: a poll should render what is true now,
622 * not block on someone else's slow server. A retry that succeeds shows up in
623 * the next poll, which is the same second or two later.
624 */
625export async function retryDeferred(slug) {
626 for (const o of offers.listDeferred(slug)) {
627 await maybeCommit(slug, o.offer_id).catch(() => { /* next poll tries again */ });
628 }
629}
630
631function notify(slug, ev) {
632 try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
633}
634
635export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship, retryDeferred, existingGuardiansOf };
Note: See TracBrowser for help on using the repository browser.