source: Klonkt/src/services/guardianship/handshake.js@ 439f095

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

Beschikbaarheid van guardians (FEP-633c 3.6): away, dormant, lapse

De Klonkt-kant van het beschikbaarheidsvoorstel, nagemaakt zoals eerst in de
daemon gevalideerd (shaer-8z7): dezelfde toestanden, dezelfde regels, dezelfde
weigeringen. De spiegel-tests dragen dezelfde namen als de daemon-tests, zodat
drift tussen de twee backends opvalt als een falende test met dezelfde woorden.

De kern is guardianship/availability.js: drie toestanden per (ward, guardian),
met als regel boven alles dat een antwoord alles herstelt, tot en met een
lopende lapse. Elke geverifieerde inbox-activiteit en elke C2S-handeling van
een guardian herstelt hem en annuleert een lapse tegen hem, nog voor er naar de
activiteit gekeken wordt. Bewust achter de handtekening-poort: een ongeverifieerde
bewering oma te zijn mag oma niet wakker maken.

Afwezig komt binnen over beide wegen: S2S als directe note met shaer:away en
endTime van een guardian elders (het gewone geval), en C2S als een guardian
hier zich afmeldt; die note draagt de marker mee naar wards elders en wordt
voor wards op deze instance direct toegepast, want een lokale inbox ontvangt
zijn eigen bezorging niet. Zonder (toekomstig) einde faalt het luid met 400,
precies zoals de daemon weigert.

Slapend volgt alleen uit onbeantwoorde direct geadresseerde verzoeken; de
follow-gating registreert die nu als bewijs. De markering notificeert verplicht
via protocol en de 6-handle, eenmalig op de overgang, centraal bedraad zodat
elke plek waar een promotie kan gebeuren hetzelfde notificeert.

De drempel van 3.5 rekent voortaan over de beschikbare set: de follow-quorums
en de gated settings allebei. De test die het waarom draagt: vijf guardians van
wie twee weg zijn gaven een drempel van drie die de twee levenden nooit haalden;
over de beschikbare set beslissen zij weer.

De lapse loopt over dezelfde draden als de gated settings: een Offer van
shaer:Lapse opent op de server van het kind, Accept/Reject stemt, het venster
loopt altijd vol, en de voltooiing verwijdert de relatie met de
nooit-leeg-grens uit 3.4 als tweede slot eronder. De offers-queue draagt de
lopende lapses en de nieuwe owner-only guardians-queue de beschikbaarheid, in
precies de vorm die de daemon serveert, dus de Shaer-apps van gisteren werken
zonder wijziging.

Changed files:
src/config/database.js

  • tabellen ap_guardian_attention, ap_attention_requests, ap_lapses
  • kolom ap_outbox.away_until

src/services/guardianship/handshake.js

  • Offer van shaer:Lapse (S2S en C2S), lapse-stemmen op Accept/Reject, one-answer op elke C2S-handeling

src/services/guardianship/gated.js

  • tally en voortgang over de beschikbare set; een stem is een antwoord

src/services/guardianship/notes.js

  • awayProps: shaer:away plus endTime op de uitgaande directe note

src/services/guardianship/delivery.js

  • away_until door het directe pad heen

src/services/guardianship/queues.js

  • guardiansCollection; offersCollection draagt de lapses

src/services/guardianship/index.js

  • exports

src/services/ActivityPubService.js

  • one-answer achter de handtekening-poort
  • away-ingest op het mention-pad en het C2S-directe pad
  • dormancy-bewijs op de follow-gating; quorum over de beschikbare set
  • de notificatieplicht van 3.6.2, een keer bedraad
  • buildReplyNote draagt awayProps

src/routes/activitypub.js

  • owner-only route /queues/guardians

src/routes/guardian.js

  • dashboard-besluit is een antwoord; quorum over de beschikbare set

src/services/guardianship/relations.js

  • guardians-queue aangekondigd in shaer:queues

test/activitypub-as2.test.js

  • guardians toegevoegd aan de queue-sleutels

New file:
src/services/guardianship/availability.js

  • de toestandsmachine, de lapse en de endTime-parser

test/availability.test.js

  • veertien spiegel-tests van de daemon, tot en met de volle lapse-flow over de S2S-draad en het vijf-guardians-rekenvoorbeeld

remarks: de PWA toont de beschikbaarheid nog niet (chips in het paneel per
kind en een lapse-kaart komen apart); de echte kruis-implementatie-testbank
blijft open op shaer-6d9. Klonkt heeft geen pinbare klok zoals de daemon; de
tests dateren bewijs terug in plaats van de tijd vooruit te zetten, en dat
staat er als kanttekening bij. Niet uitgerold.

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

  • Property mode set to 100644
