Changeset 69bd747 in Klonkt
- Timestamp:
- 08/03/2026 06:26:57 AM (5 weeks ago)
- Branches:
- main
- Children:
- 30d0e2c
- Parents:
- 5327324
- Files:
-
- 2 edited
-
src/services/guardianship/handshake.js (modified) (3 diffs)
-
test/guardianship.test.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/services/guardianship/handshake.js
r5327324 r69bd747 168 168 reason: 'not_a_teapot', candidate: offer.candidate_uri, 169 169 }); 170 return { done: null, refused: 'not_a_teapot' };170 return { done: null, refused: 'not_a_teapot', offer }; 171 171 } 172 172 … … 352 352 offers.recordAccept(site.slug, offerId, me); 353 353 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId }); 354 const { done, refused } = await maybeCommit(site.slug, offerId);354 const { done, refused, offer: voided } = await maybeCommit(site.slug, offerId); 355 355 if (refused) { 356 // §4.2: the refusal travels as a `Reject` of the Offer, from whoever was 357 // about to commit — the same voice that just sent the Accept. A `Reject` 358 // is what §3 already understands, so a server that has never heard of §4 359 // still voids its copy correctly; the marker only adds the reason. 360 await fanout(site, others, { 356 // §4.2: the refusal travels as a `Reject` of the Offer (§3.2), which an 357 // implementation unaware of §4 still handles correctly. Who is told WHY is 358 // not uniform, and deliberately so. 359 const answer = (to, withReason) => ({ 361 360 id: `${me}/answers/${Date.now().toString(36)}`, 362 type: 'Reject', actor: me, to: others, object: offerId, 'shaer:notATeapot': true, 361 type: 'Reject', actor: me, to, object: offerId, 362 ...(withReason ? { 'shaer:notATeapot': true } : {}), 363 363 }); 364 const candidate = voided && voided.candidate_uri; 365 // The ward and its existing guardians MUST learn the reason: they are 366 // parties, the condition is public data (§2.1), and a bare void would 367 // leave a ward believing an adoption completed that did not. 368 const family = others.filter((u) => u !== candidate); 369 if (family.length) await fanout(site, family, answer(family, true)); 370 // The candidate gets a BARE Reject. Commit is the last step of §3.1, so a 371 // refusal that names itself technical also discloses that every human 372 // party already accepted and only the protocol objected — which, where a 373 // guardianship is contested, is not theirs to learn. The kind path for an 374 // merely misconfigured candidate is the check on the Offer, before anyone 375 // has consented to anything. 376 if (candidate && others.includes(candidate)) await fanout(site, [candidate], answer([candidate], false)); 364 377 return { status: 202, id: offerId, url: offerId, committed: false, refused }; 365 378 } … … 460 473 const existing = recipients.filter((u) => u !== rel.ward); 461 474 if (rel.ward !== me && !existing.includes(me)) return false; 475 // §4.2: check the candidate here too, and refuse before anyone accepts. 476 // At this point no party has consented, so saying why discloses nothing 477 // about anyone's position, and a candidate that is merely misconfigured 478 // can find that out and fix it. The commit-time check stays REQUIRED as 479 // the backstop for a candidate whose state changes in between. 480 if (await candidateFitness(rel.candidate) === 'malformed') { 481 notify(site.slug, { kind: 'offer_refused', offer: idOf(activity), reason: 'not_a_teapot', candidate: rel.candidate }); 482 await fanout(site, [rel.candidate], { 483 id: `${me}/answers/${Date.now().toString(36)}`, 484 type: 'Reject', actor: me, to: [rel.candidate], object: idOf(activity), 'shaer:notATeapot': true, 485 }); 486 return true; 487 } 462 488 offers.start(site.slug, { 463 489 offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing, -
test/guardianship.test.js
r5327324 r69bd747 160 160 const stillPending = G.offersCollection(`${SAM}/queues/offers`, 'sam', SAM).orderedItems.filter((o) => o.id === id); 161 161 assert.deepEqual(stillPending, [], 'the handshake is void, not left hanging'); 162 }); 163 164 test('an Offer from a candidate that is already a ward is refused on arrival (§4.2)', async () => { 165 // The kind path. Nobody has accepted anything yet, so refusing here 166 // discloses nothing about anyone's position, and a candidate who is merely 167 // misconfigured gets told what is wrong while that is still all it means. 168 const viv = site('s13', 'viv'); // adopted first, then tries to guard 169 const zed = site('s14', 'zed'); // the would-be ward 170 const bo = site('s15', 'bo'); // adopts Viv 171 const [VIV, ZED, BO] = [A('viv'), A('zed'), A('bo')]; 172 173 const adopt = await G.handleGuardianshipOutbox(bo, { 174 type: 'Offer', object: { type: 'Relationship', subject: VIV, relationship: 'shaer:Guardian', object: BO }, 175 }); 176 await G.handleGuardianshipOutbox(viv, { type: 'Accept', object: adopt.id }); 177 assert.equal(G.listGuardians('viv').length, 1, 'Viv is a ward'); 178 179 const handled = await G.handleGuardianshipInbox(zed, { 180 id: `${VIV}/offers/x1`, type: 'Offer', actor: VIV, to: [ZED], 181 object: { type: 'Relationship', subject: ZED, relationship: 'shaer:Guardian', object: VIV }, 182 }); 183 assert.equal(handled, true, 'the activity is handled — and handling it means refusing it'); 184 assert.deepEqual( 185 G.offersCollection(`${ZED}/queues/offers`, 'zed', ZED).orderedItems, [], 186 'never stored, so it never sits in Zed\'s queue looking like a decision to make', 187 ); 188 assert.deepEqual(G.listGuardians('zed'), []); 162 189 }); 163 190
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)