Changeset 6c152a5 in Klonkt for test


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
  • test/guardianship.test.js

    r742ba7e r6c152a5  
    133133  assert.equal(G.isHelpRequest({}), false);
    134134});
     135
     136// ── §3.2/§3.3: ending a guardianship ─────────────────────────────────────
     137// This used to be a local delete that never left the building: the guardian's
     138// dashboard forgot the ward, while the ward's server kept listing them in
     139// shaer:guardians. Robin calls that a bug, and it is: the Undo has to travel.
     140// At this point in the file the kid has two guardians, parent and gran.
     141
     142test('a guardian leaving sends an Undo that both sides act on (§3.2)', async () => {
     143  assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri).sort(), [ME, GRAN].sort(), 'two guardians to start');
     144
     145  const r = await G.endGuardianship(gran, KID);
     146  assert.equal(r.status, 202);
     147  assert.equal(r.delivered, true, 'the Undo went out, it is not a local delete');
     148
     149  // The ward's own actor document is the thing that had to change.
     150  assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
     151  assert.deepEqual(AP.buildActor('https://test.example', kid)['shaer:guardians'], [ME]);
     152  assert.deepEqual(G.listWards('gran'), [], 'and the leaving guardian lost the ward');
     153  assert.deepEqual(G.listWards('parent').map((w) => w.other_uri), [KID], 'the other guardian stays');
     154});
     155
     156test('the last guardian cannot walk out alone: that is emancipation (§3.4)', async () => {
     157  const r = await G.endGuardianship(parent, KID);
     158  assert.equal(r.status, 409);
     159  assert.equal(r.error, 'would_emancipate');
     160  // Nothing moved on either side. Emptying shaer:guardians takes the flow of
     161  // §3.4 (three consenting adults, or a majority plus two witnesses), never one
     162  // party's click.
     163  assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
     164  assert.deepEqual(G.listWards('parent').map((w) => w.other_uri), [KID]);
     165});
     166
     167test('an Undo for a ward that is not yours is refused', async () => {
     168  const r = await G.endGuardianship(gran, KID);   // gran already left
     169  assert.equal(r.status, 404);
     170  assert.equal(r.error, 'not_my_ward');
     171});
     172
     173test('the same Undo over C2S takes the same path', async () => {
     174  // A Guardian app POSTs this to its own outbox; the dashboard button calls
     175  // endGuardianship directly. One path, so the two cannot drift apart.
     176  const undo = { type: 'Undo', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: GRAN } };
     177  const mine = await G.handleGuardianshipOutbox(gran, undo);
     178  assert.equal(mine.status, 404, 'gran no longer guards the kid');
     179
     180  // And you cannot end someone else's relation by describing it.
     181  const notMine = await G.handleGuardianshipOutbox(parent, undo);
     182  assert.equal(notMine.status, 403);
     183  assert.equal(notMine.error, 'not_your_relation');
     184});
     185
     186test('an inbound Undo from someone who is not the guardian changes nothing', async () => {
     187  const before = G.listGuardians('kid').map((g) => g.other_uri);
     188  await G.handleGuardianshipInbox(kid, {
     189    actor: GRAN,   // gran claims to end PARENT's relation
     190    type: 'Undo', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
     191  });
     192  assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), before);
     193});
     194
     195test('a ward on this same instance is updated even though nothing is delivered', async () => {
     196  // The browser found this: an inbox on this machine is not reachable over HTTP
     197  // from this machine (nor should it be), so a co-located ward never receives
     198  // the Undo. The guardian's side had dropped the ward while the ward's side
     199  // still listed the guardian. Each instance must write what it hosts.
     200  const kid2 = site('s4', 'kid2');
     201  const g1 = site('s5', 'g1');
     202  const g2 = site('s6', 'g2');
     203  const [KID2, G1, G2] = [A('kid2'), A('g1'), A('g2')];
     204
     205  const o1 = await G.handleGuardianshipOutbox(g1, {
     206    type: 'Offer', object: { type: 'Relationship', subject: KID2, relationship: 'shaer:Guardian', object: G1 } });
     207  await G.handleGuardianshipOutbox(kid2, { type: 'Accept', object: o1.id });
     208  const o2 = await G.handleGuardianshipOutbox(g2, {
     209    type: 'Offer', object: { type: 'Relationship', subject: KID2, relationship: 'shaer:Guardian', object: G2 } });
     210  await G.handleGuardianshipOutbox(kid2, { type: 'Accept', object: o2.id });
     211  await G.handleGuardianshipOutbox(g1, { type: 'Accept', object: o2.id });
     212  assert.deepEqual(G.listGuardians('kid2').map((g) => g.other_uri).sort(), [G1, G2].sort());
     213
     214  // Now deliver nothing at all, the way a loopback inbox behaves in practice.
     215  const wired = {
     216    selfId: A,
     217    localSlug: (uri) => (uri.startsWith('https://test.example/ap/users/') ? uri.split('/').pop() : null),
     218    deriveHandle: (uri) => '@' + uri.split('/').pop() + '@test.example',
     219    fetchActor: async () => null,
     220    deliverTo: async () => ({ delivered: false }),
     221    onEvent: null,
     222  };
     223  G.wireHandshake(wired);
     224  const r = await G.endGuardianship(g2, KID2);
     225  assert.equal(r.status, 202);
     226  assert.equal(r.delivered, false, 'nothing went over the wire');
     227  assert.deepEqual(G.listWards('g2'), [], "the guardian's side is clear");
     228  assert.deepEqual(G.listGuardians('kid2').map((g) => g.other_uri), [G1], "and so is the ward's");
     229  assert.deepEqual(AP.buildActor('https://test.example', kid2)['shaer:guardians'], [G1]);
     230
     231  // Even undelivered, it must not empty the set: that is still emancipation.
     232  const last = await G.endGuardianship(g1, KID2);
     233  assert.equal(last.status, 409);
     234  assert.deepEqual(G.listGuardians('kid2').map((g) => g.other_uri), [G1]);
     235});
Note: See TracChangeset for help on using the changeset viewer.