source: Klonkt/src/services/guardianship/handshake.js@ 6d5ce0c

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

Op deze machine gedraagt elke Klonkt zich alsof hij ergens anders staat

Robins regel, en de reden staat in de logs van deze week. Twee bugs kwamen uit
hetzelfde patroon: een tweede, lokale route die een kapotte externe route
verborg. De Undo bereikte een kind op dezelfde machine nooit, en het gated
voorstel was een maand stuk over de lijn terwijl de sluiproute de stem hier
direct opschreef en het dashboard er prima uitzag.

Samenlokatie is nu een kwestie van TRANSPORT, geen beslispad. deliverToActor
geeft een activiteit voor een lokale ontvanger door aan dezelfde inbox-handler
die de lijn zou bereiken, inclusief de controle of de ondertekenaar de afzender
is. Alles daarboven weet het verschil niet meer, en dus draait elke deployment
dezelfde code.

Directe berichten deden dat nog niet. Die zochten een inbox op en POSTten
erheen, dus een bericht aan een kind op deze machine ging naar onze eigen
hostnaam en terug, of nergens heen. Nu nemen ze dezelfde loopback.

En de kern van het probleem zat in de inbox zelf: "van onze eigen actor" werd
gelezen als "van wie dan ook op deze machine". Daardoor werd elk bericht tussen
twee sites op een instantie met een 202 aangenomen en daarna weggegooid: geen
vermelding, geen afwezigheid, geen hulpvraag. Buren zijn niet wij.

Daarmee konden twee met de hand geschreven sluiproutes weg: het lokaal
wegschrijven van een afwezigheid in de C2S-outbox en in de Guardian PWA. Die
bestonden alleen omdat de echte weg niet aankwam.

Changed files:
src/services/ActivityPubService.js

  • isLocalActor is nu "de eigenaar van deze inbox", niet "iemand op deze host"
  • localActor(): het actordocument van een site die wij hosten, uit onze eigen database in plaats van via een verzoek aan onszelf
  • de lokale sluiproute voor afwezigheid in de C2S-outbox is weg

src/services/guardianship/delivery.js

  • een lokale ontvanger krijgt het bericht via de loopback, de rest per inbox
  • een lokale ontvanger wordt lokaal opgezocht, dus hij valt niet stilletjes uit de ontvangerslijst als het verzoek aan onszelf mislukt

src/routes/guardian.js

  • /api/away schrijft niets meer zelf weg: het bericht doet het werk

src/services/guardianship/handshake.js

  • commentaar bijgewerkt bij de plekken die wel lokaal mogen schrijven

New file:
test/co-location.test.js

  • hetzelfde scenario twee keer, alles-lokaal en alles-extern, met de eis dat de eindtoestand gelijk is
  • een afwezigheid via de loopback en dezelfde brief van de lijn gelezen
  • de loopback weigert nog steeds een afzender die niet klopt
  • een bewaking die faalt zodra er een nieuwe lokale sluiproute in een beslispad verschijnt, met de legitieme uitzonderingen bij naam

remarks: 329 tests groen, en de server start. Twee dingen om te weten voor de
uitrol: sites op een instantie die elkaar volgen zien elkaars berichten nu wel
(dat is wat volgen betekent, maar het is zichtbaar anders), en gated follows
lopen voor een lokale guardian nog steeds via de gedeelde database. Die laatste
staat met naam en toenaam in de bewakingstest, zodat hij niet vergeten wordt.

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

  • Property mode set to 100644
