Changeset 780a7c6 in Klonkt


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@…>

Files:
2 added
13 edited

Legend:

Unmodified
Added
Removed
  • src/assets/css/guardian.css

    rc26cc18 r780a7c6  
    6767.tag.wait { background: #3a2f1a; color: #e8b04b; }
    6868.tag.ok { background: #17301f; color: var(--ok); }
     69.tag.co { background: #2a1f3a; color: #c39bff; }
    6970
    7071#adopt-form { display: flex; gap: 8px; }
  • src/assets/js/guardian.js

    rc26cc18 r780a7c6  
    4747  }
    4848
    49   // ── 3. Pending offers I sent ───────────────────────────────────────────
     49  // ── 3. Offers I am a party to (sent, or a co-guardianship to co-approve) ─
     50  function answer(offerId, decision, btn) {
     51    if (btn) btn.disabled = true;
     52    fetch('/guardian/offer', {
     53      method: 'POST', headers: { 'Content-Type': 'application/json' },
     54      body: JSON.stringify({ offer: offerId, answer: decision, site: S.site }),
     55    }).then(refresh);
     56  }
     57  function offerCard(o) {
     58    var card = el('div', 'g-card');
     59    var row = el('div', 'row');
     60    var subject = o['shaer:iAmCandidate']
     61      ? handleOf(o['shaer:ward'], o['shaer:wardHandle'])            // my sent offer: about the ward
     62      : handleOf(o['shaer:candidate'], o['shaer:candidateHandle']); // co-guard: who wants in
     63    row.appendChild(el('span', 'who grow', subject));
     64    if (o['shaer:iAmCandidate']) {
     65      // My own offer, waiting for the others to accept.
     66      row.appendChild(el('span', 'tag wait', T.pending));
     67      var rt = el('button', 'quiet small', T.retract);
     68      rt.addEventListener('click', function () { answer(o.id, 'reject', rt); });
     69      row.appendChild(rt);
     70    } else if (o['shaer:needsMyAccept']) {
     71      // A co-guardianship offer for a ward I already guard: my call.
     72      row.appendChild(el('span', 'tag co', T.coguard));
     73      var ac = el('button', 'small', T.accept);
     74      ac.addEventListener('click', function () { answer(o.id, 'accept', ac); });
     75      var rj = el('button', 'quiet small', T.reject);
     76      rj.addEventListener('click', function () { answer(o.id, 'reject', rj); });
     77      row.appendChild(ac); row.appendChild(rj);
     78    } else {
     79      row.appendChild(el('span', 'tag wait', T.awaiting_others));
     80    }
     81    card.appendChild(row);
     82    return card;
     83  }
    5084  function renderPending() {
    5185    var list = document.getElementById('pending-list');
    5286    list.textContent = '';
    53     var pend = S.pendingOffers || [];
    54     pend.forEach(function (w) {
    55       var card = el('div', 'g-card');
    56       var row = el('div', 'row');
    57       row.appendChild(el('span', 'who grow', handleOf(w.other_uri, w.other_handle)));
    58       row.appendChild(el('span', 'tag wait', T.pending));
    59       var btn = el('button', 'quiet small', T.retract);
    60       btn.addEventListener('click', function () { remove(w.other_uri, btn); });
    61       row.appendChild(btn);
    62       card.appendChild(row);
    63       list.appendChild(card);
    64     });
    65     show('pending-section', pend.length > 0);
     87    var offers = S.offers || [];
     88    offers.forEach(function (o) { list.appendChild(offerCard(o)); });
     89    show('pending-section', offers.length > 0);
    6690  }
    6791
  • src/config/database.js

    rc26cc18 r780a7c6  
    405405    );
    406406    CREATE INDEX IF NOT EXISTS idx_ap_blocks_target ON ap_blocks(target);
     407    -- Committed guardian ↔ ward relations, one row per local side. role
     408    -- 'ward' = the local slug is a ward of other_uri; 'guardian' = the local
     409    -- slug guards other_uri. status is always 'accepted' here now: PENDING
     410    -- offers live in ap_guardian_offers below (FEP-633c multi-party handshake).
    407411    CREATE TABLE IF NOT EXISTS ap_guardianships (
    408412      id INTEGER PRIMARY KEY AUTOINCREMENT,
     
    411415      other_uri TEXT NOT NULL,     -- the counterpart actor URI (local or remote)
    412416      other_handle TEXT,           -- cached @user@host for display
    413       status TEXT NOT NULL,        -- 'offered' (handshake pending) | 'accepted'
     417      status TEXT NOT NULL,        -- 'offered' (legacy) | 'accepted'
    414418      offer_id TEXT,               -- the Offer activity id (FEP-633c section 3)
    415419      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     
    417421    );
    418422    CREATE INDEX IF NOT EXISTS idx_ap_guardianships_slug ON ap_guardianships(slug, role, status);
     423    -- The multi-party handshake (FEP-633c section 3), one row per offer this
     424    -- instance is a party to. Mirrors the Shaer test daemon's Handshake:
     425    -- accepts accumulate in ap_guardian_offer_accepts, and the offer commits
     426    -- only when the candidate returns the handle after ward + candidate + at
     427    -- least one existing guardian have accepted.
     428    CREATE TABLE IF NOT EXISTS ap_guardian_offers (
     429      offer_id TEXT NOT NULL,      -- the Offer activity id (minted by the candidate)
     430      slug TEXT NOT NULL,          -- the local site tracking this handshake (each party keeps its own copy)
     431      ward_uri TEXT NOT NULL,      -- the ward-to-be
     432      candidate_uri TEXT NOT NULL, -- the guardian-candidate (fixed initiator)
     433      existing_guardians TEXT NOT NULL DEFAULT '[]',  -- JSON array of the ward's current guardian URIs
     434      status TEXT NOT NULL DEFAULT 'pending',         -- 'pending' | 'committed' | 'void'
     435      handle TEXT,                 -- the escalation handle returned at commit (section 6)
     436      ward_handle TEXT,            -- cached @ward@host for display
     437      candidate_handle TEXT,       -- cached @candidate@host for display
     438      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     439      PRIMARY KEY (slug, offer_id)
     440    );
     441    CREATE INDEX IF NOT EXISTS idx_ap_guardian_offers_slug ON ap_guardian_offers(slug, status);
     442    CREATE TABLE IF NOT EXISTS ap_guardian_offer_accepts (
     443      offer_id TEXT NOT NULL,      -- FK to ap_guardian_offers
     444      slug TEXT NOT NULL,          -- the local site's copy of the tally
     445      party_uri TEXT NOT NULL,     -- the party who accepted (ward | candidate | an existing guardian)
     446      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     447      PRIMARY KEY (slug, offer_id, party_uri)
     448    );
    419449    CREATE TABLE IF NOT EXISTS ap_delivery (
    420450      id INTEGER PRIMARY KEY AUTOINCREMENT,
  • src/routes/guardian.js

    rc26cc18 r780a7c6  
    3232function uiStrings(L) {
    3333  const keys = ['sent', 'sent_retry', 'sending', 'not_found', 'failed', 'network',
    34     'pending', 'active', 'retract', 'release', 'open', 'push_unavailable'];
     34    'pending', 'active', 'retract', 'release', 'open', 'push_unavailable',
     35    'accept', 'reject', 'complete', 'awaiting_others', 'coguard'];
    3536  return Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
    3637}
    3738
    3839function dashboardState(site, L) {
    39   const wards = Guardianship.listWards(site.slug);
     40  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     41  const me = AP.actorId(base, site.slug);
    4042  const help = db.prepare(
    4143    `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at
     
    4446  return {
    4547    site: site.slug,
    46     wards: wards.filter((w) => w.status === 'accepted'),
    47     pendingOffers: wards.filter((w) => w.status === 'offered'),
     48    me,
     49    wards: Guardianship.listWards(site.slug),               // committed wards
     50    offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
    4851    help,
    4952    strings: uiStrings(L),
     
    9497});
    9598
    96 // ── Manage: retract a pending offer / release a ward ─────────────────────
     99// ── Answer an offer (co-guardian accept/reject, or the candidate's final
     100//    "complete"). All three are a C2S Accept/Reject on the offer id; the
     101//    handshake module decides when it commits (§3.1).
     102router.post('/offer', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
     103  const site = siteForUser(req);
     104  if (!site) return res.status(404).json({ error: 'no_site' });
     105  const offerId = String(req.body?.offer || '').trim();
     106  const answer = req.body?.answer === 'reject' ? 'Reject' : 'Accept';
     107  if (!offerId) return res.status(400).json({ error: 'empty_offer' });
     108  const r = await AP.ingestOutboxActivity(site, req.session.user, { type: answer, object: offerId });
     109  if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'answer_failed' });
     110  res.json({ ok: true, committed: !!r.committed, readyToCommit: !!r.readyToCommit });
     111});
     112
     113// ── Manage: release a committed ward (local Undo; federation is Fase 4). ──
    97114router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
    98115  const site = siteForUser(req);
  • src/routes/posts.js

    rc26cc18 r780a7c6  
    931931    return renderPage(req, res, 'partials/messages-append', { items, seen: seenAt, hasMore, nextOffset: offset + FEED_PAGE, moreBase });
    932932  }
    933   // FEP-633c: pending guardianship offers TO this account (ward side) show
    934   // as a special message with an accept button (Robins besluit: the kid
    935   // answers in their own Klonkt; safety is handled out-of-band by the
    936   // guardians themselves).
    937   const guardianOffers = site
    938     ? Guardianship.listOffers(site.slug).filter((o) => o.role === 'ward')
    939     : [];
     933  // FEP-633c: pending guardianship offers TO this account (I am the ward)
     934  // show as a special message with an accept button (Robins besluit: the kid
     935  // answers in its own Klonkt; safety is out-of-band by the guardians).
     936  const gBase = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     937  const gMe = site ? ActivityPubService.actorId(gBase, site.slug) : null;
     938  const guardianOffers = (site
     939    ? Guardianship.offersCollection(`${gMe}/queues/offers`, site.slug, gMe).orderedItems
     940    : []).filter((o) => o['shaer:ward'] === gMe && o['shaer:needsMyAccept']);
    940941  renderPage(req, res, 'pages/messages', {
    941942    pageTitleKey: 'msg.title', bodyClass: 'on-special', items, seenAt,
     
    951952  const back = `${res.locals.siteUrlBase || ''}/messages`;
    952953  const answer = req.body.answer === 'accept' ? 'Accept' : (req.body.answer === 'reject' ? 'Reject' : null);
    953   const guardian = String(req.body.guardian || '').trim();
    954   if (!site || !answer || !guardian) return res.redirect(back + '?error=guardianship');
    955   const row = Guardianship.getRelation(site.slug, 'ward', guardian);
    956   if (!row || row.status !== 'offered') return res.redirect(back + '?error=guardianship');
     954  const offer = String(req.body.offer || '').trim();
     955  if (!site || !answer || !offer) return res.redirect(back + '?error=guardianship');
    957956  try {
    958     const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    959     const me = ActivityPubService.actorId(base, site.slug);
    960     const r = await Guardianship.handleGuardianshipOutbox(site, {
    961       type: answer,
    962       object: row.offer_id || { type: 'Relationship', subject: me, relationship: 'shaer:Guardian', object: guardian },
    963     });
     957    // Same C2S Accept/Reject the apps use; the handshake module records the
     958    // ward's accept and (once the candidate returns the handle) commits.
     959    const r = await ActivityPubService.ingestOutboxActivity(site, req.session.user, { type: answer, object: offer });
    964960    if (r && r.status < 400) return res.redirect(back + '?success=' + (answer === 'Accept' ? 'guardian_accepted' : 'guardian_rejected'));
    965961  } catch { /* fall through */ }
  • src/services/ActivityPubService.js

    rc26cc18 r780a7c6  
    13201320  // guardianship module does not recognize falls through to the old paths.
    13211321  if (type === 'Offer' || type === 'Accept' || type === 'Reject') {
    1322     let gslug = slugParam || null;
    1323     if (!gslug && type === 'Offer') {
     1322    // Every LOCAL party this activity is addressed to gets its own copy of the
     1323    // handshake (a ward and a co-guardian may both live here). Gather candidate
     1324    // local slugs from the inbox owner, the `to` list, and the ward.
     1325    const cand = new Set();
     1326    if (slugParam) cand.add(slugParam);
     1327    for (const t of (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : []))) {
     1328      if (typeof t === 'string') { const s = slugFromActorUrl(t); if (s) cand.add(s); }
     1329    }
     1330    if (type === 'Offer') {
    13241331      const rel = Guardianship.parseRelationship(act.object);
    1325       if (rel) gslug = slugFromActorUrl(rel.ward);
    1326     }
    1327     if (!gslug) {
    1328       const offerId = typeof act.object === 'string' ? act.object : (act.object && act.object.id);
    1329       const rows = offerId ? Guardianship.findByOfferId?.(offerId) || [] : [];
    1330       if (rows.length) gslug = rows[0].slug;
    1331       if (!gslug) for (const t of (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : []))) {
    1332         const s = slugFromActorUrl(t); if (s) { gslug = s; break; }
    1333       }
    1334     }
    1335     if (gslug) {
    1336       const gsite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(gslug);
    1337       if (gsite && await Guardianship.handleGuardianshipInbox(gsite, act).catch(() => false)) {
    1338         console.log('[AP] guardianship', type, 'for', gslug, 'from', claimedActor);
    1339         return 202;
    1340       }
    1341     }
     1332      if (rel) { const s = slugFromActorUrl(rel.ward); if (s) cand.add(s); }
     1333    }
     1334    let consumed = false;
     1335    for (const slug of cand) {
     1336      const gsite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
     1337      if (gsite && await Guardianship.handleGuardianshipInbox(gsite, act).catch(() => false)) consumed = true;
     1338    }
     1339    if (consumed) { console.log('[AP] guardianship', type, 'from', claimedActor); return 202; }
    13421340  }
    13431341
     
    31563154  buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery,
    31573155});
     3156// Which local site (if any) hosts this actor URI — used by the handshake to
     3157// apply the local side of a commit and to derive a ward's existing guardians.
     3158function localSlugOf(actorUri) {
     3159  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     3160  if (!actorUri || !actorUri.startsWith(`${base}/ap/users/`)) return null;
     3161  const slug = slugFromActorUrl(actorUri);
     3162  if (!slug) return null;
     3163  try { return db.prepare('SELECT slug FROM sites WHERE slug = ?').get(slug) ? slug : null; }
     3164  catch { return null; }
     3165}
    31583166Guardianship.wireHandshake({
    31593167  selfId: selfActorId,
     3168  localSlug: localSlugOf,
    31603169  deliverTo: deliverToActor,
    31613170  deriveHandle,
    3162   // Guardian PWA push: an offer or an answer lands as a notification.
     3171  fetchActor,
     3172  // Guardian PWA / Berichten push. The kid answers an incoming offer in its
     3173  // own Berichten; an existing guardian and a commit land in the PWA.
    31633174  onEvent: (slug, ev) => {
    31643175    const L = pushLang(slug);
    31653176    const texts = {
    3166       offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],
    3167       ward_accepted: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
     3177      offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],   // I am the ward
     3178      offer_for_ward: ['push.n_guard_cog_t', 'push.n_guard_cog_b'],       // I co-guard this ward
     3179      committed: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
    31683180    }[ev.kind];
    31693181    if (!texts) return;
    31703182    const who = deriveHandle(ev.candidate || ev.ward || ev.guardian || '') || '?';
    3171     // An offer is answered in the kid's own Berichten; a ward's accept lands
    3172     // in the guardian's PWA.
    31733183    const url = ev.kind === 'offer_received' ? `${pushPrefix(slug)}/messages` : '/guardian';
    31743184    pushEvent(slug, { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who }), url });
  • 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};
  • src/services/i18n.js

    rc26cc18 r780a7c6  
    6767    'admin.b_paid': 'Betaalde posts', 'admin.b_push': 'Notificaties', 'admin.back': 'Terug naar Beheer',
    6868    'push.t': 'Notificaties', 'push.intro': 'Krijg een melding op dit apparaat bij nieuwe volgers, reacties en berichten, ook als de site niet open staat. Versleuteld tot in je browser; wij sturen zo min mogelijk inhoud mee.', 'push.unavailable': 'Push is op deze server niet beschikbaar (sleutel kon niet worden aangemaakt of de dependency ontbreekt).', 'push.unsupported': 'Deze browser ondersteunt geen push-notificaties.', 'push.ios_hint': 'Op iPhone/iPad werkt dit alleen als de site op je beginscherm staat: deel-knop, dan "Zet op beginscherm", en open de site daarna vanaf daar.', 'push.this_device': 'Dit apparaat:', 'push.checking': 'controleren…', 'push.state_on': 'meldingen staan aan', 'push.state_off': 'meldingen staan uit', 'push.state_denied': 'geblokkeerd in de browserinstellingen', 'push.state_unknown': 'status onbekend', 'push.state_unsupported': 'niet ondersteund', 'push.enable': 'Zet aan op dit apparaat', 'push.disable': 'Zet uit', 'push.test': 'Stuur testmelding', 'push.what': 'Waarvoor wil je een melding?', 'push.a_follow': 'Nieuwe volger', 'push.a_reply': 'Reactie of vermelding', 'push.a_like': 'Waardering (ster)', 'push.a_boost': 'Boost', 'push.a_dm': 'Privébericht', 'push.saved': 'Opgeslagen.', 'push.devices': 'Gekoppelde apparaten', 'push.device': 'Apparaat', 'push.since': 'sinds', 'push.remove': 'Verwijder', 'push.enable_failed': 'aanzetten mislukt', 'push.on_short': 'Word supporter',
    69     'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.open': 'open', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',
     69    'push.n_follow_t': 'Nieuwe volger', 'push.n_follow_b': '{who} volgt je nu', 'push.n_reply_t': 'Reactie op "{title}"', 'push.n_mention_t': 'Vermelding', 'push.n_dm_t': 'Privébericht', 'push.n_dm_b': 'Nieuw bericht van {who}', 'push.n_like_t': 'Nieuwe waardering', 'push.n_like_b': '{who} waardeerde "{title}"', 'push.n_boost_t': 'Geboost', 'push.n_boost_b': '{who} boostte "{title}"', 'msg.guard_offer': 'wil je guardian worden. Bespreek dit met je ouders of verzorgers voordat je beslist.', 'msg.guard_accept': 'Accepteer', 'msg.guard_reject': 'Weiger', 'msg.guard_accepted': 'Guardian geaccepteerd. Jullie zijn nu verbonden.', 'msg.guard_rejected': 'Aanvraag geweigerd.', 'msg.guard_failed': 'Dat lukte niet; probeer het opnieuw.', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards beheren en hulpverzoeken opvangen.', 'guardian.acting_as': 'Je handelt als', 'guardian.help_title': 'Hulpverzoeken', 'guardian.help_sub': 'Als een ward de reddingsboei gebruikt, verschijnt het hier.', 'guardian.help_empty': 'Geen hulpverzoeken. Mooi zo.', 'guardian.adopt_title': 'Ward adopteren', 'guardian.adopt_sub': 'Vul de handle van het kind in (@kind@server.eu). Ze krijgen een aanvraag in hun Klonkt die ze accepteren.', 'guardian.adopt_label': 'Handle van de ward', 'guardian.adopt_btn': 'Verstuur aanvraag', 'guardian.pending_title': 'Verzonden aanvragen', 'guardian.pending_sub': 'Wacht tot de ward accepteert.', 'guardian.wards_title': 'Mijn wards', 'guardian.wards_empty': 'Nog geen wards. Adopteer er hierboven een.', 'guardian.push_title': 'Meldingen', 'guardian.push_sub': 'Ontvang een melding bij een hulpverzoek of voogdij-antwoord, ook als de app dicht is.', 'guardian.push_on': 'Zet meldingen aan', 'guardian.push_off': 'Meldingen staan aan; tik om uit te zetten', 'guardian.sent': 'Aanvraag verstuurd. Zie hieronder bij Verzonden aanvragen.', 'guardian.sent_retry': 'Aanvraag opgeslagen; we blijven proberen te bezorgen.', 'guardian.sending': 'Versturen…', 'guardian.not_found': 'Die handle konden we niet vinden.', 'guardian.failed': 'Mislukt', 'guardian.network': 'Netwerkfout.', 'guardian.pending': 'wacht op antwoord', 'guardian.active': 'actief', 'guardian.retract': 'Intrekken', 'guardian.release': 'Loslaten', 'guardian.open': 'open', 'guardian.accept': 'Accepteer', 'guardian.reject': 'Weiger', 'guardian.complete': 'Voltooien', 'guardian.awaiting_others': 'wacht op de andere partijen', 'guardian.coguard': 'mede-voogdij-aanvraag', 'guardian.push_unavailable': 'Push niet beschikbaar', 'push.n_help_t': 'Hulpvraag', 'push.n_help_b': '{who} vraagt om je hulp', 'push.n_guard_offer_t': 'Voogdij-aanvraag', 'push.n_guard_offer_b': '{who} wil je guardian worden', 'push.n_guard_ward_t': 'Ward geaccepteerd', 'push.n_guard_ward_b': '{who} accepteerde je als guardian', 'push.n_guard_cog_t': 'Mede-voogdij gevraagd', 'push.n_guard_cog_b': 'Er is een guardian-aanvraag voor {who}', 'push.n_test_t': 'Klonkt-testnotificatie', 'push.n_test_b': 'Werkt. Zo komen meldingen binnen op dit apparaat.',
    7070    'apaid.t': 'Betaalde posts', 'apaid.intro': 'Koppel je eigen Patreon-campagne. Supporters ontgrendelen betaalde posts met een passkey, zonder account en zonder cookie. Wij bewaren geen namen of e-mailadressen van supporters, alleen het versleutelde token van jouw campagne.', 'apaid.saved': 'Opgeslagen.', 'apaid.nokey': 'Let op: de encryptiesleutel kon niet worden aangemaakt of gelezen (schrijfrechten op de opslagmap?). Zonder sleutel kunnen secrets niet veilig worden opgeslagen.', 'apaid.status': 'Status:', 'apaid.connected': 'verbonden', 'apaid.campaign': 'campagne', 'apaid.configured': 'ingesteld, nog niet verbonden (vul een token in)', 'apaid.notyet': 'nog niet ingesteld', 'apaid.redirect_h': 'Zet deze redirect-URI in je Patreon-client', 'apaid.redirect_p': 'Bij je Patreon API-client, onder Redirect URIs, moet exact deze regel staan. Klopt hij niet, dan geeft Patreon een foutmelding in plaats van je supporters terug te sturen.', 'apaid.copy': 'Kopieer', 'apaid.copied': 'Gekopieerd', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leeg laten = huidige waarde behouden.', 'apaid.campaign_id': 'Campagne-id', 'apaid.public_page': 'Openbare Patreon-pagina', 'apaid.public_help': 'De link waar bezoekers supporter kunnen worden. Getoond als "Word supporter" wanneer iemand nog niet doneert.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'De access + refresh token krijg je op je Patreon API-clientpagina. Wij versleutelen ze en verversen automatisch.', 'apaid.min_eur': 'Standaard-steunbedrag voor een betaalde post (euro)', 'apaid.save': 'Opslaan', 'apaid.disconnect': 'Koppeling verwijderen', 'apaid.disconnect_confirm': 'Patreon-koppeling verwijderen?', 'apaid.unchanged': 'blijft ongewijzigd',
    7171    'pgate.h': 'Voor supporters', 'pgate.sub': 'Deze post is voor supporters van deze site. Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.sub_cents': 'Deze post is voor supporters van deze site (vanaf €{eur} per maand op Patreon). Word supporter en ontgrendel hem daarna met een passkey. Geen account op deze site, geen cookie.', 'pgate.join': 'Word supporter op Patreon', 'pgate.unlock_have': 'Al supporter? Ontgrendelen', 'pgate.unlock': 'Ontgrendelen met Patreon', 'pgate.join_short': 'Word supporter', 'pgate.confirm': 'Bevestig met je passkey…', 'pgate.failed': 'Ontgrendelen mislukt. Probeer opnieuw.', 'pgate.error': 'Er ging iets mis. Probeer opnieuw.',
     
    10081008    'admin.b_paid': 'Paid posts', 'admin.b_push': 'Notifications', 'admin.back': 'Back to Admin',
    10091009    'push.t': 'Notifications', 'push.intro': 'Get a notification on this device for new followers, replies and messages, even when the site is closed. Encrypted all the way to your browser; we send as little content as possible.', 'push.unavailable': 'Push is unavailable on this server (the key could not be created or the dependency is missing).', 'push.unsupported': 'This browser does not support push notifications.', 'push.ios_hint': 'On iPhone/iPad this only works when the site is on your home screen: share button, then "Add to Home Screen", and open it from there.', 'push.this_device': 'This device:', 'push.checking': 'checking…', 'push.state_on': 'notifications are on', 'push.state_off': 'notifications are off', 'push.state_denied': 'blocked in the browser settings', 'push.state_unknown': 'status unknown', 'push.state_unsupported': 'not supported', 'push.enable': 'Turn on for this device', 'push.disable': 'Turn off', 'push.test': 'Send a test notification', 'push.what': 'What do you want to be notified about?', 'push.a_follow': 'New follower', 'push.a_reply': 'Reply or mention', 'push.a_like': 'Like (star)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private message', 'push.saved': 'Saved.', 'push.devices': 'Linked devices', 'push.device': 'Device', 'push.since': 'since', 'push.remove': 'Remove', 'push.enable_failed': 'turning on failed',
    1010     'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.open': 'open', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',
     1010    'push.n_follow_t': 'New follower', 'push.n_follow_b': '{who} now follows you', 'push.n_reply_t': 'Reply to "{title}"', 'push.n_mention_t': 'Mention', 'push.n_dm_t': 'Private message', 'push.n_dm_b': 'New message from {who}', 'push.n_like_t': 'New like', 'push.n_like_b': '{who} liked "{title}"', 'push.n_boost_t': 'Boosted', 'push.n_boost_b': '{who} boosted "{title}"', 'msg.guard_offer': 'wants to become your guardian. Talk this over with your parents or carers before you decide.', 'msg.guard_accept': 'Accept', 'msg.guard_reject': 'Reject', 'msg.guard_accepted': 'Guardian accepted. You are now connected.', 'msg.guard_rejected': 'Offer rejected.', 'msg.guard_failed': 'That did not work; try again.', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Manage wards and catch calls for help.', 'guardian.acting_as': 'You act as', 'guardian.help_title': 'Help requests', 'guardian.help_sub': 'When a ward uses the help buoy, it shows up here.', 'guardian.help_empty': 'No help requests. Good.', 'guardian.adopt_title': 'Adopt a ward', 'guardian.adopt_sub': 'Enter the child handle (@kid@server.eu). They get an offer in their Klonkt to accept.', 'guardian.adopt_label': 'Ward handle', 'guardian.adopt_btn': 'Send offer', 'guardian.pending_title': 'Sent offers', 'guardian.pending_sub': 'Waiting for the ward to accept.', 'guardian.wards_title': 'My wards', 'guardian.wards_empty': 'No wards yet. Adopt one above.', 'guardian.push_title': 'Notifications', 'guardian.push_sub': 'Get notified on a call for help or a guardianship answer, even with the app closed.', 'guardian.push_on': 'Turn on notifications', 'guardian.push_off': 'Notifications are on; tap to turn off', 'guardian.sent': 'Offer sent. See it below under Sent offers.', 'guardian.sent_retry': 'Offer saved; we keep trying to deliver it.', 'guardian.sending': 'Sending…', 'guardian.not_found': 'We could not find that handle.', 'guardian.failed': 'Failed', 'guardian.network': 'Network error.', 'guardian.pending': 'awaiting answer', 'guardian.active': 'active', 'guardian.retract': 'Retract', 'guardian.release': 'Release', 'guardian.open': 'open', 'guardian.accept': 'Accept', 'guardian.reject': 'Reject', 'guardian.complete': 'Complete', 'guardian.awaiting_others': 'awaiting the other parties', 'guardian.coguard': 'co-guardianship offer', 'guardian.push_unavailable': 'Push unavailable', 'push.n_help_t': 'Call for help', 'push.n_help_b': '{who} is asking for your help', 'push.n_guard_offer_t': 'Guardianship offer', 'push.n_guard_offer_b': '{who} wants you as their guardian', 'push.n_guard_ward_t': 'Ward accepted', 'push.n_guard_ward_b': '{who} accepted you as guardian', 'push.n_guard_cog_t': 'Co-guardianship asked', 'push.n_guard_cog_b': 'A guardian offer for {who} needs you', 'push.n_test_t': 'Klonkt test notification', 'push.n_test_b': 'It works. This is how notifications arrive on this device.',
    10111011    'apaid.t': 'Paid posts', 'apaid.intro': 'Connect your own Patreon campaign. Supporters unlock paid posts with a passkey, no account and no cookie. We store no supporter names or email addresses, only the encrypted token of your campaign.', 'apaid.saved': 'Saved.', 'apaid.nokey': 'Note: the encryption key could not be created or read (write permissions on the storage directory?). Without a key, secrets cannot be stored safely.', 'apaid.status': 'Status:', 'apaid.connected': 'connected', 'apaid.campaign': 'campaign', 'apaid.configured': 'configured, not connected yet (enter a token)', 'apaid.notyet': 'not configured yet', 'apaid.redirect_h': 'Put this redirect URI in your Patreon client', 'apaid.redirect_p': 'In your Patreon API client, under Redirect URIs, exactly this line must be present. If it does not match, Patreon shows an error instead of sending your supporters back.', 'apaid.copy': 'Copy', 'apaid.copied': 'Copied', 'apaid.client_id': 'Patreon client id', 'apaid.client_secret': 'Patreon client secret', 'apaid.keep': 'Leave empty = keep the current value.', 'apaid.campaign_id': 'Campaign id', 'apaid.public_page': 'Public Patreon page', 'apaid.public_help': 'The link where visitors can become a supporter. Shown as "Become a supporter" when someone does not pledge yet.', 'apaid.access': 'Creator access token', 'apaid.refresh': 'Creator refresh token', 'apaid.token_help': 'You get the access + refresh token on your Patreon API client page. We encrypt them and refresh automatically.', 'apaid.min_eur': 'Default support amount for a paid post (euro)', 'apaid.save': 'Save', 'apaid.disconnect': 'Remove connection', 'apaid.disconnect_confirm': 'Remove the Patreon connection?', 'apaid.unchanged': 'stays unchanged',
    10121012    'pgate.h': 'For supporters', 'pgate.sub': 'This post is for supporters of this site. Become a supporter and then unlock it with a passkey. No account on this site, no cookie.', 'pgate.sub_cents': 'This post is for supporters of this site (from €{eur} per month on Patreon). Become a supporter and then unlock it with a passkey. No account on this site, no cookie.', 'pgate.join': 'Become a supporter on Patreon', 'pgate.unlock_have': 'Already a supporter? Unlock', 'pgate.unlock': 'Unlock with Patreon', 'pgate.join_short': 'Become a supporter', 'pgate.confirm': 'Confirm with your passkey…', 'pgate.failed': 'Unlocking failed. Try again.', 'pgate.error': 'Something went wrong. Try again.',
     
    19431943    'admin.b_paid': 'Bezahlte Beiträge', 'admin.b_push': 'Benachrichtigungen', 'admin.back': 'Zurück zur Verwaltung',
    19441944    'push.t': 'Benachrichtigungen', 'push.intro': 'Erhalte auf diesem Gerät eine Meldung bei neuen Followern, Antworten und Nachrichten, auch wenn die Seite geschlossen ist. Verschlüsselt bis in deinen Browser; wir senden so wenig Inhalt wie möglich mit.', 'push.unavailable': 'Push ist auf diesem Server nicht verfügbar (Schlüssel konnte nicht erstellt werden oder die Abhängigkeit fehlt).', 'push.unsupported': 'Dieser Browser unterstützt keine Push-Benachrichtigungen.', 'push.ios_hint': 'Auf iPhone/iPad funktioniert das nur, wenn die Seite auf deinem Home-Bildschirm liegt: Teilen-Knopf, dann "Zum Home-Bildschirm", und öffne sie danach von dort.', 'push.this_device': 'Dieses Gerät:', 'push.checking': 'prüfen…', 'push.state_on': 'Benachrichtigungen sind an', 'push.state_off': 'Benachrichtigungen sind aus', 'push.state_denied': 'in den Browser-Einstellungen blockiert', 'push.state_unknown': 'Status unbekannt', 'push.state_unsupported': 'nicht unterstützt', 'push.enable': 'Auf diesem Gerät einschalten', 'push.disable': 'Ausschalten', 'push.test': 'Testmeldung senden', 'push.what': 'Wofür möchtest du eine Meldung?', 'push.a_follow': 'Neuer Follower', 'push.a_reply': 'Antwort oder Erwähnung', 'push.a_like': 'Like (Stern)', 'push.a_boost': 'Boost', 'push.a_dm': 'Private Nachricht', 'push.saved': 'Gespeichert.', 'push.devices': 'Verbundene Geräte', 'push.device': 'Gerät', 'push.since': 'seit', 'push.remove': 'Entfernen', 'push.enable_failed': 'Einschalten fehlgeschlagen',
    1945     'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.open': 'öffnen', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',
     1945    'push.n_follow_t': 'Neuer Follower', 'push.n_follow_b': '{who} folgt dir jetzt', 'push.n_reply_t': 'Antwort auf "{title}"', 'push.n_mention_t': 'Erwähnung', 'push.n_dm_t': 'Private Nachricht', 'push.n_dm_b': 'Neue Nachricht von {who}', 'push.n_like_t': 'Neues Like', 'push.n_like_b': '{who} gefällt "{title}"', 'push.n_boost_t': 'Geboostet', 'push.n_boost_b': '{who} hat "{title}" geboostet', 'msg.guard_offer': 'möchte dein Guardian werden. Besprich das mit deinen Eltern oder Betreuern, bevor du entscheidest.', 'msg.guard_accept': 'Annehmen', 'msg.guard_reject': 'Ablehnen', 'msg.guard_accepted': 'Guardian angenommen. Ihr seid jetzt verbunden.', 'msg.guard_rejected': 'Angebot abgelehnt.', 'msg.guard_failed': 'Das hat nicht geklappt; versuch es erneut.', 'guardian.app_name': 'Klonkt Guardian', 'guardian.tagline': 'Wards verwalten und Hilferufe auffangen.', 'guardian.acting_as': 'Du handelst als', 'guardian.help_title': 'Hilferufe', 'guardian.help_sub': 'Wenn ein Ward die Rettungsboje nutzt, erscheint es hier.', 'guardian.help_empty': 'Keine Hilferufe. Gut so.', 'guardian.adopt_title': 'Ward adoptieren', 'guardian.adopt_sub': 'Gib das Handle des Kindes ein (@kind@server.eu). Es bekommt ein Angebot in seinem Klonkt zum Annehmen.', 'guardian.adopt_label': 'Ward-Handle', 'guardian.adopt_btn': 'Angebot senden', 'guardian.pending_title': 'Gesendete Angebote', 'guardian.pending_sub': 'Warten, bis der Ward annimmt.', 'guardian.wards_title': 'Meine Wards', 'guardian.wards_empty': 'Noch keine Wards. Adoptiere oben eins.', 'guardian.push_title': 'Meldungen', 'guardian.push_sub': 'Erhalte eine Meldung bei einem Hilferuf oder einer Vormundschafts-Antwort, auch bei geschlossener App.', 'guardian.push_on': 'Meldungen einschalten', 'guardian.push_off': 'Meldungen sind an; tippen zum Ausschalten', 'guardian.sent': 'Angebot gesendet. Siehe unten bei Gesendete Angebote.', 'guardian.sent_retry': 'Angebot gespeichert; wir versuchen weiter zuzustellen.', 'guardian.sending': 'Senden…', 'guardian.not_found': 'Dieses Handle konnten wir nicht finden.', 'guardian.failed': 'Fehlgeschlagen', 'guardian.network': 'Netzwerkfehler.', 'guardian.pending': 'wartet auf Antwort', 'guardian.active': 'aktiv', 'guardian.retract': 'Zurückziehen', 'guardian.release': 'Loslassen', 'guardian.open': 'öffnen', 'guardian.accept': 'Annehmen', 'guardian.reject': 'Ablehnen', 'guardian.complete': 'Abschließen', 'guardian.awaiting_others': 'wartet auf die anderen Parteien', 'guardian.coguard': 'Mit-Vormundschaftsangebot', 'guardian.push_unavailable': 'Push nicht verfügbar', 'push.n_help_t': 'Hilferuf', 'push.n_help_b': '{who} bittet um deine Hilfe', 'push.n_guard_offer_t': 'Vormundschaftsangebot', 'push.n_guard_offer_b': '{who} möchte dich als Guardian', 'push.n_guard_ward_t': 'Ward akzeptiert', 'push.n_guard_ward_b': '{who} hat dich als Guardian akzeptiert', 'push.n_guard_cog_t': 'Mit-Vormundschaft gefragt', 'push.n_guard_cog_b': 'Ein Guardian-Angebot für {who} braucht dich', 'push.n_test_t': 'Klonkt-Testmeldung', 'push.n_test_b': 'Funktioniert. So kommen Meldungen auf diesem Gerät an.',
    19461946    'apaid.t': 'Bezahlte Beiträge', 'apaid.intro': 'Verbinde deine eigene Patreon-Kampagne. Unterstützer entsperren bezahlte Beiträge mit einem Passkey, ohne Konto und ohne Cookie. Wir speichern keine Namen oder E-Mail-Adressen von Unterstützern, nur das verschlüsselte Token deiner Kampagne.', 'apaid.saved': 'Gespeichert.', 'apaid.nokey': 'Achtung: der Verschlüsselungsschlüssel konnte nicht erstellt oder gelesen werden (Schreibrechte auf dem Speicherordner?). Ohne Schlüssel können Secrets nicht sicher gespeichert werden.', 'apaid.status': 'Status:', 'apaid.connected': 'verbunden', 'apaid.campaign': 'Kampagne', 'apaid.configured': 'eingerichtet, noch nicht verbunden (Token eintragen)', 'apaid.notyet': 'noch nicht eingerichtet', 'apaid.redirect_h': 'Trage diese Redirect-URI in deinen Patreon-Client ein', 'apaid.redirect_p': 'In deinem Patreon-API-Client muss unter Redirect URIs genau diese Zeile stehen. Stimmt sie nicht, zeigt Patreon eine Fehlermeldung statt deine Unterstützer zurückzuschicken.', 'apaid.copy': 'Kopieren', 'apaid.copied': 'Kopiert', 'apaid.client_id': 'Patreon Client-ID', 'apaid.client_secret': 'Patreon Client-Secret', 'apaid.keep': 'Leer lassen = aktuellen Wert behalten.', 'apaid.campaign_id': 'Kampagnen-ID', 'apaid.public_page': 'Öffentliche Patreon-Seite', 'apaid.public_help': 'Der Link, unter dem Besucher Unterstützer werden können. Wird als "Unterstützer werden" gezeigt, wenn jemand noch nicht spendet.', 'apaid.access': 'Creator Access-Token', 'apaid.refresh': 'Creator Refresh-Token', 'apaid.token_help': 'Access- und Refresh-Token bekommst du auf deiner Patreon-API-Client-Seite. Wir verschlüsseln sie und erneuern automatisch.', 'apaid.min_eur': 'Standard-Unterstützungsbetrag für einen bezahlten Beitrag (Euro)', 'apaid.save': 'Speichern', 'apaid.disconnect': 'Verbindung entfernen', 'apaid.disconnect_confirm': 'Patreon-Verbindung entfernen?', 'apaid.unchanged': 'bleibt unverändert',
    19471947    'pgate.h': 'Für Unterstützer', 'pgate.sub': 'Dieser Beitrag ist für Unterstützer dieser Seite. Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.sub_cents': 'Dieser Beitrag ist für Unterstützer dieser Seite (ab €{eur} pro Monat auf Patreon). Werde Unterstützer und entsperre ihn danach mit einem Passkey. Kein Konto auf dieser Seite, kein Cookie.', 'pgate.join': 'Unterstützer werden auf Patreon', 'pgate.unlock_have': 'Schon Unterstützer? Entsperren', 'pgate.unlock': 'Mit Patreon entsperren', 'pgate.join_short': 'Unterstützer werden', 'pgate.confirm': 'Bestätige mit deinem Passkey…', 'pgate.failed': 'Entsperren fehlgeschlagen. Versuch es erneut.', 'pgate.error': 'Etwas ist schiefgegangen. Versuch es erneut.',
  • src/views/pages/messages.ejs

    rc26cc18 r780a7c6  
    1212      <span style="font-size:1.4em;">&#128735;</span>
    1313      <div style="flex:1;min-width:200px;">
    14         <strong><%= o.other_handle || o.other_uri %></strong><br>
     14        <strong><%= o['shaer:candidateHandle'] || o['shaer:candidate'] %></strong><br>
    1515        <span><%= t('msg.guard_offer') %></span>
    1616      </div>
    1717      <form method="post" action="<%= (typeof moreBase !== 'undefined' ? moreBase : '') %>/messages/guardianship" style="display:flex;gap:8px;">
    18         <input type="hidden" name="guardian" value="<%= o.other_uri %>">
     18        <input type="hidden" name="offer" value="<%= o.id %>">
    1919        <button class="btn" type="submit" name="answer" value="accept"><%= t('msg.guard_accept') %></button>
    2020        <button class="btn" type="submit" name="answer" value="reject" style="opacity:.7;"><%= t('msg.guard_reject') %></button>
  • test/guardianship.test.js

    rc26cc18 r780a7c6  
    1 // The guardianship module (FEP-633c): relations, actor props, the adoption
    2 // handshake and the dashboard queues. Pins the module's public surface so the
    3 // Shaer clients' contract stays stable.
     1// The guardianship module (FEP-633c) — the multi-party handshake (§3).
     2// Everyone lives on one in-memory instance here, so the handshake copies all
     3// converge locally; that also exercises the "multiple local parties" routing.
    44import { test } from 'node:test';
    55import assert from 'node:assert/strict';
     
    1414const G = await import('../src/services/guardianship/index.js');
    1515
     16function site(id, slug) {
     17  db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run(id, slug, slug, 'u1', id === 's1' ? 1 : 0);
     18  return db.prepare('SELECT * FROM sites WHERE id = ?').get(id);
     19}
    1620db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
    17 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run('s1', 'parent', 'Parent', 'u1', 1);
    18 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary) VALUES (?,?,?,?,?)').run('s2', 'kid', 'Kid', 'u1', 0);
    19 const parent = db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
    20 const kid = db.prepare('SELECT * FROM sites WHERE id = ?').get('s2');
    21 const ME = 'https://test.example/ap/users/parent';
    22 const KID = 'https://test.example/ap/users/kid';
     21const parent = site('s1', 'parent');   // first guardian-candidate
     22const kid = site('s2', 'kid');          // ward
     23const gran = site('s3', 'gran');        // second guardian-candidate (co-approver later)
     24const A = (slug) => `https://test.example/ap/users/${slug}`;
     25const [ME, KID, GRAN] = [A('parent'), A('kid'), A('gran')];
    2326
    24 // No network in tests: the handshake delivers via this stub.
    25 const sent = [];
     27// No network: the handshake delivers by feeding each activity straight into the
     28// inbound handler of every addressed local party (what real S2S would do).
    2629G.wireHandshake({
    27   selfId: (slug) => `https://test.example/ap/users/${slug}`,
    28   deliverTo: async (site, uri, activity) => { sent.push({ from: site.slug, to: uri, activity }); return true; },
    29   deriveHandle: (uri) => '@' + String(uri).split('/').pop() + '@test.example',
     30  selfId: A,
     31  localSlug: (uri) => (uri.startsWith('https://test.example/ap/users/') ? uri.split('/').pop() : null),
     32  deriveHandle: (uri) => '@' + uri.split('/').pop() + '@test.example',
     33  fetchActor: async () => null,
     34  deliverTo: async (fromSite, toUri, activity) => {
     35    const slug = toUri.split('/').pop();
     36    const s = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug);
     37    if (s) await G.handleGuardianshipInbox(s, activity);
     38    return { delivered: true };
     39  },
    3040  onEvent: null,
    3141});
    3242
    33 test('actor doc advertises shaer:queues (and blocked stays)', () => {
    34   const actor = AP.buildActor('https://test.example', parent);
    35   assert.equal(actor.blocked, `${ME}/blocked`);
    36   assert.deepEqual(actor['shaer:queues'], {
    37     offers: `${ME}/queues/offers`,
    38     follows: `${ME}/queues/follows`,
    39     wards: `${ME}/queues/wards`,
     43const offerIdFrom = (r) => r.id;
     44
     45test('first guardian: candidate offers, ward accepts, candidate completes', async () => {
     46  const off = await G.handleGuardianshipOutbox(parent, {
     47    type: 'Offer', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
    4048  });
    41   assert.equal(actor['shaer:isGuardian'], undefined);   // no wards yet
     49  assert.equal(off.status, 202);
     50  const id = offerIdFrom(off);
     51
     52  // The kid sees the offer and it needs its accept.
     53  const kidQ = G.offersCollection(`${KID}/queues/offers`, 'kid', KID).orderedItems;
     54  assert.equal(kidQ.length, 1);
     55  assert.equal(kidQ[0]['shaer:needsMyAccept'], true);
     56  assert.equal(kidQ[0]['shaer:iAmCandidate'], false);
     57
     58  // Not committed on a lone candidate — the ward has not accepted.
     59  assert.deepEqual(G.listGuardians('kid'), []);
     60
     61  // The kid accepts (C2S from the kid's own Klonkt). Not committed yet: the
     62  // candidate must still agree to serve (§3.1.2).
     63  await G.handleGuardianshipOutbox(kid, { type: 'Accept', object: id });
     64  assert.deepEqual(G.listGuardians('kid'), []);
     65  const parentQ = G.offersCollection(`${ME}/queues/offers`, 'parent', ME).orderedItems;
     66  assert.equal(parentQ[0]['shaer:iAmCandidate'], true);
     67  assert.equal(parentQ[0]['shaer:needsMyAccept'], true);   // candidate has not accepted
     68
     69  // The candidate accepts → tally complete → commit everywhere.
     70  const done = await G.handleGuardianshipOutbox(parent, { type: 'Accept', object: id });
     71  assert.equal(done.committed, true);
     72  assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
     73  assert.deepEqual(G.listWards('parent').map((w) => w.other_uri), [KID]);
     74
     75  // The ward actor now names its guardian; parent reads as guardian (§2).
     76  assert.deepEqual(AP.buildActor('https://test.example', kid)['shaer:guardians'], [ME]);
     77  assert.equal(AP.buildActor('https://test.example', parent)['shaer:isGuardian'], true);
     78  // §1 mutual exclusion: the ward is not also a guardian.
     79  assert.equal(AP.buildActor('https://test.example', kid)['shaer:isGuardian'], undefined);
    4280});
    4381
    44 test('C2S Offer from the candidate records + delivers (FEP-633c 3)', async () => {
    45   const r = await G.handleGuardianshipOutbox(parent, {
    46     type: 'Offer',
    47     object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
     82test('second guardian needs the EXISTING guardian to co-accept (§3.1.2)', async () => {
     83  // Gran offers to also guard the kid (who already has parent).
     84  const off = await G.handleGuardianshipOutbox(gran, {
     85    type: 'Offer', object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: GRAN },
    4886  });
    49   assert.equal(r.status, 202);
    50   assert.equal(sent.length, 1);
    51   assert.equal(sent[0].to, KID);
    52   assert.equal(sent[0].activity.type, 'Offer');
    53   const wards = G.listWards('parent');
    54   assert.equal(wards.length, 1);
    55   assert.equal(wards[0].status, 'offered');
    56   // The guardian-to-be now reads as guardian; the actor doc follows.
    57   const actor = AP.buildActor('https://test.example', parent);
    58   assert.equal(actor['shaer:isGuardian'], true);
     87  const id = offerIdFrom(off);
     88  // The existing guardian (parent) is a party and must accept.
     89  const parentQ = G.offersCollection(`${ME}/queues/offers`, 'parent', ME).orderedItems.find((o) => o.id === id);
     90  assert.ok(parentQ, 'parent sees the co-guardianship offer');
     91  assert.deepEqual(parentQ['shaer:existingGuardians'], [ME]);
     92
     93  // Kid accepts, then gran (candidate) accepts — still NOT committed, because
     94  // the existing guardian (parent) has not co-accepted (§3.1.2).
     95  await G.handleGuardianshipOutbox(kid, { type: 'Accept', object: id });
     96  const early = await G.handleGuardianshipOutbox(gran, { type: 'Accept', object: id });
     97  assert.equal(early.committed, false);
     98  assert.equal(G.listGuardians('kid').length, 1, 'still just the first guardian');
     99
     100  // The existing guardian co-accepts → tally complete → commit.
     101  await G.handleGuardianshipOutbox(parent, { type: 'Accept', object: id });
     102  assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri).sort(), [GRAN, ME].sort());
    59103});
    60104
    61 test('only the candidate may offer', async () => {
    62   const r = await G.handleGuardianshipOutbox(parent, {
    63     type: 'Offer',
    64     object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: 'https://elders.test/u/x' },
     105test('a single Reject from a required party voids the offer (§3.2)', async () => {
     106  // parent offers to guard gran (who is free).
     107  const off = await G.handleGuardianshipOutbox(parent, {
     108    type: 'Offer', object: { type: 'Relationship', subject: GRAN, relationship: 'shaer:Guardian', object: ME },
    65109  });
    66   assert.equal(r.status, 403);
     110  const id = offerIdFrom(off);
     111  await G.handleGuardianshipOutbox(gran, { type: 'Reject', object: id });
     112  const q = G.offersCollection(`${ME}/queues/offers`, 'parent', ME).orderedItems.find((o) => o.id === id);
     113  assert.equal(q, undefined, 'voided offer leaves the queue');
     114  assert.equal(G.listWards('parent').some((w) => w.other_uri === GRAN), false);
    67115});
    68116
    69 test('inbound Offer parks in the ward queue; C2S Accept commits both ends', async () => {
    70   // The kid's side receives the offer S2S.
    71   const offerId = sent[0].activity.id;
    72   const consumed = await G.handleGuardianshipInbox(kid, {
    73     id: offerId, type: 'Offer', actor: ME,
    74     object: { type: 'Relationship', subject: KID, relationship: 'shaer:Guardian', object: ME },
    75   });
    76   assert.equal(consumed, true);
    77   assert.equal(G.listOffers('kid').length, 1);
    78 
    79   // The kid's offers queue carries the daemon-contract helper fields the
    80   // Shaer clients render their accept button from.
    81   const kidQ = G.offersCollection(`${KID}/queues/offers`, 'kid', KID);
    82   assert.equal(kidQ.totalItems, 1);
    83   assert.equal(kidQ.orderedItems[0]['shaer:needsMyAccept'], true);
    84   assert.equal(kidQ.orderedItems[0]['shaer:iAmCandidate'], false);
    85   assert.equal(kidQ.orderedItems[0]['shaer:ward'], KID);
    86   assert.equal(kidQ.orderedItems[0]['shaer:candidate'], ME);
    87 
    88   // The kid accepts over C2S; the answer travels to the guardian.
    89   const r = await G.handleGuardianshipOutbox(kid, { type: 'Accept', object: offerId });
    90   assert.equal(r.status, 202);
    91   assert.deepEqual(G.listGuardians('kid').map((g) => g.other_uri), [ME]);
    92 
    93   // The guardian's side hears the Accept S2S and commits.
    94   const ok = await G.handleGuardianshipInbox(parent, { type: 'Accept', actor: KID, object: offerId });
    95   assert.equal(ok, true);
    96   const wards = G.listWards('parent').filter((w) => w.status === 'accepted');
    97   assert.deepEqual(wards.map((w) => w.other_uri), [KID]);
    98 
    99   // The ward's actor doc now names its guardian (FEP-633c 2.1).
    100   const actor = AP.buildActor('https://test.example', kid);
    101   assert.deepEqual(actor['shaer:guardians'], [ME]);
    102 });
    103 
    104 test('queues serve the daemon contract shapes', () => {
    105   const wardsQ = G.wardsCollection(`${ME}/queues/wards`, 'parent');
    106   assert.equal(wardsQ.type, 'OrderedCollection');
    107   assert.equal(wardsQ.totalItems, 1);
    108   assert.equal(wardsQ.orderedItems[0].id, KID);
    109   const followsQ = G.followsCollection(`${ME}/queues/follows`);
    110   assert.deepEqual(followsQ.orderedItems, []);
    111   const offersQ = G.offersCollection(`${ME}/queues/offers`, 'parent', ME);
    112   assert.equal(offersQ.type, 'OrderedCollection');   // empty again after the accept
    113   assert.equal(offersQ.totalItems, 0);
    114 });
    115 
    116 test('a ward cannot become a guardian (FEP-633c 1)', async () => {
     117test('a ward cannot become a guardian (§1)', async () => {
    117118  const r = await G.handleGuardianshipOutbox(kid, {
    118     type: 'Offer',
    119     object: { type: 'Relationship', subject: 'https://other.test/u/y', relationship: 'shaer:Guardian', object: KID },
     119    type: 'Offer', object: { type: 'Relationship', subject: A('someone'), relationship: 'shaer:Guardian', object: KID },
    120120  });
    121121  assert.equal(r.status, 403);
     
    123123});
    124124
     125test('only the candidate may offer (§3.1 fixed initiator)', async () => {
     126  const r = await G.handleGuardianshipOutbox(parent, {
     127    type: 'Offer', object: { type: 'Relationship', subject: A('newkid'), relationship: 'shaer:Guardian', object: GRAN },
     128  });
     129  assert.equal(r.status, 403);
     130  assert.equal(r.error, 'only_the_candidate_offers');
     131});
     132
    125133test('helpRequest props only ride direct notes', () => {
    126   assert.deepEqual(G.helpRequestProps({ visibility: 'direct', help_request: 1 }), { 'shaer:helpRequest': true });
    127   assert.deepEqual(G.helpRequestProps({ visibility: 'public', help_request: 1 }), {});
    128134  assert.equal(G.isHelpRequest({ 'shaer:helpRequest': true }), true);
    129135  assert.equal(G.isHelpRequest({}), false);
Note: See TracChangeset for help on using the changeset viewer.