| [780a7c6] | 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.
|
|---|
| [e61c289] | 4 | import { test } from 'node:test';
|
|---|
| 5 | import assert from 'node:assert/strict';
|
|---|
| 6 |
|
|---|
| 7 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 8 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 9 |
|
|---|
| 10 | const dbMod = await import('../src/config/database.js');
|
|---|
| 11 | const db = dbMod.default;
|
|---|
| 12 | dbMod.initializeDatabase();
|
|---|
| 13 | const AP = (await import('../src/services/ActivityPubService.js')).default;
|
|---|
| 14 | const G = await import('../src/services/guardianship/index.js');
|
|---|
| 15 |
|
|---|
| [780a7c6] | 16 | function 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 | }
|
|---|
| [e61c289] | 20 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
|
|---|
| [780a7c6] | 21 | const parent = site('s1', 'parent'); // first guardian-candidate
|
|---|
| 22 | const kid = site('s2', 'kid'); // ward
|
|---|
| 23 | const gran = site('s3', 'gran'); // second guardian-candidate (co-approver later)
|
|---|
| 24 | const A = (slug) => `https://test.example/ap/users/${slug}`;
|
|---|
| 25 | const [ME, KID, GRAN] = [A('parent'), A('kid'), A('gran')];
|
|---|
| 26 |
|
|---|
| 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).
|
|---|
| [e61c289] | 29 | G.wireHandshake({
|
|---|
| [780a7c6] | 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 | },
|
|---|
| [e61c289] | 40 | onEvent: null,
|
|---|
| 41 | });
|
|---|
| 42 |
|
|---|
| [780a7c6] | 43 | const offerIdFrom = (r) => r.id;
|
|---|
| [e61c289] | 44 |
|
|---|
| [780a7c6] | 45 | test('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 },
|
|---|
| [e61c289] | 48 | });
|
|---|
| [780a7c6] | 49 | assert.equal(off.status, 202);
|
|---|
| 50 | const id = offerIdFrom(off);
|
|---|
| [e61c289] | 51 |
|
|---|
| [780a7c6] | 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);
|
|---|
| [e61c289] | 80 | });
|
|---|
| 81 |
|
|---|
| [780a7c6] | 82 | test('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 },
|
|---|
| [e61c289] | 86 | });
|
|---|
| [780a7c6] | 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]);
|
|---|
| [e61c289] | 92 |
|
|---|
| [780a7c6] | 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');
|
|---|
| [e61c289] | 99 |
|
|---|
| [780a7c6] | 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());
|
|---|
| [e61c289] | 103 | });
|
|---|
| 104 |
|
|---|
| [780a7c6] | 105 | test('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 },
|
|---|
| 109 | });
|
|---|
| 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);
|
|---|
| [e61c289] | 115 | });
|
|---|
| 116 |
|
|---|
| [780a7c6] | 117 | test('a ward cannot become a guardian (§1)', async () => {
|
|---|
| [e61c289] | 118 | const r = await G.handleGuardianshipOutbox(kid, {
|
|---|
| [780a7c6] | 119 | type: 'Offer', object: { type: 'Relationship', subject: A('someone'), relationship: 'shaer:Guardian', object: KID },
|
|---|
| [e61c289] | 120 | });
|
|---|
| 121 | assert.equal(r.status, 403);
|
|---|
| 122 | assert.equal(r.error, 'a_ward_cannot_guard');
|
|---|
| 123 | });
|
|---|
| 124 |
|
|---|
| [780a7c6] | 125 | test('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 |
|
|---|
| [e61c289] | 133 | test('helpRequest props only ride direct notes', () => {
|
|---|
| 134 | assert.equal(G.isHelpRequest({ 'shaer:helpRequest': true }), true);
|
|---|
| 135 | assert.equal(G.isHelpRequest({}), false);
|
|---|
| 136 | });
|
|---|