- Timestamp:
- 07/29/2026 10:50:32 AM (6 weeks ago)
- Branches:
- main
- Children:
- 08ab8ad
- Parents:
- c8e03c6
- Location:
- src
- Files:
-
- 4 edited
-
routes/guardian.js (modified) (3 diffs)
-
services/ActivityPubService.js (modified) (6 diffs)
-
services/guardianship/delivery.js (modified) (3 diffs)
-
services/guardianship/handshake.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/guardian.js
rc8e03c6 r6d5ce0c 309 309 // handshake module decides when it commits (§3.1). 310 310 // ── Step away (FEP-633c 3.6.1): the guardian declares itself unavailable ── 311 // One direct note with shaer:away and an endTime to every ward, the same 312 // path Shaer takes over C2S. Wards on this instance are applied directly (a 313 // local inbox never receives its own delivery); the rest travels S2S. 311 // One direct note with shaer:away and an endTime to every ward, the same path 312 // Shaer takes over C2S, and the only path: a ward on this instance receives 313 // that note through the loopback and applies the absence in its own inbox 314 // handler, exactly as a ward elsewhere does. This route used to write the 315 // local wards itself as well, which meant the wire version could break without 316 // anyone here noticing. 314 317 router.post('/api/away', requireAuth, express.json({ limit: '2kb' }), async (req, res) => { 315 318 const site = siteForUser(req); … … 320 323 if (!wards.length) return res.status(409).json({ error: 'no_wards' }); 321 324 const until = Date.now() + days * 24 * 3600 * 1000; 322 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');323 const me = AP.actorId(base, site.slug);324 let applied = 0;325 for (const uri of wards) {326 const wslug = uri.startsWith(`${base}/`) ? uri.replace(/\/+$/, '').split('/').pop() : null;327 if (wslug && Guardianship.listGuardians(wslug).some((g) => g.other_uri === me)) {328 Guardianship.availability.declareAway(wslug, me, until);329 applied++;330 }331 }332 325 const L = resolveLang(req); 333 326 const text = i18nT(L, 'guardian.away_msg', { date: new Date(until).toLocaleDateString('nl-NL') }); 334 327 const r = await AP.deliverDirectNote(site, { recipients: wards, text, awayUntil: until }).catch(() => null); 335 if (! applied && !(r && r.id)) return res.status(502).json({ error: 'away_failed' });328 if (!(r && r.id)) return res.status(502).json({ error: 'away_failed' }); 336 329 res.json({ ok: true, until }); 337 330 }); … … 508 501 const offerId = `${me}/gated/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`; 509 502 const offer = Guardianship.gated.buildGatedOffer(offerId, me, uri, feature, allow); 503 // ONE path, whether the ward lives here or on the other side of the world 504 // (Robins regel, 29-7): propose over the wire and let the ward's server do 505 // what it does for everyone. deliverToActor loops a local recipient back 506 // into the same inbox handler, so co-location changes the transport and 507 // nothing else. The old shortcut recorded the vote here directly, which is 508 // how the remote path stayed broken for a month without anyone noticing. 509 AP.deliverToActor(site, uri, offer).catch(() => { /* queued, best-effort */ }); 510 510 const localSlug = (base && uri.startsWith(`${base}/`)) ? uri.replace(/\/+$/, '').split('/').pop() : null; 511 const localWard = localSlug ? db.prepare('SELECT slug FROM sites WHERE slug = ?').get(localSlug) : null; 512 if (localWard) { 513 Guardianship.gated.rememberGatedOffer(offerId, localWard.slug, feature, allow); 514 const r = Guardianship.gated.recordGatedVote(localWard.slug, feature, me, allow); 515 // Same forward as the S2S path: without it the other guardians never learn 516 // the proposal exists and a threshold of two can never be met. 517 if (r.state === 'open') { 518 const wardActor = AP.actorId(base, localWard.slug); 519 for (const g of Guardianship.listGuardians(localWard.slug).map((x) => x.other_uri)) { 520 if (g === me) continue; 521 // Signed by the ward, so the body must say the ward: anything else is 522 // a signer mismatch and the receiver answers 401 (as it should). 523 AP.deliverToActor( 524 db.prepare('SELECT * FROM sites WHERE slug = ?').get(localWard.slug), 525 g, 526 { ...offer, actor: wardActor, to: [g], 'shaer:proposer': me }, 527 ).catch(() => { /* queued */ }); 528 } 529 } 530 return res.json({ ok: true, allow, state: r.state, need: r.need, of: r.of }); 531 } 532 AP.deliverToActor(site, uri, offer).catch(() => { /* queued, best-effort */ }); 533 res.json({ ok: true, allow, state: 'open', federated: true }); 511 const progress = localSlug ? Guardianship.gated.gatedProgress(localSlug, feature) : null; 512 res.json({ ok: true, allow, state: 'open', ...(progress || { federated: true }) }); 534 513 } 535 514 -
src/services/ActivityPubService.js
rc8e03c6 r6d5ce0c 1345 1345 1346 1346 // Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox. 1347 export async function handleInbox(req, slugParam ) {1347 export async function handleInbox(req, slugParam, preVerified = null) { 1348 1348 const act = req.body || {}; 1349 1349 const type = act.type; … … 1352 1352 const ip = req.ip || (req.connection && req.connection.remoteAddress) || '?'; 1353 1353 const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, ''); 1354 const verified = await verifyRequest(req).catch(() => null); 1354 // preVerified is the loopback (see deliverToActor): a delivery between two 1355 // actors on THIS instance never crosses a socket, so there is no signature to 1356 // check — but we do know who signed, because we signed it. Handing that in 1357 // keeps everything below identical, including the actor-versus-signer check, 1358 // which is exactly the check that must not be skipped for being local. 1359 const verified = preVerified || await verifyRequest(req).catch(() => null); 1355 1360 1356 1361 // ENFORCE HTTP signatures: a data-affecting activity must be signed by the very … … 1534 1539 const actorUri = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id); 1535 1540 const resolveActor = async (uri) => ((verified && verified.id === uri) ? verified : await fetchActor(uri).catch(() => null)); 1536 // Activities from our OWN actors are already stored via ap_outbox — don't re-store. 1537 const isLocalActor = !!(base && actorUri && actorUri.startsWith(`${base}/ap/users/`)); 1541 // Our OWN activity is already stored via ap_outbox: don't store it twice. 1542 // "Our own" means THIS inbox's owner, not "anyone who happens to live on this 1543 // machine". The old reading dropped every activity between two sites on one 1544 // instance, so a note from a co-located guardian to its ward was accepted 1545 // with a 202 and then quietly thrown away: no mention, no away, no help 1546 // request. Neighbours are not us (Robins regel, 29-7: on this machine 1547 // everything behaves as if every Klonkt were somewhere else). 1548 const isLocalActor = !!(actorUri && slugParam && actorUri === actorId(base, slugParam)); 1538 1549 1539 1550 // Inbound reply: a Create whose object replies to one of our notes (post OR comment). … … 2253 2264 awayUntil = Guardianship.availability.parseEndTime(object.endTime); 2254 2265 if (!awayUntil || awayUntil <= Date.now()) return { status: 400, error: 'away_needs_an_end' }; 2255 // A ward we host ourselves never receives its own delivery 2256 // (private ranges, loopback): apply locally, the way the 2257 // handshake commit does. 2258 const meUri = selfActorId(site.slug); 2259 for (const uri of recipients) { 2260 const wslug = uri.startsWith(`${base}/`) ? slugFromActorUrl(uri) : null; 2261 if (wslug && Guardianship.listGuardians(wslug).some((g) => g.other_uri === meUri)) { 2262 Guardianship.availability.declareAway(wslug, meUri, awayUntil); 2263 } 2264 } 2266 // No local shortcut here: the note below reaches a ward on this 2267 // instance through the loopback, and its inbox handler applies the 2268 // absence like it does for a ward anywhere else. One path. 2265 2269 } 2266 2270 const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null, attachments: atts, helpRequest: help, awayUntil }); … … 3807 3811 const keys = getOrCreateKeys(site.slug); 3808 3812 const payload = { '@context': AP_CONTEXT, ...activity }; 3813 // Co-location is a TRANSPORT detail, never a decision path (Robins regel, 3814 // 29-7). An inbox on this machine is not reachable over HTTP from this 3815 // machine, and should not be, so a local recipient is handed the activity 3816 // straight into the same inbox handler the wire would reach. Everything 3817 // above this line therefore behaves as if every Klonkt were remote: one code 3818 // path, exercised by every deployment, including the checks. Two bugs in one 3819 // day came from having a second, local-only path that hid a broken remote 3820 // one. 3821 const localSlug = localSlugOf(actorUri); 3822 if (localSlug && db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(localSlug)) { 3823 const host = (() => { try { return new URL(selfActorId(site.slug)).host; } catch { return ''; } })(); 3824 const req = { body: payload, ip: 'loopback', protocol: 'https', get: () => host, headers: {} }; 3825 // The signer is us, and we say so: the actor-versus-signer check runs 3826 // exactly as it does over the wire, so a mismatch fails here too. 3827 const status = await handleInbox(req, localSlug, { id: me }).catch(() => 500); 3828 const ok = status >= 200 && status < 300; 3829 console.log('[AP]', activity.type, ok ? 'delivered (loopback) →' : `got ${status} (loopback) from`, actorUri); 3830 return { delivered: ok, inbox: `${actorUri}/inbox`, loopback: true, status }; 3831 } 3809 3832 const a = await fetchActor(actorUri).catch(() => null); 3810 3833 const inbox = a && (a.inbox || (a.endpoints && a.endpoints.sharedInbox)); … … 3822 3845 } 3823 3846 Guardianship.wireDelivery({ 3824 actorId, fetchActor, deriveHandle, escHtml, linkUrls, linkHashtags,3847 actorId, fetchActor, localActor, deliverTo: deliverToActor, deriveHandle, escHtml, linkUrls, linkHashtags, 3825 3848 getOutboxRow: (id) => iStmts().getO.get(id), 3826 3849 buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery, 3827 3850 }); 3851 /** 3852 * The actor document of a site WE host, read straight from the database. 3853 * Same shape fetchActor returns for anyone else, plus `local: true` so the 3854 * caller can take the loopback instead of a POST to our own hostname. 3855 * Null for an actor we do not host: that one really is fetched. 3856 */ 3857 function localActor(actorUri) { 3858 const slug = localSlugOf(actorUri); 3859 if (!slug) return null; 3860 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 3861 const site = db.prepare('SELECT * FROM sites WHERE slug = ?').get(slug); 3862 if (!site) return null; 3863 // primary_slug is what buildActor uses to pick '/' over '/user/<slug>'; the 3864 // actor route sets it the same way before building. 3865 const p = db.prepare('SELECT slug FROM sites WHERE is_primary = 1').get(); 3866 try { return { ...buildActor(base, { ...site, primary_slug: p && p.slug }), local: true }; } catch { return null; } 3867 } 3828 3868 // Which local site (if any) hosts this actor URI — used by the handshake to 3829 3869 // apply the local side of a commit and to derive a ward's existing guardians. -
src/services/guardianship/delivery.js
rc8e03c6 r6d5ce0c 41 41 // call-for-help path). 42 42 export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest, wave, awayUntil }) { 43 const { actorId, fetchActor, deriveHandle, escHtml, linkUrls, linkHashtags,43 const { actorId, fetchActor, localActor, deliverTo, deriveHandle, escHtml, linkUrls, linkHashtags, 44 44 getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps; 45 45 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); … … 50 50 const resolved = []; 51 51 for (const uri of list) { 52 const a = await fetchActor(uri).catch(() => null); 52 // An actor we host is read from our own database, not fetched from our own 53 // hostname: that request has to leave the machine and come back, and when 54 // it does not, the recipient is silently dropped from the note. Everything 55 // that decides anything still runs below, for local and remote alike. 56 const a = (localActor && localActor(uri)) || await fetchActor(uri).catch(() => null); 53 57 if (!a || !(a.inbox || (a.endpoints && a.endpoints.sharedInbox))) continue; 54 resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, handle: deriveHandle(uri), url: a.url || uri });58 resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, local: !!a.local, handle: deriveHandle(uri), url: a.url || uri }); 55 59 } 56 60 if (!resolved.length) return null; … … 83 87 const keyId = `${me}#main-key`; 84 88 let delivered = 0; 85 for (const inbox of [...new Set(resolved.map((r) => r.inbox))]) { 89 // A recipient on this machine takes the loopback (deliverToActor), which 90 // hands the Create to the same inbox handler an HTTP POST would reach: the 91 // note is stored, the mention is stored, and a shaer:away on it is applied, 92 // all by the code that does it for everyone else. A hairpin POST to our own 93 // hostname is not that code path, it is a second one that only appears to be. 94 for (const r of resolved.filter((x) => x.local)) { 95 const res = await deliverTo(site, r.uri, create).catch(() => null); 96 if (res && res.delivered) delivered++; 97 } 98 // Remote: one POST per inbox, so two guardians on the same server share it. 99 for (const inbox of [...new Set(resolved.filter((x) => !x.local).map((r) => r.inbox))]) { 86 100 let ok = false; 87 101 try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; } -
src/services/guardianship/handshake.js
rc8e03c6 r6d5ce0c 219 219 const lp = availability.parseLapse(activity.object); 220 220 if (lp) { 221 // ONE path (Robins regel, 29-7): the ward's server opens, tallies and 222 // enforces, wherever it lives. A local ward is reached by the same 223 // deliverTo, which loops back into the inbox handler; co-location is a 224 // transport detail and never a shortcut past the decision. 221 225 const id = `${me}/lapses/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`; 222 const wardSlug = deps.localSlug(lp.ward);223 if (wardSlug) {224 const r = availability.openLapse({ id, wardSlug, wardUri: lp.ward, target: lp.target, openedBy: me, now: Date.now() });225 if (r.error) return { status: r.error === 'not_in_available_set' ? 403 : 409, error: r.error };226 deps.deliverTo(site, lp.target, { id, type: 'Offer', actor: me, to: [lp.target], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } }).catch(() => { /* best-effort */ });227 notify(wardSlug, { kind: 'lapse_opened', lapse: id, target: lp.target, set: r.set });228 return { status: 202, id, url: id, 'shaer:set': r.set, 'shaer:threshold': r.threshold };229 }230 226 const offer = { id, type: 'Offer', actor: me, to: [lp.ward], object: { type: 'shaer:Lapse', 'shaer:ward': lp.ward, object: lp.target } }; 231 227 const delivered = await fanout(site, [lp.ward], offer);
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)