| [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 | */
|
|---|
| 18 | import { isGuardianRelationship, GUARDIAN_RELATIONSHIP_COMPACT } from './context.js';
|
|---|
| [780a7c6] | 19 | import * as offers from './offers.js';
|
|---|
| [6b5d7da] | 20 | import * as relations from './relations.js';
|
|---|
| [65abc85] | 21 | import * as gated from './gated.js';
|
|---|
| [6b5d7da] | 22 |
|
|---|
| 23 | let deps = null;
|
|---|
| 24 | export function wireHandshake(d) { deps = d; }
|
|---|
| 25 |
|
|---|
| 26 | const idOf = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.id === 'string' ? v.id : null));
|
|---|
| [780a7c6] | 27 | const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
|
|---|
| [6b5d7da] | 28 |
|
|---|
| [6c152a5] | 29 | /**
|
|---|
| 30 | * FEP-633c §3.2/§3.3 — ending a guardianship.
|
|---|
| 31 | *
|
|---|
| 32 | * "After commit, either side MAY end the relationship with `Undo` of the
|
|---|
| 33 | * `Relationship`. An `Undo` from a guardian, or from the ward co-signed by an
|
|---|
| 34 | * existing guardian, removes the guardian from `shaer:guardians`."
|
|---|
| 35 | *
|
|---|
| 36 | * §3.3 bounds it: this is how ONE guardian goes while others remain. Removing
|
|---|
| 37 | * the last one empties `shaer:guardians` and that is emancipation (§3.4), which
|
|---|
| 38 | * has its own flow and is explicitly not a single party's call. So an Undo that
|
|---|
| 39 | * would leave a ward with nobody is refused here rather than quietly performed.
|
|---|
| 40 | */
|
|---|
| 41 | export function parseUndoRelationship(activity) {
|
|---|
| 42 | const type = Array.isArray(activity && activity.type) ? activity.type[0] : (activity && activity.type);
|
|---|
| 43 | if (type !== 'Undo') return null;
|
|---|
| 44 | return parseRelationship(activity && activity.object);
|
|---|
| 45 | }
|
|---|
| 46 |
|
|---|
| [6b5d7da] | 47 | /** Parse a Relationship object into {ward, candidate} or null. */
|
|---|
| 48 | export function parseRelationship(rel) {
|
|---|
| 49 | if (!rel || typeof rel !== 'object') return null;
|
|---|
| 50 | const type = Array.isArray(rel.type) ? rel.type[0] : rel.type;
|
|---|
| 51 | if (type !== 'Relationship') return null;
|
|---|
| 52 | if (!isGuardianRelationship(String(rel.relationship || ''))) return null;
|
|---|
| 53 | const ward = idOf(rel.subject);
|
|---|
| 54 | const candidate = idOf(rel.object);
|
|---|
| 55 | return ward && candidate ? { ward, candidate } : null;
|
|---|
| 56 | }
|
|---|
| 57 |
|
|---|
| [780a7c6] | 58 | /** The existing guardians of a ward: local list, or the remote actor's shaer:guardians. */
|
|---|
| 59 | async function existingGuardiansOf(wardUri) {
|
|---|
| 60 | const local = deps.localSlug(wardUri);
|
|---|
| 61 | if (local) return relations.listGuardians(local).map((r) => r.other_uri);
|
|---|
| 62 | const doc = await deps.fetchActor(wardUri).catch(() => null);
|
|---|
| 63 | const g = doc && doc['shaer:guardians'];
|
|---|
| 64 | return Array.isArray(g) ? g.filter((x) => typeof x === 'string') : [];
|
|---|
| 65 | }
|
|---|
| 66 |
|
|---|
| 67 | function offerActivity(offerId, ward, candidate, recipients) {
|
|---|
| 68 | return {
|
|---|
| 69 | id: offerId, type: 'Offer', actor: candidate, to: recipients,
|
|---|
| 70 | object: { type: 'Relationship', subject: ward, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: candidate },
|
|---|
| 71 | };
|
|---|
| 72 | }
|
|---|
| 73 |
|
|---|
| 74 | /** Deliver `activity` to every uri in `recipients` (skipping the local self). */
|
|---|
| 75 | async function fanout(site, recipients, activity) {
|
|---|
| 76 | let anyDelivered = false;
|
|---|
| 77 | for (const uri of [...new Set(recipients)]) {
|
|---|
| 78 | const r = await deps.deliverTo(site, uri, activity).catch(() => ({ delivered: false }));
|
|---|
| 79 | if (r && r.delivered !== false) anyDelivered = true;
|
|---|
| 80 | }
|
|---|
| 81 | return anyDelivered;
|
|---|
| 82 | }
|
|---|
| 83 |
|
|---|
| 84 | /** Apply the local side of a commit: the ward writes its guardian, the
|
|---|
| [fcd6964] | 85 | * candidate writes its ward. Each instance writes only what it hosts.
|
|---|
| 86 | * other_handle is the human @handle for display (from the offer); the FEP
|
|---|
| 87 | * escalation handle (candidate inbox) lives on the offer row, not here. */
|
|---|
| 88 | function applyCommitLocally(offer) {
|
|---|
| [780a7c6] | 89 | const wardSlug = deps.localSlug(offer.ward_uri);
|
|---|
| 90 | const candSlug = deps.localSlug(offer.candidate_uri);
|
|---|
| [fcd6964] | 91 | if (wardSlug) relations.commitGuardianForWard(wardSlug, offer.candidate_uri, { handle: offer.candidate_handle, offerId: offer.offer_id });
|
|---|
| 92 | if (candSlug) relations.commitWardForGuardian(candSlug, offer.ward_uri, { handle: offer.ward_handle, offerId: offer.offer_id });
|
|---|
| [780a7c6] | 93 | }
|
|---|
| 94 |
|
|---|
| 95 | /** Commit this local copy of the offer when the tally is complete (ward +
|
|---|
| 96 | * candidate + ≥1 existing guardian, §3.1.2). The handle is the candidate's
|
|---|
| 97 | * inbox (§6 minimum); the commit is order-independent, so whichever accept
|
|---|
| 98 | * lands last triggers it on every copy. */
|
|---|
| 99 | function maybeCommit(slug, offerId) {
|
|---|
| 100 | const offer = offers.getOffer(slug, offerId);
|
|---|
| 101 | if (!offer || !offers.readyToCommit(offer)) return null;
|
|---|
| 102 | const done = offers.commit(slug, offerId, `${offer.candidate_uri}/inbox`);
|
|---|
| [fcd6964] | 103 | if (done) { applyCommitLocally(done); notify(slug, { kind: 'committed', ward: done.ward_uri, guardian: done.candidate_uri }); }
|
|---|
| [780a7c6] | 104 | return done;
|
|---|
| 105 | }
|
|---|
| 106 |
|
|---|
| [6c152a5] | 107 | /**
|
|---|
| 108 | * End a guardianship from the local guardian's side and let it travel (§3.2).
|
|---|
| 109 | *
|
|---|
| 110 | * One path for both callers: the button in the Guardian PWA and an `Undo` a
|
|---|
| 111 | * Guardian app POSTs to its own outbox. Addressed like the Offer that started
|
|---|
| 112 | * it (§3.1.1): the ward, and every other guardian, so no copy is left behind
|
|---|
| 113 | * believing the relation still stands.
|
|---|
| 114 | */
|
|---|
| 115 | export async function endGuardianship(site, wardUri) {
|
|---|
| 116 | const me = deps.selfId(site.slug);
|
|---|
| 117 | if (!relations.getRelation(site.slug, 'guardian', wardUri)) return { status: 404, error: 'not_my_ward' };
|
|---|
| 118 | const set = await existingGuardiansOf(wardUri);
|
|---|
| 119 | const others = set.filter((g) => g !== me);
|
|---|
| 120 | // Only a set we actually read counts as proof. A remote ward whose server is
|
|---|
| 121 | // down reads as an empty set; refusing on that would trap the guardian, and
|
|---|
| 122 | // the ward's server checks again on arrival anyway.
|
|---|
| 123 | if (set.length && others.length === 0) return { status: 409, error: 'would_emancipate' };
|
|---|
| 124 | const recipients = [wardUri, ...others];
|
|---|
| 125 | const undo = {
|
|---|
| 126 | id: `${me}/undo/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`,
|
|---|
| 127 | type: 'Undo', actor: me, to: recipients,
|
|---|
| 128 | object: { type: 'Relationship', subject: wardUri, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: me },
|
|---|
| 129 | };
|
|---|
| 130 | const delivered = await fanout(site, recipients, undo);
|
|---|
| 131 | relations.removeRelation(site.slug, 'guardian', wardUri);
|
|---|
| 132 | // A ward we host ourselves never receives its own delivery: an inbox on this
|
|---|
| 133 | // machine is not reachable over HTTP from this machine (and should not be).
|
|---|
| 134 | // The commit path has the same shape and solves it the same way — each
|
|---|
| 135 | // instance writes what it hosts (applyCommitLocally).
|
|---|
| 136 | const wardSlug = deps.localSlug(wardUri);
|
|---|
| 137 | if (wardSlug) dropGuardianFromWard(wardSlug, deps.selfId(site.slug));
|
|---|
| 138 | notify(site.slug, { kind: 'guardianship_ended', ward: wardUri, delivered });
|
|---|
| 139 | return { status: 202, delivered, guardiansLeft: others.length };
|
|---|
| 140 | }
|
|---|
| 141 |
|
|---|
| 142 | /**
|
|---|
| 143 | * The ward's side of an ended guardianship: drop that guardian, unless doing so
|
|---|
| 144 | * would empty the set. §3.3 only permits this while more than one remains;
|
|---|
| 145 | * emptying it is emancipation (§3.4) and no single party decides that.
|
|---|
| 146 | */
|
|---|
| 147 | function dropGuardianFromWard(wardSlug, guardianUri) {
|
|---|
| 148 | const set = relations.listGuardians(wardSlug).map((r) => r.other_uri);
|
|---|
| 149 | if (!set.includes(guardianUri)) return false; // already gone: an Undo is idempotent
|
|---|
| 150 | if (set.length <= 1) {
|
|---|
| 151 | notify(wardSlug, { kind: 'guardianship_end_refused', guardian: guardianUri, reason: 'would_emancipate' });
|
|---|
| 152 | return false;
|
|---|
| 153 | }
|
|---|
| 154 | relations.removeRelation(wardSlug, 'ward', guardianUri);
|
|---|
| 155 | notify(wardSlug, { kind: 'guardian_left', guardian: guardianUri });
|
|---|
| 156 | return true;
|
|---|
| 157 | }
|
|---|
| 158 |
|
|---|
| 159 | /** The receiving side of that Undo. Returns true when consumed. */
|
|---|
| 160 | function applyInboundUndo(site, activity) {
|
|---|
| 161 | const rel = parseUndoRelationship(activity);
|
|---|
| 162 | if (!rel) return false;
|
|---|
| 163 | const me = deps.selfId(site.slug);
|
|---|
| 164 | const actor = idOf(activity.actor);
|
|---|
| 165 | const ward = rel.ward;
|
|---|
| 166 | const guardian = rel.candidate; // in an Undo the Relationship's object is the leaving guardian
|
|---|
| 167 |
|
|---|
| 168 | if (ward === me) {
|
|---|
| 169 | // I am the ward. Only the guardian itself may end its own relation here;
|
|---|
| 170 | // the ward-co-signed variant of §3.2 needs a second signature and is not
|
|---|
| 171 | // built, so it is refused rather than half-honoured.
|
|---|
| 172 | if (actor !== guardian) return false;
|
|---|
| 173 | dropGuardianFromWard(site.slug, guardian);
|
|---|
| 174 | return true;
|
|---|
| 175 | }
|
|---|
| 176 |
|
|---|
| 177 | // I am one of the other guardians: nothing of mine changes, but being left
|
|---|
| 178 | // as one of fewer is exactly the kind of thing a guardian should hear about.
|
|---|
| 179 | if (relations.getRelation(site.slug, 'guardian', ward)) {
|
|---|
| 180 | notify(site.slug, { kind: 'coguardian_left', ward, guardian });
|
|---|
| 181 | return true;
|
|---|
| 182 | }
|
|---|
| 183 | return false;
|
|---|
| 184 | }
|
|---|
| 185 |
|
|---|
| [780a7c6] | 186 | // ── C2S: a LOCAL party acts (PWA, Berichten, or the Shaer app outbox) ──────
|
|---|
| [6b5d7da] | 187 |
|
|---|
| 188 | /**
|
|---|
| [780a7c6] | 189 | * Handle a guardianship activity POSTed to the local outbox. Returns null when
|
|---|
| 190 | * it is not ours, else {status, ...} for the route.
|
|---|
| [6b5d7da] | 191 | */
|
|---|
| 192 | export async function handleOutbox(site, activity) {
|
|---|
| 193 | const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
|
|---|
| [6c152a5] | 194 | if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return null;
|
|---|
| [780a7c6] | 195 | const me = deps.selfId(site.slug);
|
|---|
| [6b5d7da] | 196 |
|
|---|
| [6c152a5] | 197 | // ── Undo: a guardian ends its own guardianship (§3.2). Same path as the
|
|---|
| 198 | // button in the Guardian PWA, so an app and the dashboard cannot drift.
|
|---|
| 199 | if (type === 'Undo') {
|
|---|
| 200 | const rel = parseUndoRelationship(activity);
|
|---|
| 201 | if (!rel) return null;
|
|---|
| 202 | if (rel.candidate !== me) return { status: 403, error: 'not_your_relation' };
|
|---|
| 203 | return endGuardianship(site, rel.ward);
|
|---|
| 204 | }
|
|---|
| 205 |
|
|---|
| [780a7c6] | 206 | // ── Offer: the local site is the guardian-candidate. ───────────────────
|
|---|
| [6b5d7da] | 207 | if (type === 'Offer') {
|
|---|
| 208 | const rel = parseRelationship(activity.object);
|
|---|
| [780a7c6] | 209 | if (!rel) return null;
|
|---|
| 210 | if (rel.candidate !== me) return { status: 403, error: 'only_the_candidate_offers' }; // fixed initiator (§3.1)
|
|---|
| 211 | if (relations.listGuardians(site.slug).length) return { status: 403, error: 'a_ward_cannot_guard' }; // §1
|
|---|
| 212 | const existing = await existingGuardiansOf(rel.ward);
|
|---|
| 213 | const offerId = `${me}/offers/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
|
|---|
| 214 | offers.start(site.slug, {
|
|---|
| 215 | offerId, ward: rel.ward, candidate: me, existingGuardians: existing,
|
|---|
| 216 | wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(me),
|
|---|
| 217 | });
|
|---|
| [3ffbedd] | 218 | // The Offer IS the candidate's agreement to serve: record it as the
|
|---|
| 219 | // candidate's accept. So a FREE ward commits on its own single accept (no
|
|---|
| 220 | // second guardian to co-approve yet); once it IS a ward, adding another
|
|---|
| 221 | // guardian still needs an existing guardian to co-accept.
|
|---|
| 222 | offers.recordAccept(site.slug, offerId, me);
|
|---|
| [780a7c6] | 223 | // Addressed to the ward AND every existing guardian (§3.1.1).
|
|---|
| 224 | const recipients = [rel.ward, ...existing];
|
|---|
| 225 | const delivered = await fanout(site, recipients, offerActivity(offerId, rel.ward, me, recipients));
|
|---|
| [6b5d7da] | 226 | notify(site.slug, { kind: 'offer_sent', ward: rel.ward });
|
|---|
| [780a7c6] | 227 | return { status: 202, id: offerId, url: offerId, delivered };
|
|---|
| [6b5d7da] | 228 | }
|
|---|
| 229 |
|
|---|
| [780a7c6] | 230 | // ── Accept / Reject: the local site is a party answering an offer. ─────
|
|---|
| 231 | const offerId = idOf(activity.object);
|
|---|
| 232 | if (!offerId) return { status: 400, error: 'missing_offer' };
|
|---|
| 233 | let offer = offers.getOffer(site.slug, offerId);
|
|---|
| 234 | if (!offer) return { status: 404, error: 'no_such_offer' };
|
|---|
| 235 | const others = offers.parties(offer).filter((p) => p !== me);
|
|---|
| 236 |
|
|---|
| 237 | if (type === 'Reject') {
|
|---|
| 238 | offers.recordReject(site.slug, offerId, me);
|
|---|
| 239 | await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Reject', actor: me, to: others, object: offerId });
|
|---|
| 240 | notify(site.slug, { kind: 'offer_rejected', offer: offerId });
|
|---|
| 241 | return { status: 202, id: offerId, url: offerId };
|
|---|
| [6b5d7da] | 242 | }
|
|---|
| [780a7c6] | 243 |
|
|---|
| 244 | // Accept: record my accept, broadcast it to the other parties, and commit
|
|---|
| 245 | // this copy if the tally is now complete (order-independent, §3.1.3).
|
|---|
| 246 | offers.recordAccept(site.slug, offerId, me);
|
|---|
| 247 | await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId });
|
|---|
| 248 | const done = maybeCommit(site.slug, offerId);
|
|---|
| 249 | return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) };
|
|---|
| [6b5d7da] | 250 | }
|
|---|
| 251 |
|
|---|
| [780a7c6] | 252 | // ── S2S: a REMOTE party's activity arrives in a local inbox ────────────────
|
|---|
| [6b5d7da] | 253 |
|
|---|
| 254 | /**
|
|---|
| [780a7c6] | 255 | * Handle an inbound guardianship activity for the local site `site` (the inbox
|
|---|
| 256 | * owner). Returns true when consumed.
|
|---|
| [6b5d7da] | 257 | */
|
|---|
| 258 | export async function handleInbox(site, activity) {
|
|---|
| 259 | const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
|
|---|
| [6c152a5] | 260 | if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return false;
|
|---|
| 261 | if (type === 'Undo') return applyInboundUndo(site, activity);
|
|---|
| [780a7c6] | 262 | const me = deps.selfId(site.slug);
|
|---|
| [6b5d7da] | 263 | const actor = idOf(activity.actor);
|
|---|
| 264 |
|
|---|
| [65abc85] | 265 | // §5.6: a guardian proposes a gated setting for THIS ward. The ward's server
|
|---|
| 266 | // tallies and enforces, so the decision lands here, not on the proposer.
|
|---|
| [6b5d7da] | 267 | if (type === 'Offer') {
|
|---|
| [65abc85] | 268 | const gs = gated.parseGatedSetting(activity.object);
|
|---|
| 269 | if (gs) {
|
|---|
| 270 | if (gs.ward !== me) return false; // not our ward
|
|---|
| 271 | gated.rememberGatedOffer(idOf(activity), site.slug, gs.feature, gs.value);
|
|---|
| 272 | // The proposer's Offer carries its own agreement (§3.1's one-step clause).
|
|---|
| 273 | const r = gated.recordGatedVote(site.slug, gs.feature, actor, gs.value);
|
|---|
| 274 | notify(site.slug, { kind: 'gated_setting', feature: gs.feature, value: gs.value, state: r.state });
|
|---|
| 275 | return true;
|
|---|
| 276 | }
|
|---|
| [6b5d7da] | 277 | const rel = parseRelationship(activity.object);
|
|---|
| [780a7c6] | 278 | if (!rel) return false;
|
|---|
| 279 | // I must be a party: the ward, or one of the existing guardians in `to`.
|
|---|
| 280 | const recipients = arr(activity.to);
|
|---|
| 281 | const existing = recipients.filter((u) => u !== rel.ward);
|
|---|
| 282 | if (rel.ward !== me && !existing.includes(me)) return false;
|
|---|
| 283 | offers.start(site.slug, {
|
|---|
| 284 | offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing,
|
|---|
| 285 | wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(rel.candidate),
|
|---|
| 286 | });
|
|---|
| [3ffbedd] | 287 | // The Offer carries the candidate's agreement (see the C2S side): record it
|
|---|
| 288 | // so this copy's tally matches — a free ward then commits on its own accept.
|
|---|
| 289 | offers.recordAccept(site.slug, idOf(activity), rel.candidate);
|
|---|
| [780a7c6] | 290 | notify(site.slug, { kind: rel.ward === me ? 'offer_received' : 'offer_for_ward', ward: rel.ward, candidate: rel.candidate });
|
|---|
| [6b5d7da] | 291 | return true;
|
|---|
| 292 | }
|
|---|
| 293 |
|
|---|
| [780a7c6] | 294 | // Accept / Reject of an offer we (also) track.
|
|---|
| 295 | const offerId = idOf(activity.object);
|
|---|
| [65abc85] | 296 | // §5.6: a fellow guardian answering a gated-setting proposal. The Accept only
|
|---|
| 297 | // references the offer, so the value comes from the proposal we stored. A
|
|---|
| 298 | // Reject is a vote for the opposite, not a shrug: it is still an answer.
|
|---|
| 299 | const gsOffer = gated.recallGatedOffer(offerId);
|
|---|
| 300 | if (gsOffer && gsOffer.slug === site.slug) {
|
|---|
| 301 | const value = type === 'Accept' ? !!gsOffer.value : !gsOffer.value;
|
|---|
| 302 | const r = gated.recordGatedVote(site.slug, gsOffer.feature, actor, value);
|
|---|
| 303 | notify(site.slug, { kind: 'gated_setting', feature: gsOffer.feature, value, state: r.state });
|
|---|
| 304 | return true;
|
|---|
| 305 | }
|
|---|
| [780a7c6] | 306 | let offer = offers.getOffer(site.slug, offerId);
|
|---|
| 307 | if (!offer) return false;
|
|---|
| 308 | if (!offers.isParty(offer, actor)) return false;
|
|---|
| 309 |
|
|---|
| 310 | if (type === 'Reject') {
|
|---|
| 311 | offers.recordReject(site.slug, offerId, actor);
|
|---|
| 312 | notify(site.slug, { kind: 'offer_rejected', offer: offerId });
|
|---|
| 313 | return true;
|
|---|
| [6b5d7da] | 314 | }
|
|---|
| [780a7c6] | 315 |
|
|---|
| 316 | offers.recordAccept(site.slug, offerId, actor);
|
|---|
| 317 | maybeCommit(site.slug, offerId); // commits this copy once the tally is complete
|
|---|
| [6b5d7da] | 318 | return true;
|
|---|
| 319 | }
|
|---|
| 320 |
|
|---|
| 321 | function notify(slug, ev) {
|
|---|
| 322 | try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
|
|---|
| 323 | }
|
|---|
| 324 |
|
|---|
| [6c152a5] | 325 | export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship };
|
|---|