Changeset 780a7c6 in Klonkt for src/services/guardianship


Ignore:
Timestamp:
07/24/2026 07:44:15 PM (7 weeks ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
b5924eb
Parents:
c26cc18
Message:

Guardianship Fase 0+1: de echte multi-party handshake (FEP-633c §3)

De eerste versie committeerde na één accept. Nu de spec: geen enkele partij
maakt een voogdij alleen, en een nieuwe guardian erbij kan niet zonder
toestemming van de bestaande. Daemon als blauwdruk, zodat Klonkt en de
test-daemon exact hetzelfde gedragen en de Shaer-clients één contract lezen.

Fase 0 (datamodel): ap_guardian_offers (per lokale partij een kopie van de
handshake, PK slug+offer_id) + ap_guardian_offer_accepts (de accept-tally).
ap_guardianships houdt alleen nog de GECOMMITTE relaties.

Fase 1 (state-machine): offers.js is een getrouwe port van de daemon-Handshake
(accepts over ward+candidate+existing; ready = ward && candidate && (geen
existing OF >=1 existing); een Reject voidt). handshake.js orchestreert het
gedistribueerd: de kandidaat adresseert de Offer aan ward + alle bestaande
guardians (§3.1.1); elke Accept wordt aan alle andere partijen gebroadcast, dus
elke instance-kopie convergeert; zodra een kopie compleet is committeert die
lokaal (ward schrijft shaer:guardians, guardian schrijft z'n ward), met de
kandidaat-inbox als handle (§6). Volgorde-onafhankelijk.

Ook: §1 wederzijdse uitsluiting (een ward is nooit ook guardian in het
actor-doc), de queues vullen nu de echte accept-tally (needsMyAccept/
readyToCommit/acceptedBy/existingGuardians), en de PWA + Berichten beantwoorden
via de C2S Accept/Reject-pijplijn per offer-id. De co-guardian ziet een
mede-voogdij-aanvraag met accepteer/weiger in de PWA.

Changed files:
src/config/database.js

  • tabellen ap_guardian_offers + ap_guardian_offer_accepts

src/services/guardianship/offers.js (NEW)

  • de handshake-state-machine (daemon-port), per-instance in SQLite

src/services/guardianship/relations.js

  • alleen commit-writers + actor-props (§1 uitsluiting)

src/services/guardianship/handshake.js

  • gedistribueerde multi-party C2S/S2S orchestratie

src/services/guardianship/queues.js

  • offers-queue uit de state-machine

src/services/guardianship/index.js

  • exports bijgewerkt

src/services/ActivityPubService.js

  • wire localSlug + fetchActor; inbound-routing naar alle lokale partijen

src/routes/guardian.js

  • dashboard toont offers met tally; POST /guardian/offer (accept/reject)

src/routes/posts.js

  • Berichten toont ward-offers uit de state-machine; accept via offer-id

src/views/pages/messages.ejs, src/assets/js/guardian.js, src/assets/css/guardian.css

  • offer-kaarten per state (mijn aanvraag / mede-voogdij / wachten)

src/services/i18n.js

  • accept/reject/complete/coguard + co-guardian push (nl/en/de)

test/guardianship.test.js

  • multi-party: eerste guardian, co-approval bestaande guardian, reject voidt, ward-mag-niet-guarden, vaste initiator

remarks: Fase 2 (follow-gating), 3 (hasGuardians + Not-a-Teapot), 4 (Undo/
emancipatie) volgen. 164 tests groen.

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

Location:
src/services/guardianship
Files:
1 added
4 edited

Legend:

Unmodified
Added
Removed
  • src/services/guardianship/handshake.js

    rc26cc18 r780a7c6  
    11/**
    2  * Guardianship (FEP-633c §3) — the adoption handshake.
     2 * Guardianship (FEP-633c §3) — the adoption handshake, multi-party and
     3 * distributed across instances.
    34 *
    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.
     5 * The candidate Offers a Relationship{subject: ward, object: candidate},
     6 * addressed to the ward AND every existing guardian of the ward. Each party
     7 * (ward, existing guardians, and finally the candidate) Accepts, addressed to
     8 * all the others, so every instance's copy of the tally converges. The
     9 * candidate's Accept is the LAST one and carries the escalation handle in
     10 * `result`: that return is the atomic commit (§3.1.3). Only then does the
     11 * ward gain the guardian in shaer:guardians and the guardian gain the ward.
     12 * A single Reject from any party voids the offer (§3.2).
    913 *
    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.
     14 * The state machine lives in offers.js (a faithful port of the Shaer test
     15 * daemon); this module wires it onto Klonkt's C2S/S2S plumbing. AP helpers
     16 * arrive once via wireHandshake(deps); nothing here imports ActivityPubService.
    1317 */
    1418import { isGuardianRelationship, GUARDIAN_RELATIONSHIP_COMPACT } from './context.js';
     19import * as offers from './offers.js';
    1520import * as relations from './relations.js';
    1621
     
    1924
    2025const idOf = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.id === 'string' ? v.id : null));
     26const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
    2127
    2228/** Parse a Relationship object into {ward, candidate} or null. */
     
    3137}
    3238
    33 // ── C2S: the local account acts (PWA or Shaer app, via the outbox) ────────
     39/** The existing guardians of a ward: local list, or the remote actor's shaer:guardians. */
     40async function existingGuardiansOf(wardUri) {
     41  const local = deps.localSlug(wardUri);
     42  if (local) return relations.listGuardians(local).map((r) => r.other_uri);
     43  const doc = await deps.fetchActor(wardUri).catch(() => null);
     44  const g = doc && doc['shaer:guardians'];
     45  return Array.isArray(g) ? g.filter((x) => typeof x === 'string') : [];
     46}
     47
     48function offerActivity(offerId, ward, candidate, recipients) {
     49  return {
     50    id: offerId, type: 'Offer', actor: candidate, to: recipients,
     51    object: { type: 'Relationship', subject: ward, relationship: GUARDIAN_RELATIONSHIP_COMPACT, object: candidate },
     52  };
     53}
     54
     55/** Deliver `activity` to every uri in `recipients` (skipping the local self). */
     56async function fanout(site, recipients, activity) {
     57  let anyDelivered = false;
     58  for (const uri of [...new Set(recipients)]) {
     59    const r = await deps.deliverTo(site, uri, activity).catch(() => ({ delivered: false }));
     60    if (r && r.delivered !== false) anyDelivered = true;
     61  }
     62  return anyDelivered;
     63}
     64
     65/** Apply the local side of a commit: the ward writes its guardian, the
     66 *  candidate writes its ward. Each instance writes only what it hosts. */
     67function applyCommitLocally(offer, handle) {
     68  const wardSlug = deps.localSlug(offer.ward_uri);
     69  const candSlug = deps.localSlug(offer.candidate_uri);
     70  if (wardSlug) relations.commitGuardianForWard(wardSlug, offer.candidate_uri, { handle, offerId: offer.offer_id });
     71  if (candSlug) relations.commitWardForGuardian(candSlug, offer.ward_uri, { handle, offerId: offer.offer_id });
     72}
     73
     74/** Commit this local copy of the offer when the tally is complete (ward +
     75 *  candidate + ≥1 existing guardian, §3.1.2). The handle is the candidate's
     76 *  inbox (§6 minimum); the commit is order-independent, so whichever accept
     77 *  lands last triggers it on every copy. */
     78function maybeCommit(slug, offerId) {
     79  const offer = offers.getOffer(slug, offerId);
     80  if (!offer || !offers.readyToCommit(offer)) return null;
     81  const done = offers.commit(slug, offerId, `${offer.candidate_uri}/inbox`);
     82  if (done) { applyCommitLocally(done, done.handle); notify(slug, { kind: 'committed', ward: done.ward_uri, guardian: done.candidate_uri }); }
     83  return done;
     84}
     85
     86// ── C2S: a LOCAL party acts (PWA, Berichten, or the Shaer app outbox) ──────
    3487
    3588/**
    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.
     89 * Handle a guardianship activity POSTed to the local outbox. Returns null when
     90 * it is not ours, else {status, ...} for the route.
    3891 */
    3992export async function handleOutbox(site, activity) {
    40   const { selfId, deliverTo, deriveHandle } = deps;
    4193  const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
    4294  if (!['Offer', 'Accept', 'Reject'].includes(type)) return null;
    43   const me = selfId(site.slug);
     95  const me = deps.selfId(site.slug);
    4496
     97  // ── Offer: the local site is the guardian-candidate. ───────────────────
    4598  if (type === 'Offer') {
    4699    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     // 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 }));
     100    if (!rel) return null;
     101    if (rel.candidate !== me) return { status: 403, error: 'only_the_candidate_offers' };   // fixed initiator (§3.1)
     102    if (relations.listGuardians(site.slug).length) return { status: 403, error: 'a_ward_cannot_guard' };  // §1
     103    const existing = await existingGuardiansOf(rel.ward);
     104    const offerId = `${me}/offers/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
     105    offers.start(site.slug, {
     106      offerId, ward: rel.ward, candidate: me, existingGuardians: existing,
     107      wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(me),
     108    });
     109    // Addressed to the ward AND every existing guardian (§3.1.1).
     110    const recipients = [rel.ward, ...existing];
     111    const delivered = await fanout(site, recipients, offerActivity(offerId, rel.ward, me, recipients));
    61112    notify(site.slug, { kind: 'offer_sent', ward: rel.ward });
    62     return { status: 202, id: offerId, url: offerId, delivered: res && res.delivered !== false };
     113    return { status: 202, id: offerId, url: offerId, delivered };
    63114  }
    64115
    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' };
     116  // ── Accept / Reject: the local site is a party answering an offer. ─────
     117  const offerId = idOf(activity.object);
     118  if (!offerId) return { status: 400, error: 'missing_offer' };
     119  let offer = offers.getOffer(site.slug, offerId);
     120  if (!offer) return { status: 404, error: 'no_such_offer' };
     121  const others = offers.parties(offer).filter((p) => p !== me);
    73122
    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);
     123  if (type === 'Reject') {
     124    offers.recordReject(site.slug, offerId, me);
     125    await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Reject', actor: me, to: others, object: offerId });
     126    notify(site.slug, { kind: 'offer_rejected', offer: offerId });
     127    return { status: 202, id: offerId, url: offerId };
    85128  }
    86   // The answer is committed locally; delivery is async + retried.
    87   const res = await deliverTo(site, row.other_uri, answer).catch(() => ({ delivered: false }));
    88   notify(site.slug, { kind: type === 'Accept' ? 'offer_accepted' : 'offer_rejected', guardian: row.other_uri });
    89   return { status: 202, id: answer.id, url: answer.id, delivered: res && res.delivered !== false };
     129
     130  // Accept: record my accept, broadcast it to the other parties, and commit
     131  // this copy if the tally is now complete (order-independent, §3.1.3).
     132  offers.recordAccept(site.slug, offerId, me);
     133  await fanout(site, others, { id: `${me}/answers/${Date.now().toString(36)}`, type: 'Accept', actor: me, to: others, object: offerId });
     134  const done = maybeCommit(site.slug, offerId);
     135  return { status: 202, id: offerId, url: offerId, committed: !!done, readyToCommit: offers.readyToCommit(offers.getOffer(site.slug, offerId)) };
    90136}
    91137
    92 // ── S2S: a remote party acts (arrives in the local inbox) ────────────────
     138// ── S2S: a REMOTE party's activity arrives in a local inbox ────────────────
    93139
    94140/**
    95  * Handle an inbound guardianship activity for local site `site`. Returns
    96  * true when consumed (the generic inbox skips it), false otherwise.
     141 * Handle an inbound guardianship activity for the local site `site` (the inbox
     142 * owner). Returns true when consumed.
    97143 */
    98144export async function handleInbox(site, activity) {
    99   const { selfId } = deps;
    100145  const type = Array.isArray(activity.type) ? activity.type[0] : activity.type;
    101146  if (!['Offer', 'Accept', 'Reject'].includes(type)) return false;
    102   const me = selfId(site.slug);
     147  const me = deps.selfId(site.slug);
    103148  const actor = idOf(activity.actor);
    104149
    105150  if (type === 'Offer') {
    106151    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 });
     152    if (!rel) return false;
     153    // I must be a party: the ward, or one of the existing guardians in `to`.
     154    const recipients = arr(activity.to);
     155    const existing = recipients.filter((u) => u !== rel.ward);
     156    if (rel.ward !== me && !existing.includes(me)) return false;
     157    offers.start(site.slug, {
     158      offerId: idOf(activity), ward: rel.ward, candidate: rel.candidate, existingGuardians: existing,
     159      wardHandle: deps.deriveHandle(rel.ward), candidateHandle: deps.deriveHandle(rel.candidate),
     160    });
     161    notify(site.slug, { kind: rel.ward === me ? 'offer_received' : 'offer_for_ward', ward: rel.ward, candidate: rel.candidate });
    111162    return true;
    112163  }
    113164
    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;
     165  // Accept / Reject of an offer we (also) track.
     166  const offerId = idOf(activity.object);
     167  let offer = offers.getOffer(site.slug, offerId);
     168  if (!offer) return false;
     169  if (!offers.isParty(offer, actor)) return false;
    123170
    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 });
     171  if (type === 'Reject') {
     172    offers.recordReject(site.slug, offerId, actor);
     173    notify(site.slug, { kind: 'offer_rejected', offer: offerId });
     174    return true;
    130175  }
     176
     177  offers.recordAccept(site.slug, offerId, actor);
     178  maybeCommit(site.slug, offerId);   // commits this copy once the tally is complete
    131179  return true;
    132180}
  • src/services/guardianship/index.js

    rc26cc18 r780a7c6  
    44 * Klonkt's kid-safety feature as one cohesive unit:
    55 *  - context.js:   the shaer JSON-LD namespace + Relationship vocabulary
    6  *  - relations.js: ward ↔ guardian relations (ap_guardianships) + actor props
     6 *  - offers.js:    the multi-party handshake state (a port of the Shaer daemon)
     7 *  - relations.js: the COMMITTED ward ↔ guardian relations + actor props
    78 *  - handshake.js: the adoption Offer/Accept/Reject over C2S and S2S
    89 *  - queues.js:    the owner-only dashboard collections (offers/follows/wards)
     
    1011 *  - delivery.js:  the direct-note leg a ward's call-for-help rides
    1112 *
    12  * The shared blocklist (Shaer's "in Orbit") intentionally lives NEXT TO this
    13  * module in BlocklistService: Klonkt's own Block tab uses it too.
    14  *
    15  * ActivityPubService wires the AP helpers in once (wireDelivery/wireHandshake)
    16  * and delegates; nothing here imports ActivityPubService back.
     13 * The shared blocklist (Shaer's "in Orbit") lives NEXT TO this module in
     14 * BlocklistService. ActivityPubService wires the AP helpers in once and
     15 * delegates; nothing here imports ActivityPubService back.
    1716 */
    1817export { SHAER_CONTEXT, GUARDIAN_RELATIONSHIP, GUARDIAN_RELATIONSHIP_COMPACT, isGuardianRelationship } from './context.js';
     
    2120export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship } from './handshake.js';
    2221export { offersCollection, followsCollection, wardsCollection } from './queues.js';
     22export { listForParty as listOffersForParty, getOffer, findOfferAnywhere } from './offers.js';
    2323export {
    24   listGuardians, listWards, listOffers, isGuardian, getRelation, findByOfferId,
    25   recordOffer, acceptRelation, removeRelation, actorProps as guardianshipActorProps,
     24  listGuardians, listWards, isGuardian, getRelation, removeRelation,
     25  actorProps as guardianshipActorProps,
    2626} from './relations.js';
  • src/services/guardianship/queues.js

    rc26cc18 r780a7c6  
    33 *
    44 * Three OrderedCollections on the actor (shaer:queues), same contract as the
    5  * Shaer test daemon so the iOS/Android guardian dashboards read them as-is:
    6  *  - offers:  pending guardianship offers where I am a party (§3)
    7  *  - follows: pending follows for my wards (§5.3) — Klonkt has no gated
    8  *             follows yet, so this collection is empty for now
    9  *  - wards:   my wards, for the dashboard's wards list
     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 for my wards (§5.3) — Fase 2, empty for now
     9 *  - wards:   my committed wards
    1010 */
    11 import { GUARDIAN_RELATIONSHIP_COMPACT } from './context.js';
     11import * as offers from './offers.js';
    1212import * as relations from './relations.js';
    1313
     
    1616});
    1717
    18 /** Pending offers, reconstructed as Offer activities (either side). Each item
    19  *  also carries the daemon-contract helper fields (shaer:ward, candidate,
    20  *  needsMyAccept, iAmCandidate, …): the Shaer clients render their accept
    21  *  button from those, so the shapes must match the test daemon exactly. */
     18/** Pending offers where the local site is a party, each with its accept tally. */
    2219export function offersCollection(id, slug, me) {
    23   const items = relations.listOffers(slug).map((r) => {
    24     const ward = r.role === 'guardian' ? r.other_uri : me;
    25     const candidate = r.role === 'guardian' ? me : r.other_uri;
    26     return {
    27       id: r.offer_id || `${me}/offers/pending-${r.id}`,
    28       type: 'Offer',
    29       actor: candidate,
    30       object: {
    31         type: 'Relationship',
    32         subject: ward,
    33         relationship: GUARDIAN_RELATIONSHIP_COMPACT,
    34         object: candidate,
    35       },
    36       'shaer:ward': ward,
    37       'shaer:candidate': candidate,
    38       'shaer:existingGuardians': relations.listGuardians(slug).map((g) => g.other_uri),
    39       'shaer:acceptedBy': [],
    40       // Klonkt's flow is single-phase: the ward's Accept commits at once, so
    41       // only the ward-side owner has an action here.
    42       'shaer:needsMyAccept': r.role === 'ward',
    43       'shaer:readyToCommit': false,
    44       'shaer:iAmCandidate': r.role === 'guardian',
    45       'shaer:handle': r.other_handle || undefined,
    46       published: r.created_at,
    47     };
    48   });
     20  const items = offers.listForParty(slug, me).map((o) => offers.queueItem(o, me));
    4921  return collection(id, items);
    5022}
    5123
    52 /** Gated follows awaiting guardian approval — not built in Klonkt yet. */
     24/** Gated follows awaiting guardian approval — not built in Klonkt yet (Fase 2). */
    5325export function followsCollection(id) {
    5426  return collection(id, []);
    5527}
    5628
    57 /** The guardian's wards (accepted), with cached handle for display. */
     29/** The guardian's committed wards, with cached handle for display. */
    5830export function wardsCollection(id, slug) {
    5931  const items = relations.listWards(slug)
    60     .filter((r) => r.status === 'accepted')
    6132    .map((r) => ({ id: r.other_uri, 'shaer:handle': r.other_handle || undefined, since: r.created_at }));
    6233  return collection(id, items);
  • src/services/guardianship/relations.js

    rc26cc18 r780a7c6  
    11/**
    2  * Guardianship (FEP-633c) — the ward ↔ guardian relations (ap_guardianships).
    3  *
    4  * Every row is one relation seen from a LOCAL site: role 'guardian' means the
    5  * site guards `other_uri` (a ward, possibly remote); role 'ward' means
    6  * `other_uri` guards the site. A local ward with a local guardian yields two
    7  * rows, one per perspective — intentional, each side reads its own.
    8  *
    9  * The handshake (spec §3): the guardian-candidate — and only the candidate —
    10  * Offers a Relationship {subject: ward, relationship: shaer:Guardian,
    11  * object: candidate}; the ward Accepts (or Rejects). Status walks
    12  * 'offered' → 'accepted'; a Reject deletes the row.
     2 * Guardianship (FEP-633c) — the COMMITTED ward ↔ guardian relations
     3 * (ap_guardianships). Pending offers live in offers.js; a row here means the
     4 * handshake committed (§3.1.4). Every row is one relation seen from a LOCAL
     5 * site: role 'guardian' = the site guards other_uri; role 'ward' = other_uri
     6 * guards the site.
    137 */
    148import db from '../../config/database.js';
     
    1812  if (!_s) {
    1913    _s = {
    20       ins: db.prepare(`INSERT OR IGNORE INTO ap_guardianships (slug, role, other_uri, other_handle, status, offer_id, created_at)
    21                        VALUES (?,?,?,?,?,?,CURRENT_TIMESTAMP)`),
    22       accept: db.prepare(`UPDATE ap_guardianships SET status='accepted' WHERE slug=? AND role=? AND other_uri=?`),
     14      commit: db.prepare(`INSERT INTO ap_guardianships (slug, role, other_uri, other_handle, status, offer_id, created_at)
     15                          VALUES (?,?,?,?, 'accepted', ?, CURRENT_TIMESTAMP)
     16                          ON CONFLICT(slug, role, other_uri) DO UPDATE SET status='accepted', offer_id=excluded.offer_id`),
    2317      del: db.prepare('DELETE FROM ap_guardianships WHERE slug=? AND role=? AND other_uri=?'),
    24       bySlugRole: db.prepare('SELECT * FROM ap_guardianships WHERE slug=? AND role=? ORDER BY created_at DESC'),
     18      bySlugRole: db.prepare("SELECT * FROM ap_guardianships WHERE slug=? AND role=? AND status='accepted' ORDER BY created_at DESC"),
    2519      one: db.prepare('SELECT * FROM ap_guardianships WHERE slug=? AND role=? AND other_uri=?'),
    26       byOffer: db.prepare('SELECT * FROM ap_guardianships WHERE offer_id=?'),
    2720    };
    2821  }
     
    3326
    3427/** Accepted guardian URIs of a local ward (feeds shaer:guardians). */
    35 export function listGuardians(slug) {
    36   return stmts().bySlugRole.all(slug, 'ward').filter((r) => r.status === 'accepted');
     28export function listGuardians(slug) { return stmts().bySlugRole.all(slug, 'ward'); }
     29
     30/** Accepted wards of a local guardian (the wards queue). */
     31export function listWards(slug) { return stmts().bySlugRole.all(slug, 'guardian'); }
     32
     33/** A site is a guardian once it stands in any accepted guardian relation. */
     34export function isGuardian(slug) { return listWards(slug).length > 0; }
     35
     36export function getRelation(slug, role, otherUri) { return stmts().one.get(slug, role, otherUri); }
     37
     38// ── Writes (only the handshake commit lands here) ────────────────────────
     39
     40/** The local ward gains a guardian (commit, §3.1.4). */
     41export function commitGuardianForWard(wardSlug, guardianUri, { handle = null, offerId = null } = {}) {
     42  stmts().commit.run(wardSlug, 'ward', guardianUri, handle, offerId);
     43  return stmts().one.get(wardSlug, 'ward', guardianUri);
    3744}
    3845
    39 /** All ward relations of a local guardian (accepted + pending offers). */
    40 export function listWards(slug) {
    41   return stmts().bySlugRole.all(slug, 'guardian');
     46/** The local guardian gains a ward (commit, §3.1.4). */
     47export function commitWardForGuardian(guardianSlug, wardUri, { handle = null, offerId = null } = {}) {
     48  stmts().commit.run(guardianSlug, 'guardian', wardUri, handle, offerId);
     49  return stmts().one.get(guardianSlug, 'guardian', wardUri);
    4250}
    4351
    44 /** Pending offers where the local site is a party (either side). */
    45 export function listOffers(slug) {
    46   return [...stmts().bySlugRole.all(slug, 'guardian'), ...stmts().bySlugRole.all(slug, 'ward')]
    47     .filter((r) => r.status === 'offered');
    48 }
    49 
    50 /** A site is a guardian once it stands in any guardian-side relation. */
    51 export function isGuardian(slug) {
    52   return stmts().bySlugRole.all(slug, 'guardian').length > 0;
    53 }
    54 
    55 export function getRelation(slug, role, otherUri) { return stmts().one.get(slug, role, otherUri); }
    56 export function findByOfferId(offerId) { return offerId ? stmts().byOffer.all(offerId) : []; }
    57 
    58 // ── Writes (the handshake walks through these) ───────────────────────────
    59 
    60 /** Record an outgoing/incoming Offer on the local side with `role`. */
    61 export function recordOffer(slug, role, otherUri, { handle = null, offerId = null } = {}) {
    62   stmts().ins.run(slug, role, otherUri, handle, 'offered', offerId);
    63   return stmts().one.get(slug, role, otherUri);
    64 }
    65 
    66 /** The ward said yes (or our own offer was accepted): relation becomes real. */
    67 export function acceptRelation(slug, role, otherUri) {
    68   stmts().accept.run(slug, role, otherUri);
    69   return stmts().one.get(slug, role, otherUri);
    70 }
    71 
    72 /** Reject / retract / end a relation: the row disappears. */
     52/** End a relation locally (Undo, §3.2 — federation of the Undo is Fase 4). */
    7353export function removeRelation(slug, role, otherUri) {
    7454  stmts().del.run(slug, role, otherUri);
     
    7959
    8060/**
    81  * The guardianship properties for a local actor doc. `id` is the actor URI.
    82  * - shaer:guardians: accepted guardians of this ward (omitted when none)
     61 * Guardianship props for a local actor doc. `id` is the actor URI.
     62 * - shaer:guardians: accepted guardians of this ward (omitted when none, §2.1)
    8363 * - shaer:isGuardian: true once the site guards anyone
    84  * - shaer:queues: the owner-only dashboard collections (always advertised,
    85  *   like `blocked`: clients discover, the routes enforce auth)
     64 * - shaer:queues: the owner-only dashboard collections
     65 *
     66 * §1 mutual exclusion: a ward (has guardians) is never a guardian, so
     67 * shaer:isGuardian is suppressed if guardians exist; the offer path already
     68 * bars a ward from offering.
    8669 */
    8770export function actorProps(id, slug) {
     
    9477  };
    9578  const guardians = listGuardians(slug).map((r) => r.other_uri);
    96   if (guardians.length) props['shaer:guardians'] = guardians;
    97   if (isGuardian(slug)) props['shaer:isGuardian'] = true;
     79  if (guardians.length) {
     80    props['shaer:guardians'] = guardians;   // a ward
     81  } else if (isGuardian(slug)) {
     82    props['shaer:isGuardian'] = true;        // a guardian (never both, §1)
     83  }
    9884  return props;
    9985}
    10086
    10187export default {
    102   listGuardians, listWards, listOffers, isGuardian, getRelation, findByOfferId,
    103   recordOffer, acceptRelation, removeRelation, actorProps,
     88  listGuardians, listWards, isGuardian, getRelation,
     89  commitGuardianForWard, commitWardForGuardian, removeRelation, actorProps,
    10490};
Note: See TracChangeset for help on using the changeset viewer.