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

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

De Undo bij het loslaten van een ward reist nu echt mee

Loslaten was een lokale delete. De guardian vergat het kind, terwijl de server
van het kind hem gewoon in shaer:guardians bleef noemen. De vorige commit zette
dat als waarschuwing in beeld; Robin noemt het terecht een bug, want FEP-633c
3.2 zegt gewoon dat een Undo van de Relationship de guardian eruit haalt.

Nu gaat er een Undo naar het kind en naar de andere guardians, met dezelfde
adressering als de Offer waarmee het begon (3.1.1), zodat geen kopie achterblijft
die denkt dat de band er nog is. De ontvangende kant haalt de guardian eruit,
maar alleen als de guardian zelf tekent: de variant waarbij het kind opzegt met
een mede-ondertekenende guardian heeft een tweede handtekening nodig en is niet
gebouwd, dus die wordt geweigerd in plaats van half uitgevoerd.

De laatste guardian kan niet meer alleen weglopen. 3.3 geldt zolang er meer dan
een over is; de set leegmaken is emancipatie (3.4) en daar gaat geen enkele
partij alleen over. Dat wordt geweigerd aan beide kanten, en de knop biedt in
dat geval alleen nog een nee aan in plaats van een ja die toch een fout geeft.

Een kind op DEZELFDE instance kreeg de Undo niet: een inbox op deze machine is
van deze machine niet over HTTP bereikbaar, en dat hoort ook niet. De commit-kant
lost dat al zo op dat elke instance schrijft wat hij host; het loslaten doet dat
nu ook. In de browser gevonden nadat de guardian-kant leeg was en de kant van het
kind nog niet.

Een guardian-app kan hetzelfde over C2S: een Undo naar de eigen outbox loopt
langs precies dezelfde functie als de knop in de PWA, zodat die twee niet uit
elkaar kunnen groeien.

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

  • endGuardianship: bouwt en verstuurt de Undo, weigert emancipatie, en schrijft de kant van een lokaal gehost kind zelf
  • applyInboundUndo + dropGuardianFromWard: de ontvangende kant
  • handleOutbox accepteert Undo; handleInbox routeert hem

src/services/guardianship/index.js

  • endGuardianship en parseUndoRelationship geexporteerd

src/services/ActivityPubService.js

  • de guardianship-dispatch ziet Undo nu voordat de generieke Undo-tak hem opslokt met een 202
  • push voor een vertrokken guardian en een vertrokken mede-guardian

src/routes/guardian.js

  • /wards/remove loopt langs endGuardianship in plaats van een lokale delete
  • release-check meldt niet langer dat de Undo blijft liggen

src/assets/js/guardian.js

  • geen ja-knop meer als jij de laatste bent; een 409 wordt getoond in plaats van stil hertekend

src/services/i18n.js

  • de waarschuwing klopt weer, plus push-teksten in nl, en, de

test/guardianship.test.js

  • zes tests: de Undo werkt aan beide kanten, de laatste guardian wordt geweigerd, hij is idempotent, C2S loopt hetzelfde pad, een vreemde Undo verandert niets, en een kind op dezelfde instance wordt ook bijgewerkt terwijl er niets bezorgd is

remarks: end-to-end nagekeken in de browser: na het loslaten staat guard niet
meer in shaer:guardians van het actor-document van het kind, en een POST die de
knop omzeilt krijgt 409 would_emancipate.

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

  • Property mode set to 100644
File size: 15.9 KB
RevLine 
[6b5d7da]1/**
[780a7c6]2 * Guardianship (FEP-633c §3) — the adoption handshake, multi-party and
3 * distributed across instances.
[6b5d7da]4 *
[780a7c6]5 * The candidate Offers a Relationship{subject: ward, object: candidate},
6 * addressed to the ward AND every existing guardian of the ward. Each party
7 * (ward, existing guardians, and finally the candidate) Accepts, addressed to
8 * all the others, so every instance's copy of the tally converges. The
9 * candidate's Accept is the LAST one and carries the escalation handle in
10 * `result`: that return is the atomic commit (§3.1.3). Only then does the
11 * ward gain the guardian in shaer:guardians and the guardian gain the ward.
12 * A single Reject from any party voids the offer (§3.2).
[6b5d7da]13 *
[780a7c6]14 * The state machine lives in offers.js (a faithful port of the Shaer test
15 * daemon); this module wires it onto Klonkt's C2S/S2S plumbing. AP helpers
16 * arrive once via wireHandshake(deps); nothing here imports ActivityPubService.
[6b5d7da]17 */
18import { isGuardianRelationship, GUARDIAN_RELATIONSHIP_COMPACT } from './context.js';
[780a7c6]19import * as offers from './offers.js';
[6b5d7da]20import * as relations from './relations.js';
[65abc85]21import * as gated from './gated.js';
[6b5d7da]22
23let deps = null;
24export function wireHandshake(d) { deps = d; }
25
26const idOf = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.id === 'string' ? v.id : null));
[780a7c6]27const 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 */
41export 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. */
48export 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. */
59async 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
67function 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). */
75async 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. */
88function 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. */
99function 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 */
115export 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 */
147function 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. */
160function 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 */
192export 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 */
258export 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
321function notify(slug, ev) {
322 try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
323}
324
[6c152a5]325export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship };
Note: See TracBrowser for help on using the repository browser.