Ignore:
Timestamp:
07/28/2026 08:15:31 PM (6 weeks ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
6eab7e9
Parents:
742ba7e
Message:

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

File:
1 edited

Legend:

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

    r742ba7e r6c152a5  
    2626const idOf = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.id === 'string' ? v.id : null));
    2727const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
     28
     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}
    2846
    2947/** Parse a Relationship object into {ward, candidate} or null. */
     
    87105}
    88106
     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
    89186// ── C2S: a LOCAL party acts (PWA, Berichten, or the Shaer app outbox) ──────
    90187
     
    95192export async function handleOutbox(site, activity) {
    96193  const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
    97   if (!['Offer', 'Accept', 'Reject'].includes(type)) return null;
     194  if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return null;
    98195  const me = deps.selfId(site.slug);
     196
     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  }
    99205
    100206  // ── Offer: the local site is the guardian-candidate. ───────────────────
     
    152258export async function handleInbox(site, activity) {
    153259  const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
    154   if (!['Offer', 'Accept', 'Reject'].includes(type)) return false;
     260  if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return false;
     261  if (type === 'Undo') return applyInboundUndo(site, activity);
    155262  const me = deps.selfId(site.slug);
    156263  const actor = idOf(activity.actor);
     
    216323}
    217324
    218 export default { wireHandshake, handleOutbox, handleInbox, parseRelationship };
     325export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship };
Note: See TracChangeset for help on using the changeset viewer.