Changeset 6eab7e9 in Klonkt for src/services/guardianship
- Timestamp:
- 07/28/2026 11:22:07 PM (6 weeks ago)
- Branches:
- main
- Children:
- 0202104
- Parents:
- 6c152a5
- Location:
- src/services/guardianship
- Files:
-
- 1 added
- 7 edited
-
availability.js (added)
-
delivery.js (modified) (2 diffs)
-
gated.js (modified) (3 diffs)
-
handshake.js (modified) (6 diffs)
-
index.js (modified) (1 diff)
-
notes.js (modified) (1 diff)
-
queues.js (modified) (3 diffs)
-
relations.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/services/guardianship/delivery.js
r6c152a5 r6eab7e9 40 40 // guardian on any instance receives it as a private mention (the ward 41 41 // call-for-help path). 42 export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest, wave }) {42 export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest, wave, awayUntil }) { 43 43 const { actorId, fetchActor, deriveHandle, escHtml, linkUrls, linkHashtags, 44 44 getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps; … … 70 70 .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) })); 71 71 const id = crypto.randomUUID(); 72 db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, wave, created_at)73 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`)74 .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0, wave ? 1 : 0 );72 db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, wave, away_until, created_at) 73 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`) 74 .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0, wave ? 1 : 0, awayUntil || null); 75 75 const row = getOutboxRow(id); 76 76 const note = buildReplyNote(base, site, row); -
src/services/guardianship/gated.js
r6c152a5 r6eab7e9 15 15 import db from '../../config/database.js'; 16 16 import { listGuardians } from './relations.js'; 17 import * as availability from './availability.js'; 17 18 18 19 /** The window a gated-setting decision stays open. Reversible, so a day. */ … … 67 68 const column = featureColumn(feature); 68 69 if (!column) return { state: 'expired', error: 'unknown_feature' }; 69 const guardians = listGuardians(slug).map((g) => g.other_uri); 70 if (!guardians.includes(guardianUri)) return { state: 'expired', error: 'not_a_guardian' }; 70 const all = listGuardians(slug).map((g) => g.other_uri); 71 if (!all.includes(guardianUri)) return { state: 'expired', error: 'not_a_guardian' }; 72 // A vote is an answer, whatever it is a vote on (§3.6): the voter is 73 // restored first, so it always counts itself back into the set below. 74 availability.oneAnswer(guardianUri, Date.now()); 75 // §3.5: the threshold runs over the AVAILABLE set. Membership is checked 76 // against the full list above: any guardian may answer, and answering is 77 // exactly what brings it back in. 78 const guardians = availability.availableSet(slug, all, Date.now()); 71 79 72 80 // The window opens with the first answer, and a stale decision starts over: … … 100 108 const votes = db.prepare('SELECT guardian_uri, value FROM ap_gated_votes WHERE slug = ? AND feature = ?') 101 109 .all(slug, feature); 102 const guardians = listGuardians(slug).map((g) => g.other_uri); 110 // Progress over the available set (§3.5), like the tally itself. 111 const guardians = availability.availableSet(slug, listGuardians(slug).map((g) => g.other_uri), Date.now()); 103 112 return { votes: votes.length, need: thresholdFor(guardians.length), of: guardians.length }; 104 113 } -
src/services/guardianship/handshake.js
r6c152a5 r6eab7e9 20 20 import * as relations from './relations.js'; 21 21 import * as gated from './gated.js'; 22 import * as availability from './availability.js'; 22 23 23 24 let deps = null; … … 194 195 if (!['Offer', 'Accept', 'Reject', 'Undo'].includes(type)) return null; 195 196 const me = deps.selfId(site.slug); 197 // One answer restores everything (§3.6): any C2S activity from this actor 198 // is that answer, for every local ward it guards. Runs before anything is 199 // even looked at, so the target of a running lapse cancels it by doing 200 // anything at all — including trying to vote on it. 201 try { availability.oneAnswer(me, Date.now()); } catch { /* never load-bearing */ } 196 202 197 203 // ── Undo: a guardian ends its own guardianship (§3.2). Same path as the … … 206 212 // ── Offer: the local site is the guardian-candidate. ─────────────────── 207 213 if (type === 'Offer') { 214 // §3.6.3 over C2S: a guardian here proposes releasing a dormant 215 // co-guardian. A ward we host opens locally; a remote ward gets the 216 // proposal delivered, because the ward's server is the one that tallies 217 // and enforces (the §5.6 line: a guardian next door must not have more 218 // say than one far away). 219 const lp = availability.parseLapse(activity.object); 220 if (lp) { 221 const id = `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`; 222 const wardSlug = deps.localSlug(lp.ward); 223 if (wardSlug) { 224 const r = availability.openLapse({ id, wardSlug, wardUri: lp.ward, target: lp.target, openedBy: me, now: Date.now() }); 225 if (r.error) return { status: r.error === 'not_in_available_set' ? 403 : 409, error: r.error }; 226 deps.deliverTo(site, lp.target, { id, type: 'Offer', actor: me, to: [lp.target], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } }).catch(() => { /* best-effort */ }); 227 notify(wardSlug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set }); 228 return { status: 202, id, url: id, 'shaer:set': r.set, 'shaer:threshold': r.threshold }; 229 } 230 const offer = { id, type: 'Offer', actor: me, to: [lp.ward], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } }; 231 const delivered = await fanout(site, [lp.ward], offer); 232 return { status: 202, id, url: id, delivered }; 233 } 208 234 const rel = parseRelationship(activity.object); 209 235 if (!rel) return null; … … 231 257 const offerId = idOf(activity.object); 232 258 if (!offerId) return { status: 400, error: 'missing_offer' }; 259 // A lapse vote over C2S (§3.6.3): the same Accept/Reject wire the offers 260 // and gated follows use, which is exactly why the Shaer clients need no 261 // new verbs for it. 262 if (availability.getLapse(offerId)) { 263 const r = availability.lapseVote(offerId, me, type === 'Accept', Date.now()); 264 if (r && r.error) return { status: r.error === 'not_in_set' ? 403 : 409, error: r.error }; 265 return { status: 202, id: offerId, url: offerId, 'shaer:outcome': 'open', 'shaer:accepts': r.accepts, 'shaer:threshold': r.threshold }; 266 } 233 267 let offer = offers.getOffer(site.slug, offerId); 234 268 if (!offer) return { status: 404, error: 'no_such_offer' }; … … 273 307 const r = gated.recordGatedVote(site.slug, gs.feature, actor, gs.value); 274 308 notify(site.slug, { kind: 'gated_setting', feature: gs.feature, value: gs.value, state: r.state }); 309 return true; 310 } 311 // §3.6.3: a co-guardian proposes releasing a dormant guardian of THIS 312 // ward. The ward's server opens, tallies and (after the full window) 313 // executes, exactly as it does for the gated settings above. 314 const lp = availability.parseLapse(activity.object); 315 if (lp) { 316 if (lp.ward !== me) return false; // not our ward 317 const id = idOf(activity) || `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`; 318 const r = availability.openLapse({ id, wardSlug: site.slug, wardUri: me, target: lp.target, openedBy: actor, now: Date.now() }); 319 if (r.error) { 320 notify(site.slug, { kind: 'lapse_refused', reason: r.error, target: lp.target }); 321 return true; // consumed: the refusal is the answer 322 } 323 // The target is notified like any dormancy marking (§3.6.2): in 324 // protocol (a copy of the Offer, so one answer can cancel it) AND the 325 // §6 handle, which for a committed guardian is its inbox — the same 326 // door this delivery knocks on. 327 deps.deliverTo(site, lp.target, activity).catch(() => { /* best-effort */ }); 328 notify(site.slug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set }); 275 329 return true; 276 330 } … … 304 358 return true; 305 359 } 360 // §3.6.3: a set member answering a running lapse. Irreversible, so even a 361 // full tally leaves it open until the window closes (§3.5); the completion 362 // happens lazily on reads (queues) once the window has run. 363 if (availability.getLapse(offerId)) { 364 const r = availability.lapseVote(offerId, actor, type === 'Accept', Date.now()); 365 notify(site.slug, { kind: 'lapse_vote', lapse: offerId, by: actor, state: r && !r.error ? 'recorded' : (r && r.error) || 'refused' }); 366 return true; 367 } 306 368 let offer = offers.getOffer(site.slug, offerId); 307 369 if (!offer) return false; -
src/services/guardianship/index.js
r6c152a5 r6eab7e9 16 16 */ 17 17 export { SHAER_CONTEXT, GUARDIAN_RELATIONSHIP, GUARDIAN_RELATIONSHIP_COMPACT, isGuardianRelationship } from './context.js'; 18 export { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed } from './notes.js';18 export { helpRequestProps, isHelpRequest, waveProps, isWave, awayProps, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed } from './notes.js'; 19 19 export { wireDelivery, c2sVisibility, deliverDirectNote } from './delivery.js'; 20 20 export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship, parseUndoRelationship, endGuardianship } from './handshake.js'; 21 export { offersCollection, followsCollection, wardsCollection } from './queues.js'; 21 export { offersCollection, followsCollection, wardsCollection, guardiansCollection } from './queues.js'; 22 export * as availability from './availability.js'; 23 export { wireAvailability } from './availability.js'; 22 24 export * as follows from './follows.js'; 23 25 export { listForParty as listOffersForParty, getOffer, findOfferAnywhere } from './offers.js'; -
src/services/guardianship/notes.js
r6c152a5 r6eab7e9 65 65 } 66 66 67 export default { helpRequestProps, isHelpRequest, waveProps, isWave, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed }; 67 /** shaer:away (3.6.1): a guardian declaring itself away to its ward, with an 68 * end. Rides a direct note like the help request, so a ward on a plain 69 * server reads a human message; endTime is plain AS2. */ 70 export function awayProps(post) { 71 return (post && post.visibility === 'direct' && post.away_until) 72 ? { 'shaer:away': true, endTime: new Date(post.away_until).toISOString() } 73 : {}; 74 } 75 76 export default { helpRequestProps, isHelpRequest, waveProps, isWave, awayProps, hasGuardiansProps, objectHasGuardians, externalEmbedsAllowed }; -
src/services/guardianship/queues.js
r6c152a5 r6eab7e9 11 11 import * as offers from './offers.js'; 12 12 import * as relations from './relations.js'; 13 import * as availability from './availability.js'; 13 14 14 15 const collection = (id, items) => ({ … … 16 17 }); 17 18 18 /** Pending offers where the local site is a party, each with its accept tally. */ 19 /** Pending offers where the local site is a party, each with its accept 20 * tally. The same collection carries the running lapses (§3.6.3) this 21 * account is a party to, exactly as the daemon serves them, so the Shaer 22 * clients render both without a second fetch. */ 19 23 export function offersCollection(id, slug, me) { 20 24 const items = offers.listForParty(slug, me).map((o) => offers.queueItem(o, me)); 25 items.push(...availability.lapseQueueItems(slug, me, Date.now())); 21 26 return collection(id, items); 22 27 } … … 34 39 } 35 40 36 export default { offersCollection, followsCollection, wardsCollection }; 41 /** The ward's guardians with their availability (§3.6.1: never public, 42 * owner-only): the real size of the safety net. Same shape as the daemon. */ 43 export function guardiansCollection(id, slug) { 44 const uris = relations.listGuardians(slug).map((r) => r.other_uri); 45 return collection(id, availability.statusesFor(slug, uris, Date.now())); 46 } 47 48 export default { offersCollection, followsCollection, wardsCollection, guardiansCollection }; -
src/services/guardianship/relations.js
r6c152a5 r6eab7e9 74 74 follows: `${id}/queues/follows`, 75 75 wards: `${id}/queues/wards`, 76 guardians: `${id}/queues/guardians`, 76 77 }, 77 78 };
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)