Changeset 6c152a5 in Klonkt for src/services/guardianship
- Timestamp:
- 07/28/2026 08:15:31 PM (6 weeks ago)
- Branches:
- main
- Children:
- 6eab7e9
- Parents:
- 742ba7e
- Location:
- src/services/guardianship
- Files:
-
- 2 edited
-
handshake.js (modified) (5 diffs)
-
index.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/services/guardianship/handshake.js
r742ba7e r6c152a5 26 26 const idOf = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.id === 'string' ? v.id : null)); 27 27 const 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 */ 41 export 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 } 28 46 29 47 /** Parse a Relationship object into {ward, candidate} or null. */ … … 87 105 } 88 106 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 */ 115 export 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 */ 147 function 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. */ 160 function 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 89 186 // ── C2S: a LOCAL party acts (PWA, Berichten, or the Shaer app outbox) ────── 90 187 … … 95 192 export async function handleOutbox(site, activity) { 96 193 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; 98 195 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 } 99 205 100 206 // ── Offer: the local site is the guardian-candidate. ─────────────────── … … 152 258 export async function handleInbox(site, activity) { 153 259 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); 155 262 const me = deps.selfId(site.slug); 156 263 const actor = idOf(activity.actor); … … 216 323 } 217 324 218 export default { wireHandshake, handleOutbox, handleInbox, parseRelationship };325 export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship }; -
src/services/guardianship/index.js
r742ba7e r6c152a5 18 18 export { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed } from './notes.js'; 19 19 export { wireDelivery, c2sVisibility, deliverDirectNote } from './delivery.js'; 20 export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship } from './handshake.js';20 export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship, parseUndoRelationship, endGuardianship } from './handshake.js'; 21 21 export { offersCollection, followsCollection, wardsCollection } from './queues.js'; 22 22 export * as follows from './follows.js';
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)