File size: 22.0 KB
RevLine 
[6b5d7da]1/**
[780a7c6]2 * Guardianship (FEP-633c §3) — the adoption handshake, multi-party and
3 * distributed across instances.
[6b5d7da]4 *
[780a7c6]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).
[6b5d7da]13 *
[780a7c6]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.
[6b5d7da]17 */
18import { isGuardianRelationship, GUARDIAN_RELATIONSHIP_COMPACT } from './context.js';
[780a7c6]19import * as offers from './offers.js';
[6b5d7da]20import * as relations from './relations.js';
[65abc85]21import * as gated from './gated.js';
[6eab7e9]22import * as availability from './availability.js';
[6b5d7da]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));
[780a7c6]28const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
[6b5d7da]29
[6c152a5]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
[6b5d7da]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
[780a7c6]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/** Apply the local side of a commit: the ward writes its guardian, the
[fcd6964]86 * candidate writes its ward. Each instance writes only what it hosts.
87 * other_handle is the human @handle for display (from the offer); the FEP
88 * escalation handle (candidate inbox) lives on the offer row, not here. */
89function applyCommitLocally(offer) {
[780a7c6]90 const wardSlug = deps.localSlug(offer.ward_uri);
91 const candSlug = deps.localSlug(offer.candidate_uri);
[fcd6964]92 if (wardSlug) relations.commitGuardianForWard(wardSlug, offer.candidate_uri, { handle: offer.candidate_handle, offerId: offer.offer_id });
93 if (candSlug) relations.commitWardForGuardian(candSlug, offer.ward_uri, { handle: offer.ward_handle, offerId: offer.offer_id });
[780a7c6]94}
95
96/** Commit this local copy of the offer when the tally is complete (ward +
97 * candidate + ≥1 existing guardian, §3.1.2). The handle is the candidate's
98 * inbox (§6 minimum); the commit is order-independent, so whichever accept
99 * lands last triggers it on every copy. */
100function maybeCommit(slug, offerId) {
101 const offer = offers.getOffer(slug, offerId);
102 if (!offer || !offers.readyToCommit(offer)) return null;
103 const done = offers.commit(slug, offerId, `${offer.candidate_uri}/inbox`);
[fcd6964]104 if (done) { applyCommitLocally(done); notify(slug, { kind: 'committed', ward: done.ward_uri, guardian: done.candidate_uri }); }
[780a7c6]105 return done;
106}
107
[6c152a5]108/**
109 * End a guardianship from the local guardian's side and let it travel (§3.2).
110 *
111 * One path for both callers: the button in the Guardian PWA and an `Undo` a
112 * Guardian app POSTs to its own outbox. Addressed like the Offer that started
113 * it (§3.1.1): the ward, and every other guardian, so no copy is left behind
114 * believing the relation still stands.
115 */
116export async function endGuardianship(site, wardUri) {
117 const me = deps.selfId(site.slug);
118 if (!relations.getRelation(site.slug, 'guardian', wardUri)) return { status: 404, error: 'not_my_ward' };
119 const set = await existingGuardiansOf(wardUri);
120 const others = set.filter((g) => g !== me);
121 // Only a set we actually read counts as proof. A remote ward whose server is
122 // down reads as an empty set; refusing on that would trap the guardian, and
123 // the ward's server checks again on arrival anyway.
124 if (set.length && others.length === 0) return { status: 409, error: 'would_emancipate' };
125 const recipients = [wardUri, ...others];
126 const undo = {
127 id: `${me}/undo/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`,
128 type: 'Undo', actor: me, to: recipients,
129 object: { type: 'Relationship', subject: wardUri, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: me },
130 };
131 const delivered = await fanout(site, recipients, undo);
132 relations.removeRelation(site.slug, 'guardian', wardUri);
133 // A ward we host ourselves never receives its own delivery: an inbox on this
134 // machine is not reachable over HTTP from this machine (and should not be).
135 // The commit path has the same shape and solves it the same way — each
136 // instance writes what it hosts (applyCommitLocally).
137 const wardSlug = deps.localSlug(wardUri);
138 if (wardSlug) dropGuardianFromWard(wardSlug, deps.selfId(site.slug));
139 notify(site.slug, { kind: 'guardianship_ended', ward: wardUri, delivered });
140 return { status: 202, delivered, guardiansLeft: others.length };
141}
142
143/**
144 * The ward's side of an ended guardianship: drop that guardian, unless doing so
145 * would empty the set. §3.3 only permits this while more than one remains;
146 * emptying it is emancipation (§3.4) and no single party decides that.
147 */
148function dropGuardianFromWard(wardSlug, guardianUri) {
149 const set = relations.listGuardians(wardSlug).map((r) => r.other_uri);
150 if (!set.includes(guardianUri)) return false; // already gone: an Undo is idempotent
151 if (set.length <= 1) {
152 notify(wardSlug, { kind: 'guardianship_end_refused', guardian: guardianUri, reason: 'would_emancipate' });
153 return false;
154 }
155 relations.removeRelation(wardSlug, 'ward', guardianUri);
156 notify(wardSlug, { kind: 'guardian_left', guardian: guardianUri });
157 return true;
158}
159
160/** The receiving side of that Undo. Returns true when consumed. */
161function applyInboundUndo(site, activity) {
162 const rel = parseUndoRelationship(activity);
163 if (!rel) return false;
164 const me = deps.selfId(site.slug);
165 const actor = idOf(activity.actor);
166 const ward = rel.ward;
167 const guardian = rel.candidate; // in an Undo the Relationship's object is the leaving guardian
168
169 if (ward === me) {
170 // I am the ward. Only the guardian itself may end its own relation here;
171 // the ward-co-signed variant of §3.2 needs a second signature and is not
172 // built, so it is refused rather than half-honoured.
173 if (actor !== guardian) return false;
174 dropGuardianFromWard(site.slug, guardian);
175 return true;
176 }
177
178 // I am one of the other guardians: nothing of mine changes, but being left
179 // as one of fewer is exactly the kind of thing a guardian should hear about.
180 if (relations.getRelation(site.slug, 'guardian', ward)) {
181 notify(site.slug, { kind: 'coguardian_left', ward, guardian });
182 return true;
183 }
184 return false;
185}
186
[780a7c6]187// ── C2S: a LOCAL party acts (PWA, Berichten, or the Shaer app outbox) ──────
[6b5d7da]188
189/**
[780a7c6]190 * Handle a guardianship activity POSTed to the local outbox. Returns null when
191 * it is not ours, else {status, ...} for the route.
[6b5d7da]192 */
193export async function handleOutbox(site, activity) {
194 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
[6c152a5]195 if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return null;
[780a7c6]196 const me = deps.selfId(site.slug);
[6eab7e9]197 // One answer restores everything (§3.6): any C2S activity from this actor
198 // is that answer, for every local ward it guards. Runs before anything is
199 // even looked at, so the target of a running lapse cancels it by doing
200 // anything at all — including trying to vote on it.
201 try { availability.oneAnswer(me, Date.now()); } catch { /* never load-bearing */ }
[6b5d7da]202
[6c152a5]203 // ── Undo: a guardian ends its own guardianship (§3.2). Same path as the
204 // button in the Guardian PWA, so an app and the dashboard cannot drift.
205 if (type === 'Undo') {
206 const rel = parseUndoRelationship(activity);
207 if (!rel) return null;
208 if (rel.candidate !== me) return { status: 403, error: 'not_your_relation' };
209 return endGuardianship(site, rel.ward);
210 }
211
[780a7c6]212 // ── Offer: the local site is the guardian-candidate. ───────────────────
[6b5d7da]213 if (type === 'Offer') {
[6eab7e9]214 // §3.6.3 over C2S: a guardian here proposes releasing a dormant
215 // co-guardian. A ward we host opens locally; a remote ward gets the
216 // proposal delivered, because the ward's server is the one that tallies
217 // and enforces (the §5.6 line: a guardian next door must not have more
218 // say than one far away).
219 const lp = availability.parseLapse(activity.object);
220 if (lp) {
[6d5ce0c]221 // ONE path (Robins regel, 29-7): the ward's server opens, tallies and
222 // enforces, wherever it lives. A local ward is reached by the same
223 // deliverTo, which loops back into the inbox handler; co-location is a
224 // transport detail and never a shortcut past the decision.
[6eab7e9]225 const id = `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
226 const offer = { id, type: 'Offer', actor: me, to: [lp.ward], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } };
227 const delivered = await fanout(site, [lp.ward], offer);
228 return { status: 202, id, url: id, delivered };
229 }
[6b5d7da]230 const rel = parseRelationship(activity.object);
[780a7c6]231 if (!rel) return null;
232 if (rel.candidate !== me) return { status: 403, error: 'only_the_candidate_offers' }; // fixed initiator (§3.1)
233 if (relations.listGuardians(site.slug).length) return { status: 403, error: 'a_ward_cannot_guard' }; // §1
234 const existing = await existingGuardiansOf(rel.ward);
235 const offerId = `${me}/offers/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
236 offers.start(site.slug, {
237 offerId, ward: rel.ward, candidate: me, existingGuardians: existing,
238 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(me),
239 });
[3ffbedd]240 // The Offer IS the candidate's agreement to serve: record it as the
241 // candidate's accept. So a FREE ward commits on its own single accept (no
242 // second guardian to co-approve yet); once it IS a ward, adding another
243 // guardian still needs an existing guardian to co-accept.
244 offers.recordAccept(site.slug, offerId, me);
[780a7c6]245 // Addressed to the ward AND every existing guardian (§3.1.1).
246 const recipients = [rel.ward, ...existing];
247 const delivered = await fanout(site, recipients, offerActivity(offerId, rel.ward, me, recipients));
[6b5d7da]248 notify(site.slug, { kind: 'offer_sent', ward: rel.ward });
[780a7c6]249 return { status: 202, id: offerId, url: offerId, delivered };
[6b5d7da]250 }
251
[780a7c6]252 // ── Accept / Reject: the local site is a party answering an offer. ─────
253 const offerId = idOf(activity.object);
254 if (!offerId) return { status: 400, error: 'missing_offer' };
[6eab7e9]255 // A lapse vote over C2S (§3.6.3): the same Accept/Reject wire the offers
256 // and gated follows use, which is exactly why the Shaer clients need no
257 // new verbs for it.
258 if (availability.getLapse(offerId)) {
259 const r = availability.lapseVote(offerId, me, type === 'Accept', Date.now());
260 if (r && r.error) return { status: r.error === 'not_in_set' ? 403 : 409, error: r.error };
261 return { status: 202, id: offerId, url: offerId, 'shaer:outcome': 'open', 'shaer:accepts': r.accepts, 'shaer:threshold': r.threshold };
262 }
[780a7c6]263 let offer = offers.getOffer(site.slug, offerId);
264 if (!offer) return { status: 404, error: 'no_such_offer' };
265 const others = offers.parties(offer).filter((p) => p !== me);
266
267 if (type === 'Reject') {
268 offers.recordReject(site.slug, offerId, me);
269 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Reject', actor: me, to: others, object: offerId });
270 notify(site.slug, { kind: 'offer_rejected', offer: offerId });
271 return { status: 202, id: offerId, url: offerId };
[6b5d7da]272 }
[780a7c6]273
274 // Accept: record my accept, broadcast it to the other parties, and commit
275 // this copy if the tally is now complete (order-independent, §3.1.3).
276 offers.recordAccept(site.slug, offerId, me);
277 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId });
278 const done = maybeCommit(site.slug, offerId);
279 return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) };
[6b5d7da]280}
281
[780a7c6]282// ── S2S: a REMOTE party's activity arrives in a local inbox ────────────────
[6b5d7da]283
284/**
[780a7c6]285 * Handle an inbound guardianship activity for the local site `site` (the inbox
286 * owner). Returns true when consumed.
[6b5d7da]287 */
288export async function handleInbox(site, activity) {
289 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
[6c152a5]290 if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return false;
291 if (type === 'Undo') return applyInboundUndo(site, activity);
[780a7c6]292 const me = deps.selfId(site.slug);
[6b5d7da]293 const actor = idOf(activity.actor);
294
[65abc85]295 // §5.6: a guardian proposes a gated setting for THIS ward. The ward's server
296 // tallies and enforces, so the decision lands here, not on the proposer.
[6b5d7da]297 if (type === 'Offer') {
[65abc85]298 const gs = gated.parseGatedSetting(activity.object);
299 if (gs) {
[88d7c8f]300 const offerId = idOf(activity);
301 // ── I am the WARD: record, tally, and forward to the other guardians.
302 if (gs.ward === me) {
303 gated.rememberGatedOffer(offerId, site.slug, gs.feature, gs.value);
304 // The proposer's Offer carries its own agreement (§3.1's one-step clause).
305 const r = gated.recordGatedVote(site.slug, gs.feature, actor, gs.value);
306 // The forward is the leg that was missing. A proposal addressed to the
307 // ward's server reaches only the proposer and the ward; the other
308 // guardians never learn it exists, so a threshold of two can never be
309 // met and every proposal expires unanswered. The ward's server is the
310 // one that knows the authoritative guardian list, which is exactly why
311 // §5.3 forwards a gated follow from here too.
312 if (r.state === 'open') {
313 for (const g of relations.listGuardians(site.slug).map((x) => x.other_uri)) {
314 if (g === actor) continue; // the proposer already answered
[c8e03c6]315 // The forward goes out AS THE WARD, because the ward's key signs
316 // it. Keeping the proposer in `actor` made every receiver answer
317 // 401 signer mismatch, and rightly so: the body claimed one author
318 // and the signature proved another. §5.3 forwards a gated follow
319 // the same way. Who proposed it rides along separately, for the
320 // guardian's screen.
[88d7c8f]321 deps.deliverTo(site, g, {
[c8e03c6]322 id: offerId, type: 'Offer', actor: me, to: [g], object: activity.object,
323 'shaer:proposer': actor,
[88d7c8f]324 }).catch(() => { /* the delivery queue retries */ });
325 }
326 } else {
327 gated.clearGatedReviews(offerId); // settled at once: nothing left to ask
328 }
329 notify(site.slug, { kind: 'gated_setting', feature: gs.feature, value: gs.value, state: r.state });
330 return true;
331 }
332 // ── I am one of the GUARDIANS: the forwarded copy. Store it so this
333 // guardian can answer; the answer goes back to the ward, which tallies.
334 if (relations.getRelation(site.slug, 'guardian', gs.ward)) {
335 const wardDoc = await deps.fetchActor(gs.ward).catch(() => null);
336 gated.recordGatedReview(site.slug, {
337 id: offerId, wardUri: gs.ward, wardInbox: wardDoc && wardDoc.inbox,
[c8e03c6]338 // A forward is signed by the ward, so `actor` is the ward; the
339 // guardian who opened it travels in shaer:proposer.
340 proposer: (typeof activity['shaer:proposer'] === 'string' ? activity['shaer:proposer'] : actor),
341 feature: gs.feature, value: gs.value,
[88d7c8f]342 });
343 notify(site.slug, { kind: 'gated_review', feature: gs.feature, value: gs.value, ward: gs.ward });
344 return true;
345 }
346 return false; // not our ward, and not a ward we guard
[65abc85]347 }
[6eab7e9]348 // §3.6.3: a co-guardian proposes releasing a dormant guardian of THIS
349 // ward. The ward's server opens, tallies and (after the full window)
350 // executes, exactly as it does for the gated settings above.
351 const lp = availability.parseLapse(activity.object);
352 if (lp) {
353 if (lp.ward !== me) return false; // not our ward
354 const id = idOf(activity) || `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
355 const r = availability.openLapse({ id, wardSlug: site.slug, wardUri: me, target: lp.target, openedBy: actor, now: Date.now() });
356 if (r.error) {
357 notify(site.slug, { kind: 'lapse_refused', reason: r.error, target: lp.target });
358 return true; // consumed: the refusal is the answer
359 }
360 // The target is notified like any dormancy marking (§3.6.2): in
361 // protocol (a copy of the Offer, so one answer can cancel it) AND the
362 // §6 handle, which for a committed guardian is its inbox — the same
363 // door this delivery knocks on.
364 deps.deliverTo(site, lp.target, activity).catch(() => { /* best-effort */ });
365 notify(site.slug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set });
366 return true;
367 }
[6b5d7da]368 const rel = parseRelationship(activity.object);
[780a7c6]369 if (!rel) return false;
370 // I must be a party: the ward, or one of the existing guardians in `to`.
371 const recipients = arr(activity.to);
372 const existing = recipients.filter((u) => u !== rel.ward);
373 if (rel.ward !== me && !existing.includes(me)) return false;
374 offers.start(site.slug, {
375 offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing,
376 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(rel.candidate),
377 });
[3ffbedd]378 // The Offer carries the candidate's agreement (see the C2S side): record it
379 // so this copy's tally matches — a free ward then commits on its own accept.
380 offers.recordAccept(site.slug, idOf(activity), rel.candidate);
[780a7c6]381 notify(site.slug, { kind: rel.ward === me ? 'offer_received' : 'offer_for_ward', ward: rel.ward, candidate: rel.candidate });
[6b5d7da]382 return true;
383 }
384
[780a7c6]385 // Accept / Reject of an offer we (also) track.
386 const offerId = idOf(activity.object);
[65abc85]387 // §5.6: a fellow guardian answering a gated-setting proposal. The Accept only
388 // references the offer, so the value comes from the proposal we stored. A
389 // Reject is a vote for the opposite, not a shrug: it is still an answer.
390 const gsOffer = gated.recallGatedOffer(offerId);
391 if (gsOffer && gsOffer.slug === site.slug) {
392 const value = type === 'Accept' ? !!gsOffer.value : !gsOffer.value;
393 const r = gated.recordGatedVote(site.slug, gsOffer.feature, actor, value);
394 notify(site.slug, { kind: 'gated_setting', feature: gsOffer.feature, value, state: r.state });
395 return true;
396 }
[6eab7e9]397 // §3.6.3: a set member answering a running lapse. Irreversible, so even a
398 // full tally leaves it open until the window closes (§3.5); the completion
399 // happens lazily on reads (queues) once the window has run.
400 if (availability.getLapse(offerId)) {
401 const r = availability.lapseVote(offerId, actor, type === 'Accept', Date.now());
402 notify(site.slug, { kind: 'lapse_vote', lapse: offerId, by: actor, state: r && !r.error ? 'recorded' : (r && r.error) || 'refused' });
403 return true;
404 }
[780a7c6]405 let offer = offers.getOffer(site.slug, offerId);
406 if (!offer) return false;
407 if (!offers.isParty(offer, actor)) return false;
408
409 if (type === 'Reject') {
410 offers.recordReject(site.slug, offerId, actor);
411 notify(site.slug, { kind: 'offer_rejected', offer: offerId });
412 return true;
[6b5d7da]413 }
[780a7c6]414
415 offers.recordAccept(site.slug, offerId, actor);
416 maybeCommit(site.slug, offerId); // commits this copy once the tally is complete
[6b5d7da]417 return true;
418}
419
420function notify(slug, ev) {
421 try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
422}
423
[6c152a5]424export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship };
Note: See TracBrowser for help on using the repository browser.