source: Klonkt/src/services/guardianship/handshake.js@ 780a7c6

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

Guardianship Fase 0+1: de echte multi-party handshake (FEP-633c §3)

De eerste versie committeerde na één accept. Nu de spec: geen enkele partij
maakt een voogdij alleen, en een nieuwe guardian erbij kan niet zonder
toestemming van de bestaande. Daemon als blauwdruk, zodat Klonkt en de
test-daemon exact hetzelfde gedragen en de Shaer-clients één contract lezen.

Fase 0 (datamodel): ap_guardian_offers (per lokale partij een kopie van de
handshake, PK slug+offer_id) + ap_guardian_offer_accepts (de accept-tally).
ap_guardianships houdt alleen nog de GECOMMITTE relaties.

Fase 1 (state-machine): offers.js is een getrouwe port van de daemon-Handshake
(accepts over ward+candidate+existing; ready = ward && candidate && (geen
existing OF >=1 existing); een Reject voidt). handshake.js orchestreert het
gedistribueerd: de kandidaat adresseert de Offer aan ward + alle bestaande
guardians (§3.1.1); elke Accept wordt aan alle andere partijen gebroadcast, dus
elke instance-kopie convergeert; zodra een kopie compleet is committeert die
lokaal (ward schrijft shaer:guardians, guardian schrijft z'n ward), met de
kandidaat-inbox als handle (§6). Volgorde-onafhankelijk.

Ook: §1 wederzijdse uitsluiting (een ward is nooit ook guardian in het
actor-doc), de queues vullen nu de echte accept-tally (needsMyAccept/
readyToCommit/acceptedBy/existingGuardians), en de PWA + Berichten beantwoorden
via de C2S Accept/Reject-pijplijn per offer-id. De co-guardian ziet een
mede-voogdij-aanvraag met accepteer/weiger in de PWA.

Changed files:
src/config/database.js

  • tabellen ap_guardian_offers + ap_guardian_offer_accepts

src/services/guardianship/offers.js (NEW)

  • de handshake-state-machine (daemon-port), per-instance in SQLite

src/services/guardianship/relations.js

  • alleen commit-writers + actor-props (§1 uitsluiting)

src/services/guardianship/handshake.js

  • gedistribueerde multi-party C2S/S2S orchestratie

src/services/guardianship/queues.js

  • offers-queue uit de state-machine

src/services/guardianship/index.js

  • exports bijgewerkt

src/services/ActivityPubService.js

  • wire localSlug + fetchActor; inbound-routing naar alle lokale partijen

src/routes/guardian.js

  • dashboard toont offers met tally; POST /guardian/offer (accept/reject)

src/routes/posts.js

  • Berichten toont ward-offers uit de state-machine; accept via offer-id

src/views/pages/messages.ejs, src/assets/js/guardian.js, src/assets/css/guardian.css

  • offer-kaarten per state (mijn aanvraag / mede-voogdij / wachten)

src/services/i18n.js

  • accept/reject/complete/coguard + co-guardian push (nl/en/de)

test/guardianship.test.js

  • multi-party: eerste guardian, co-approval bestaande guardian, reject voidt, ward-mag-niet-guarden, vaste initiator

remarks: Fase 2 (follow-gating), 3 (hasGuardians + Not-a-Teapot), 4 (Undo/
emancipatie) volgen. 164 tests groen.

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

  • Property mode set to 100644
File size: 8.9 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';
21
22let deps = null;
23export function wireHandshake(d) { deps = d; }
24
25const idOf = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.id === 'string' ? v.id : null));
26const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
27
28/** Parse a Relationship object into {ward, candidate} or null. */
29export function parseRelationship(rel) {
30 if (!rel || typeof rel !== 'object') return null;
31 const type = Array.isArray(rel.type) ? rel.type[0] : rel.type;
32 if (type !== 'Relationship') return null;
33 if (!isGuardianRelationship(String(rel.relationship || ''))) return null;
34 const ward = idOf(rel.subject);
35 const candidate = idOf(rel.object);
36 return ward && candidate ? { ward, candidate } : null;
37}
38
39/** The existing guardians of a ward: local list, or the remote actor's shaer:guardians. */
40async function existingGuardiansOf(wardUri) {
41 const local = deps.localSlug(wardUri);
42 if (local) return relations.listGuardians(local).map((r) => r.other_uri);
43 const doc = await deps.fetchActor(wardUri).catch(() => null);
44 const g = doc && doc['shaer:guardians'];
45 return Array.isArray(g) ? g.filter((x) => typeof x === 'string') : [];
46}
47
48function offerActivity(offerId, ward, candidate, recipients) {
49 return {
50 id: offerId, type: 'Offer', actor: candidate, to: recipients,
51 object: { type: 'Relationship', subject: ward, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: candidate },
52 };
53}
54
55/** Deliver `activity` to every uri in `recipients` (skipping the local self). */
56async function fanout(site, recipients, activity) {
57 let anyDelivered = false;
58 for (const uri of [...new Set(recipients)]) {
59 const r = await deps.deliverTo(site, uri, activity).catch(() => ({ delivered: false }));
60 if (r && r.delivered !== false) anyDelivered = true;
61 }
62 return anyDelivered;
63}
64
65/** Apply the local side of a commit: the ward writes its guardian, the
66 * candidate writes its ward. Each instance writes only what it hosts. */
67function applyCommitLocally(offer, handle) {
68 const wardSlug = deps.localSlug(offer.ward_uri);
69 const candSlug = deps.localSlug(offer.candidate_uri);
70 if (wardSlug) relations.commitGuardianForWard(wardSlug, offer.candidate_uri, { handle, offerId: offer.offer_id });
71 if (candSlug) relations.commitWardForGuardian(candSlug, offer.ward_uri, { handle, offerId: offer.offer_id });
72}
73
74/** Commit this local copy of the offer when the tally is complete (ward +
75 * candidate + ≥1 existing guardian, §3.1.2). The handle is the candidate's
76 * inbox (§6 minimum); the commit is order-independent, so whichever accept
77 * lands last triggers it on every copy. */
78function maybeCommit(slug, offerId) {
79 const offer = offers.getOffer(slug, offerId);
80 if (!offer || !offers.readyToCommit(offer)) return null;
81 const done = offers.commit(slug, offerId, `${offer.candidate_uri}/inbox`);
82 if (done) { applyCommitLocally(done, done.handle); notify(slug, { kind: 'committed', ward: done.ward_uri, guardian: done.candidate_uri }); }
83 return done;
84}
85
86// ── C2S: a LOCAL party acts (PWA, Berichten, or the Shaer app outbox) ──────
87
88/**
89 * Handle a guardianship activity POSTed to the local outbox. Returns null when
90 * it is not ours, else {status, ...} for the route.
91 */
92export async function handleOutbox(site, activity) {
93 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
94 if (!['Offer', 'Accept', 'Reject'].includes(type)) return null;
95 const me = deps.selfId(site.slug);
96
97 // ── Offer: the local site is the guardian-candidate. ───────────────────
98 if (type === 'Offer') {
99 const rel = parseRelationship(activity.object);
100 if (!rel) return null;
101 if (rel.candidate !== me) return { status: 403, error: 'only_the_candidate_offers' }; // fixed initiator (§3.1)
102 if (relations.listGuardians(site.slug).length) return { status: 403, error: 'a_ward_cannot_guard' }; // §1
103 const existing = await existingGuardiansOf(rel.ward);
104 const offerId = `${me}/offers/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
105 offers.start(site.slug, {
106 offerId, ward: rel.ward, candidate: me, existingGuardians: existing,
107 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(me),
108 });
109 // Addressed to the ward AND every existing guardian (§3.1.1).
110 const recipients = [rel.ward, ...existing];
111 const delivered = await fanout(site, recipients, offerActivity(offerId, rel.ward, me, recipients));
112 notify(site.slug, { kind: 'offer_sent', ward: rel.ward });
113 return { status: 202, id: offerId, url: offerId, delivered };
114 }
115
116 // ── Accept / Reject: the local site is a party answering an offer. ─────
117 const offerId = idOf(activity.object);
118 if (!offerId) return { status: 400, error: 'missing_offer' };
119 let offer = offers.getOffer(site.slug, offerId);
120 if (!offer) return { status: 404, error: 'no_such_offer' };
121 const others = offers.parties(offer).filter((p) => p !== me);
122
123 if (type === 'Reject') {
124 offers.recordReject(site.slug, offerId, me);
125 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Reject', actor: me, to: others, object: offerId });
126 notify(site.slug, { kind: 'offer_rejected', offer: offerId });
127 return { status: 202, id: offerId, url: offerId };
128 }
129
130 // Accept: record my accept, broadcast it to the other parties, and commit
131 // this copy if the tally is now complete (order-independent, §3.1.3).
132 offers.recordAccept(site.slug, offerId, me);
133 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId });
134 const done = maybeCommit(site.slug, offerId);
135 return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) };
136}
137
138// ── S2S: a REMOTE party's activity arrives in a local inbox ────────────────
139
140/**
141 * Handle an inbound guardianship activity for the local site `site` (the inbox
142 * owner). Returns true when consumed.
143 */
144export async function handleInbox(site, activity) {
145 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
146 if (!['Offer', 'Accept', 'Reject'].includes(type)) return false;
147 const me = deps.selfId(site.slug);
148 const actor = idOf(activity.actor);
149
150 if (type === 'Offer') {
151 const rel = parseRelationship(activity.object);
152 if (!rel) return false;
153 // I must be a party: the ward, or one of the existing guardians in `to`.
154 const recipients = arr(activity.to);
155 const existing = recipients.filter((u) => u !== rel.ward);
156 if (rel.ward !== me && !existing.includes(me)) return false;
157 offers.start(site.slug, {
158 offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing,
159 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(rel.candidate),
160 });
161 notify(site.slug, { kind: rel.ward === me ? 'offer_received' : 'offer_for_ward', ward: rel.ward, candidate: rel.candidate });
162 return true;
163 }
164
165 // Accept / Reject of an offer we (also) track.
166 const offerId = idOf(activity.object);
167 let offer = offers.getOffer(site.slug, offerId);
168 if (!offer) return false;
169 if (!offers.isParty(offer, actor)) return false;
170
171 if (type === 'Reject') {
172 offers.recordReject(site.slug, offerId, actor);
173 notify(site.slug, { kind: 'offer_rejected', offer: offerId });
174 return true;
175 }
176
177 offers.recordAccept(site.slug, offerId, actor);
178 maybeCommit(site.slug, offerId); // commits this copy once the tally is complete
179 return true;
180}
181
182function notify(slug, ev) {
183 try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
184}
185
186export default { wireHandshake, handleOutbox, handleInbox, parseRelationship };
Note: See TracBrowser for help on using the repository browser.