source: Klonkt/src/services/guardianship/queues.js@ aed0092

main
Last change on this file since aed0092 was aed0092, checked in by Robin <roboburr@…>, 4 weeks ago

Elke collectie pagineert nu, via een helper in plaats van tien keer dezelfde regels

Vervolg op cb3001e, waar alleen de outbox eraan geloofde omdat Funkwhale daar
over viel. Robins opdracht: de rest ook, om compleetheid te garanderen. Dat is
de goede volgorde -- een foutmelding repareren waar hij valt laat de volgende
lezer op de volgende collectie stuklopen.

pagedCollection in ap-core, en daar hangen ze nu allemaal aan:

outbox followers following featured
tracks playlists playlist post-tracks
replies + de zeven guardianship-wachtrijen

De items blijven overal INLINE op de wortel; first en last wijzen naar dezelfde
pagina, want onze collecties zijn gekapt en er is er precies een. Wie ze vandaag
zonder pagineren leest -- Shaer doet dat -- merkt er niets van.

DRIE PLEKKEN DIE BEWUST AFWIJKEN, want een sleepnet is geen zorgvuldigheid:

  • de guardianship-wachtrijen krijgen hun @context van de route (queueRoute), dus daar staan de velden er met de hand bij. pagedCollection zou de context een tweede keer toevoegen.
  • de thread-collectie draagt al een ?object= in zijn id. Daar ?page= achteraan plakken pagineert niets, het herhaalt de vraag. Owner-only en door Shaer gelezen, dus geen federatiebelang.
  • followers en following geven publiek alleen een AANTAL. Die krijgen de velden juist wel: anders is "ik mag de lijst niet zien" niet te onderscheiden van een kapot antwoord -- dezelfde stille dubbelzinnigheid die we vandaag bij een ander aantroffen.

Vier tests erbij, waaronder een die bewaakt dat de helper attributedTo niet
opeet en een die bewaakt dat de wachtrijen GEEN eigen @context krijgen.

Co-Authored-By: Claude Opus 5 <noreply@…>

  • Property mode set to 100644
