Changeset 6eab7e9 in Klonkt for src/services
- Timestamp:
- 07/28/2026 11:22:07 PM (6 weeks ago)
- Branches:
- main
- Children:
- 0202104
- Parents:
- 6c152a5
- Location:
- src/services
- Files:
-
- 1 added
- 8 edited
-
ActivityPubService.js (modified) (7 diffs)
-
guardianship/availability.js (added)
-
guardianship/delivery.js (modified) (2 diffs)
-
guardianship/gated.js (modified) (3 diffs)
-
guardianship/handshake.js (modified) (6 diffs)
-
guardianship/index.js (modified) (1 diff)
-
guardianship/notes.js (modified) (1 diff)
-
guardianship/queues.js (modified) (3 diffs)
-
guardianship/relations.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
r6c152a5 r6eab7e9 288 288 ...Guardianship.helpRequestProps(post), 289 289 ...Guardianship.waveProps(post), 290 ...Guardianship.awayProps(post), 290 291 // FEP-633c §2.2: object hint that the author is a ward. 291 292 ...Guardianship.hasGuardiansProps(site.slug), … … 1365 1366 return 401; 1366 1367 } 1368 // One answer restores everything (FEP-633c 3.6): any VERIFIED activity 1369 // from an actor that guards someone here restores it to active for those 1370 // wards and cancels any lapse running against it, before the activity is 1371 // even looked at. Signature-gated on purpose: an unverified claim of 1372 // being gran must not wake gran up. 1373 try { 1374 const ev = Guardianship.availability.oneAnswer(claimedActor, Date.now()); 1375 if (ev.restored.length) console.log('[AP] guardian restored (one answer, 3.6):', claimedActor, '→', ev.restored.join(', ')); 1376 for (const c of ev.cancelledLapses) console.log('[AP] lapse cancelled by an answer from its target:', c.id); 1377 } catch { /* availability is never load-bearing for delivery */ } 1367 1378 } 1368 1379 … … 1454 1465 const wardKeys = getOrCreateKeys(slug); 1455 1466 const followObj = { id: followId, type: 'Follow', actor: who, object: wardActor }; 1467 // Dormancy evidence (FEP-633c 3.6.2): this decision directly addresses 1468 // every guardian. The ONLY admissible evidence is a request like this 1469 // one going unanswered; recordRequest itself skips a declared absence. 1470 for (const g of wardGuardians) { 1471 try { Guardianship.availability.recordRequest(slug, g, followId, Date.now()); } catch { /* never load-bearing */ } 1472 } 1456 1473 for (const g of wardGuardians) { 1457 1474 // Local ONLY when the guardian lives on THIS instance: slugFromActorUrl … … 1637 1654 const wave = Guardianship.isWave(o); 1638 1655 const hasG = Guardianship.objectHasGuardians(o); // §2.2 hint, register-only 1656 // FEP-633c 3.6.1: a guardian declares itself away to its ward, on the 1657 // same direct note the mention below stores (so the kid also reads it 1658 // as an ordinary message). Recorded only from an actual guardian of 1659 // the addressed ward, and only with an end: an absence without an end 1660 // is logged and dropped, never guessed. 1661 if (Guardianship.availability.isAway(o)) { 1662 const until = Guardianship.availability.parseEndTime(o.endTime); 1663 for (const slug of slugs) { 1664 const isG = (() => { try { return Guardianship.listGuardians(slug).some((g) => g.other_uri === actorUri); } catch { return false; } })(); 1665 if (!isG) continue; 1666 if (!until || until <= Date.now()) { console.warn('[AP] away without a (future) end ignored (3.6.1):', actorUri, '→', slug); continue; } 1667 Guardianship.availability.declareAway(slug, actorUri, until); 1668 console.log('[AP] guardian declared away (3.6.1):', actorUri, '→', slug, 'until', new Date(until).toISOString()); 1669 } 1670 } 1639 1671 for (const slug of slugs) { 1640 1672 try { … … 2213 2245 .filter(Boolean); 2214 2246 const help = object['shaer:helpRequest'] === true || object.helpRequest === true; 2215 const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null, attachments: atts, helpRequest: help }); 2247 // FEP-633c 3.6.1: a guardian here declaring itself away to its 2248 // wards. An away without a (future) end fails loudly, exactly as 2249 // the daemon refuses it: stored quietly it would be a nominal 2250 // guardian holding a seat. 2251 let awayUntil = null; 2252 if (Guardianship.availability.isAway(object)) { 2253 awayUntil = Guardianship.availability.parseEndTime(object.endTime); 2254 if (!awayUntil || awayUntil <= Date.now()) return { status: 400, error: 'away_needs_an_end' }; 2255 // A ward we host ourselves never receives its own delivery 2256 // (private ranges, loopback): apply locally, the way the 2257 // handshake commit does. 2258 const meUri = selfActorId(site.slug); 2259 for (const uri of recipients) { 2260 const wslug = uri.startsWith(`${base}/`) ? slugFromActorUrl(uri) : null; 2261 if (wslug && Guardianship.listGuardians(wslug).some((g) => g.other_uri === meUri)) { 2262 Guardianship.availability.declareAway(wslug, meUri, awayUntil); 2263 } 2264 } 2265 } 2266 const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null, attachments: atts, helpRequest: help, awayUntil }); 2216 2267 if (!r || !r.id) return { status: 502, error: 'direct_failed' }; 2217 2268 return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` }; … … 3439 3490 const pending = Guardianship.follows.getPending(followId); 3440 3491 if (!pending) return false; 3441 const guardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);3442 if (! guardians.includes(actorUri)) return false; // only a real guardian of this ward decides3492 const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri); 3493 if (!allGuardians.includes(actorUri)) return false; // only a real guardian of this ward decides 3443 3494 const decision = type === 'Reject' ? 'reject' : 'approve'; 3495 // §3.5: the quorum runs over the AVAILABLE set. The voter itself was 3496 // restored by the one-answer rule when its activity arrived, so answering 3497 // is exactly what counts a guardian back in. 3498 const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now()); 3444 3499 const r = Guardianship.follows.decide(followId, actorUri, decision, guardians); 3445 3500 try { … … 3784 3839 }); 3785 3840 3841 // The notification duty of FEP-633c 3.6.2, wired once for every place a 3842 // dormancy promotion can happen (queue reads, fan-outs, tallies): marking a 3843 // guardian dormant MUST notify it, in protocol AND over the §6 handle. The 3844 // one-answer rule is worthless to someone who does not know an answer is 3845 // wanted. The handle of a committed guardian is its inbox (§6 minimum), which 3846 // is the same door this delivery knocks on; both attempts are logged. 3847 Guardianship.wireAvailability({ 3848 onDormant: (wardSlug, guardianUri) => { 3849 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 3850 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(wardSlug); 3851 if (!base || !site) return; 3852 const me = selfActorId(wardSlug); 3853 const note = { 3854 id: `${me}/dormant/${Date.now().toString(36)}${rid()}`, 3855 type: 'Note', attributedTo: me, to: [guardianUri], 3856 'shaer:dormant': true, 3857 content: '<p>You have been observed dormant as a guardian. Nothing is wrong and nothing is held against you: one answer restores everything (FEP-633c 3.6.2).</p>', 3858 }; 3859 deliverToActor(site, guardianUri, { id: `${note.id}#create`, type: 'Create', actor: me, to: [guardianUri], object: note }) 3860 .catch(() => { /* retried by the queue */ }); 3861 console.log('[AP] guardian observed dormant (3.6.2):', guardianUri, 'ward', wardSlug, '(notified in protocol; the §6 handle is the same inbox)'); 3862 }, 3863 }); 3864 3786 3865 export default { 3787 3866 AP_CONTEXT, getOrCreateKeys, apWants, sendAP, actorId, noteId, -
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)