source: Klonkt/src/services/guardianship/handshake.js@ 6b5d7da

main
Last change on this file since 6b5d7da was 6b5d7da, checked in by Robin Genis <roboburr@…>, 7 weeks ago

Guardianship als eigen module + gedeelde BlocklistService

FEP-633c (Guardians) lag verspreid door ActivityPubService; nu is het één
cohesief onderdeel in src/services/guardianship/ met submodules. De
blocklist staat er bewust NAAST (BlocklistService): die wordt gedeeld met
Klonkt zelf (Block-tab) en is niet guardianship-specifiek.

De module importeert ActivityPubService nooit terug: de AP-helpers gaan er
één keer in via wireDelivery/wireHandshake, en de service delegeert met
dunne wrappers zodat elke bestaande aanroep blijft werken.

Naast de verhuizing ook de serverkant die nog miste (shaer-bh1): de
ap_guardianships-relaties, shaer:guardians/isGuardian/queues op het
actor-doc, en de adoptie-handshake Offer/Accept/Reject over C2S en S2S,
met het contract van de Shaer test-daemon zodat de iOS/Android-clients het
ongewijzigd spreken. Een inkomend hulpverzoek (shaer:helpRequest op een
directe mention) krijgt een eigen vlag in ap_mentions en pusht als
'help'-type richting de Guardian-PWA (volgende commit).

Changed files:
src/services/ActivityPubService.js

  • shaer-context, actor-props en helpRequest uit de module gespread
  • blocklist-functies zijn delegaties naar BlocklistService
  • c2sVisibility/deliverDirectNote re-export uit guardianship/delivery
  • C2S: Offer/Accept/Reject eerst langs de handshake-module
  • S2S: Offer aan GATED (signature-eis) + handshake-routering
  • inbound mention: help_request-vlag + 'help'/'guardian'-push-events

src/config/database.js

  • tabel ap_guardianships (slug, role, other_uri, status, offer_id)
  • kolom ap_mentions.help_request

test/activitypub-as2.test.js

  • shaer:queues/offers/follows/wards in de AS2-allowlist

New file:
src/services/BlocklistService.js

  • ap_blocks-opslag, blockTarget/unblock/listBlocks/isBlockedAny, purge; handle-resolver via injectie (geen circulaire import)

src/services/guardianship/index.js

  • de publieke API van het onderdeel

src/services/guardianship/context.js

  • shaer-namespace + Relationship-vocabulaire

src/services/guardianship/relations.js

  • ap_guardianships-API + actor-props (FEP-633c paragraaf 2)

src/services/guardianship/handshake.js

src/services/guardianship/queues.js

src/services/guardianship/notes.js

  • shaer:helpRequest lezen/schrijven

src/services/guardianship/delivery.js

  • de directe-note-route (call-for-help), gedrag ongewijzigd

remarks: alle 158 tests groen. push-teksten (push.n_help_*, push.n_guard_*)
en de queue-routes + Guardian-PWA volgen in de volgende commits.

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

  • Property mode set to 100644
