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

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

Het doorgestuurde voorstel werd geweigerd: verkeerde afzender

Robin meldde dat stap 2 niet gebeurt, en beta's log zegt waarom:

[AP] guardianship Offer got 401 from https://boiert.eu/ap/users/opie/inbox
[AP] guardianship Offer got 401 from https://boiert.eu/ap/users/boiert/inbox

Het doorsturen werkt dus wel, maar de ontvangers weigeren het, en terecht. Ik
stuurde door met de voorsteller in actor terwijl de sleutel van het kind
ondertekent. Het lichaam beweerde de ene afzender, de handtekening bewees de
andere: signer mismatch, 401, precies wat die controle hoort te doen.

5.3 doet het al goed en had het voorbeeld moeten zijn: een gated follow wordt
doorgestuurd ALS HET KIND. Nu deze ook, met de voorsteller in shaer:proposer
zodat het scherm van de guardian nog steeds de juiste naam toont.

En de test die dit had moeten vangen: die controleerde DAT er doorgestuurd
werd, niet namens wie. Hij stond groen terwijl er niets aankwam. Nu controleert
hij de afzender en de proposer, en hij faalt op de oude code.

Changed files:
src/services/guardianship/handshake.js

  • doorsturen met actor = het kind, plus shaer:proposer
  • de ontvanger leest de voorsteller uit shaer:proposer

src/routes/guardian.js

  • hetzelfde op het lokale pad: ondertekenen en beweren moeten kloppen

test/gated-settings.test.js

  • de afzender van het doorgestuurde voorstel is het kind; de proposer reist apart mee en komt op het scherm terecht

remarks: 322 tests groen. De 401's die in de bezorgwachtrij staan dragen nog de
oude vorm en blijven falen tot ze opgeven; een nieuw voorstel is de weg vooruit.

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

  • Property mode set to 100644
