Ignore:
Timestamp:
07/24/2026 07:44:15 PM (7 weeks ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
b5924eb
Parents:
c26cc18
Message:

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@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/guardianship/handshake.js

    rc26cc18 r780a7c6  
    11/**
    2  * Guardianship (FEP-633c §3) — the adoption handshake.
     2 * Guardianship (FEP-633c §3) — the adoption handshake, multi-party and
     3 * distributed across instances.
    34 *
    4  * Offer(Relationship{subject: ward, relationship: shaer:Guardian, object:
    5  * candidate}) travels from the guardian-candidate to the ward; the ward
    6  * answers Accept (relation becomes real) or Reject (row disappears). The
    7  * shape mirrors the Shaer test daemon, so the iOS/Android clients speak it
    8  * unchanged.
     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).
    913 *
    10  * Wired like delivery.js: no import back into ActivityPubService; the AP
    11  * helpers arrive once via wireHandshake(deps). `deps.onEvent(slug, ev)` is an
    12  * optional hook the Guardian PWA uses for push notifications.
     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.
    1317 */
    1418import { isGuardianRelationship, GUARDIAN_RELATIONSHIP_COMPACT } from './context.js';
     19import * as offers from './offers.js';
    1520import * as relations from './relations.js';
    1621
     
    1924
    2025const 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');
    2127
    2228/** Parse a Relationship object into {ward, candidate} or null. */
     
    3137}
    3238
    33 // ── C2S: the local account acts (PWA or Shaer app, via the outbox) ────────
     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) ──────
    3487
    3588/**
    36  * Handle a guardianship activity POSTed to the local outbox. Returns null
    37  * when the activity is not ours to handle, else {status, ...} for the route.
     89 * Handle a guardianship activity POSTed to the local outbox. Returns null when
     90 * it is not ours, else {status, ...} for the route.
    3891 */
    3992export async function handleOutbox(site, activity) {
    40   const { selfId, deliverTo, deriveHandle } = deps;
    4193  const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
    4294  if (!['Offer', 'Accept', 'Reject'].includes(type)) return null;
    43   const me = selfId(site.slug);
     95  const me = deps.selfId(site.slug);
    4496
     97  // ── Offer: the local site is the guardian-candidate. ───────────────────
    4598  if (type === 'Offer') {
    4699    const rel = parseRelationship(activity.object);
    47     if (!rel) return null;                                   // not a guardianship offer
    48     // Fixed initiator (FEP resolved B): only the aspirant guardian offers.
    49     if (rel.candidate !== me) return { status: 403, error: 'only_the_candidate_offers' };
    50     // A ward can never become a guardian (FEP §1).
    51     if (relations.listGuardians(site.slug).length) return { status: 403, error: 'a_ward_cannot_guard' };
    52     const offerId = `${me}/offers/${Date.now().toString(36)}`;
    53     const offer = {
    54       id: offerId, type: 'Offer', actor: me, to: [rel.ward],
    55       object: { type: 'Relationship', subject: rel.ward, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: me },
    56     };
    57     relations.recordOffer(site.slug, 'guardian', rel.ward, { handle: deriveHandle(rel.ward), offerId });
    58     // The offer is now recorded (the guardian sees it as pending); delivery is
    59     // async + retried, so a slow ward server never fails the whole action.
    60     const res = await deliverTo(site, rel.ward, offer).catch(() => ({ delivered: false }));
     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));
    61112    notify(site.slug, { kind: 'offer_sent', ward: rel.ward });
    62     return { status: 202, id: offerId, url: offerId, delivered: res && res.delivered !== false };
     113    return { status: 202, id: offerId, url: offerId, delivered };
    63114  }
    64115
    65   // Accept / Reject: the local ward answers a pending offer.
    66   const obj = activity.object;
    67   const offerId = idOf(obj);
    68   const rel = parseRelationship(obj && obj.object) || parseRelationship(obj);
    69   let row = null;
    70   if (offerId) row = relations.findByOfferId(offerId).find((r) => r.slug === site.slug && r.role === 'ward') || null;
    71   if (!row && rel) row = relations.getRelation(site.slug, 'ward', rel.candidate) || null;
    72   if (!row) return { status: 404, error: 'no_such_offer' };
     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);
    73122
    74   const answer = {
    75     id: `${me}/answers/${Date.now().toString(36)}`, type, actor: me, to: [row.other_uri],
    76     object: row.offer_id || { type: 'Relationship', subject: me, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: row.other_uri },
    77   };
    78   if (type === 'Accept') {
    79     // The committed handle rides in `result` (daemon contract): the guardian
    80     // learns where the ward lives.
    81     answer.result = `${me}/inbox`;
    82     relations.acceptRelation(site.slug, 'ward', row.other_uri);
    83   } else {
    84     relations.removeRelation(site.slug, 'ward', row.other_uri);
     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 };
    85128  }
    86   // The answer is committed locally; delivery is async + retried.
    87   const res = await deliverTo(site, row.other_uri, answer).catch(() => ({ delivered: false }));
    88   notify(site.slug, { kind: type === 'Accept' ? 'offer_accepted' : 'offer_rejected', guardian: row.other_uri });
    89   return { status: 202, id: answer.id, url: answer.id, delivered: res && res.delivered !== false };
     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)) };
    90136}
    91137
    92 // ── S2S: a remote party acts (arrives in the local inbox) ────────────────
     138// ── S2S: a REMOTE party's activity arrives in a local inbox ────────────────
    93139
    94140/**
    95  * Handle an inbound guardianship activity for local site `site`. Returns
    96  * true when consumed (the generic inbox skips it), false otherwise.
     141 * Handle an inbound guardianship activity for the local site `site` (the inbox
     142 * owner). Returns true when consumed.
    97143 */
    98144export async function handleInbox(site, activity) {
    99   const { selfId } = deps;
    100145  const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
    101146  if (!['Offer', 'Accept', 'Reject'].includes(type)) return false;
    102   const me = selfId(site.slug);
     147  const me = deps.selfId(site.slug);
    103148  const actor = idOf(activity.actor);
    104149
    105150  if (type === 'Offer') {
    106151    const rel = parseRelationship(activity.object);
    107     if (!rel || rel.ward !== me) return false;
    108     // A remote candidate offers to guard the local ward: park it in the queue.
    109     relations.recordOffer(site.slug, 'ward', rel.candidate, { handle: deps.deriveHandle(rel.candidate), offerId: idOf(activity) });
    110     notify(site.slug, { kind: 'offer_received', candidate: rel.candidate });
     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 });
    111162    return true;
    112163  }
    113164
    114   // Accept / Reject of an offer WE (local guardian) sent.
    115   const obj = activity.object;
    116   const offerId = idOf(obj);
    117   const rel = parseRelationship(obj && obj.object) || parseRelationship(obj);
    118   let row = null;
    119   if (offerId) row = relations.findByOfferId(offerId).find((r) => r.slug === site.slug && r.role === 'guardian') || null;
    120   if (!row && actor) row = relations.getRelation(site.slug, 'guardian', actor) || null;
    121   if (!row && rel) row = relations.getRelation(site.slug, 'guardian', rel.ward) || null;
    122   if (!row) return false;
     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;
    123170
    124   if (type === 'Accept') {
    125     relations.acceptRelation(site.slug, 'guardian', row.other_uri);
    126     notify(site.slug, { kind: 'ward_accepted', ward: row.other_uri });
    127   } else {
    128     relations.removeRelation(site.slug, 'guardian', row.other_uri);
    129     notify(site.slug, { kind: 'ward_rejected', ward: row.other_uri });
     171  if (type === 'Reject') {
     172    offers.recordReject(site.slug, offerId, actor);
     173    notify(site.slug, { kind: 'offer_rejected', offer: offerId });
     174    return true;
    130175  }
     176
     177  offers.recordAccept(site.slug, offerId, actor);
     178  maybeCommit(site.slug, offerId);   // commits this copy once the tally is complete
    131179  return true;
    132180}
Note: See TracChangeset for help on using the changeset viewer.