File size: 15.5 KB
Line 
1/**
2 * Guardianship (FEP-633c) — the owner-only dashboard queues.
3 *
4 * Three OrderedCollections on the actor (shaer:queues), same contract as the
5 * 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 ON my wards (§5.3), Fase 2 (shaer-jdb)
9 * - wards: my committed wards
10 */
11import * as offers from './offers.js';
12import * as relations from './relations.js';
13import * as availability from './availability.js';
14import * as outgoing from './outgoing.js';
15import * as follows from './follows.js';
16import * as gated from './gated.js';
17import * as gatereq from './gatereq.js';
18import * as help from './help.js';
19import db from '../../config/database.js';
20import * as handshake from './handshake.js';
21
22// De @context zet de route erop (queueRoute), dus hier bewust niet
23// pagedCollection uit ap-core -- die voegt hem toe en dan staat hij er twee
24// keer. Wel dezelfde paginavelden, om dezelfde reden: een lezer die de
25// paginaweg volgt hoort niet dood te lopen (Funkwhale, 11-8).
26const collection = (id, items) => ({
27 id, type: 'OrderedCollection', totalItems: items.length,
28 first: `${id}?page=1`, last: `${id}?page=1`,
29 orderedItems: items,
30});
31
32/** Pending offers where the local site is a party, each with its accept
33 * tally. The same collection carries the running lapses (§3.6.3) this
34 * account is a party to, exactly as the daemon serves them, so the Shaer
35 * clients render both without a second fetch. */
36export function offersCollection(id, slug, me) {
37 // §4.2: a handshake whose candidate could not be dereferenced is deferred,
38 // not decided, and the last Accept may already have landed — so nothing else
39 // would ever retry it. This poll is the schedule. Not awaited: the read
40 // answers with what is true now, and a retry that succeeds surfaces in the
41 // next one. `listForParty` settles closed windows on the way past.
42 handshake.retryDeferred(slug).catch(() => { /* the next read tries again */ });
43 const items = offers.listForParty(slug, me).map((o) => offers.queueItem(o, me));
44 items.push(...availability.lapseQueueItems(slug, me, Date.now()));
45 return collection(id, items);
46}
47
48/**
49 * Gate-verzoeken OP mijn wards die op mijn antwoord wachten (Guardianship Fase 2,
50 * shaer-jdb). Dit was een lege stub: de gating zelf werkt sinds shaer-hxg, maar
51 * werd nooit aan een C2S-client doorgegeven omdat de koers toen op de PWA lag.
52 *
53 * Twee bronnen, want een guardian kan wards op andere servers hebben en (nog)
54 * op deze:
55 * - ap_follow_reviews: de doorgestuurde kopie van een REMOTE ward
56 * - ap_pending_follows: een ward op deze instance
57 * Zie shaer-h6u: die tweede hoort op termijn ook over de lijn te gaan.
58 */
59export function followsCollection(id, slug, me) {
60 const items = follows.listReviewsByDirection(slug, 'incoming')
61 .map((r) => follows.reviewQueueItem(r, me));
62 for (const w of relations.listWards(slug)) {
63 const wardSlug = slugOf(w.other_uri);
64 if (!wardSlug) continue;
65 for (const p of follows.listForWard(wardSlug)) {
66 items.push({
67 id: p.id, type: 'Follow', actor: p.follower_uri, object: w.other_uri,
68 'shaer:direction': 'incoming', 'shaer:ward': w.other_uri,
69 'shaer:follower': p.follower_uri, 'shaer:followerHandle': p.follower_handle || undefined,
70 'shaer:quorum': p.quorum || 'any', published: p.created_at,
71 });
72 }
73 }
74 return collection(id, items);
75}
76
77/** De slug van een actor-uri op DEZE instance, of null als hij elders woont. */
78function slugOf(uri) {
79 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
80 if (!base || !String(uri || '').startsWith(`${base}/ap/users/`)) return null;
81 return decodeURIComponent(String(uri).slice(`${base}/ap/users/`.length).split(/[/?#]/)[0]) || null;
82}
83
84/**
85 * §5.3 uitgaand. Twee lezers, een wachtrij, en dat kan omdat §1 een ward en een
86 * guardian wederzijds uitsluit: je bent het een of het ander.
87 *
88 * ALS WARD wat IK wil volgen en waar mijn guardians nog over moeten
89 * ALS GUARDIAN wat mijn WARDS willen volgen en waar IK over moet (shaer-jdb)
90 *
91 * Dat tweede ontbrak. De wachtrij serveerde alleen listForWard(slug), en voor
92 * een guardian is dat per definitie leeg -- dus het scherm "Your wards want to
93 * follow" kon nooit iets tonen.
94 */
95export function outgoingFollowsCollection(id, slug, me) {
96 const items = outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me));
97 for (const r of follows.listReviewsByDirection(slug, 'outgoing')) {
98 items.push(follows.reviewQueueItem(r, me));
99 }
100 return collection(id, items);
101}
102
103/** The guardian's committed wards, with cached handle for display. */
104export function wardsCollection(id, slug) {
105 const items = relations.listWards(slug)
106 .map((r) => ({
107 id: r.other_uri,
108 'shaer:handle': r.other_handle || undefined,
109 since: r.created_at,
110 // Alles wat voor dit kind gated is, met soort, drempel en lopend voorstel
111 // (shaer-ahy.1). Zonder dit kon een app wel een ward TONEN maar niets over
112 // hem zeggen -- en dat is precies de helft van het antwoord op "wat mag
113 // dit kind". Dezelfde rijen als het PWA-paneel, uit dezelfde functie.
114 'shaer:gates': wardGates(slug, r.other_uri),
115 }));
116 return collection(id, items);
117}
118
119/** The ward's guardians with their availability (§3.6.1: never public,
120 * owner-only): the real size of the safety net. Same shape as the daemon. */
121export function guardiansCollection(id, slug) {
122 const uris = relations.listGuardians(slug).map((r) => r.other_uri);
123 return collection(id, availability.statusesFor(slug, uris, Date.now()));
124}
125
126/**
127 * Het logboek (§4.2): wat er is besloten, en waarom.
128 *
129 * Geen wachtrij, en daarom een eigen sleutel op de actor. Elk item draagt zijn
130 * soort en, als die er was, de REDEN -- want zonder die reden merkte een ward
131 * een weigering alleen doordat er iets uit een lijst verdween.
132 *
133 * `type` is geen AS2-werkwoord: de meeste soorten zijn er geen. Een lapse-stem
134 * of een opgepakte hulpvraag is geen Accept, en het zo noemen zou netter lezen
135 * dan het is.
136 *
137 * De lezer levert `listEvents` aan; deze module kent ActivityPubService niet en
138 * houdt dat zo (zie de kop van delivery.js).
139 */
140export function logCollection(id, slug, listEvents) {
141 const items = (typeof listEvents === 'function' ? listEvents(slug) : []).map((e) => {
142 const { id: n, kind, created, ...rest } = e;
143 const item = { id: `${id}/${n}`, type: 'shaer:Event', 'shaer:kind': kind, published: created };
144 for (const [k, v] of Object.entries(rest)) {
145 if (v === undefined || v === null) continue;
146 item[k.startsWith('shaer:') ? k : `shaer:${k}`] = v;
147 }
148 return item;
149 });
150 return collection(id, items);
151}
152
153export default { offersCollection, followsCollection, outgoingFollowsCollection, wardsCollection, guardiansCollection, helpCollection, helpItemsFor, wardGates, wardGuardianStatuses, logCollection };
154
155// ── Wat er voor een ward gated is (shaer-ahy.1) ─────────────────────────
156//
157// STOND IN routes/guardian.js, en daar kon alleen de PWA erbij. De Shaer-apps
158// lezen dezelfde toestand via de wards-queue, en een tweede berekening naast
159// deze zou vroeg of laat een ander antwoord geven op dezelfde vraag -- dat is
160// hier geen schoonheidsfoutje maar twee guardians die een verschillend beeld
161// van hetzelfde kind krijgen. Een plek dus, en beide schermen lezen eruit.
162/** The guardians of a ward WE host, with availability (3.6.1: owner-only in
163 * spirit; the co-guardians are among the owners of the relationship). Null
164 * for a remote ward: its server tracks availability, not us. */
165export function wardGuardianStatuses(wardUri) {
166 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
167 if (!base || !String(wardUri || '').startsWith(`${base}/`)) return null;
168 const slug = String(wardUri).trim().replace(/\/+$/, '').split('/').pop();
169 try {
170 const uris = relations.listGuardians(slug).map((g) => ({ uri: g.other_uri, handle: g.other_handle }));
171 const st = Object.fromEntries(
172 availability.statusesFor(slug, uris.map((u) => u.uri), Date.now()).map((s) => [s.id, s]),
173 );
174 return uris.map((u) => ({
175 uri: u.uri,
176 handle: u.handle,
177 availability: (st[u.uri] || {})['shaer:availability'] || 'active',
178 awayUntil: (st[u.uri] || {})['shaer:awayUntil'] || null,
179 lapse: (st[u.uri] || {})['shaer:lapse'] || null,
180 }));
181 } catch { return null; }
182}
183/**
184 * De gate-rijen van een ward voor het paneel.
185 *
186 * De standen komen uit onze eigen kolommen als we het kind hosten; bij een ward
187 * elders weten we ze niet en blijft het NULL -- onbekend, niet uit. Het aantal
188 * guardians idem: dat wordt op de server van die ward bijgehouden, en zonder dat
189 * getal wordt er geen drempel verzonnen.
190 */
191export function wardGates(mySlug, wardUri) {
192 const statuses = wardGuardianStatuses(wardUri);
193 // Per richting geteld, want het zijn twee zorgen. "follows: 3 wachtend" liet
194 // een guardian niet zien of er drie vreemden bij zijn kind willen of dat zijn
195 // kind drie keer heeft gevraagd of het iemand mag volgen (shaer-p729).
196 const wachtendIn = follows.listReviewsByDirection(mySlug, 'incoming')
197 .filter((r) => r.ward_uri === wardUri).length;
198 const wachtendUit = follows.listReviewsByDirection(mySlug, 'outgoing')
199 .filter((r) => r.ward_uri === wardUri).length;
200 return gated.gateRows({
201 // Uit de BESLUITEN, niet uit onze eigen kolom. Er zijn geen lokale accounts:
202 // elke ward woont elders, dus wardEmbedSetting() gaf voor iedere ward null en
203 // stond er in het paneel overal "onbekend". Wat een guardian wel heeft is de
204 // uitslag van wat hij voorstelde.
205 settings: Object.fromEntries(gated.GATE_CATALOGUE
206 .filter((g) => g.available !== false && gated.featureColumn(g.feature))
207 .map((g) => [g.feature, gated.knownSetting(mySlug, wardUri, g.feature)])),
208 guardianCount: statuses ? statuses.length : null,
209 proposals: gated.listSent(mySlug, wardUri).map((p) => ({
210 feature: p.feature, value: !!p.value, status: gated.sentStatus(p, Date.now()),
211 })),
212 waiting: {
213 'shaer:follows': wachtendIn || undefined,
214 'shaer:following': wachtendUit || undefined,
215 },
216 // De vraag van het kind zelf staat APART van wat er in een wachtrij staat
217 // (shaer-8ru). Allebei "n waiting" noemen maakt van twee verschillende
218 // dingen een getal: drie onbekenden die je kind willen volgen is iets heel
219 // anders dan je kind dat een keer vraagt of muziek aan mag. Wel bij de poort
220 // waar het over gaat, want een aparte lijst vergeet je.
221 requested: gatereq.waitingFor(mySlug, wardUri),
222 });
223}
224
225
226// ── Hulpvragen met hun staat (shaer-lgo, shaer-ahy.1) ───────────────────
227//
228// De PWA had dit al; de apps kregen alleen de losse notes uit de feed en wisten
229// dus NIET of er al iemand op af was. Daarom bleef een afgehandeld verzoek daar
230// gewoon staan -- Barts melding. De staat wordt hier een keer berekend, zoals bij
231// wardGates: twee berekeningen zouden twee guardians een ander beeld geven van
232// hetzelfde kind.
233
234/**
235 * De hulpvragen van deze guardian, met wie erop af is en of het dicht is.
236 *
237 * OPEN VRAGEN WORDEN NOOIT AFGEKAPT, en dat is geen ruimhartigheid maar de reden
238 * dat de app iets mag CONCLUDEREN uit afwezigheid (Barts punt, 8-8).
239 *
240 * Dit stond op 50, en ik noemde 'tientallen hulpvragen bij een guardian' een
241 * randgeval. Bart wees op de jeugdzorgmedewerker: die heeft geen handvol wards
242 * maar een caseload, en voor hem is dat een gewone dinsdag. De gebruiker die dit
243 * het hardst nodig heeft was precies degene voor wie het brak.
244 *
245 * Met een afkap op alles zag een app een oudere vraag niet in de queue, vond geen
246 * staat, en toonde hem -- terecht, want bij twijfel OPEN -- als openstaand. Een
247 * allang afgehandelde hulpvraag die weer om aandacht vraagt. Nu geldt: staat hij
248 * niet in de queue, dan is hij NIET open. Die gevolgtrekking klopt alleen zolang
249 * we open vragen volledig leveren.
250 *
251 * De geschiedenis mag wel afgekapt: die vraagt niets, en wat eraf valt is nog
252 * steeds op de server te vinden.
253 */
254export function helpItemsFor(slug, historyLimit = 50) {
255 let rijen = [];
256 try {
257 // Alle velden die een kaart kan tonen, niet alleen die van de queue: de
258 // PWA had hierom een EIGEN kopie van deze query -- mét een afkap op 50,
259 // waardoor de fix hierboven aan het paneel voorbijging (Barts 429-jacht,
260 // 9-8). Een tweede weg naar dezelfde staat is precies wat er bij de
261 // reply-gate al misging; nu is dit de enige weg, en dan hoort hij ook te
262 // dragen wat een kaart nodig heeft. De extra kolommen kosten de queue
263 // niets: die leest ze gewoon niet.
264 rijen = db.prepare(
265 `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at,
266 emoji_json, actor_emoji_json, media_json, quote_json, embed_json
267 FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC`,
268 ).all(slug);
269 } catch { return []; }
270 const staat = help.statusFor(rijen.map((r) => r.object_uri));
271 const mijn = new Set(relations.listWards(slug).map((w) => w.other_uri));
272 const alles = rijen.map((r) => ({
273 ...r,
274 // Bij twijfel OPEN. Een hulpvraag die er afgehandeld uitziet terwijl hij dat
275 // niet is, is de gevaarlijke fout -- niet andersom.
276 state: help.withWardship(
277 staat.get(r.object_uri) || { open: true, pickedUpBy: [], handled: null, oldestPickupAt: null },
278 mijn.has(r.actor_uri),
279 ),
280 }));
281 const open = alles.filter((h) => h.state.open);
282 const rest = alles.filter((h) => !h.state.open).slice(0, historyLimit);
283 return [...open, ...rest];
284}
285
286/** Dezelfde vragen als collectie voor de apps (5.2.1). */
287export function helpCollection(id, slug) {
288 const items = helpItemsFor(slug).map((h) => ({
289 id: h.object_uri,
290 type: 'Note',
291 attributedTo: h.actor_uri,
292 'shaer:handle': h.actor_handle || undefined,
293 content: h.content || '',
294 published: h.published || h.created_at,
295 'shaer:helpRequest': true,
296 // De staat als platte velden: een app hoeft hem niet af te leiden, en kan
297 // hem dus ook niet anders afleiden dan het paneel.
298 'shaer:open': h.state.open,
299 'shaer:handledBy': h.state.handled ? (h.state.handled.handle || h.state.handled.uri) : undefined,
300 'shaer:handledAt': h.state.handled ? h.state.handled.at : undefined,
301 'shaer:pickedUpBy': h.state.pickedUpBy.map((p) => p.handle || p.uri),
302 // HOE OUD het oudste oppakken is (shaer-lgo). Barts besluit was dat
303 // "opgepikt" niet vervalt maar zichtbaar VEROUDERT -- het verschil tussen
304 // "er is iemand mee bezig" en "er was ooit iemand mee bezig". Het paneel
305 // toonde dat al; de apps konden het niet, want dit veld bleef hier liggen.
306 // Een oppak van vijf minuten zag er daar uit als een van vijf dagen, en de
307 // faalstand is hier nou juist "iedereen denkt dat het geregeld is".
308 //
309 // Als TIJDSTIP en niet als leeftijd: een leeftijd maakt elk antwoord anders
310 // en dan kan de ETag nooit gelijk zijn. De client rekent zelf terug, precies
311 // zoals guardian.js het doet.
312 'shaer:oldestPickupAt': h.state.oldestPickupAt || undefined,
313 'shaer:formerWard': h.state.formerWard || undefined,
314 }));
315 const coll = collection(id, items);
316 // Het teken dat de app mag concluderen uit afwezigheid: elke OPEN vraag zit
317 // hierin. Ontbreekt deze vlag (een oudere server), dan valt de app terug op
318 // bij-twijfel-open, en dat is de veilige kant.
319 coll['shaer:openComplete'] = true;
320 return coll;
321}
Note: See TracBrowser for help on using the repository browser.