Changeset 780a7c6 in Klonkt for src/services/guardianship
- Timestamp:
- 07/24/2026 07:44:15 PM (7 weeks ago)
- Branches:
- main
- Children:
- b5924eb
- Parents:
- c26cc18
- Location:
- src/services/guardianship
- Files:
-
- 1 added
- 4 edited
-
handshake.js (modified) (3 diffs)
-
index.js (modified) (3 diffs)
-
offers.js (added)
-
queues.js (modified) (2 diffs)
-
relations.js (modified) (5 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/services/guardianship/handshake.js
rc26cc18 r780a7c6 1 1 /** 2 * Guardianship (FEP-633c §3) — the adoption handshake. 2 * Guardianship (FEP-633c §3) — the adoption handshake, multi-party and 3 * distributed across instances. 3 4 * 4 * Offer(Relationship{subject: ward, relationship: shaer:Guardian, object: 5 * candidate}) travels from the guardian-candidate to the ward; the ward 6 * answers Accept (relation becomes real) or Reject (row disappears). The 7 * shape mirrors the Shaer test daemon, so the iOS/Android clients speak it 8 * unchanged. 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). 9 13 * 10 * Wired like delivery.js: no import back into ActivityPubService; the AP11 * helpers arrive once via wireHandshake(deps). `deps.onEvent(slug, ev)` is an12 * optional hook the Guardian PWA uses for push notifications.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. 13 17 */ 14 18 import { isGuardianRelationship, GUARDIAN_RELATIONSHIP_COMPACT } from './context.js'; 19 import * as offers from './offers.js'; 15 20 import * as relations from './relations.js'; 16 21 … … 19 24 20 25 const idOf = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.id === 'string' ? v.id : null)); 26 const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string'); 21 27 22 28 /** Parse a Relationship object into {ward, candidate} or null. */ … … 31 37 } 32 38 33 // ── C2S: the local account acts (PWA or Shaer app, via the outbox) ──────── 39 /** The existing guardians of a ward: local list, or the remote actor's shaer:guardians. */ 40 async function existingGuardiansOf(wardUri) { 41 const local = deps.localSlug(wardUri); 42 if (local) return relations.listGuardians(local).map((r) => r.other_uri); 43 const doc = await deps.fetchActor(wardUri).catch(() => null); 44 const g = doc && doc['shaer:guardians']; 45 return Array.isArray(g) ? g.filter((x) => typeof x === 'string') : []; 46 } 47 48 function offerActivity(offerId, ward, candidate, recipients) { 49 return { 50 id: offerId, type: 'Offer', actor: candidate, to: recipients, 51 object: { type: 'Relationship', subject: ward, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: candidate }, 52 }; 53 } 54 55 /** Deliver `activity` to every uri in `recipients` (skipping the local self). */ 56 async function fanout(site, recipients, activity) { 57 let anyDelivered = false; 58 for (const uri of [...new Set(recipients)]) { 59 const r = await deps.deliverTo(site, uri, activity).catch(() => ({ delivered: false })); 60 if (r && r.delivered !== false) anyDelivered = true; 61 } 62 return anyDelivered; 63 } 64 65 /** Apply the local side of a commit: the ward writes its guardian, the 66 * candidate writes its ward. Each instance writes only what it hosts. */ 67 function applyCommitLocally(offer, handle) { 68 const wardSlug = deps.localSlug(offer.ward_uri); 69 const candSlug = deps.localSlug(offer.candidate_uri); 70 if (wardSlug) relations.commitGuardianForWard(wardSlug, offer.candidate_uri, { handle, offerId: offer.offer_id }); 71 if (candSlug) relations.commitWardForGuardian(candSlug, offer.ward_uri, { handle, offerId: offer.offer_id }); 72 } 73 74 /** Commit this local copy of the offer when the tally is complete (ward + 75 * candidate + ≥1 existing guardian, §3.1.2). The handle is the candidate's 76 * inbox (§6 minimum); the commit is order-independent, so whichever accept 77 * lands last triggers it on every copy. */ 78 function maybeCommit(slug, offerId) { 79 const offer = offers.getOffer(slug, offerId); 80 if (!offer || !offers.readyToCommit(offer)) return null; 81 const done = offers.commit(slug, offerId, `${offer.candidate_uri}/inbox`); 82 if (done) { applyCommitLocally(done, done.handle); notify(slug, { kind: 'committed', ward: done.ward_uri, guardian: done.candidate_uri }); } 83 return done; 84 } 85 86 // ── C2S: a LOCAL party acts (PWA, Berichten, or the Shaer app outbox) ────── 34 87 35 88 /** 36 * Handle a guardianship activity POSTed to the local outbox. Returns null 37 * when the activity is not ours to handle, else {status, ...} for the route.89 * Handle a guardianship activity POSTed to the local outbox. Returns null when 90 * it is not ours, else {status, ...} for the route. 38 91 */ 39 92 export async function handleOutbox(site, activity) { 40 const { selfId, deliverTo, deriveHandle } = deps;41 93 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type; 42 94 if (!['Offer', 'Accept', 'Reject'].includes(type)) return null; 43 const me = selfId(site.slug);95 const me = deps.selfId(site.slug); 44 96 97 // ── Offer: the local site is the guardian-candidate. ─────────────────── 45 98 if (type === 'Offer') { 46 99 const rel = parseRelationship(activity.object); 47 if (!rel) return null; // not a guardianship offer 48 // Fixed initiator (FEP resolved B): only the aspirant guardian offers. 49 if (rel.candidate !== me) return { status: 403, error: 'only_the_candidate_offers' }; 50 // A ward can never become a guardian (FEP §1). 51 if (relations.listGuardians(site.slug).length) return { status: 403, error: 'a_ward_cannot_guard' }; 52 const offerId = `${me}/offers/${Date.now().toString(36)}`; 53 const offer = { 54 id: offerId, type: 'Offer', actor: me, to: [rel.ward], 55 object: { type: 'Relationship', subject: rel.ward, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: me }, 56 }; 57 relations.recordOffer(site.slug, 'guardian', rel.ward, { handle: deriveHandle(rel.ward), offerId }); 58 // The offer is now recorded (the guardian sees it as pending); delivery is 59 // async + retried, so a slow ward server never fails the whole action. 60 const res = await deliverTo(site, rel.ward, offer).catch(() => ({ delivered: false })); 100 if (!rel) return null; 101 if (rel.candidate !== me) return { status: 403, error: 'only_the_candidate_offers' }; // fixed initiator (§3.1) 102 if (relations.listGuardians(site.slug).length) return { status: 403, error: 'a_ward_cannot_guard' }; // §1 103 const existing = await existingGuardiansOf(rel.ward); 104 const offerId = `${me}/offers/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`; 105 offers.start(site.slug, { 106 offerId, ward: rel.ward, candidate: me, existingGuardians: existing, 107 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(me), 108 }); 109 // Addressed to the ward AND every existing guardian (§3.1.1). 110 const recipients = [rel.ward, ...existing]; 111 const delivered = await fanout(site, recipients, offerActivity(offerId, rel.ward, me, recipients)); 61 112 notify(site.slug, { kind: 'offer_sent', ward: rel.ward }); 62 return { status: 202, id: offerId, url: offerId, delivered : res && res.delivered !== false};113 return { status: 202, id: offerId, url: offerId, delivered }; 63 114 } 64 115 65 // Accept / Reject: the local ward answers a pending offer. 66 const obj = activity.object; 67 const offerId = idOf(obj); 68 const rel = parseRelationship(obj && obj.object) || parseRelationship(obj); 69 let row = null; 70 if (offerId) row = relations.findByOfferId(offerId).find((r) => r.slug === site.slug && r.role === 'ward') || null; 71 if (!row && rel) row = relations.getRelation(site.slug, 'ward', rel.candidate) || null; 72 if (!row) return { status: 404, error: 'no_such_offer' }; 116 // ── Accept / Reject: the local site is a party answering an offer. ───── 117 const offerId = idOf(activity.object); 118 if (!offerId) return { status: 400, error: 'missing_offer' }; 119 let offer = offers.getOffer(site.slug, offerId); 120 if (!offer) return { status: 404, error: 'no_such_offer' }; 121 const others = offers.parties(offer).filter((p) => p !== me); 73 122 74 const answer = { 75 id: `${me}/answers/${Date.now().toString(36)}`, type, actor: me, to: [row.other_uri], 76 object: row.offer_id || { type: 'Relationship', subject: me, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: row.other_uri }, 77 }; 78 if (type === 'Accept') { 79 // The committed handle rides in `result` (daemon contract): the guardian 80 // learns where the ward lives. 81 answer.result = `${me}/inbox`; 82 relations.acceptRelation(site.slug, 'ward', row.other_uri); 83 } else { 84 relations.removeRelation(site.slug, 'ward', row.other_uri); 123 if (type === 'Reject') { 124 offers.recordReject(site.slug, offerId, me); 125 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Reject', actor: me, to: others, object: offerId }); 126 notify(site.slug, { kind: 'offer_rejected', offer: offerId }); 127 return { status: 202, id: offerId, url: offerId }; 85 128 } 86 // The answer is committed locally; delivery is async + retried. 87 const res = await deliverTo(site, row.other_uri, answer).catch(() => ({ delivered: false })); 88 notify(site.slug, { kind: type === 'Accept' ? 'offer_accepted' : 'offer_rejected', guardian: row.other_uri }); 89 return { status: 202, id: answer.id, url: answer.id, delivered: res && res.delivered !== false }; 129 130 // Accept: record my accept, broadcast it to the other parties, and commit 131 // this copy if the tally is now complete (order-independent, §3.1.3). 132 offers.recordAccept(site.slug, offerId, me); 133 await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId }); 134 const done = maybeCommit(site.slug, offerId); 135 return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) }; 90 136 } 91 137 92 // ── S2S: a remote party acts (arrives in the local inbox)────────────────138 // ── S2S: a REMOTE party's activity arrives in a local inbox ──────────────── 93 139 94 140 /** 95 * Handle an inbound guardianship activity for local site `site`. Returns96 * true when consumed (the generic inbox skips it), false otherwise.141 * Handle an inbound guardianship activity for the local site `site` (the inbox 142 * owner). Returns true when consumed. 97 143 */ 98 144 export async function handleInbox(site, activity) { 99 const { selfId } = deps;100 145 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type; 101 146 if (!['Offer', 'Accept', 'Reject'].includes(type)) return false; 102 const me = selfId(site.slug);147 const me = deps.selfId(site.slug); 103 148 const actor = idOf(activity.actor); 104 149 105 150 if (type === 'Offer') { 106 151 const rel = parseRelationship(activity.object); 107 if (!rel || rel.ward !== me) return false; 108 // A remote candidate offers to guard the local ward: park it in the queue. 109 relations.recordOffer(site.slug, 'ward', rel.candidate, { handle: deps.deriveHandle(rel.candidate), offerId: idOf(activity) }); 110 notify(site.slug, { kind: 'offer_received', candidate: rel.candidate }); 152 if (!rel) return false; 153 // I must be a party: the ward, or one of the existing guardians in `to`. 154 const recipients = arr(activity.to); 155 const existing = recipients.filter((u) => u !== rel.ward); 156 if (rel.ward !== me && !existing.includes(me)) return false; 157 offers.start(site.slug, { 158 offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing, 159 wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(rel.candidate), 160 }); 161 notify(site.slug, { kind: rel.ward === me ? 'offer_received' : 'offer_for_ward', ward: rel.ward, candidate: rel.candidate }); 111 162 return true; 112 163 } 113 164 114 // Accept / Reject of an offer WE (local guardian) sent. 115 const obj = activity.object; 116 const offerId = idOf(obj); 117 const rel = parseRelationship(obj && obj.object) || parseRelationship(obj); 118 let row = null; 119 if (offerId) row = relations.findByOfferId(offerId).find((r) => r.slug === site.slug && r.role === 'guardian') || null; 120 if (!row && actor) row = relations.getRelation(site.slug, 'guardian', actor) || null; 121 if (!row && rel) row = relations.getRelation(site.slug, 'guardian', rel.ward) || null; 122 if (!row) return false; 165 // Accept / Reject of an offer we (also) track. 166 const offerId = idOf(activity.object); 167 let offer = offers.getOffer(site.slug, offerId); 168 if (!offer) return false; 169 if (!offers.isParty(offer, actor)) return false; 123 170 124 if (type === 'Accept') { 125 relations.acceptRelation(site.slug, 'guardian', row.other_uri); 126 notify(site.slug, { kind: 'ward_accepted', ward: row.other_uri }); 127 } else { 128 relations.removeRelation(site.slug, 'guardian', row.other_uri); 129 notify(site.slug, { kind: 'ward_rejected', ward: row.other_uri }); 171 if (type === 'Reject') { 172 offers.recordReject(site.slug, offerId, actor); 173 notify(site.slug, { kind: 'offer_rejected', offer: offerId }); 174 return true; 130 175 } 176 177 offers.recordAccept(site.slug, offerId, actor); 178 maybeCommit(site.slug, offerId); // commits this copy once the tally is complete 131 179 return true; 132 180 } -
src/services/guardianship/index.js
rc26cc18 r780a7c6 4 4 * Klonkt's kid-safety feature as one cohesive unit: 5 5 * - context.js: the shaer JSON-LD namespace + Relationship vocabulary 6 * - relations.js: ward ↔ guardian relations (ap_guardianships) + actor props 6 * - offers.js: the multi-party handshake state (a port of the Shaer daemon) 7 * - relations.js: the COMMITTED ward ↔ guardian relations + actor props 7 8 * - handshake.js: the adoption Offer/Accept/Reject over C2S and S2S 8 9 * - queues.js: the owner-only dashboard collections (offers/follows/wards) … … 10 11 * - delivery.js: the direct-note leg a ward's call-for-help rides 11 12 * 12 * The shared blocklist (Shaer's "in Orbit") intentionally lives NEXT TO this 13 * module in BlocklistService: Klonkt's own Block tab uses it too. 14 * 15 * ActivityPubService wires the AP helpers in once (wireDelivery/wireHandshake) 16 * and delegates; nothing here imports ActivityPubService back. 13 * The shared blocklist (Shaer's "in Orbit") lives NEXT TO this module in 14 * BlocklistService. ActivityPubService wires the AP helpers in once and 15 * delegates; nothing here imports ActivityPubService back. 17 16 */ 18 17 export { SHAER_CONTEXT, GUARDIAN_RELATIONSHIP, GUARDIAN_RELATIONSHIP_COMPACT, isGuardianRelationship } from './context.js'; … … 21 20 export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship } from './handshake.js'; 22 21 export { offersCollection, followsCollection, wardsCollection } from './queues.js'; 22 export { listForParty as listOffersForParty, getOffer, findOfferAnywhere } from './offers.js'; 23 23 export { 24 listGuardians, listWards, listOffers, isGuardian, getRelation, findByOfferId,25 recordOffer, acceptRelation, removeRelation,actorProps as guardianshipActorProps,24 listGuardians, listWards, isGuardian, getRelation, removeRelation, 25 actorProps as guardianshipActorProps, 26 26 } from './relations.js'; -
src/services/guardianship/queues.js
rc26cc18 r780a7c6 3 3 * 4 4 * Three OrderedCollections on the actor (shaer:queues), same contract as the 5 * Shaer test daemon so the iOS/Android guardiandashboards read them as-is:6 * - offers: pending guardianship offers where I am a party (§3)7 * - follows: pending follows for my wards (§5.3) — Klonkt has no gated8 * follows yet, so this collection isempty for now9 * - wards: my wards, for the dashboard's wards list5 * Shaer test daemon so the iOS/Android dashboards read them as-is: 6 * - offers: pending handshake offers where I am a party (§3), with the full 7 * accept tally so the client shows the right action 8 * - follows: pending gated follows for my wards (§5.3) — Fase 2, empty for now 9 * - wards: my committed wards 10 10 */ 11 import { GUARDIAN_RELATIONSHIP_COMPACT } from './context.js';11 import * as offers from './offers.js'; 12 12 import * as relations from './relations.js'; 13 13 … … 16 16 }); 17 17 18 /** Pending offers, reconstructed as Offer activities (either side). Each item 19 * also carries the daemon-contract helper fields (shaer:ward, candidate, 20 * needsMyAccept, iAmCandidate, …): the Shaer clients render their accept 21 * button from those, so the shapes must match the test daemon exactly. */ 18 /** Pending offers where the local site is a party, each with its accept tally. */ 22 19 export function offersCollection(id, slug, me) { 23 const items = relations.listOffers(slug).map((r) => { 24 const ward = r.role === 'guardian' ? r.other_uri : me; 25 const candidate = r.role === 'guardian' ? me : r.other_uri; 26 return { 27 id: r.offer_id || `${me}/offers/pending-${r.id}`, 28 type: 'Offer', 29 actor: candidate, 30 object: { 31 type: 'Relationship', 32 subject: ward, 33 relationship: GUARDIAN_RELATIONSHIP_COMPACT, 34 object: candidate, 35 }, 36 'shaer:ward': ward, 37 'shaer:candidate': candidate, 38 'shaer:existingGuardians': relations.listGuardians(slug).map((g) => g.other_uri), 39 'shaer:acceptedBy': [], 40 // Klonkt's flow is single-phase: the ward's Accept commits at once, so 41 // only the ward-side owner has an action here. 42 'shaer:needsMyAccept': r.role === 'ward', 43 'shaer:readyToCommit': false, 44 'shaer:iAmCandidate': r.role === 'guardian', 45 'shaer:handle': r.other_handle || undefined, 46 published: r.created_at, 47 }; 48 }); 20 const items = offers.listForParty(slug, me).map((o) => offers.queueItem(o, me)); 49 21 return collection(id, items); 50 22 } 51 23 52 /** Gated follows awaiting guardian approval — not built in Klonkt yet . */24 /** Gated follows awaiting guardian approval — not built in Klonkt yet (Fase 2). */ 53 25 export function followsCollection(id) { 54 26 return collection(id, []); 55 27 } 56 28 57 /** The guardian's wards (accepted), with cached handle for display. */29 /** The guardian's committed wards, with cached handle for display. */ 58 30 export function wardsCollection(id, slug) { 59 31 const items = relations.listWards(slug) 60 .filter((r) => r.status === 'accepted')61 32 .map((r) => ({ id: r.other_uri, 'shaer:handle': r.other_handle || undefined, since: r.created_at })); 62 33 return collection(id, items); -
src/services/guardianship/relations.js
rc26cc18 r780a7c6 1 1 /** 2 * Guardianship (FEP-633c) — the ward ↔ guardian relations (ap_guardianships). 3 * 4 * Every row is one relation seen from a LOCAL site: role 'guardian' means the 5 * site guards `other_uri` (a ward, possibly remote); role 'ward' means 6 * `other_uri` guards the site. A local ward with a local guardian yields two 7 * rows, one per perspective — intentional, each side reads its own. 8 * 9 * The handshake (spec §3): the guardian-candidate — and only the candidate — 10 * Offers a Relationship {subject: ward, relationship: shaer:Guardian, 11 * object: candidate}; the ward Accepts (or Rejects). Status walks 12 * 'offered' → 'accepted'; a Reject deletes the row. 2 * Guardianship (FEP-633c) — the COMMITTED ward ↔ guardian relations 3 * (ap_guardianships). Pending offers live in offers.js; a row here means the 4 * handshake committed (§3.1.4). Every row is one relation seen from a LOCAL 5 * site: role 'guardian' = the site guards other_uri; role 'ward' = other_uri 6 * guards the site. 13 7 */ 14 8 import db from '../../config/database.js'; … … 18 12 if (!_s) { 19 13 _s = { 20 ins: db.prepare(`INSERT OR IGNOREINTO ap_guardianships (slug, role, other_uri, other_handle, status, offer_id, created_at)21 VALUES (?,?,?,?,?,?,CURRENT_TIMESTAMP)`),22 accept: db.prepare(`UPDATE ap_guardianships SET status='accepted' WHERE slug=? AND role=? AND other_uri=?`),14 commit: db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, other_handle, status, offer_id, created_at) 15 VALUES (?,?,?,?, 'accepted', ?, CURRENT_TIMESTAMP) 16 ON CONFLICT(slug, role, other_uri) DO UPDATE SET status='accepted', offer_id=excluded.offer_id`), 23 17 del: db.prepare('DELETE FROM ap_guardianships WHERE slug=? AND role=? AND other_uri=?'), 24 bySlugRole: db.prepare( 'SELECT * FROM ap_guardianships WHERE slug=? AND role=? ORDER BY created_at DESC'),18 bySlugRole: db.prepare("SELECT * FROM ap_guardianships WHERE slug=? AND role=? AND status='accepted' ORDER BY created_at DESC"), 25 19 one: db.prepare('SELECT * FROM ap_guardianships WHERE slug=? AND role=? AND other_uri=?'), 26 byOffer: db.prepare('SELECT * FROM ap_guardianships WHERE offer_id=?'),27 20 }; 28 21 } … … 33 26 34 27 /** Accepted guardian URIs of a local ward (feeds shaer:guardians). */ 35 export function listGuardians(slug) { 36 return stmts().bySlugRole.all(slug, 'ward').filter((r) => r.status === 'accepted'); 28 export function listGuardians(slug) { return stmts().bySlugRole.all(slug, 'ward'); } 29 30 /** Accepted wards of a local guardian (the wards queue). */ 31 export function listWards(slug) { return stmts().bySlugRole.all(slug, 'guardian'); } 32 33 /** A site is a guardian once it stands in any accepted guardian relation. */ 34 export function isGuardian(slug) { return listWards(slug).length > 0; } 35 36 export function getRelation(slug, role, otherUri) { return stmts().one.get(slug, role, otherUri); } 37 38 // ── Writes (only the handshake commit lands here) ──────────────────────── 39 40 /** The local ward gains a guardian (commit, §3.1.4). */ 41 export function commitGuardianForWard(wardSlug, guardianUri, { handle = null, offerId = null } = {}) { 42 stmts().commit.run(wardSlug, 'ward', guardianUri, handle, offerId); 43 return stmts().one.get(wardSlug, 'ward', guardianUri); 37 44 } 38 45 39 /** All ward relations of a local guardian (accepted + pending offers). */ 40 export function listWards(slug) { 41 return stmts().bySlugRole.all(slug, 'guardian'); 46 /** The local guardian gains a ward (commit, §3.1.4). */ 47 export function commitWardForGuardian(guardianSlug, wardUri, { handle = null, offerId = null } = {}) { 48 stmts().commit.run(guardianSlug, 'guardian', wardUri, handle, offerId); 49 return stmts().one.get(guardianSlug, 'guardian', wardUri); 42 50 } 43 51 44 /** Pending offers where the local site is a party (either side). */ 45 export function listOffers(slug) { 46 return [...stmts().bySlugRole.all(slug, 'guardian'), ...stmts().bySlugRole.all(slug, 'ward')] 47 .filter((r) => r.status === 'offered'); 48 } 49 50 /** A site is a guardian once it stands in any guardian-side relation. */ 51 export function isGuardian(slug) { 52 return stmts().bySlugRole.all(slug, 'guardian').length > 0; 53 } 54 55 export function getRelation(slug, role, otherUri) { return stmts().one.get(slug, role, otherUri); } 56 export function findByOfferId(offerId) { return offerId ? stmts().byOffer.all(offerId) : []; } 57 58 // ── Writes (the handshake walks through these) ─────────────────────────── 59 60 /** Record an outgoing/incoming Offer on the local side with `role`. */ 61 export function recordOffer(slug, role, otherUri, { handle = null, offerId = null } = {}) { 62 stmts().ins.run(slug, role, otherUri, handle, 'offered', offerId); 63 return stmts().one.get(slug, role, otherUri); 64 } 65 66 /** The ward said yes (or our own offer was accepted): relation becomes real. */ 67 export function acceptRelation(slug, role, otherUri) { 68 stmts().accept.run(slug, role, otherUri); 69 return stmts().one.get(slug, role, otherUri); 70 } 71 72 /** Reject / retract / end a relation: the row disappears. */ 52 /** End a relation locally (Undo, §3.2 — federation of the Undo is Fase 4). */ 73 53 export function removeRelation(slug, role, otherUri) { 74 54 stmts().del.run(slug, role, otherUri); … … 79 59 80 60 /** 81 * The guardianship properties for a local actor doc. `id` is the actor URI.82 * - shaer:guardians: accepted guardians of this ward (omitted when none )61 * Guardianship props for a local actor doc. `id` is the actor URI. 62 * - shaer:guardians: accepted guardians of this ward (omitted when none, §2.1) 83 63 * - shaer:isGuardian: true once the site guards anyone 84 * - shaer:queues: the owner-only dashboard collections (always advertised, 85 * like `blocked`: clients discover, the routes enforce auth) 64 * - shaer:queues: the owner-only dashboard collections 65 * 66 * §1 mutual exclusion: a ward (has guardians) is never a guardian, so 67 * shaer:isGuardian is suppressed if guardians exist; the offer path already 68 * bars a ward from offering. 86 69 */ 87 70 export function actorProps(id, slug) { … … 94 77 }; 95 78 const guardians = listGuardians(slug).map((r) => r.other_uri); 96 if (guardians.length) props['shaer:guardians'] = guardians; 97 if (isGuardian(slug)) props['shaer:isGuardian'] = true; 79 if (guardians.length) { 80 props['shaer:guardians'] = guardians; // a ward 81 } else if (isGuardian(slug)) { 82 props['shaer:isGuardian'] = true; // a guardian (never both, §1) 83 } 98 84 return props; 99 85 } 100 86 101 87 export default { 102 listGuardians, listWards, listOffers, isGuardian, getRelation, findByOfferId,103 recordOffer, acceptRelation, removeRelation, actorProps,88 listGuardians, listWards, isGuardian, getRelation, 89 commitGuardianForWard, commitWardForGuardian, removeRelation, actorProps, 104 90 };
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)