| [6b5d7da] | 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 | */
|
|---|
| 14 | import { isGuardianRelationship, GUARDIAN_RELATIONSHIP_COMPACT } from './context.js';
|
|---|
| 15 | import * as relations from './relations.js';
|
|---|
| 16 |
|
|---|
| 17 | let deps = null;
|
|---|
| 18 | export function wireHandshake(d) { deps = d; }
|
|---|
| 19 |
|
|---|
| 20 | const 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. */
|
|---|
| 23 | export 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 | */
|
|---|
| 39 | export 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 });
|
|---|
| [c26cc18] | 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 }));
|
|---|
| [6b5d7da] | 61 | notify(site.slug, { kind: 'offer_sent', ward: rel.ward });
|
|---|
| [c26cc18] | 62 | return { status: 202, id: offerId, url: offerId, delivered: res && res.delivered !== false };
|
|---|
| [6b5d7da] | 63 | }
|
|---|
| 64 |
|
|---|
| 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' };
|
|---|
| 73 |
|
|---|
| 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);
|
|---|
| 85 | }
|
|---|
| [c26cc18] | 86 | // The answer is committed locally; delivery is async + retried.
|
|---|
| 87 | const res = await deliverTo(site, row.other_uri, answer).catch(() => ({ delivered: false }));
|
|---|
| [6b5d7da] | 88 | notify(site.slug, { kind: type === 'Accept' ? 'offer_accepted' : 'offer_rejected', guardian: row.other_uri });
|
|---|
| [c26cc18] | 89 | return { status: 202, id: answer.id, url: answer.id, delivered: res && res.delivered !== false };
|
|---|
| [6b5d7da] | 90 | }
|
|---|
| 91 |
|
|---|
| 92 | // ── S2S: a remote party acts (arrives in the local inbox) ────────────────
|
|---|
| 93 |
|
|---|
| 94 | /**
|
|---|
| 95 | * Handle an inbound guardianship activity for local site `site`. Returns
|
|---|
| 96 | * true when consumed (the generic inbox skips it), false otherwise.
|
|---|
| 97 | */
|
|---|
| 98 | export async function handleInbox(site, activity) {
|
|---|
| 99 | const { selfId } = deps;
|
|---|
| 100 | const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
|
|---|
| 101 | if (!['Offer', 'Accept', 'Reject'].includes(type)) return false;
|
|---|
| 102 | const me = selfId(site.slug);
|
|---|
| 103 | const actor = idOf(activity.actor);
|
|---|
| 104 |
|
|---|
| 105 | if (type === 'Offer') {
|
|---|
| 106 | 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 });
|
|---|
| 111 | return true;
|
|---|
| 112 | }
|
|---|
| 113 |
|
|---|
| 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;
|
|---|
| 123 |
|
|---|
| 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 });
|
|---|
| 130 | }
|
|---|
| 131 | return true;
|
|---|
| 132 | }
|
|---|
| 133 |
|
|---|
| 134 | function notify(slug, ev) {
|
|---|
| 135 | try { if (deps && typeof deps.onEvent === 'function') deps.onEvent(slug, ev); } catch { /* best-effort */ }
|
|---|
| 136 | }
|
|---|
| 137 |
|
|---|
| 138 | export default { wireHandshake, handleOutbox, handleInbox, parseRelationship };
|
|---|