File size: 6.3 KB
Line 
1/**
2 * Guardianship (FEP-633c §3) — the adoption handshake.
3 *
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.
9 *
10 * Wired like delivery.js: no import back into ActivityPubService; the AP
11 * helpers arrive once via wireHandshake(deps). `deps.onEvent(slug, ev)` is an
12 * optional hook the Guardian PWA uses for push notifications.
13 */
14import { isGuardianRelationship, GUARDIAN_RELATIONSHIP_COMPACT } from './context.js';
15import * as relations from './relations.js';
16
17let deps = null;
18export function wireHandshake(d) { deps = d; }
19
20const idOf = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.id === 'string' ? v.id : null));
21
22/** Parse a Relationship object into {ward, candidate} or null. */
23export function parseRelationship(rel) {
24 if (!rel || typeof rel !== 'object') return null;
25 const type = Array.isArray(rel.type) ? rel.type[0] : rel.type;
26 if (type !== 'Relationship') return null;
27 if (!isGuardianRelationship(String(rel.relationship || ''))) return null;
28 const ward = idOf(rel.subject);
29 const candidate = idOf(rel.object);
30 return ward && candidate ? { ward, candidate } : null;
31}
32
33// ── C2S: the local account acts (PWA or Shaer app, via the outbox) ────────
34
35/**
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.
38 */
39export async function handleOutbox(site, activity) {
40 const { selfId, deliverTo, deriveHandle } = deps;
41 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
42 if (!['Offer', 'Accept', 'Reject'].includes(type)) return null;
43 const me = selfId(site.slug);
44
45 if (type === 'Offer') {
46 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 const delivered = await deliverTo(site, rel.ward, offer).catch(() => false);
59 notify(site.slug, { kind: 'offer_sent', ward: rel.ward });
60 return { status: delivered ? 202 : 502, id: offerId, url: offerId };
61 }
62
63 // Accept / Reject: the local ward answers a pending offer.
64 const obj = activity.object;
65 const offerId = idOf(obj);
66 const rel = parseRelationship(obj && obj.object) || parseRelationship(obj);
67 let row = null;
68 if (offerId) row = relations.findByOfferId(offerId).find((r) => r.slug === site.slug && r.role === 'ward') || null;
69 if (!row && rel) row = relations.getRelation(site.slug, 'ward', rel.candidate) || null;
70 if (!row) return { status: 404, error: 'no_such_offer' };
71
72 const answer = {
73 id: `${me}/answers/${Date.now().toString(36)}`, type, actor: me, to: [row.other_uri],
74 object: row.offer_id || { type: 'Relationship', subject: me, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: row.other_uri },
75 };
76 if (type === 'Accept') {
77 // The committed handle rides in `result` (daemon contract): the guardian
78 // learns where the ward lives.
79 answer.result = `${me}/inbox`;
80 relations.acceptRelation(site.slug, 'ward', row.other_uri);
81 } else {
82 relations.removeRelation(site.slug, 'ward', row.other_uri);
83 }
84 const delivered = await deliverTo(site, row.other_uri, answer).catch(() => false);
85 notify(site.slug, { kind: type === 'Accept' ? 'offer_accepted' : 'offer_rejected', guardian: row.other_uri });
86 return { status: delivered ? 202 : 502, id: answer.id, url: answer.id };
87}
88
89// ── S2S: a remote party acts (arrives in the local inbox) ────────────────
90
91/**
92 * Handle an inbound guardianship activity for local site `site`. Returns
93 * true when consumed (the generic inbox skips it), false otherwise.
94 */
95export async function handleInbox(site, activity) {
96 const { selfId } = deps;
97 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
98 if (!['Offer', 'Accept', 'Reject'].includes(type)) return false;
99 const me = selfId(site.slug);
100 const actor = idOf(activity.actor);
101
102 if (type === 'Offer') {
103 const rel = parseRelationship(activity.object);
104 if (!rel || rel.ward !== me) return false;
105 // A remote candidate offers to guard the local ward: park it in the queue.
106 relations.recordOffer(site.slug, 'ward', rel.candidate, { handle: deps.deriveHandle(rel.candidate), offerId: idOf(activity) });
107 notify(site.slug, { kind: 'offer_received', candidate: rel.candidate });
108 return true;
109 }
110
111 // Accept / Reject of an offer WE (local guardian) sent.
112 const obj = activity.object;
113 const offerId = idOf(obj);
114 const rel = parseRelationship(obj && obj.object) || parseRelationship(obj);
115 let row = null;
116 if (offerId) row = relations.findByOfferId(offerId).find((r) => r.slug === site.slug && r.role === 'guardian') || null;
117 if (!row && actor) row = relations.getRelation(site.slug, 'guardian', actor) || null;
118 if (!row && rel) row = relations.getRelation(site.slug, 'guardian', rel.ward) || null;
119 if (!row) return false;
120
121 if (type === 'Accept') {
122 relations.acceptRelation(site.slug, 'guardian', row.other_uri);
123 notify(site.slug, { kind: 'ward_accepted', ward: row.other_uri });
124 } else {
125 relations.removeRelation(site.slug, 'guardian', row.other_uri);
126 notify(site.slug, { kind: 'ward_rejected', ward: row.other_uri });
127 }
128 return true;
129}
130
131function notify(slug, ev) {
132 try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
133}
134
135export default { wireHandshake, handleOutbox, handleInbox, parseRelationship };
Note: See TracBrowser for help on using the repository browser.