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

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

guardianship: §1 is flat in beide richtingen, ook bij accept

Het Offer-pad belet een ward om te offeren (a_ward_cannot_guard), maar het
Accept-pad kende geen spiegelbeeld: een actor die al wards heeft kon een
guardianship-Offer accepteren en werd zo ward én guardian tegelijk. §1 eist
flatheid in beide richtingen.

De fout zou zich bovendien niet melden. actorProps() kiest met een if/else
één rol en publiceert dan shaer:guardians terwijl shaer:isGuardian wegvalt:
lokaal blijven escalaties van de eigen wards binnenkomen, terwijl elke
remote §4-check de actor als malformed leest en laat vallen. Een ward die
denkt bewaakt te zijn en het niet is, stil aan beide kanten.

Alleen de accept van de ward kan die toestand maken, dus de kandidaat en de
bestaande guardians lopen er ongemoeid langs.

721 tests groen. Controleproef gedraaid: zonder de controle wordt de nieuwe
test rood en valt ook 'a guardian leaving sends an Undo (§3.2)' om, doordat
de actor dan werkelijk ward wordt.

Refs shaer-gk2; stable (1.6.0) heeft dezelfde omissie, zie shaer-pmo.

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

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