source: Klonkt/src/services/guardianship/handshake.js@ 29eb745

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

Doorslaggevend schuift mee, en de daemons hoeven geen TLS

Drie dingen van Bart (8-8).

  1. DOORSLAGGEVEND IS EEN STAND, GEEN MOMENT -- een fout in wat ik een uur eerder

bouwde. Ik berekende shaer:decisive een keer bij het doorsturen en bevroor het.
Bij vijf guardians staat er dan "je beslist niets", en zodra er een ja bij komt IS
elk van de anderen de doorslag. Precies de stille kant: het scherm zwijgt op het
moment dat het moet spreken. Na elke stem die het besluit open laat krijgen de
overgeblevenen nu een herziene stand, en alleen zij.

  1. DE VOLGPOORT MAG UITGEROLD (Barts akkoord). Zit in deze reeks.
  1. GEEN TLS VOOR DE TESTKUDDE. Barts vraag: waarom via https, gewoon localhost

::1. Terecht -- een certificaat en een publieke hostname toevoegen aan een doos
die ook andermans sites draait, om honderd neptestkinderen te bereiken, is de
verkeerde prijs.

Daarvoor mag de SSRF-poort gericht open: AP_ALLOW_HOSTS, een lijst van precieze
host:poort-paren. Geen "loopback is oke"-vlag. [::1]:3060 opent niet 127.0.0.1,
niet 3061, en niets in het interne netwerk -- want een brede vlag zet de
bescherming uit in een dev-omgeving, en dev-omgevingen worden productie. Leeg
tenzij een beheerder hem vult, en dat is de stand van elke productie-instance.

En het toetst iets dat we toch moesten kunnen: http MET HTTP-signatures, zonder
TLS. Een echt federatiegeval, tot nu toe nergens uitgeprobeerd.

ONDERWEG EEN LATENTE FOUT GEVONDEN. URL.hostname geeft een IPv6-literal met
blokhaken, en net.isIP herkent die vorm niet -- elk IPv6-adres viel door naar de
DNS-tak en strandde daar op ENOTFOUND. Geweigerd, maar per ongeluk en met de
verkeerde reden; isBlockedIp strippte die haken al en verwachtte dus input die
nooit aankwam. Nu wordt een IPv6-literal echt gecontroleerd.

EN EEN SLAAGTEST, want die ontbrak. Al mijn SSRF-toetsen controleerden alleen
weigeringen: de lijst helemaal negeren gaf nul rode toetsen, en de farm zou
stilletjes nooit werken. Nu bewijst er een dat wat op de lijst staat er ook
doorheen komt.

Suite 755/755. Drie mutaties gecontroleerd.

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