Changeset 780a7c6 in Klonkt
- Timestamp:
- 07/24/2026 07:44:15 PM (7 weeks ago)
- Branches:
- main
- Children:
- b5924eb
- Parents:
- c26cc18
- Files:
-
- 2 added
- 13 edited
-
.claude/launch.json (added)
-
src/assets/css/guardian.css (modified) (1 diff)
-
src/assets/js/guardian.js (modified) (1 diff)
-
src/config/database.js (modified) (3 diffs)
-
src/routes/guardian.js (modified) (3 diffs)
-
src/routes/posts.js (modified) (2 diffs)
-
src/services/ActivityPubService.js (modified) (2 diffs)
-
src/services/guardianship/handshake.js (modified) (3 diffs)
-
src/services/guardianship/index.js (modified) (3 diffs)
-
src/services/guardianship/offers.js (added)
-
src/services/guardianship/queues.js (modified) (2 diffs)
-
src/services/guardianship/relations.js (modified) (5 diffs)
-
src/services/i18n.js (modified) (3 diffs)
-
src/views/pages/messages.ejs (modified) (1 diff)
-
test/guardianship.test.js (modified) (3 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/assets/css/guardian.css
rc26cc18 r780a7c6 67 67 .tag.wait { background: #3a2f1a; color: #e8b04b; } 68 68 .tag.ok { background: #17301f; color: var(--ok); } 69 .tag.co { background: #2a1f3a; color: #c39bff; } 69 70 70 71 #adopt-form { display: flex; gap: 8px; } -
src/assets/js/guardian.js
rc26cc18 r780a7c6 47 47 } 48 48 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 } 50 84 function renderPending() { 51 85 var list = document.getElementById('pending-list'); 52 86 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); 66 90 } 67 91 -
src/config/database.js
rc26cc18 r780a7c6 405 405 ); 406 406 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). 407 411 CREATE TABLE IF NOT EXISTS ap_guardianships ( 408 412 id INTEGER PRIMARY KEY AUTOINCREMENT, … … 411 415 other_uri TEXT NOT NULL, -- the counterpart actor URI (local or remote) 412 416 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' 414 418 offer_id TEXT, -- the Offer activity id (FEP-633c section 3) 415 419 created_at DATETIME DEFAULT CURRENT_TIMESTAMP, … … 417 421 ); 418 422 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 ); 419 449 CREATE TABLE IF NOT EXISTS ap_delivery ( 420 450 id INTEGER PRIMARY KEY AUTOINCREMENT, -
src/routes/guardian.js
rc26cc18 r780a7c6 32 32 function uiStrings(L) { 33 33 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']; 35 36 return Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)])); 36 37 } 37 38 38 39 function 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); 40 42 const help = db.prepare( 41 43 `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at … … 44 46 return { 45 47 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, 48 51 help, 49 52 strings: uiStrings(L), … … 94 97 }); 95 98 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). 102 router.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). ── 97 114 router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), (req, res) => { 98 115 const site = siteForUser(req); -
src/routes/posts.js
rc26cc18 r780a7c6 931 931 return renderPage(req, res, 'partials/messages-append', { items, seen: seenAt, hasMore, nextOffset: offset + FEED_PAGE, moreBase }); 932 932 } 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']); 940 941 renderPage(req, res, 'pages/messages', { 941 942 pageTitleKey: 'msg.title', bodyClass: 'on-special', items, seenAt, … … 951 952 const back = `${res.locals.siteUrlBase || ''}/messages`; 952 953 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'); 957 956 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 }); 964 960 if (r && r.status < 400) return res.redirect(back + '?success=' + (answer === 'Accept' ? 'guardian_accepted' : 'guardian_rejected')); 965 961 } catch { /* fall through */ } -
src/services/ActivityPubService.js
rc26cc18 r780a7c6 1320 1320 // guardianship module does not recognize falls through to the old paths. 1321 1321 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') { 1324 1331 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; } 1342 1340 } 1343 1341 … … 3156 3154 buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery, 3157 3155 }); 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. 3158 function 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 } 3158 3166 Guardianship.wireHandshake({ 3159 3167 selfId: selfActorId, 3168 localSlug: localSlugOf, 3160 3169 deliverTo: deliverToActor, 3161 3170 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. 3163 3174 onEvent: (slug, ev) => { 3164 3175 const L = pushLang(slug); 3165 3176 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'], 3168 3180 }[ev.kind]; 3169 3181 if (!texts) return; 3170 3182 const who = deriveHandle(ev.candidate || ev.ward || ev.guardian || '') || '?'; 3171 // An offer is answered in the kid's own Berichten; a ward's accept lands3172 // in the guardian's PWA.3173 3183 const url = ev.kind === 'offer_received' ? `${pushPrefix(slug)}/messages` : '/guardian'; 3174 3184 pushEvent(slug, { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who }), url }); -
src/services/guardianship/handshake.js
rc26cc18 r780a7c6 1 1 /** 2 * Guardianship (FEP-633c §3) — the adoption handshake. 2 * Guardianship (FEP-633c §3) — the adoption handshake, multi-party and 3 * distributed across instances. 3 4 * 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). 9 13 * 10 * Wired like delivery.js: no import back into ActivityPubService; the AP11 * helpers arrive once via wireHandshake(deps). `deps.onEvent(slug, ev)` is an12 * 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. 13 17 */ 14 18 import { isGuardianRelationship, GUARDIAN_RELATIONSHIP_COMPACT } from './context.js'; 19 import * as offers from './offers.js'; 15 20 import * as relations from './relations.js'; 16 21 … … 19 24 20 25 const idOf = (v) => (typeof v === 'string' ? v : (v && typeof v === 'object' && typeof v.id === 'string' ? v.id : null)); 26 const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string'); 21 27 22 28 /** Parse a Relationship object into {ward, candidate} or null. */ … … 31 37 } 32 38 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. */ 40 async 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 48 function 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). */ 56 async 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. */ 67 function 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. */ 78 function 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) ────── 34 87 35 88 /** 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. 38 91 */ 39 92 export async function handleOutbox(site, activity) { 40 const { selfId, deliverTo, deriveHandle } = deps;41 93 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type; 42 94 if (!['Offer', 'Accept', 'Reject'].includes(type)) return null; 43 const me = selfId(site.slug);95 const me = deps.selfId(site.slug); 44 96 97 // ── Offer: the local site is the guardian-candidate. ─────────────────── 45 98 if (type === 'Offer') { 46 99 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)); 61 112 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 }; 63 114 } 64 115 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); 73 122 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 }; 85 128 } 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)) }; 90 136 } 91 137 92 // ── S2S: a remote party acts (arrives in the local inbox)────────────────138 // ── S2S: a REMOTE party's activity arrives in a local inbox ──────────────── 93 139 94 140 /** 95 * Handle an inbound guardianship activity for local site `site`. Returns96 * 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. 97 143 */ 98 144 export async function handleInbox(site, activity) { 99 const { selfId } = deps;100 145 const type = Array.isArray(activity.type) ? activity.type[0] : activity.type; 101 146 if (!['Offer', 'Accept', 'Reject'].includes(type)) return false; 102 const me = selfId(site.slug);147 const me = deps.selfId(site.slug); 103 148 const actor = idOf(activity.actor); 104 149 105 150 if (type === 'Offer') { 106 151 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 }); 111 162 return true; 112 163 } 113 164 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; 123 170 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; 130 175 } 176 177 offers.recordAccept(site.slug, offerId, actor); 178 maybeCommit(site.slug, offerId); // commits this copy once the tally is complete 131 179 return true; 132 180 } -
src/services/guardianship/index.js
rc26cc18 r780a7c6 4 4 * Klonkt's kid-safety feature as one cohesive unit: 5 5 * - 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 7 8 * - handshake.js: the adoption Offer/Accept/Reject over C2S and S2S 8 9 * - queues.js: the owner-only dashboard collections (offers/follows/wards) … … 10 11 * - delivery.js: the direct-note leg a ward's call-for-help rides 11 12 * 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. 17 16 */ 18 17 export { SHAER_CONTEXT, GUARDIAN_RELATIONSHIP, GUARDIAN_RELATIONSHIP_COMPACT, isGuardianRelationship } from './context.js'; … … 21 20 export { wireHandshake, handleOutbox as handleGuardianshipOutbox, handleInbox as handleGuardianshipInbox, parseRelationship } from './handshake.js'; 22 21 export { offersCollection, followsCollection, wardsCollection } from './queues.js'; 22 export { listForParty as listOffersForParty, getOffer, findOfferAnywhere } from './offers.js'; 23 23 export { 24 listGuardians, listWards, listOffers, isGuardian, getRelation, findByOfferId,25 recordOffer, acceptRelation, removeRelation,actorProps as guardianshipActorProps,24 listGuardians, listWards, isGuardian, getRelation, removeRelation, 25 actorProps as guardianshipActorProps, 26 26 } from './relations.js'; -
src/services/guardianship/queues.js
rc26cc18 r780a7c6 3 3 * 4 4 * Three OrderedCollections on the actor (shaer:queues), same contract as the 5 * Shaer test daemon so the iOS/Android guardiandashboards 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 gated8 * follows yet, so this collection isempty for now9 * - wards: my wards, for the dashboard's wards list5 * 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 10 10 */ 11 import { GUARDIAN_RELATIONSHIP_COMPACT } from './context.js';11 import * as offers from './offers.js'; 12 12 import * as relations from './relations.js'; 13 13 … … 16 16 }); 17 17 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. */ 22 19 export 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)); 49 21 return collection(id, items); 50 22 } 51 23 52 /** Gated follows awaiting guardian approval — not built in Klonkt yet . */24 /** Gated follows awaiting guardian approval — not built in Klonkt yet (Fase 2). */ 53 25 export function followsCollection(id) { 54 26 return collection(id, []); 55 27 } 56 28 57 /** The guardian's wards (accepted), with cached handle for display. */29 /** The guardian's committed wards, with cached handle for display. */ 58 30 export function wardsCollection(id, slug) { 59 31 const items = relations.listWards(slug) 60 .filter((r) => r.status === 'accepted')61 32 .map((r) => ({ id: r.other_uri, 'shaer:handle': r.other_handle || undefined, since: r.created_at })); 62 33 return collection(id, items); -
src/services/guardianship/relations.js
rc26cc18 r780a7c6 1 1 /** 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. 13 7 */ 14 8 import db from '../../config/database.js'; … … 18 12 if (!_s) { 19 13 _s = { 20 ins: db.prepare(`INSERT OR IGNOREINTO 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`), 23 17 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"), 25 19 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=?'),27 20 }; 28 21 } … … 33 26 34 27 /** 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'); 28 export function listGuardians(slug) { return stmts().bySlugRole.all(slug, 'ward'); } 29 30 /** Accepted wards of a local guardian (the wards queue). */ 31 export function listWards(slug) { return stmts().bySlugRole.all(slug, 'guardian'); } 32 33 /** A site is a guardian once it stands in any accepted guardian relation. */ 34 export function isGuardian(slug) { return listWards(slug).length > 0; } 35 36 export 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). */ 41 export 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); 37 44 } 38 45 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). */ 47 export 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); 42 50 } 43 51 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). */ 73 53 export function removeRelation(slug, role, otherUri) { 74 54 stmts().del.run(slug, role, otherUri); … … 79 59 80 60 /** 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) 83 63 * - 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. 86 69 */ 87 70 export function actorProps(id, slug) { … … 94 77 }; 95 78 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 } 98 84 return props; 99 85 } 100 86 101 87 export default { 102 listGuardians, listWards, listOffers, isGuardian, getRelation, findByOfferId,103 recordOffer, acceptRelation, removeRelation, actorProps,88 listGuardians, listWards, isGuardian, getRelation, 89 commitGuardianForWard, commitWardForGuardian, removeRelation, actorProps, 104 90 }; -
src/services/i18n.js
rc26cc18 r780a7c6 67 67 'admin.b_paid': 'Betaalde posts', 'admin.b_push': 'Notificaties', 'admin.back': 'Terug naar Beheer', 68 68 '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.', 70 70 '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', 71 71 '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.', … … 1008 1008 'admin.b_paid': 'Paid posts', 'admin.b_push': 'Notifications', 'admin.back': 'Back to Admin', 1009 1009 '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.', 1011 1011 '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', 1012 1012 '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.', … … 1943 1943 'admin.b_paid': 'Bezahlte Beiträge', 'admin.b_push': 'Benachrichtigungen', 'admin.back': 'Zurück zur Verwaltung', 1944 1944 '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.', 1946 1946 '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', 1947 1947 '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 12 12 <span style="font-size:1.4em;">🛟</span> 13 13 <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> 15 15 <span><%= t('msg.guard_offer') %></span> 16 16 </div> 17 17 <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 %>"> 19 19 <button class="btn" type="submit" name="answer" value="accept"><%= t('msg.guard_accept') %></button> 20 20 <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 adoption2 // handshake and the dashboard queues. Pins the module's public surface so the3 // 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. 4 4 import { test } from 'node:test'; 5 5 import assert from 'node:assert/strict'; … … 14 14 const G = await import('../src/services/guardianship/index.js'); 15 15 16 function 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 } 16 20 db.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'; 21 const parent = site('s1', 'parent'); // first guardian-candidate 22 const kid = site('s2', 'kid'); // ward 23 const gran = site('s3', 'gran'); // second guardian-candidate (co-approver later) 24 const A = (slug) => `https://test.example/ap/users/${slug}`; 25 const [ME, KID, GRAN] = [A('parent'), A('kid'), A('gran')]; 23 26 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). 26 29 G.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 }, 30 40 onEvent: null, 31 41 }); 32 42 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`, 43 const offerIdFrom = (r) => r.id; 44 45 test('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 }, 40 48 }); 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); 42 80 }); 43 81 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},82 test('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 }, 48 86 }); 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()); 59 103 }); 60 104 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'},105 test('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 }, 65 109 }); 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); 67 115 }); 68 116 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 () => { 117 test('a ward cannot become a guardian (§1)', async () => { 117 118 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 }, 120 120 }); 121 121 assert.equal(r.status, 403); … … 123 123 }); 124 124 125 test('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 125 133 test('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 }), {});128 134 assert.equal(G.isHelpRequest({ 'shaer:helpRequest': true }), true); 129 135 assert.equal(G.isHelpRequest({}), false);
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)