| 1 | /**
|
|---|
| 2 | * Guardianship (FEP-633c §3) — the adoption handshake, multi-party and
|
|---|
| 3 | * distributed across instances.
|
|---|
| 4 | *
|
|---|
| 5 | * The candidate Offers a Relationship{subject: ward, object: candidate},
|
|---|
| 6 | * addressed to the ward AND every existing guardian of the ward. Each party
|
|---|
| 7 | * (ward, existing guardians, and finally the candidate) Accepts, addressed to
|
|---|
| 8 | * all the others, so every instance's copy of the tally converges. The
|
|---|
| 9 | * candidate's Accept is the LAST one and carries the escalation handle in
|
|---|
| 10 | * `result`: that return is the atomic commit (§3.1.3). Only then does the
|
|---|
| 11 | * ward gain the guardian in shaer:guardians and the guardian gain the ward.
|
|---|
| 12 | * A single Reject from any party voids the offer (§3.2).
|
|---|
| 13 | *
|
|---|
| 14 | * The state machine lives in offers.js (a faithful port of the Shaer test
|
|---|
| 15 | * daemon); this module wires it onto Klonkt's C2S/S2S plumbing. AP helpers
|
|---|
| 16 | * arrive once via wireHandshake(deps); nothing here imports ActivityPubService.
|
|---|
| 17 | */
|
|---|
| 18 | import { isGuardianRelationship, GUARDIAN_RELATIONSHIP_COMPACT, carriesGuardians } from './context.js';
|
|---|
| 19 | import * as offers from './offers.js';
|
|---|
| 20 | import * as relations from './relations.js';
|
|---|
| 21 | import * as gated from './gated.js';
|
|---|
| 22 | import * as availability from './availability.js';
|
|---|
| 23 |
|
|---|
| 24 | let deps = null;
|
|---|
| 25 | export function wireHandshake(d) { deps = d; }
|
|---|
| 26 |
|
|---|
| 27 | const idOf = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.id === 'string' ? v.id : null));
|
|---|
| 28 | const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
|
|---|
| 29 |
|
|---|
| 30 | /**
|
|---|
| 31 | * FEP-633c §3.2/§3.3 — ending a guardianship.
|
|---|
| 32 | *
|
|---|
| 33 | * "After commit, either side MAY end the relationship with `Undo` of the
|
|---|
| 34 | * `Relationship`. An `Undo` from a guardian, or from the ward co-signed by an
|
|---|
| 35 | * existing guardian, removes the guardian from `shaer:guardians`."
|
|---|
| 36 | *
|
|---|
| 37 | * §3.3 bounds it: this is how ONE guardian goes while others remain. Removing
|
|---|
| 38 | * the last one empties `shaer:guardians` and that is emancipation (§3.4), which
|
|---|
| 39 | * has its own flow and is explicitly not a single party's call. So an Undo that
|
|---|
| 40 | * would leave a ward with nobody is refused here rather than quietly performed.
|
|---|
| 41 | */
|
|---|
| 42 | export function parseUndoRelationship(activity) {
|
|---|
| 43 | const type = Array.isArray(activity && activity.type) ? activity.type[0] : (activity && activity.type);
|
|---|
| 44 | if (type !== 'Undo') return null;
|
|---|
| 45 | return parseRelationship(activity && activity.object);
|
|---|
| 46 | }
|
|---|
| 47 |
|
|---|
| 48 | /**
|
|---|
| 49 | * De overgebleven guardians opnieuw vertellen of ZIJ nu de doorslag geven
|
|---|
| 50 | * (shaer-8vt, Barts correctie 8-8).
|
|---|
| 51 | *
|
|---|
| 52 | * "Doorslaggevend" is geen eigenschap van een moment maar van een STAND: zodra
|
|---|
| 53 | * er nog een stem nodig is, is iedereen die nog moet antwoorden het. Eenmalig
|
|---|
| 54 | * berekenen bij het doorsturen bevriest een antwoord dat verandert.
|
|---|
| 55 | *
|
|---|
| 56 | * Nooit dragend: lukt de update niet, dan blijft de oude waarde staan. Die is
|
|---|
| 57 | * dan te voorzichtig of te stil -- en juist daarom staat de FAALSTAND aan de
|
|---|
| 58 | * kant van waarschuwen (isDecisive leest onbekend als "ja, jij beslist").
|
|---|
| 59 | */
|
|---|
| 60 | function herzieDoorslag(site, offerId, gsOffer, laatsteStem) {
|
|---|
| 61 | try {
|
|---|
| 62 | const p = gated.gatedProgress(site.slug, gsOffer.feature);
|
|---|
| 63 | if (!gated.isDecisive(p.votes, p.need)) return; // nog niets veranderd
|
|---|
| 64 | const me = deps.selfId(site.slug);
|
|---|
| 65 | const gestemd = new Set([gsOffer.proposer, laatsteStem].filter(Boolean));
|
|---|
| 66 | for (const g of relations.listGuardians(site.slug).map((x) => x.other_uri)) {
|
|---|
| 67 | if (gestemd.has(g)) continue;
|
|---|
| 68 | deps.deliverTo(site, g, {
|
|---|
| 69 | id: offerId, type: 'Offer', actor: me, to: [g],
|
|---|
| 70 | object: { type: 'shaer:GatedSetting', 'shaer:ward': me, 'shaer:feature': gsOffer.feature, 'shaer:value': !!gsOffer.value },
|
|---|
| 71 | 'shaer:proposer': gsOffer.proposer || undefined,
|
|---|
| 72 | 'shaer:decisive': true,
|
|---|
| 73 | }).catch(() => { /* de bezorgwachtrij probeert opnieuw */ });
|
|---|
| 74 | }
|
|---|
| 75 | } catch { /* nooit dragend */ }
|
|---|
| 76 | }
|
|---|
| 77 |
|
|---|
| 78 | /** Parse a Relationship object into {ward, candidate} or null. */
|
|---|
| 79 | export function parseRelationship(rel) {
|
|---|
| 80 | if (!rel || typeof rel !== 'object') return null;
|
|---|
| 81 | const type = Array.isArray(rel.type) ? rel.type[0] : rel.type;
|
|---|
| 82 | if (type !== 'Relationship') return null;
|
|---|
| 83 | if (!isGuardianRelationship(String(rel.relationship || ''))) return null;
|
|---|
| 84 | const ward = idOf(rel.subject);
|
|---|
| 85 | const candidate = idOf(rel.object);
|
|---|
| 86 | return ward && candidate ? { ward, candidate } : null;
|
|---|
| 87 | }
|
|---|
| 88 |
|
|---|
| 89 | /** The existing guardians of a ward: local list, or the remote actor's shaer:guardians. */
|
|---|
| 90 | async function existingGuardiansOf(wardUri) {
|
|---|
| 91 | const local = deps.localSlug(wardUri);
|
|---|
| 92 | if (local) return relations.listGuardians(local).map((r) => r.other_uri);
|
|---|
| 93 | const doc = await deps.fetchActor(wardUri).catch(() => null);
|
|---|
| 94 | const g = doc && doc['shaer:guardians'];
|
|---|
| 95 | return Array.isArray(g) ? g.filter((x) => typeof x === 'string') : [];
|
|---|
| 96 | }
|
|---|
| 97 |
|
|---|
| 98 | function offerActivity(offerId, ward, candidate, recipients) {
|
|---|
| 99 | return {
|
|---|
| 100 | id: offerId, type: 'Offer', actor: candidate, to: recipients,
|
|---|
| 101 | object: { type: 'Relationship', subject: ward, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: candidate },
|
|---|
| 102 | };
|
|---|
| 103 | }
|
|---|
| 104 |
|
|---|
| 105 | /** Deliver `activity` to every uri in `recipients` (skipping the local self). */
|
|---|
| 106 | async function fanout(site, recipients, activity) {
|
|---|
| 107 | let anyDelivered = false;
|
|---|
| 108 | for (const uri of [...new Set(recipients)]) {
|
|---|
| 109 | const r = await deps.deliverTo(site, uri, activity).catch(() => ({ delivered: false }));
|
|---|
| 110 | if (r && r.delivered !== false) anyDelivered = true;
|
|---|
| 111 | }
|
|---|
| 112 | return anyDelivered;
|
|---|
| 113 | }
|
|---|
| 114 |
|
|---|
| 115 | /**
|
|---|
| 116 | * §5.6, the closing of the loop: a settled gated decision answers the Offer
|
|---|
| 117 | * that opened it. Accept when it settled on the proposed value, Reject when on
|
|---|
| 118 | * the opposite. Without this the proposer's screen can only ever say
|
|---|
| 119 | * "waiting", forever, whatever actually happened: the tally lives on the
|
|---|
| 120 | * ward's server and nobody else may read it, so the ward's server must speak.
|
|---|
| 121 | */
|
|---|
| 122 | function answerGatedProposer(site, offerId, r) {
|
|---|
| 123 | const o = gated.recallGatedOffer(offerId);
|
|---|
| 124 | if (!o || !o.proposer) return;
|
|---|
| 125 | const me = deps.selfId(site.slug);
|
|---|
| 126 | if (o.proposer === me) return; // the ward proposed to itself: nothing to write home
|
|---|
| 127 | const agreed = r.value === !!o.value;
|
|---|
| 128 | deps.deliverTo(site, o.proposer, {
|
|---|
| 129 | id: `${me}#gatedanswer-${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`,
|
|---|
| 130 | type: agreed ? 'Accept' : 'Reject',
|
|---|
| 131 | actor: me, to: [o.proposer], object: offerId,
|
|---|
| 132 | }).catch(() => { /* the delivery queue retries */ });
|
|---|
| 133 | }
|
|---|
| 134 |
|
|---|
| 135 | /** Apply the local side of a commit: the ward writes its guardian, the
|
|---|
| 136 | * candidate writes its ward. Each instance writes only what it hosts.
|
|---|
| 137 | * other_handle is the human @handle for display (from the offer); the FEP
|
|---|
| 138 | * escalation handle (candidate inbox) lives on the offer row, not here. */
|
|---|
| 139 | function applyCommitLocally(offer) {
|
|---|
| 140 | const wardSlug = deps.localSlug(offer.ward_uri);
|
|---|
| 141 | const candSlug = deps.localSlug(offer.candidate_uri);
|
|---|
| 142 | if (wardSlug) relations.commitGuardianForWard(wardSlug, offer.candidate_uri, { handle: offer.candidate_handle, offerId: offer.offer_id });
|
|---|
| 143 | if (candSlug) relations.commitWardForGuardian(candSlug, offer.ward_uri, { handle: offer.ward_handle, offerId: offer.offer_id });
|
|---|
| 144 | }
|
|---|
| 145 |
|
|---|
| 146 | /**
|
|---|
| 147 | * FEP-633c §4.2 — is this candidate fit to be a guardian at all?
|
|---|
| 148 | *
|
|---|
| 149 | * A guardian MUST be free of guardians (§1). Checked here and not at the Offer,
|
|---|
| 150 | * because guardianship state can change in between: a candidate that was free
|
|---|
| 151 | * when it offered may have been adopted before the ward accepted. So the check
|
|---|
| 152 | * runs against a freshly dereferenced actor document, at the moment the
|
|---|
| 153 | * relationship would become real.
|
|---|
| 154 | *
|
|---|
| 155 | * Three answers, and the third is not a failure of this check but a failure to
|
|---|
| 156 | * perform it:
|
|---|
| 157 | * 'ok' — free of guardians, may serve
|
|---|
| 158 | * 'malformed' — carries shaer:guardians; a teapot (§4)
|
|---|
| 159 | * 'unverified' — the actor could not be read at all
|
|---|
| 160 | */
|
|---|
| 161 | async function candidateFitness(candidateUri) {
|
|---|
| 162 | // A candidate on this instance needs no dereference: our own tables are the
|
|---|
| 163 | // document, and fresher than anything we could fetch from ourselves. This is
|
|---|
| 164 | // also the co-located case (ward and guardian on one Klonkt), where there is
|
|---|
| 165 | // no network to be unreachable on.
|
|---|
| 166 | const local = deps.localSlug(candidateUri);
|
|---|
| 167 | if (local) return relations.listGuardians(local).length > 0 ? 'malformed' : 'ok';
|
|---|
| 168 |
|
|---|
| 169 | const doc = await deps.fetchActor(candidateUri).catch(() => null);
|
|---|
| 170 | if (!doc) return 'unverified';
|
|---|
| 171 | return carriesGuardians(doc) ? 'malformed' : 'ok';
|
|---|
| 172 | }
|
|---|
| 173 |
|
|---|
| 174 | /** Commit this local copy of the offer when the tally is complete (ward +
|
|---|
| 175 | * candidate + ≥1 existing guardian, §3.1.2). The handle is the candidate's
|
|---|
| 176 | * inbox (§6 minimum); the commit is order-independent, so whichever accept
|
|---|
| 177 | * lands last triggers it on every copy. */
|
|---|
| 178 | async function maybeCommit(slug, offerId) {
|
|---|
| 179 | const offer = offers.getOffer(slug, offerId);
|
|---|
| 180 | if (!offer || !offers.readyToCommit(offer)) return { done: null, refused: null };
|
|---|
| 181 |
|
|---|
| 182 | const fitness = await candidateFitness(offer.candidate_uri);
|
|---|
| 183 |
|
|---|
| 184 | // §4.2: unlike the soft skip at delivery (§4.1), this refusal is loud. A
|
|---|
| 185 | // handshake concerns exactly one candidate, so there is no remaining
|
|---|
| 186 | // well-formed target to continue to; committing anyway would leave the ward
|
|---|
| 187 | // counting a guardian whose escalations get dropped. Voiding is all this
|
|---|
| 188 | // function does; saying so on the wire belongs to whoever was acting.
|
|---|
| 189 | if (fitness === 'malformed') {
|
|---|
| 190 | offers.recordReject(slug, offerId, offer.ward_uri); // voids this copy (§3.2)
|
|---|
| 191 | notify(slug, {
|
|---|
| 192 | kind: 'offer_rejected', offer: offerId,
|
|---|
| 193 | reason: 'not_a_teapot', candidate: offer.candidate_uri,
|
|---|
| 194 | });
|
|---|
| 195 | return { done: null, refused: 'not_a_teapot', offer };
|
|---|
| 196 | }
|
|---|
| 197 |
|
|---|
| 198 | // Could not read the candidate: neither commit nor void. Refusing outright
|
|---|
| 199 | // would let a momentary outage destroy a multi-party adoption; committing
|
|---|
| 200 | // would record a guardian nobody checked. The offer stays pending and the
|
|---|
| 201 | // next accept retries.
|
|---|
| 202 | if (fitness === 'unverified') return { done: null, refused: null };
|
|---|
| 203 |
|
|---|
| 204 | const done = offers.commit(slug, offerId, `${offer.candidate_uri}/inbox`);
|
|---|
| 205 | if (done) { applyCommitLocally(done); notify(slug, { kind: 'committed', ward: done.ward_uri, guardian: done.candidate_uri }); }
|
|---|
| 206 | return { done, refused: null };
|
|---|
| 207 | }
|
|---|
| 208 |
|
|---|
| 209 | /**
|
|---|
| 210 | * End a guardianship from the local guardian's side and let it travel (§3.2).
|
|---|
| 211 | *
|
|---|
| 212 | * One path for both callers: the button in the Guardian PWA and an `Undo` a
|
|---|
| 213 | * Guardian app POSTs to its own outbox. Addressed like the Offer that started
|
|---|
| 214 | * it (§3.1.1): the ward, and every other guardian, so no copy is left behind
|
|---|
| 215 | * believing the relation still stands.
|
|---|
| 216 | */
|
|---|
| 217 | export async function endGuardianship(site, wardUri) {
|
|---|
| 218 | const me = deps.selfId(site.slug);
|
|---|
| 219 | if (!relations.getRelation(site.slug, 'guardian', wardUri)) return { status: 404, error: 'not_my_ward' };
|
|---|
| 220 | const set = await existingGuardiansOf(wardUri);
|
|---|
| 221 | const others = set.filter((g) => g !== me);
|
|---|
| 222 | // Only a set we actually read counts as proof. A remote ward whose server is
|
|---|
| 223 | // down reads as an empty set; refusing on that would trap the guardian, and
|
|---|
| 224 | // the ward's server checks again on arrival anyway.
|
|---|
| 225 | if (set.length && others.length === 0) return { status: 409, error: 'would_emancipate' };
|
|---|
| 226 | const recipients = [wardUri, ...others];
|
|---|
| 227 | const undo = {
|
|---|
| 228 | id: `${me}/undo/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`,
|
|---|
| 229 | type: 'Undo', actor: me, to: recipients,
|
|---|
| 230 | object: { type: 'Relationship', subject: wardUri, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: me },
|
|---|
| 231 | };
|
|---|
| 232 | const delivered = await fanout(site, recipients, undo);
|
|---|
| 233 | relations.removeRelation(site.slug, 'guardian', wardUri);
|
|---|
| 234 | // A ward we host ourselves never receives its own delivery: an inbox on this
|
|---|
| 235 | // machine is not reachable over HTTP from this machine (and should not be).
|
|---|
| 236 | // The commit path has the same shape and solves it the same way — each
|
|---|
| 237 | // instance writes what it hosts (applyCommitLocally).
|
|---|
| 238 | const wardSlug = deps.localSlug(wardUri);
|
|---|
| 239 | if (wardSlug) dropGuardianFromWard(wardSlug, deps.selfId(site.slug));
|
|---|
| 240 | notify(site.slug, { kind: 'guardianship_ended', ward: wardUri, delivered });
|
|---|
| 241 | return { status: 202, delivered, guardiansLeft: others.length };
|
|---|
| 242 | }
|
|---|
| 243 |
|
|---|
| 244 | /**
|
|---|
| 245 | * The ward's side of an ended guardianship: drop that guardian, unless doing so
|
|---|
| 246 | * would empty the set. §3.3 only permits this while more than one remains;
|
|---|
| 247 | * emptying it is emancipation (§3.4) and no single party decides that.
|
|---|
| 248 | */
|
|---|
| 249 | function dropGuardianFromWard(wardSlug, guardianUri) {
|
|---|
| 250 | const set = relations.listGuardians(wardSlug).map((r) => r.other_uri);
|
|---|
| 251 | if (!set.includes(guardianUri)) return false; // already gone: an Undo is idempotent
|
|---|
| 252 | if (set.length <= 1) {
|
|---|
| 253 | notify(wardSlug, { kind: 'guardianship_end_refused', guardian: guardianUri, reason: 'would_emancipate' });
|
|---|
| 254 | return false;
|
|---|
| 255 | }
|
|---|
| 256 | relations.removeRelation(wardSlug, 'ward', guardianUri);
|
|---|
| 257 | notify(wardSlug, { kind: 'guardian_left', guardian: guardianUri });
|
|---|
| 258 | return true;
|
|---|
| 259 | }
|
|---|
| 260 |
|
|---|
| 261 | /** The receiving side of that Undo. Returns true when consumed. */
|
|---|
| 262 | function applyInboundUndo(site, activity) {
|
|---|
| 263 | const rel = parseUndoRelationship(activity);
|
|---|
| 264 | if (!rel) return false;
|
|---|
| 265 | const me = deps.selfId(site.slug);
|
|---|
| 266 | const actor = idOf(activity.actor);
|
|---|
| 267 | const ward = rel.ward;
|
|---|
| 268 | const guardian = rel.candidate; // in an Undo the Relationship's object is the leaving guardian
|
|---|
| 269 |
|
|---|
| 270 | if (ward === me) {
|
|---|
| 271 | // I am the ward. Only the guardian itself may end its own relation here;
|
|---|
| 272 | // the ward-co-signed variant of §3.2 needs a second signature and is not
|
|---|
| 273 | // built, so it is refused rather than half-honoured.
|
|---|
| 274 | if (actor !== guardian) return false;
|
|---|
| 275 | dropGuardianFromWard(site.slug, guardian);
|
|---|
| 276 | return true;
|
|---|
| 277 | }
|
|---|
| 278 |
|
|---|
| 279 | // I am one of the other guardians: nothing of mine changes, but being left
|
|---|
| 280 | // as one of fewer is exactly the kind of thing a guardian should hear about.
|
|---|
| 281 | if (relations.getRelation(site.slug, 'guardian', ward)) {
|
|---|
| 282 | notify(site.slug, { kind: 'coguardian_left', ward, guardian });
|
|---|
| 283 | return true;
|
|---|
| 284 | }
|
|---|
| 285 | return false;
|
|---|
| 286 | }
|
|---|
| 287 |
|
|---|
| 288 | // ── C2S: a LOCAL party acts (PWA, Berichten, or the Shaer app outbox) ──────
|
|---|
| 289 |
|
|---|
| 290 | /**
|
|---|
| 291 | * Handle a guardianship activity POSTed to the local outbox. Returns null when
|
|---|
| 292 | * it is not ours, else {status, ...} for the route.
|
|---|
| 293 | */
|
|---|
| 294 | export async function handleOutbox(site, activity) {
|
|---|
| 295 | const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
|
|---|
| 296 | if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return null;
|
|---|
| 297 | const me = deps.selfId(site.slug);
|
|---|
| 298 | // One answer restores everything (§3.6): any C2S activity from this actor
|
|---|
| 299 | // is that answer, for every local ward it guards. Runs before anything is
|
|---|
| 300 | // even looked at, so the target of a running lapse cancels it by doing
|
|---|
| 301 | // anything at all — including trying to vote on it.
|
|---|
| 302 | try { availability.oneAnswer(me, Date.now()); } catch { /* never load-bearing */ }
|
|---|
| 303 |
|
|---|
| 304 | // ── Undo: a guardian ends its own guardianship (§3.2). Same path as the
|
|---|
| 305 | // button in the Guardian PWA, so an app and the dashboard cannot drift.
|
|---|
| 306 | if (type === 'Undo') {
|
|---|
| 307 | const rel = parseUndoRelationship(activity);
|
|---|
| 308 | if (!rel) return null;
|
|---|
| 309 | if (rel.candidate !== me) return { status: 403, error: 'not_your_relation' };
|
|---|
| 310 | return endGuardianship(site, rel.ward);
|
|---|
| 311 | }
|
|---|
| 312 |
|
|---|
| 313 | // ── Offer: the local site is the guardian-candidate. ───────────────────
|
|---|
| 314 | if (type === 'Offer') {
|
|---|
| 315 | // §3.6.3 over C2S: a guardian here proposes releasing a dormant
|
|---|
| 316 | // co-guardian. A ward we host opens locally; a remote ward gets the
|
|---|
| 317 | // proposal delivered, because the ward's server is the one that tallies
|
|---|
| 318 | // and enforces (the §5.6 line: a guardian next door must not have more
|
|---|
| 319 | // say than one far away).
|
|---|
| 320 | const lp = availability.parseLapse(activity.object);
|
|---|
| 321 | if (lp) {
|
|---|
| 322 | // ONE path (Robins regel, 29-7): the ward's server opens, tallies and
|
|---|
| 323 | // enforces, wherever it lives. A local ward is reached by the same
|
|---|
| 324 | // deliverTo, which loops back into the inbox handler; co-location is a
|
|---|
| 325 | // transport detail and never a shortcut past the decision.
|
|---|
| 326 | const id = `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
|
|---|
| 327 | const offer = { id, type: 'Offer', actor: me, to: [lp.ward], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } };
|
|---|
| 328 | const delivered = await fanout(site, [lp.ward], offer);
|
|---|
| 329 | return { status: 202, id, url: id, delivered };
|
|---|
| 330 | }
|
|---|
| 331 | const rel = parseRelationship(activity.object);
|
|---|
| 332 | if (!rel) return null;
|
|---|
| 333 | if (rel.candidate !== me) return { status: 403, error: 'only_the_candidate_offers' }; // fixed initiator (§3.1)
|
|---|
| 334 | if (relations.listGuardians(site.slug).length) return { status: 403, error: 'a_ward_cannot_guard' }; // §1
|
|---|
| 335 | const existing = await existingGuardiansOf(rel.ward);
|
|---|
| 336 | const offerId = `${me}/offers/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
|
|---|
| 337 | offers.start(site.slug, {
|
|---|
| 338 | offerId, ward: rel.ward, candidate: me, existingGuardians: existing,
|
|---|
| 339 | wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(me),
|
|---|
| 340 | });
|
|---|
| 341 | // The Offer IS the candidate's agreement to serve: record it as the
|
|---|
| 342 | // candidate's accept. So a FREE ward commits on its own single accept (no
|
|---|
| 343 | // second guardian to co-approve yet); once it IS a ward, adding another
|
|---|
| 344 | // guardian still needs an existing guardian to co-accept.
|
|---|
| 345 | offers.recordAccept(site.slug, offerId, me);
|
|---|
| 346 | // Addressed to the ward AND every existing guardian (§3.1.1).
|
|---|
| 347 | const recipients = [rel.ward, ...existing];
|
|---|
| 348 | const delivered = await fanout(site, recipients, offerActivity(offerId, rel.ward, me, recipients));
|
|---|
| 349 | notify(site.slug, { kind: 'offer_sent', ward: rel.ward });
|
|---|
| 350 | return { status: 202, id: offerId, url: offerId, delivered };
|
|---|
| 351 | }
|
|---|
| 352 |
|
|---|
| 353 | // ── Accept / Reject: the local site is a party answering an offer. ─────
|
|---|
| 354 | const offerId = idOf(activity.object);
|
|---|
| 355 | if (!offerId) return { status: 400, error: 'missing_offer' };
|
|---|
| 356 | // A lapse vote over C2S (§3.6.3): the same Accept/Reject wire the offers
|
|---|
| 357 | // and gated follows use, which is exactly why the Shaer clients need no
|
|---|
| 358 | // new verbs for it.
|
|---|
| 359 | if (availability.getLapse(offerId)) {
|
|---|
| 360 | const r = availability.lapseVote(offerId, me, type === 'Accept', Date.now());
|
|---|
| 361 | if (r && r.error) return { status: r.error === 'not_in_set' ? 403 : 409, error: r.error };
|
|---|
| 362 | return { status: 202, id: offerId, url: offerId, 'shaer:outcome': 'open', 'shaer:accepts': r.accepts, 'shaer:threshold': r.threshold };
|
|---|
| 363 | }
|
|---|
| 364 | let offer = offers.getOffer(site.slug, offerId);
|
|---|
| 365 | if (!offer) return { status: 404, error: 'no_such_offer' };
|
|---|
| 366 | const others = offers.parties(offer).filter((p) => p !== me);
|
|---|
| 367 |
|
|---|
| 368 | if (type === 'Reject') {
|
|---|
| 369 | offers.recordReject(site.slug, offerId, me);
|
|---|
| 370 | await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Reject', actor: me, to: others, object: offerId });
|
|---|
| 371 | notify(site.slug, { kind: 'offer_rejected', offer: offerId });
|
|---|
| 372 | return { status: 202, id: offerId, url: offerId };
|
|---|
| 373 | }
|
|---|
| 374 |
|
|---|
| 375 | // §1 is flat in BOTH directions. The Offer path above bars a ward from
|
|---|
| 376 | // offering to guard; this is the mirror: an actor that already guards wards
|
|---|
| 377 | // must not become a ward itself. Only the ward's own accept can create that
|
|---|
| 378 | // state, so the candidate and the existing guardians pass through untouched.
|
|---|
| 379 | //
|
|---|
| 380 | // Without it the inconsistency would also be invisible. actorProps() picks
|
|---|
| 381 | // one role with an if/else and would publish shaer:guardians while dropping
|
|---|
| 382 | // shaer:isGuardian, so this account keeps routing its wards' escalations
|
|---|
| 383 | // locally while every remote §4 check reads it as malformed and drops it —
|
|---|
| 384 | // a ward believing it is watched over when it is not, silent on both sides.
|
|---|
| 385 | if (me === offer.ward_uri && relations.listWards(site.slug).length) {
|
|---|
| 386 | return { status: 403, error: 'a_guardian_cannot_be_guarded' };
|
|---|
| 387 | }
|
|---|
| 388 |
|
|---|
| 389 | // Accept: record my accept, broadcast it to the other parties, and commit
|
|---|
| 390 | // this copy if the tally is now complete (order-independent, §3.1.3).
|
|---|
| 391 | offers.recordAccept(site.slug, offerId, me);
|
|---|
| 392 | await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId });
|
|---|
| 393 | const { done, refused, offer: voided } = await maybeCommit(site.slug, offerId);
|
|---|
| 394 | if (refused) {
|
|---|
| 395 | // §4.2: the refusal travels as a `Reject` of the Offer (§3.2), which an
|
|---|
| 396 | // implementation unaware of §4 still handles correctly. Who is told WHY is
|
|---|
| 397 | // not uniform, and deliberately so.
|
|---|
| 398 | const answer = (to, withReason) => ({
|
|---|
| 399 | id: `${me}/answers/${Date.now().toString(36)}`,
|
|---|
| 400 | type: 'Reject', actor: me, to, object: offerId,
|
|---|
| 401 | ...(withReason ? { 'shaer:notATeapot': true } : {}),
|
|---|
| 402 | });
|
|---|
| 403 | const candidate = voided && voided.candidate_uri;
|
|---|
| 404 | // The ward and its existing guardians MUST learn the reason: they are
|
|---|
| 405 | // parties, the condition is public data (§2.1), and a bare void would
|
|---|
| 406 | // leave a ward believing an adoption completed that did not.
|
|---|
| 407 | const family = others.filter((u) => u !== candidate);
|
|---|
| 408 | if (family.length) await fanout(site, family, answer(family, true));
|
|---|
| 409 | // The candidate gets a BARE Reject. Commit is the last step of §3.1, so a
|
|---|
| 410 | // refusal that names itself technical also discloses that every human
|
|---|
| 411 | // party already accepted and only the protocol objected — which, where a
|
|---|
| 412 | // guardianship is contested, is not theirs to learn. The kind path for an
|
|---|
| 413 | // merely misconfigured candidate is the check on the Offer, before anyone
|
|---|
| 414 | // has consented to anything.
|
|---|
| 415 | if (candidate && others.includes(candidate)) await fanout(site, [candidate], answer([candidate], false));
|
|---|
| 416 | return { status: 202, id: offerId, url: offerId, committed: false, refused };
|
|---|
| 417 | }
|
|---|
| 418 | return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) };
|
|---|
| 419 | }
|
|---|
| 420 |
|
|---|
| 421 | // ── S2S: a REMOTE party's activity arrives in a local inbox ────────────────
|
|---|
| 422 |
|
|---|
| 423 | /**
|
|---|
| 424 | * Handle an inbound guardianship activity for the local site `site` (the inbox
|
|---|
| 425 | * owner). Returns true when consumed.
|
|---|
| 426 | */
|
|---|
| 427 | export async function handleInbox(site, activity) {
|
|---|
| 428 | const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
|
|---|
| 429 | if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return false;
|
|---|
| 430 | if (type === 'Undo') return applyInboundUndo(site, activity);
|
|---|
| 431 | const me = deps.selfId(site.slug);
|
|---|
| 432 | const actor = idOf(activity.actor);
|
|---|
| 433 |
|
|---|
| 434 | // §5.6: a guardian proposes a gated setting for THIS ward. The ward's server
|
|---|
| 435 | // tallies and enforces, so the decision lands here, not on the proposer.
|
|---|
| 436 | if (type === 'Offer') {
|
|---|
| 437 | const gs = gated.parseGatedSetting(activity.object);
|
|---|
| 438 | if (gs) {
|
|---|
| 439 | const offerId = idOf(activity);
|
|---|
| 440 | // ── I am the WARD: record, tally, and forward to the other guardians.
|
|---|
| 441 | if (gs.ward === me) {
|
|---|
| 442 | gated.rememberGatedOffer(offerId, site.slug, gs.feature, gs.value, actor);
|
|---|
| 443 | // The proposer's Offer carries its own agreement (§3.1's one-step clause).
|
|---|
| 444 | const r = gated.recordGatedVote(site.slug, gs.feature, actor, gs.value);
|
|---|
| 445 | // The forward is the leg that was missing. A proposal addressed to the
|
|---|
| 446 | // ward's server reaches only the proposer and the ward; the other
|
|---|
| 447 | // guardians never learn it exists, so a threshold of two can never be
|
|---|
| 448 | // met and every proposal expires unanswered. The ward's server is the
|
|---|
| 449 | // one that knows the authoritative guardian list, which is exactly why
|
|---|
| 450 | // §5.3 forwards a gated follow from here too.
|
|---|
| 451 | if (r.state === 'open') {
|
|---|
| 452 | for (const g of relations.listGuardians(site.slug).map((x) => x.other_uri)) {
|
|---|
| 453 | if (g === actor) continue; // the proposer already answered
|
|---|
| 454 | // The forward goes out AS THE WARD, because the ward's key signs
|
|---|
| 455 | // it. Keeping the proposer in `actor` made every receiver answer
|
|---|
| 456 | // 401 signer mismatch, and rightly so: the body claimed one author
|
|---|
| 457 | // and the signature proved another. §5.3 forwards a gated follow
|
|---|
| 458 | // the same way. Who proposed it rides along separately, for the
|
|---|
| 459 | // guardian's screen.
|
|---|
| 460 | // Zou DIT antwoord het besluit afmaken (shaer-8vt)? De telling loopt
|
|---|
| 461 | // hier, op de server van het kind, en nergens anders -- zonder dit
|
|---|
| 462 | // veld kan een guardian elders onmogelijk weten dat hij de doorslag
|
|---|
| 463 | // geeft. Een ja/nee en geen getal: zie isDecisive.
|
|---|
| 464 | const p = gated.gatedProgress(site.slug, gs.feature);
|
|---|
| 465 | deps.deliverTo(site, g, {
|
|---|
| 466 | id: offerId, type: 'Offer', actor: me, to: [g], object: activity.object,
|
|---|
| 467 | 'shaer:proposer': actor,
|
|---|
| 468 | 'shaer:decisive': gated.isDecisive(p.votes, p.need),
|
|---|
| 469 | }).catch(() => { /* the delivery queue retries */ });
|
|---|
| 470 | }
|
|---|
| 471 | } else {
|
|---|
| 472 | gated.clearGatedReviews(offerId); // settled at once: nothing left to ask
|
|---|
| 473 | answerGatedProposer(site, offerId, r);
|
|---|
| 474 | }
|
|---|
| 475 | notify(site.slug, { kind: 'gated_setting', feature: gs.feature, value: gs.value, state: r.state });
|
|---|
| 476 | return true;
|
|---|
| 477 | }
|
|---|
| 478 | // ── I am one of the GUARDIANS: the forwarded copy. Store it so this
|
|---|
| 479 | // guardian can answer; the answer goes back to the ward, which tallies.
|
|---|
| 480 | if (relations.getRelation(site.slug, 'guardian', gs.ward)) {
|
|---|
| 481 | const wardDoc = await deps.fetchActor(gs.ward).catch(() => null);
|
|---|
| 482 | gated.recordGatedReview(site.slug, {
|
|---|
| 483 | id: offerId, wardUri: gs.ward, wardInbox: wardDoc && wardDoc.inbox,
|
|---|
| 484 | // A forward is signed by the ward, so `actor` is the ward; the
|
|---|
| 485 | // guardian who opened it travels in shaer:proposer.
|
|---|
| 486 | proposer: (typeof activity['shaer:proposer'] === 'string' ? activity['shaer:proposer'] : actor),
|
|---|
| 487 | feature: gs.feature, value: gs.value,
|
|---|
| 488 | // Ontbreekt het veld (een oudere server), dan WAARSCHUWEN we: niets
|
|---|
| 489 | // zeggen terwijl je beslist is de gevaarlijke kant (shaer-8vt).
|
|---|
| 490 | decisive: activity['shaer:decisive'] !== false,
|
|---|
| 491 | });
|
|---|
| 492 | notify(site.slug, { kind: 'gated_review', feature: gs.feature, value: gs.value, ward: gs.ward });
|
|---|
| 493 | return true;
|
|---|
| 494 | }
|
|---|
| 495 | return false; // not our ward, and not a ward we guard
|
|---|
| 496 | }
|
|---|
| 497 | // §3.6.3: a co-guardian proposes releasing a dormant guardian of THIS
|
|---|
| 498 | // ward. The ward's server opens, tallies and (after the full window)
|
|---|
| 499 | // executes, exactly as it does for the gated settings above.
|
|---|
| 500 | const lp = availability.parseLapse(activity.object);
|
|---|
| 501 | if (lp) {
|
|---|
| 502 | if (lp.ward !== me) return false; // not our ward
|
|---|
| 503 | const id = idOf(activity) || `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
|
|---|
| 504 | const r = availability.openLapse({ id, wardSlug: site.slug, wardUri: me, target: lp.target, openedBy: actor, now: Date.now() });
|
|---|
| 505 | if (r.error) {
|
|---|
| 506 | notify(site.slug, { kind: 'lapse_refused', reason: r.error, target: lp.target });
|
|---|
| 507 | return true; // consumed: the refusal is the answer
|
|---|
| 508 | }
|
|---|
| 509 | // The target is notified like any dormancy marking (§3.6.2): in
|
|---|
| 510 | // protocol (a copy of the Offer, so one answer can cancel it) AND the
|
|---|
| 511 | // §6 handle, which for a committed guardian is its inbox — the same
|
|---|
| 512 | // door this delivery knocks on.
|
|---|
| 513 | deps.deliverTo(site, lp.target, activity).catch(() => { /* best-effort */ });
|
|---|
| 514 | notify(site.slug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set });
|
|---|
| 515 | return true;
|
|---|
| 516 | }
|
|---|
| 517 | const rel = parseRelationship(activity.object);
|
|---|
| 518 | if (!rel) return false;
|
|---|
| 519 | // I must be a party: the ward, or one of the existing guardians in `to`.
|
|---|
| 520 | const recipients = arr(activity.to);
|
|---|
| 521 | const existing = recipients.filter((u) => u !== rel.ward);
|
|---|
| 522 | if (rel.ward !== me && !existing.includes(me)) return false;
|
|---|
| 523 | // §4.2: check the candidate here too, and refuse before anyone accepts.
|
|---|
| 524 | // At this point no party has consented, so saying why discloses nothing
|
|---|
| 525 | // about anyone's position, and a candidate that is merely misconfigured
|
|---|
| 526 | // can find that out and fix it. The commit-time check stays REQUIRED as
|
|---|
| 527 | // the backstop for a candidate whose state changes in between.
|
|---|
| 528 | if (await candidateFitness(rel.candidate) === 'malformed') {
|
|---|
| 529 | notify(site.slug, { kind: 'offer_refused', offer: idOf(activity), reason: 'not_a_teapot', candidate: rel.candidate });
|
|---|
| 530 | await fanout(site, [rel.candidate], {
|
|---|
| 531 | id: `${me}/answers/${Date.now().toString(36)}`,
|
|---|
| 532 | type: 'Reject', actor: me, to: [rel.candidate], object: idOf(activity), 'shaer:notATeapot': true,
|
|---|
| 533 | });
|
|---|
| 534 | return true;
|
|---|
| 535 | }
|
|---|
| 536 | offers.start(site.slug, {
|
|---|
| 537 | offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing,
|
|---|
| 538 | wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(rel.candidate),
|
|---|
| 539 | });
|
|---|
| 540 | // The Offer carries the candidate's agreement (see the C2S side): record it
|
|---|
| 541 | // so this copy's tally matches — a free ward then commits on its own accept.
|
|---|
| 542 | offers.recordAccept(site.slug, idOf(activity), rel.candidate);
|
|---|
| 543 | notify(site.slug, { kind: rel.ward === me ? 'offer_received' : 'offer_for_ward', ward: rel.ward, candidate: rel.candidate });
|
|---|
| 544 | return true;
|
|---|
| 545 | }
|
|---|
| 546 |
|
|---|
| 547 | // Accept / Reject of an offer we (also) track.
|
|---|
| 548 | const offerId = idOf(activity.object);
|
|---|
| 549 | // §5.6, the answer coming HOME: the ward's server settled a decision we
|
|---|
| 550 | // proposed and answers our Offer. Accept = it settled on what we proposed,
|
|---|
| 551 | // Reject = on the opposite. Only the ward may say so: the answer must come
|
|---|
| 552 | // from the ward the proposal was about, or anyone could close our books.
|
|---|
| 553 | const sent = gated.recallSent(offerId);
|
|---|
| 554 | if (sent && sent.guardian_slug === site.slug) {
|
|---|
| 555 | if (actor !== sent.ward_uri) return false; // not the ward's voice: not an outcome
|
|---|
| 556 | const outcome = type === 'Accept' ? 'accepted' : 'rejected';
|
|---|
| 557 | gated.settleSent(offerId, outcome);
|
|---|
| 558 | notify(site.slug, { kind: 'gated_outcome', feature: sent.feature, value: !!sent.value, outcome, ward: sent.ward_uri });
|
|---|
| 559 | return true;
|
|---|
| 560 | }
|
|---|
| 561 | // §5.6: a fellow guardian answering a gated-setting proposal. The Accept only
|
|---|
| 562 | // references the offer, so the value comes from the proposal we stored. A
|
|---|
| 563 | // Reject is a vote for the opposite, not a shrug: it is still an answer.
|
|---|
| 564 | const gsOffer = gated.recallGatedOffer(offerId);
|
|---|
| 565 | if (gsOffer && gsOffer.slug === site.slug) {
|
|---|
| 566 | const value = type === 'Accept' ? !!gsOffer.value : !gsOffer.value;
|
|---|
| 567 | const r = gated.recordGatedVote(site.slug, gsOffer.feature, actor, value);
|
|---|
| 568 | if (r.state === 'settled') answerGatedProposer(site, offerId, r);
|
|---|
| 569 | // DOORSLAGGEVEND SCHUIFT MEE (Barts correctie, 8-8). Ik berekende dit een
|
|---|
| 570 | // keer bij het doorsturen en bevroor het. Bij vijf guardians staat er dan
|
|---|
| 571 | // "je beslist niets" -- en zodra er een ja bij komt IS elk van de anderen de
|
|---|
| 572 | // doorslag. Dat is precies de stille kant: het scherm zwijgt op het moment
|
|---|
| 573 | // dat het moet spreken.
|
|---|
| 574 | //
|
|---|
| 575 | // Dus na elke stem die het open laat: de overgeblevenen opnieuw vertellen
|
|---|
| 576 | // waar ze staan. Alleen wie NOG NIET geantwoord heeft, en alleen als het
|
|---|
| 577 | // antwoord verandert -- anders is dit een bericht per stem per guardian.
|
|---|
| 578 | else herzieDoorslag(site, offerId, gsOffer, actor);
|
|---|
| 579 | notify(site.slug, { kind: 'gated_setting', feature: gsOffer.feature, value, state: r.state });
|
|---|
| 580 | return true;
|
|---|
| 581 | }
|
|---|
| 582 | // §3.6.3: a set member answering a running lapse. Irreversible, so even a
|
|---|
| 583 | // full tally leaves it open until the window closes (§3.5); the completion
|
|---|
| 584 | // happens lazily on reads (queues) once the window has run.
|
|---|
| 585 | if (availability.getLapse(offerId)) {
|
|---|
| 586 | const r = availability.lapseVote(offerId, actor, type === 'Accept', Date.now());
|
|---|
| 587 | notify(site.slug, { kind: 'lapse_vote', lapse: offerId, by: actor, state: r && !r.error ? 'recorded' : (r && r.error) || 'refused' });
|
|---|
| 588 | return true;
|
|---|
| 589 | }
|
|---|
| 590 | let offer = offers.getOffer(site.slug, offerId);
|
|---|
| 591 | if (!offer) return false;
|
|---|
| 592 | if (!offers.isParty(offer, actor)) return false;
|
|---|
| 593 |
|
|---|
| 594 | if (type === 'Reject') {
|
|---|
| 595 | offers.recordReject(site.slug, offerId, actor);
|
|---|
| 596 | notify(site.slug, { kind: 'offer_rejected', offer: offerId });
|
|---|
| 597 | return true;
|
|---|
| 598 | }
|
|---|
| 599 |
|
|---|
| 600 | offers.recordAccept(site.slug, offerId, actor);
|
|---|
| 601 | await maybeCommit(site.slug, offerId); // commits this copy once the tally is complete (§4.2 may refuse)
|
|---|
| 602 | return true;
|
|---|
| 603 | }
|
|---|
| 604 |
|
|---|
| 605 | /**
|
|---|
| 606 | * §4.2 SHOULD: retry the dereference for handshakes left deferred because the
|
|---|
| 607 | * candidate could not be read.
|
|---|
| 608 | *
|
|---|
| 609 | * Waiting for a further activity from a party is not enough: the commit is
|
|---|
| 610 | * triggered by the LAST `Accept`, so if that one has already arrived nothing
|
|---|
| 611 | * will ever poke it again and the handshake would sit until its window closed.
|
|---|
| 612 | * The ward's dashboard polling its own offers queue is this instance's
|
|---|
| 613 | * schedule, exactly as a read settles a lapse (§3.6.3).
|
|---|
| 614 | *
|
|---|
| 615 | * Deliberately not awaited by the read: a poll should render what is true now,
|
|---|
| 616 | * not block on someone else's slow server. A retry that succeeds shows up in
|
|---|
| 617 | * the next poll, which is the same second or two later.
|
|---|
| 618 | */
|
|---|
| 619 | export async function retryDeferred(slug) {
|
|---|
| 620 | for (const o of offers.listDeferred(slug)) {
|
|---|
| 621 | await maybeCommit(slug, o.offer_id).catch(() => { /* next poll tries again */ });
|
|---|
| 622 | }
|
|---|
| 623 | }
|
|---|
| 624 |
|
|---|
| 625 | function notify(slug, ev) {
|
|---|
| 626 | try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
|
|---|
| 627 | }
|
|---|
| 628 |
|
|---|
| 629 | export default { wireHandshake, handleOutbox, handleInbox, parseRelationship, parseUndoRelationship, endGuardianship, retryDeferred };
|
|---|