File size: 20.0 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 } 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/** Apply the local side of a commit: the ward writes its guardian, the
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) {
90 const wardSlug = deps.localSlug(offer.ward_uri);
91 const candSlug = deps.localSlug(offer.candidate_uri);
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 });
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`);
104 if (done) { applyCommitLocally(done); notify(slug, { kind: 'committed', ward: done.ward_uri, guardian: done.candidate_uri }); }
105 return done;
106}
107
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
187// ── C2S: a LOCAL party acts (PWA, Berichten, or the Shaer app outbox) ──────
188
189/**
190 * Handle a guardianship activity POSTed to the local outbox. Returns null when
191 * it is not ours, else {status, ...} for the route.
192 */
193export async function handleOutbox(site, activity) {
194 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
195 if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return null;
196 const me = deps.selfId(site.slug);
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 */ }
202
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
212 // ── Offer: the local site is the guardian-candidate. ───────────────────
213 if (type === 'Offer') {
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) {
221 const id = `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
222 const wardSlug = deps.localSlug(lp.ward);
223 if (wardSlug) {
224 const r = availability.openLapse({ id, wardSlug, wardUri: lp.ward, target: lp.target, openedBy: me, now: Date.now() });
225 if (r.error) return { status: r.error === 'not_in_available_set' ? 403 : 409, error: r.error };
226 deps.deliverTo(site, lp.target, { id, type: 'Offer', actor: me, to: [lp.target], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } }).catch(() => { /* best-effort */ });
227 notify(wardSlug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set });
228 return { status: 202, id, url: id, 'shaer:set': r.set, 'shaer:threshold': r.threshold };
229 }
230 const offer = { id, type: 'Offer', actor: me, to: [lp.ward], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } };
231 const delivered = await fanout(site, [lp.ward], offer);
232 return { status: 202, id, url: id, delivered };
233 }
234 const rel = parseRelationship(activity.object);
235 if (!rel) return null;
236 if (rel.candidate !== me) return { status: 403, error: 'only_the_candidate_offers' }; // fixed initiator (§3.1)
237 if (relations.listGuardians(site.slug).length) return { status: 403, error: 'a_ward_cannot_guard' }; // §1
238 const existing = await existingGuardiansOf(rel.ward);
239 const offerId = `${me}/offers/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
240 offers.start(site.slug, {
241 offerId, ward: rel.ward, candidate: me, existingGuardians: existing,
242 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(me),
243 });
244 // The Offer IS the candidate's agreement to serve: record it as the
245 // candidate's accept. So a FREE ward commits on its own single accept (no
246 // second guardian to co-approve yet); once it IS a ward, adding another
247 // guardian still needs an existing guardian to co-accept.
248 offers.recordAccept(site.slug, offerId, me);
249 // Addressed to the ward AND every existing guardian (§3.1.1).
250 const recipients = [rel.ward, ...existing];
251 const delivered = await fanout(site, recipients, offerActivity(offerId, rel.ward, me, recipients));
252 notify(site.slug, { kind: 'offer_sent', ward: rel.ward });
253 return { status: 202, id: offerId, url: offerId, delivered };
254 }
255
256 // ── Accept / Reject: the local site is a party answering an offer. ─────
257 const offerId = idOf(activity.object);
258 if (!offerId) return { status: 400, error: 'missing_offer' };
259 // A lapse vote over C2S (§3.6.3): the same Accept/Reject wire the offers
260 // and gated follows use, which is exactly why the Shaer clients need no
261 // new verbs for it.
262 if (availability.getLapse(offerId)) {
263 const r = availability.lapseVote(offerId, me, type === 'Accept', Date.now());
264 if (r && r.error) return { status: r.error === 'not_in_set' ? 403 : 409, error: r.error };
265 return { status: 202, id: offerId, url: offerId, 'shaer:outcome': 'open', 'shaer:accepts': r.accepts, 'shaer:threshold': r.threshold };
266 }
267 let offer = offers.getOffer(site.slug, offerId);
268 if (!offer) return { status: 404, error: 'no_such_offer' };
269 const others = offers.parties(offer).filter((p) => p !== me);
270
271 if (type === 'Reject') {
272 offers.recordReject(site.slug, offerId, me);
273 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Reject', actor: me, to: others, object: offerId });
274 notify(site.slug, { kind: 'offer_rejected', offer: offerId });
275 return { status: 202, id: offerId, url: offerId };
276 }
277
278 // Accept: record my accept, broadcast it to the other parties, and commit
279 // this copy if the tally is now complete (order-independent, §3.1.3).
280 offers.recordAccept(site.slug, offerId, me);
281 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId });
282 const done = maybeCommit(site.slug, offerId);
283 return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) };
284}
285
286// ── S2S: a REMOTE party's activity arrives in a local inbox ────────────────
287
288/**
289 * Handle an inbound guardianship activity for the local site `site` (the inbox
290 * owner). Returns true when consumed.
291 */
292export async function handleInbox(site, activity) {
293 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
294 if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return false;
295 if (type === 'Undo') return applyInboundUndo(site, activity);
296 const me = deps.selfId(site.slug);
297 const actor = idOf(activity.actor);
298
299 // §5.6: a guardian proposes a gated setting for THIS ward. The ward's server
300 // tallies and enforces, so the decision lands here, not on the proposer.
301 if (type === 'Offer') {
302 const gs = gated.parseGatedSetting(activity.object);
303 if (gs) {
304 if (gs.ward !== me) return false; // not our ward
305 gated.rememberGatedOffer(idOf(activity), site.slug, gs.feature, gs.value);
306 // The proposer's Offer carries its own agreement (§3.1's one-step clause).
307 const r = gated.recordGatedVote(site.slug, gs.feature, actor, gs.value);
308 notify(site.slug, { kind: 'gated_setting', feature: gs.feature, value: gs.value, state: r.state });
309 return true;
310 }
311 // §3.6.3: a co-guardian proposes releasing a dormant guardian of THIS
312 // ward. The ward's server opens, tallies and (after the full window)
313 // executes, exactly as it does for the gated settings above.
314 const lp = availability.parseLapse(activity.object);
315 if (lp) {
316 if (lp.ward !== me) return false; // not our ward
317 const id = idOf(activity) || `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
318 const r = availability.openLapse({ id, wardSlug: site.slug, wardUri: me, target: lp.target, openedBy: actor, now: Date.now() });
319 if (r.error) {
320 notify(site.slug, { kind: 'lapse_refused', reason: r.error, target: lp.target });
321 return true; // consumed: the refusal is the answer
322 }
323 // The target is notified like any dormancy marking (§3.6.2): in
324 // protocol (a copy of the Offer, so one answer can cancel it) AND the
325 // §6 handle, which for a committed guardian is its inbox — the same
326 // door this delivery knocks on.
327 deps.deliverTo(site, lp.target, activity).catch(() => { /* best-effort */ });
328 notify(site.slug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set });
329 return true;
330 }
331 const rel = parseRelationship(activity.object);
332 if (!rel) return false;
333 // I must be a party: the ward, or one of the existing guardians in `to`.
334 const recipients = arr(activity.to);
335 const existing = recipients.filter((u) => u !== rel.ward);
336 if (rel.ward !== me && !existing.includes(me)) return false;
337 offers.start(site.slug, {
338 offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing,
339 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(rel.candidate),
340 });
341 // The Offer carries the candidate's agreement (see the C2S side): record it
342 // so this copy's tally matches — a free ward then commits on its own accept.
343 offers.recordAccept(site.slug, idOf(activity), rel.candidate);
344 notify(site.slug, { kind: rel.ward === me ? 'offer_received' : 'offer_for_ward', ward: rel.ward, candidate: rel.candidate });
345 return true;
346 }
347
348 // Accept / Reject of an offer we (also) track.
349 const offerId = idOf(activity.object);
350 // §5.6: a fellow guardian answering a gated-setting proposal. The Accept only
351 // references the offer, so the value comes from the proposal we stored. A
352 // Reject is a vote for the opposite, not a shrug: it is still an answer.
353 const gsOffer = gated.recallGatedOffer(offerId);
354 if (gsOffer && gsOffer.slug === site.slug) {
355 const value = type === 'Accept' ? !!gsOffer.value : !gsOffer.value;
356 const r = gated.recordGatedVote(site.slug, gsOffer.feature, actor, value);
357 notify(site.slug, { kind: 'gated_setting', feature: gsOffer.feature, value, state: r.state });
358 return true;
359 }
360 // §3.6.3: a set member answering a running lapse. Irreversible, so even a
361 // full tally leaves it open until the window closes (§3.5); the completion
362 // happens lazily on reads (queues) once the window has run.
363 if (availability.getLapse(offerId)) {
364 const r = availability.lapseVote(offerId, actor, type === 'Accept', Date.now());
365 notify(site.slug, { kind: 'lapse_vote', lapse: offerId, by: actor, state: r && !r.error ? 'recorded' : (r && r.error) || 'refused' });
366 return true;
367 }
368 let offer = offers.getOffer(site.slug, offerId);
369 if (!offer) return false;
370 if (!offers.isParty(offer, actor)) return false;
371
372 if (type === 'Reject') {
373 offers.recordReject(site.slug, offerId, actor);
374 notify(site.slug, { kind: 'offer_rejected', offer: offerId });
375 return true;
376 }
377
378 offers.recordAccept(site.slug, offerId, actor);
379 maybeCommit(site.slug, offerId); // commits this copy once the tally is complete
380 return true;
381}
382
383function notify(slug, ev) {
384 try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
385}
386
387export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship };
Note: See TracBrowser for help on using the repository browser.