Changeset 6b5d7da in Klonkt


Ignore:
Timestamp:
07/24/2026 04:56:51 PM (7 weeks ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
e61c289
Parents:
9a00f28
Message:

Guardianship als eigen module + gedeelde BlocklistService

FEP-633c (Guardians) lag verspreid door ActivityPubService; nu is het één
cohesief onderdeel in src/services/guardianship/ met submodules. De
blocklist staat er bewust NAAST (BlocklistService): die wordt gedeeld met
Klonkt zelf (Block-tab) en is niet guardianship-specifiek.

De module importeert ActivityPubService nooit terug: de AP-helpers gaan er
één keer in via wireDelivery/wireHandshake, en de service delegeert met
dunne wrappers zodat elke bestaande aanroep blijft werken.

Naast de verhuizing ook de serverkant die nog miste (shaer-bh1): de
ap_guardianships-relaties, shaer:guardians/isGuardian/queues op het
actor-doc, en de adoptie-handshake Offer/Accept/Reject over C2S en S2S,
met het contract van de Shaer test-daemon zodat de iOS/Android-clients het
ongewijzigd spreken. Een inkomend hulpverzoek (shaer:helpRequest op een
directe mention) krijgt een eigen vlag in ap_mentions en pusht als
'help'-type richting de Guardian-PWA (volgende commit).

Changed files:
src/services/ActivityPubService.js

  • shaer-context, actor-props en helpRequest uit de module gespread
  • blocklist-functies zijn delegaties naar BlocklistService
  • c2sVisibility/deliverDirectNote re-export uit guardianship/delivery
  • C2S: Offer/Accept/Reject eerst langs de handshake-module
  • S2S: Offer aan GATED (signature-eis) + handshake-routering
  • inbound mention: help_request-vlag + 'help'/'guardian'-push-events

src/config/database.js

  • tabel ap_guardianships (slug, role, other_uri, status, offer_id)
  • kolom ap_mentions.help_request

test/activitypub-as2.test.js

  • shaer:queues/offers/follows/wards in de AS2-allowlist

New file:
src/services/BlocklistService.js

  • ap_blocks-opslag, blockTarget/unblock/listBlocks/isBlockedAny, purge; handle-resolver via injectie (geen circulaire import)

src/services/guardianship/index.js

  • de publieke API van het onderdeel

src/services/guardianship/context.js

  • shaer-namespace + Relationship-vocabulaire

src/services/guardianship/relations.js

  • ap_guardianships-API + actor-props (FEP-633c paragraaf 2)

src/services/guardianship/handshake.js

src/services/guardianship/queues.js

src/services/guardianship/notes.js

  • shaer:helpRequest lezen/schrijven

src/services/guardianship/delivery.js

  • de directe-note-route (call-for-help), gedrag ongewijzigd

remarks: alle 158 tests groen. push-teksten (push.n_help_*, push.n_guard_*)
en de queue-routes + Guardian-PWA volgen in de volgende commits.

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

Files:
8 added
3 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r9a00f28 r6b5d7da  
    405405    );
    406406    CREATE INDEX IF NOT EXISTS idx_ap_blocks_target ON ap_blocks(target);
     407    CREATE TABLE IF NOT EXISTS ap_guardianships (
     408      id INTEGER PRIMARY KEY AUTOINCREMENT,
     409      slug TEXT NOT NULL,          -- our local site in this relation (guardianship module)
     410      role TEXT NOT NULL,          -- 'guardian' (slug guards other) | 'ward' (other guards slug)
     411      other_uri TEXT NOT NULL,     -- the counterpart actor URI (local or remote)
     412      other_handle TEXT,           -- cached @user@host for display
     413      status TEXT NOT NULL,        -- 'offered' (handshake pending) | 'accepted'
     414      offer_id TEXT,               -- the Offer activity id (FEP-633c section 3)
     415      created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
     416      UNIQUE(slug, role, other_uri)
     417    );
     418    CREATE INDEX IF NOT EXISTS idx_ap_guardianships_slug ON ap_guardianships(slug, role, status);
    407419    CREATE TABLE IF NOT EXISTS ap_delivery (
    408420      id INTEGER PRIMARY KEY AUTOINCREMENT,
     
    485497  ensureColumn('ap_outbox', 'to_actors', 'TEXT');   // JSON array of recipient actor URIs for direct notes
    486498  ensureColumn('ap_outbox', 'help_request', 'INTEGER'); // FEP-633c shaer:helpRequest (ward's call for help)
     499  ensureColumn('ap_mentions', 'help_request', 'INTEGER'); // inbound ward call-for-help (Guardian PWA message centre)
    487500  ensureColumn('ap_followers', 'name', 'TEXT');    // cached display name (shaer-aa3)
    488501  ensureColumn('ap_followers', 'handle', 'TEXT');  // @user@host
  • src/services/ActivityPubService.js

    r9a00f28 r6b5d7da  
    2525import { getTenancy } from './SettingsService.js';
    2626import { t as i18nT } from './i18n.js';
     27import Blocklist from './BlocklistService.js';
     28import * as Guardianship from './guardianship/index.js';
    2729
    2830const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
     
    4951    // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount).
    5052    votersCount: 'toot:votersCount',
    51     // FEP-633c (Guardians): the shaer namespace. helpRequest marks a direct
    52     // note as a ward's call for help (spec 5.2.1); ignorable by everyone else.
    53     shaer: 'https://ns.klonkt.com/shaer#',
     53    // FEP-633c (Guardians): the shaer namespace, owned by the guardianship
     54    // module (src/services/guardianship/).
     55    ...Guardianship.SHAER_CONTEXT,
    5456  },
    5557];
     
    174176    // separate state.
    175177    blocked: `${id}/blocked`,
     178    // FEP-633c §2: shaer:guardians / shaer:isGuardian / shaer:queues
     179    // (guardianship module owns these).
     180    ...Guardianship.guardianshipActorProps(id, site.slug),
    176181    // C2S clients (Shaer apps) discover auth + upload here — no hardcoded paths.
    177182    // All four are ActivityPub-spec `endpoints` terms. Dynamic client registration
     
    270275      cc: post.visibility === 'direct' ? [] : [PUBLIC, `${meR}/followers`],
    271276      // FEP-633c 5.2.1: a ward's call for help. Only ever on direct notes.
    272       'shaer:helpRequest': (post.visibility === 'direct' && post.help_request) ? true : undefined,
     277      ...Guardianship.helpRequestProps(post),
    273278      tag: [
    274279        ...mentionTags(post.content),
     
    13031308  // Blocked actor/domain → silently drop (202, don't reveal the block).
    13041309  if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; }
    1305   const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag'];
     1310  const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer'];
    13061311  if (GATED.includes(type)) {
    13071312    if (!verified || !claimedActor || verified.id !== claimedActor) {
    13081313      console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)');
    13091314      return 401;
     1315    }
     1316  }
     1317
     1318  // FEP-633c: the adoption handshake. An Offer lands at the local ward; an
     1319  // Accept/Reject answers an offer a local guardian sent. Anything the
     1320  // guardianship module does not recognize falls through to the old paths.
     1321  if (type === 'Offer' || type === 'Accept' || type === 'Reject') {
     1322    let gslug = slugParam || null;
     1323    if (!gslug && type === 'Offer') {
     1324      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      }
    13101341    }
    13111342  }
     
    14631494        const ai = actorInfo(await resolveActor(actorUri), actorUri);
    14641495        const html = HtmlSanitizerService.sanitize(o.content || '');
     1496        // FEP-633c 5.2.1: a ward's call for help rides a direct mention; the
     1497        // flag is stored so the Guardian PWA's message centre can list it.
     1498        const help = Guardianship.isHelpRequest(o);
    14651499        for (const slug of slugs) {
    14661500          try {
    1467             const r = db.prepare('INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
    1468               .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null);
     1501            const r = db.prepare('INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')
     1502              .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0);
    14691503            if (r.changes) {
    1470               console.log('[AP] mention', actorUri, '→', slug);
     1504              console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : '');
    14711505              const vis = noteVisibility(o);
    14721506              const priv = vis === 'direct' || vis === 'followers';
     
    14741508              const who = ai.name || ai.handle || i18nT(L, 'notif.someone');
    14751509              // Same privacy rule as replies: private mentions push without content.
    1476               if (priv) pushEvent(slug, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(slug)}/messages` });
     1510              // A help request pushes as its own alert type, aimed at the
     1511              // Guardian PWA's message centre.
     1512              if (help) pushEvent(slug, { type: 'help', title: i18nT(L, 'push.n_help_t'), body: i18nT(L, 'push.n_help_b', { who }), url: '/guardian' });
     1513              else if (priv) pushEvent(slug, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(slug)}/messages` });
    14771514              else pushEvent(slug, { type: 'reply', title: i18nT(L, 'push.n_mention_t'), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` });
    14781515            }
     
    19591996  if (type === 'Note' || type === 'Article') { object = activity; type = 'Create'; }
    19601997  if (Array.isArray(type)) type = type.find((t) => typeof t === 'string');
     1998
     1999  // FEP-633c: the adoption handshake (Offer/Accept/Reject on a guardianship
     2000  // Relationship) belongs to the guardianship module; anything else falls
     2001  // through to the switch below.
     2002  if (type === 'Offer' || type === 'Accept' || type === 'Reject') {
     2003    const g = await Guardianship.handleGuardianshipOutbox(site, activity).catch(() => null);
     2004    if (g) return g;
     2005  }
    19612006
    19622007  try {
     
    20972142}
    20982143
    2099 // Addressing → visibility. Arrays or bare strings; unknown shapes read as the
    2100 // safest bucket they match.
    2101 export function c2sVisibility(object) {
    2102   const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
    2103   const to = arr(object.to), cc = arr(object.cc);
    2104   const isPublic = (x) => x === PUBLIC || x === 'as:Public' || x === 'Public';
    2105   const isFollowers = (x) => /\/followers\/?$/.test(x);
    2106   if (to.some(isPublic)) return 'public';
    2107   if (cc.some(isPublic)) return 'quiet';
    2108   if (to.some(isFollowers) || cc.some(isFollowers)) return 'friends';
    2109   if (!to.length && !cc.length) return 'public';   // no addressing at all: legacy client, keep old behavior
    2110   return 'direct';
    2111 }
    2112 
    2113 // A direct note (private mention, shaer-tqc): a NEW conversation (or a direct
    2114 // reply) addressed to specific actors only. Stored in ap_outbox with
    2115 // visibility 'direct' + the recipient list, delivered to exactly those
    2116 // inboxes: no followers fan-out, no Public, so no boosts and no timelines.
    2117 // The same S2S leg a Mastodon DM takes, so a guardian on any instance
    2118 // receives it as a private mention (the ward call-for-help path).
    2119 export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest }) {
    2120   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    2121   const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8);
    2122   if (!base || !site || !site.slug || !list.length || !String(text || '').trim()) return null;
    2123   const me = actorId(base, site.slug);
    2124   // Resolve every recipient for a mention anchor + a delivery inbox.
    2125   const resolved = [];
    2126   for (const uri of list) {
    2127     const a = await fetchActor(uri).catch(() => null);
    2128     if (!a || !(a.inbox || (a.endpoints && a.endpoints.sharedInbox))) continue;
    2129     resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, handle: deriveHandle(uri), url: a.url || uri });
    2130   }
    2131   if (!resolved.length) return null;
    2132   const mention = resolved.map((r) => {
    2133     const disp = r.handle && r.handle[0] === '@' ? r.handle : '@' + (r.handle || '');
    2134     return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
    2135   }).join('');
    2136   const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
    2137   const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
    2138   const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
    2139   // Attachments: same rules as deliverReply (own /media/ uploads only,
    2140   // image/audio/video, max 4) — the help-buoy capture rides this.
    2141   const media = (Array.isArray(attachments) ? attachments : [])
    2142     .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
    2143       && /^(image|audio|video)\//.test(String(a.mediaType || '')))
    2144     .slice(0, 4)
    2145     .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
    2146   const id = crypto.randomUUID();
    2147   db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, created_at)
    2148               VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
    2149     .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0);
    2150   const row = iStmts().getO.get(id);
    2151   const note = buildReplyNote(base, site, row);
    2152   const create = {
    2153     '@context': AP_CONTEXT,
    2154     id: note.id + '#create', type: 'Create', actor: me,
    2155     published: note.published, to: note.to, cc: note.cc, object: note,
    2156   };
    2157   const keys = getOrCreateKeys(site.slug);
    2158   const keyId = `${me}#main-key`;
    2159   let delivered = 0;
    2160   for (const inbox of [...new Set(resolved.map((r) => r.inbox))]) {
    2161     let ok = false;
    2162     try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
    2163     if (ok) delivered++;
    2164     else enqueueDelivery(site.slug, inbox, create);
    2165   }
    2166   console.log('[AP] direct note', site.slug, '→', resolved.length, 'recipient(s), delivered', delivered);
    2167   return { id, content, delivered };
    2168 }
     2144// The direct-note leg (ward call-for-help) lives in the guardianship module
     2145// (src/services/guardianship/delivery.js); wired with our AP helpers at the
     2146// bottom of this file. Re-exported so every existing caller keeps working.
     2147export const c2sVisibility = Guardianship.c2sVisibility;
     2148export const deliverDirectNote = Guardianship.deliverDirectNote;
    21692149
    21702150// Send a reply FROM this site to a remote actor (in reply to their inbound reply).
     
    30263006
    30273007// ── Blocking / defederation ───────────────────────────────────────
    3028 let _insBl, _delBl, _listBl;
    3029 function blStmts() {
    3030   if (!_insBl) {
    3031     _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
    3032     _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?');
    3033     _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC');
    3034   }
    3035   return { ins: _insBl, del: _delBl, list: _listBl };
    3036 }
    3037 export function listBlocks(slug) { return blStmts().list.all(slug); }
     3008// Extracted to BlocklistService (shared: Klonkt's Block tab + Shaer's "in
     3009// Orbit"). Thin delegations keep every existing caller working.
     3010export function listBlocks(slug) { return Blocklist.listBlocks(slug); }
    30383011
    30393012// True if an actor (or its whole domain) is blocked anywhere on this instance.
     
    31403113}
    31413114
    3142 export function isBlockedAny(actorUri) {
    3143   if (!actorUri) return false;
    3144   let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ }
    3145   try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); }
    3146   catch { return false; }
    3147 }
    3148 
    3149 function purgeBlocked(kind, target) {
     3115export function isBlockedAny(actorUri) { return Blocklist.isBlockedAny(actorUri); }
     3116
     3117// Block an actor (@handle or actor URL) or a whole domain; purges their content.
     3118// The handle resolver is ours; the storage/purge lives in BlocklistService.
     3119export async function blockTarget(site, input) { return Blocklist.blockTarget(site, input, webfingerResolve); }
     3120
     3121export function unblock(site, target) { return Blocklist.unblock(site, target); }
     3122
     3123// ── Guardianship module wiring (src/services/guardianship/) ────────
     3124// The module owns FEP-633c (context, relations, handshake, queues, the
     3125// direct-note leg); we hand it our AP helpers ONCE and delegate. It never
     3126// imports us back.
     3127function selfActorId(slug) {
     3128  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     3129  return actorId(base, slug);
     3130}
     3131// Deliver one activity to one actor's inbox, signed; queued on failure.
     3132async function deliverToActor(site, actorUri, activity) {
     3133  const a = await fetchActor(actorUri).catch(() => null);
     3134  const inbox = a && (a.inbox || (a.endpoints && a.endpoints.sharedInbox));
     3135  if (!inbox) return false;
     3136  const me = selfActorId(site.slug);
     3137  const keys = getOrCreateKeys(site.slug);
     3138  const payload = { '@context': AP_CONTEXT, ...activity };
    31503139  try {
    3151     if (kind === 'domain') {
    3152       // Exact host match (a URL LIKE over-/under-matches: it misses bare-domain or :port
    3153       // actor URIs and can catch look-alikes). Filter by parsed host, same as isBlockedAny.
    3154       const purge = (table, col) => {
    3155         let rows = [];
    3156         try { rows = db.prepare(`SELECT DISTINCT ${col} AS u FROM ${table} WHERE ${col} IS NOT NULL AND ${col} != ''`).all(); } catch { return; }
    3157         const del = db.prepare(`DELETE FROM ${table} WHERE ${col} = ?`);
    3158         for (const r of rows) { let h = ''; try { h = new URL(r.u).host; } catch { /* skip */ } if (h === target) { try { del.run(r.u); } catch { /* ignore */ } } }
    3159       };
    3160       purge('ap_interactions', 'actor_uri');
    3161       purge('ap_timeline', 'author_uri');
    3162       purge('ap_followers', 'actor_uri');
    3163     } else {
    3164       db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target);
    3165       db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target);
    3166       db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target);
    3167     }
    3168   } catch { /* best-effort */ }
    3169 }
    3170 
    3171 // Block an actor (@handle or actor URL) or a whole domain; purges their content.
    3172 export async function blockTarget(site, input) {
    3173   const raw = String(input || '').trim();
    3174   if (!site || !site.slug || !raw) return { error: 'empty' };
    3175   let kind, target, label;
    3176   if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; }
    3177   else if (raw.includes('@')) {
    3178     const actorUrl = await webfingerResolve(raw);
    3179     if (!actorUrl) return { error: 'not_found' };
    3180     kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw);
    3181   } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); }
    3182   blStmts().ins.run(site.slug, target, kind, label);
    3183   purgeBlocked(kind, target);
    3184   console.log('[AP] block', site.slug, kind, target);
    3185   return { ok: true, label };
    3186 }
    3187 
    3188 export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; }
     3140    const st = await deliver(inbox, payload, `${me}#main-key`, keys.private_pem);
     3141    if (st >= 200 && st < 300) return true;
     3142  } catch { /* fall through to the queue */ }
     3143  enqueueDelivery(site.slug, inbox, payload);
     3144  return true;   // queued: it will arrive
     3145}
     3146Guardianship.wireDelivery({
     3147  actorId, fetchActor, deriveHandle, escHtml, linkUrls, linkHashtags,
     3148  getOutboxRow: (id) => iStmts().getO.get(id),
     3149  buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery,
     3150});
     3151Guardianship.wireHandshake({
     3152  selfId: selfActorId,
     3153  deliverTo: deliverToActor,
     3154  deriveHandle,
     3155  // Guardian PWA push: an offer or an answer lands as a notification.
     3156  onEvent: (slug, ev) => {
     3157    const L = pushLang(slug);
     3158    const texts = {
     3159      offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'],
     3160      ward_accepted: ['push.n_guard_ward_t', 'push.n_guard_ward_b'],
     3161    }[ev.kind];
     3162    if (!texts) return;
     3163    const who = deriveHandle(ev.candidate || ev.ward || ev.guardian || '') || '?';
     3164    pushEvent(slug, { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who }), url: '/guardian' });
     3165  },
     3166});
    31893167
    31903168export default {
  • test/activitypub-as2.test.js

    r9a00f28 r6b5d7da  
    3232  // ActivityPub §5.6: the private blocked collection (owner-only GET).
    3333  'blocked',
     34  // FEP-633c (Guardians): the owner-only dashboard queues on the actor; the
     35  // sub-keys are the daemon-contract collection names the Shaer clients read.
     36  'shaer:queues', 'offers', 'follows', 'wards',
    3437  // ActivityPub §4.1 `endpoints` vocabulary (same category as sharedInbox), used for C2S.
    3538  'oauthAuthorizationEndpoint', 'oauthTokenEndpoint', 'uploadMedia',
Note: See TracChangeset for help on using the changeset viewer.