File size: 22.4 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 const offerId = idOf(activity);
305 // ── I am the WARD: record, tally, and forward to the other guardians.
306 if (gs.ward === me) {
307 gated.rememberGatedOffer(offerId, site.slug, gs.feature, gs.value);
308 // The proposer's Offer carries its own agreement (§3.1's one-step clause).
309 const r = gated.recordGatedVote(site.slug, gs.feature, actor, gs.value);
310 // The forward is the leg that was missing. A proposal addressed to the
311 // ward's server reaches only the proposer and the ward; the other
312 // guardians never learn it exists, so a threshold of two can never be
313 // met and every proposal expires unanswered. The ward's server is the
314 // one that knows the authoritative guardian list, which is exactly why
315 // §5.3 forwards a gated follow from here too.
316 if (r.state === 'open') {
317 for (const g of relations.listGuardians(site.slug).map((x) => x.other_uri)) {
318 if (g === actor) continue; // the proposer already answered
319 // The forward goes out AS THE WARD, because the ward's key signs
320 // it. Keeping the proposer in `actor` made every receiver answer
321 // 401 signer mismatch, and rightly so: the body claimed one author
322 // and the signature proved another. §5.3 forwards a gated follow
323 // the same way. Who proposed it rides along separately, for the
324 // guardian's screen.
325 deps.deliverTo(site, g, {
326 id: offerId, type: 'Offer', actor: me, to: [g], object: activity.object,
327 'shaer:proposer': actor,
328 }).catch(() => { /* the delivery queue retries */ });
329 }
330 } else {
331 gated.clearGatedReviews(offerId); // settled at once: nothing left to ask
332 }
333 notify(site.slug, { kind: 'gated_setting', feature: gs.feature, value: gs.value, state: r.state });
334 return true;
335 }
336 // ── I am one of the GUARDIANS: the forwarded copy. Store it so this
337 // guardian can answer; the answer goes back to the ward, which tallies.
338 if (relations.getRelation(site.slug, 'guardian', gs.ward)) {
339 const wardDoc = await deps.fetchActor(gs.ward).catch(() => null);
340 gated.recordGatedReview(site.slug, {
341 id: offerId, wardUri: gs.ward, wardInbox: wardDoc && wardDoc.inbox,
342 // A forward is signed by the ward, so `actor` is the ward; the
343 // guardian who opened it travels in shaer:proposer.
344 proposer: (typeof activity['shaer:proposer'] === 'string' ? activity['shaer:proposer'] : actor),
345 feature: gs.feature, value: gs.value,
346 });
347 notify(site.slug, { kind: 'gated_review', feature: gs.feature, value: gs.value, ward: gs.ward });
348 return true;
349 }
350 return false; // not our ward, and not a ward we guard
351 }
352 // §3.6.3: a co-guardian proposes releasing a dormant guardian of THIS
353 // ward. The ward's server opens, tallies and (after the full window)
354 // executes, exactly as it does for the gated settings above.
355 const lp = availability.parseLapse(activity.object);
356 if (lp) {
357 if (lp.ward !== me) return false; // not our ward
358 const id = idOf(activity) || `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
359 const r = availability.openLapse({ id, wardSlug: site.slug, wardUri: me, target: lp.target, openedBy: actor, now: Date.now() });
360 if (r.error) {
361 notify(site.slug, { kind: 'lapse_refused', reason: r.error, target: lp.target });
362 return true; // consumed: the refusal is the answer
363 }
364 // The target is notified like any dormancy marking (§3.6.2): in
365 // protocol (a copy of the Offer, so one answer can cancel it) AND the
366 // §6 handle, which for a committed guardian is its inbox — the same
367 // door this delivery knocks on.
368 deps.deliverTo(site, lp.target, activity).catch(() => { /* best-effort */ });
369 notify(site.slug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set });
370 return true;
371 }
372 const rel = parseRelationship(activity.object);
373 if (!rel) return false;
374 // I must be a party: the ward, or one of the existing guardians in `to`.
375 const recipients = arr(activity.to);
376 const existing = recipients.filter((u) => u !== rel.ward);
377 if (rel.ward !== me && !existing.includes(me)) return false;
378 offers.start(site.slug, {
379 offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing,
380 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(rel.candidate),
381 });
382 // The Offer carries the candidate's agreement (see the C2S side): record it
383 // so this copy's tally matches — a free ward then commits on its own accept.
384 offers.recordAccept(site.slug, idOf(activity), rel.candidate);
385 notify(site.slug, { kind: rel.ward === me ? 'offer_received' : 'offer_for_ward', ward: rel.ward, candidate: rel.candidate });
386 return true;
387 }
388
389 // Accept / Reject of an offer we (also) track.
390 const offerId = idOf(activity.object);
391 // §5.6: a fellow guardian answering a gated-setting proposal. The Accept only
392 // references the offer, so the value comes from the proposal we stored. A
393 // Reject is a vote for the opposite, not a shrug: it is still an answer.
394 const gsOffer = gated.recallGatedOffer(offerId);
395 if (gsOffer && gsOffer.slug === site.slug) {
396 const value = type === 'Accept' ? !!gsOffer.value : !gsOffer.value;
397 const r = gated.recordGatedVote(site.slug, gsOffer.feature, actor, value);
398 notify(site.slug, { kind: 'gated_setting', feature: gsOffer.feature, value, state: r.state });
399 return true;
400 }
401 // §3.6.3: a set member answering a running lapse. Irreversible, so even a
402 // full tally leaves it open until the window closes (§3.5); the completion
403 // happens lazily on reads (queues) once the window has run.
404 if (availability.getLapse(offerId)) {
405 const r = availability.lapseVote(offerId, actor, type === 'Accept', Date.now());
406 notify(site.slug, { kind: 'lapse_vote', lapse: offerId, by: actor, state: r && !r.error ? 'recorded' : (r && r.error) || 'refused' });
407 return true;
408 }
409 let offer = offers.getOffer(site.slug, offerId);
410 if (!offer) return false;
411 if (!offers.isParty(offer, actor)) return false;
412
413 if (type === 'Reject') {
414 offers.recordReject(site.slug, offerId, actor);
415 notify(site.slug, { kind: 'offer_rejected', offer: offerId });
416 return true;
417 }
418
419 offers.recordAccept(site.slug, offerId, actor);
420 maybeCommit(site.slug, offerId); // commits this copy once the tally is complete
421 return true;
422}
423
424function notify(slug, ev) {
425 try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
426}
427
428export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship };
Note: See TracBrowser for help on using the repository browser.