Changeset 780a7c6 in Klonkt for test


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

    rc26cc18 r780a7c6  
    1 // The guardianship module (FEP-633c): relations, actor props, the adoption
    2 // handshake and the dashboard queues. Pins the module's public surface so the
    3 // Shaer clients' contract stays stable.
     1// The guardianship module (FEP-633c) — the multi-party handshake (§3).
     2// Everyone lives on one in-memory instance here, so the handshake copies all
     3// converge locally; that also exercises the "multiple local parties" routing.
    44import { test } from 'node:test';
    55import assert from 'node:assert/strict';
     
    1414const G = await import('../src/services/guardianship/index.js');
    1515
     16function site(id, slug) {
     17  db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run(id, slug, slug, 'u1', id === 's1' ? 1 : 0);
     18  return db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
     19}
    1620db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
    17 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run('s1', 'parent', 'Parent', 'u1', 1);
    18 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run('s2', 'kid', 'Kid', 'u1', 0);
    19 const parent = db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
    20 const kid = db.prepare('SELECT * FROM sites WHERE id = ?').get('s2');
    21 const ME = 'https://test.example/ap/users/parent';
    22 const KID = 'https://test.example/ap/users/kid';
     21const parent = site('s1', 'parent');   // first guardian-candidate
     22const kid = site('s2', 'kid');          // ward
     23const gran = site('s3', 'gran');        // second guardian-candidate (co-approver later)
     24const A = (slug) => `https://test.example/ap/users/${slug}`;
     25const [ME, KID, GRAN] = [A('parent'), A('kid'), A('gran')];
    2326
    24 // No network in tests: the handshake delivers via this stub.
    25 const sent = [];
     27// No network: the handshake delivers by feeding each activity straight into the
     28// inbound handler of every addressed local party (what real S2S would do).
    2629G.wireHandshake({
    27   selfId: (slug) => `https://test.example/ap/users/${slug}`,
    28   deliverTo: async (site, uri, activity) => { sent.push({ from: site.slug, to: uri, activity }); return true; },
    29   deriveHandle: (uri) => '@' + String(uri).split('/').pop() + '@test.example',
     30  selfId: A,
     31  localSlug: (uri) => (uri.startsWith('https://test.example/ap/users/') ? uri.split('/').pop() : null),
     32  deriveHandle: (uri) => '@' + uri.split('/').pop() + '@test.example',
     33  fetchActor: async () => null,
     34  deliverTo: async (fromSite, toUri, activity) => {
     35    const slug = toUri.split('/').pop();
     36    const s = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
     37    if (s) await G.handleGuardianshipInbox(s, activity);
     38    return { delivered: true };
     39  },
    3040  onEvent: null,
    3141});
    3242
    33 test('actor doc advertises shaer:queues (and blocked stays)', () => {
    34   const actor = AP.buildActor('https://test.example', parent);
    35   assert.equal(actor.blocked, `${ME}/blocked`);
    36   assert.deepEqual(actor['shaer:queues'], {
    37     offers: `${ME}/queues/offers`,
    38     follows: `${ME}/queues/follows`,
    39     wards: `${ME}/queues/wards`,
     43const offerIdFrom = (r) => r.id;
     44
     45test('first guardian: candidate offers, ward accepts, candidate completes', async () => {
     46  const off = await G.handleGuardianshipOutbox(parent, {
     47    type: 'Offer', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
    4048  });
    41   assert.equal(actor['shaer:isGuardian'], undefined);   // no wards yet
     49  assert.equal(off.status, 202);
     50  const id = offerIdFrom(off);
     51
     52  // The kid sees the offer and it needs its accept.
     53  const kidQ = G.offersCollection(`${KID}/queues/offers`, 'kid', KID).orderedItems;
     54  assert.equal(kidQ.length, 1);
     55  assert.equal(kidQ[0]['shaer:needsMyAccept'], true);
     56  assert.equal(kidQ[0]['shaer:iAmCandidate'], false);
     57
     58  // Not committed on a lone candidate — the ward has not accepted.
     59  assert.deepEqual(G.listGuardians('kid'), []);
     60
     61  // The kid accepts (C2S from the kid's own Klonkt). Not committed yet: the
     62  // candidate must still agree to serve (§3.1.2).
     63  await G.handleGuardianshipOutbox(kid, { type: 'Accept', object: id });
     64  assert.deepEqual(G.listGuardians('kid'), []);
     65  const parentQ = G.offersCollection(`${ME}/queues/offers`, 'parent', ME).orderedItems;
     66  assert.equal(parentQ[0]['shaer:iAmCandidate'], true);
     67  assert.equal(parentQ[0]['shaer:needsMyAccept'], true);   // candidate has not accepted
     68
     69  // The candidate accepts → tally complete → commit everywhere.
     70  const done = await G.handleGuardianshipOutbox(parent, { type: 'Accept', object: id });
     71  assert.equal(done.committed, true);
     72  assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
     73  assert.deepEqual(G.listWards('parent').map((w) => w.other_uri), [KID]);
     74
     75  // The ward actor now names its guardian; parent reads as guardian (§2).
     76  assert.deepEqual(AP.buildActor('https://test.example', kid)['shaer:guardians'], [ME]);
     77  assert.equal(AP.buildActor('https://test.example', parent)['shaer:isGuardian'], true);
     78  // §1 mutual exclusion: the ward is not also a guardian.
     79  assert.equal(AP.buildActor('https://test.example', kid)['shaer:isGuardian'], undefined);
    4280});
    4381
    44 test('C2S Offer from the candidate records + delivers (FEP-633c 3)', async () => {
    45   const r = await G.handleGuardianshipOutbox(parent, {
    46     type: 'Offer',
    47     object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
     82test('second guardian needs the EXISTING guardian to co-accept (§3.1.2)', async () => {
     83  // Gran offers to also guard the kid (who already has parent).
     84  const off = await G.handleGuardianshipOutbox(gran, {
     85    type: 'Offer', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: GRAN },
    4886  });
    49   assert.equal(r.status, 202);
    50   assert.equal(sent.length, 1);
    51   assert.equal(sent[0].to, KID);
    52   assert.equal(sent[0].activity.type, 'Offer');
    53   const wards = G.listWards('parent');
    54   assert.equal(wards.length, 1);
    55   assert.equal(wards[0].status, 'offered');
    56   // The guardian-to-be now reads as guardian; the actor doc follows.
    57   const actor = AP.buildActor('https://test.example', parent);
    58   assert.equal(actor['shaer:isGuardian'], true);
     87  const id = offerIdFrom(off);
     88  // The existing guardian (parent) is a party and must accept.
     89  const parentQ = G.offersCollection(`${ME}/queues/offers`, 'parent', ME).orderedItems.find((o) => o.id === id);
     90  assert.ok(parentQ, 'parent sees the co-guardianship offer');
     91  assert.deepEqual(parentQ['shaer:existingGuardians'], [ME]);
     92
     93  // Kid accepts, then gran (candidate) accepts — still NOT committed, because
     94  // the existing guardian (parent) has not co-accepted (§3.1.2).
     95  await G.handleGuardianshipOutbox(kid, { type: 'Accept', object: id });
     96  const early = await G.handleGuardianshipOutbox(gran, { type: 'Accept', object: id });
     97  assert.equal(early.committed, false);
     98  assert.equal(G.listGuardians('kid').length, 1, 'still just the first guardian');
     99
     100  // The existing guardian co-accepts → tally complete → commit.
     101  await G.handleGuardianshipOutbox(parent, { type: 'Accept', object: id });
     102  assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri).sort(), [GRAN, ME].sort());
    59103});
    60104
    61 test('only the candidate may offer', async () => {
    62   const r = await G.handleGuardianshipOutbox(parent, {
    63     type: 'Offer',
    64     object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: 'https://elders.test/u/x' },
     105test('a single Reject from a required party voids the offer (§3.2)', async () => {
     106  // parent offers to guard gran (who is free).
     107  const off = await G.handleGuardianshipOutbox(parent, {
     108    type: 'Offer', object: { type: 'Relationship', subject: GRAN, relationship: 'shaer:Guardian', object: ME },
    65109  });
    66   assert.equal(r.status, 403);
     110  const id = offerIdFrom(off);
     111  await G.handleGuardianshipOutbox(gran, { type: 'Reject', object: id });
     112  const q = G.offersCollection(`${ME}/queues/offers`, 'parent', ME).orderedItems.find((o) => o.id === id);
     113  assert.equal(q, undefined, 'voided offer leaves the queue');
     114  assert.equal(G.listWards('parent').some((w) => w.other_uri === GRAN), false);
    67115});
    68116
    69 test('inbound Offer parks in the ward queue; C2S Accept commits both ends', async () => {
    70   // The kid's side receives the offer S2S.
    71   const offerId = sent[0].activity.id;
    72   const consumed = await G.handleGuardianshipInbox(kid, {
    73     id: offerId, type: 'Offer', actor: ME,
    74     object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
    75   });
    76   assert.equal(consumed, true);
    77   assert.equal(G.listOffers('kid').length, 1);
    78 
    79   // The kid's offers queue carries the daemon-contract helper fields the
    80   // Shaer clients render their accept button from.
    81   const kidQ = G.offersCollection(`${KID}/queues/offers`, 'kid', KID);
    82   assert.equal(kidQ.totalItems, 1);
    83   assert.equal(kidQ.orderedItems[0]['shaer:needsMyAccept'], true);
    84   assert.equal(kidQ.orderedItems[0]['shaer:iAmCandidate'], false);
    85   assert.equal(kidQ.orderedItems[0]['shaer:ward'], KID);
    86   assert.equal(kidQ.orderedItems[0]['shaer:candidate'], ME);
    87 
    88   // The kid accepts over C2S; the answer travels to the guardian.
    89   const r = await G.handleGuardianshipOutbox(kid, { type: 'Accept', object: offerId });
    90   assert.equal(r.status, 202);
    91   assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
    92 
    93   // The guardian's side hears the Accept S2S and commits.
    94   const ok = await G.handleGuardianshipInbox(parent, { type: 'Accept', actor: KID, object: offerId });
    95   assert.equal(ok, true);
    96   const wards = G.listWards('parent').filter((w) => w.status === 'accepted');
    97   assert.deepEqual(wards.map((w) => w.other_uri), [KID]);
    98 
    99   // The ward's actor doc now names its guardian (FEP-633c 2.1).
    100   const actor = AP.buildActor('https://test.example', kid);
    101   assert.deepEqual(actor['shaer:guardians'], [ME]);
    102 });
    103 
    104 test('queues serve the daemon contract shapes', () => {
    105   const wardsQ = G.wardsCollection(`${ME}/queues/wards`, 'parent');
    106   assert.equal(wardsQ.type, 'OrderedCollection');
    107   assert.equal(wardsQ.totalItems, 1);
    108   assert.equal(wardsQ.orderedItems[0].id, KID);
    109   const followsQ = G.followsCollection(`${ME}/queues/follows`);
    110   assert.deepEqual(followsQ.orderedItems, []);
    111   const offersQ = G.offersCollection(`${ME}/queues/offers`, 'parent', ME);
    112   assert.equal(offersQ.type, 'OrderedCollection');   // empty again after the accept
    113   assert.equal(offersQ.totalItems, 0);
    114 });
    115 
    116 test('a ward cannot become a guardian (FEP-633c 1)', async () => {
     117test('a ward cannot become a guardian (§1)', async () => {
    117118  const r = await G.handleGuardianshipOutbox(kid, {
    118     type: 'Offer',
    119     object: { type: 'Relationship', subject: 'https://other.test/u/y', relationship: 'shaer:Guardian', object: KID },
     119    type: 'Offer', object: { type: 'Relationship', subject: A('someone'), relationship: 'shaer:Guardian', object: KID },
    120120  });
    121121  assert.equal(r.status, 403);
     
    123123});
    124124
     125test('only the candidate may offer (§3.1 fixed initiator)', async () => {
     126  const r = await G.handleGuardianshipOutbox(parent, {
     127    type: 'Offer', object: { type: 'Relationship', subject: A('newkid'), relationship: 'shaer:Guardian', object: GRAN },
     128  });
     129  assert.equal(r.status, 403);
     130  assert.equal(r.error, 'only_the_candidate_offers');
     131});
     132
    125133test('helpRequest props only ride direct notes', () => {
    126   assert.deepEqual(G.helpRequestProps({ visibility: 'direct', help_request: 1 }), { 'shaer:helpRequest': true });
    127   assert.deepEqual(G.helpRequestProps({ visibility: 'public', help_request: 1 }), {});
    128134  assert.equal(G.isHelpRequest({ 'shaer:helpRequest': true }), true);
    129135  assert.equal(G.isHelpRequest({}), false);
Note: See TracChangeset for help on using the changeset viewer.