source: Klonkt/src/services/guardianship/handshake.js@ 329873e

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

Een gated voorstel bereikte de andere guardians nooit

Op de vloot nagekeken waarom YouTube-voorbeelden bij beta uit blijven: het
voorstel van sound-fabrics staat er, met een ja van een van de drie guardians,
en het is stil verlopen. Niet omdat iemand bezwaar had, maar omdat niemand
anders het ooit gezien heeft.

Het voorstel wordt geadresseerd aan de server van het kind, want die telt en
handhaaft (5.6). Maar daarmee bereikt het alleen de voorsteller en het kind. De
twee guardians op andere servers weten van niets, kunnen dus niet antwoorden, en
een drempel van twee is onhaalbaar. Elk voorstel verloopt na een dag.

De ontbrekende schakel is het doorsturen, precies wat 5.3 al doet voor een
gated follow: de server van het kind kent de gezaghebbende guardian-lijst, dus
die stuurt het voorstel door. Elke guardian bewaart een kopie die hij kan
beantwoorden, en het antwoord reist terug naar het kind, dat telt.

Changed files:
src/config/database.js

  • tabel ap_gated_reviews, de guardian-kopie (zelfde vorm als ap_follow_reviews)

src/services/guardianship/gated.js

  • de kopie-opslag: bewaren, lezen, beantwoorden, opruimen

src/services/guardianship/handshake.js

  • ward-kant: doorsturen naar de andere guardians zodra het voorstel openstaat
  • guardian-kant: de doorgestuurde kopie bewaren om te kunnen antwoorden

src/routes/guardian.js

  • gatedReviews in de dashboardstaat; POST /guardian/api/gated/:id stuurt het antwoord naar de inbox van het kind

src/assets/js/guardian.js, src/assets/css/guardian.css

src/services/i18n.js

  • de teksten in nl, en, de

test/gated-settings.test.js

  • de hele keten: voorstellen, doorsturen naar allebei de anderen, de kopie opslaan, antwoorden, en pas bij twee van drie gaat de gate open

remarks: dit forceert niets; het maakt alleen mogelijk wat de spec al bedoelde.
Twee van de drie guardians moeten nog steeds akkoord gaan, en het venster is
24 uur.

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

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