Changeset 30871c1 in Klonkt


Ignore:
Timestamp:
07/20/2026 11:35:45 PM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
562e1b0
Parents:
5143ccf
git-author:
Robin <roboburr@…> (07/20/2026 11:35:44 PM)
git-committer:
Robin <roboburr@…> (07/20/2026 11:35:45 PM)
Message:

Feature: enrichment is opt-in via Prefer (FEP-9876 conformance)

Klonkt is the reference implementation of FEP-9876. The owner
followers/following collections now enrich member representations only
when the client asks with Prefer: return=representation (RFC 7240),
echo Preference-Applied and always set Vary: Prefer; the default is
bare id strings, so the collections match the AP norm and existing
consumers are unaffected. The Prefer predicate is a pure, tested
function.

Changed files:
src/services/ActivityPubService.js

  • prefersEnriched(preferHeader): pure Prefer detector, exported

src/routes/activitypub.js

  • owner followers/following: enrich only on the preference, set Preference-Applied + Vary: Prefer

test/c2s-contacts.test.js

  • prefersEnriched cases

docs/shaer-c2s-api.md

  • document the Prefer opt-in

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

Files:
4 edited

Legend:

Unmodified
Added
Removed
  • docs/shaer-c2s-api.md

    r5143ccf r30871c1  
    8484
    8585**Followers and following** are count-only for the public (privacy). With the
    86 owner's bearer they return the real entries as AS2 actor references with display,
    87 so a client shows names and avatars instead of bare ids:
     86owner's bearer they return the real entries. By default these are bare id
     87strings; send `Prefer: return=representation` (FEP-9876) to get them enriched as
     88AS2 actor references with display, so a client shows names and avatars instead of
     89bare ids. The server echoes `Preference-Applied: return=representation` and sets
     90`Vary: Prefer`:
    8891
    8992```json
  • src/routes/activitypub.js

    r5143ccf r30871c1  
    169169// URIs so their own client can build a friends list; everyone else gets the
    170170// count only (privacy).
     171// FEP-9876: enrichment is opt-in via `Prefer: return=representation` (RFC 7240).
     172// Returns true and sets the response headers when the owner asked for it.
     173function wantsEnriched(req, res) {
     174  res.set('Vary', 'Prefer');   // enriched and bare are two representations
     175  if (AP.prefersEnriched(req.get('Prefer'))) {
     176    res.set('Preference-Applied', 'return=representation');
     177    return true;
     178  }
     179  return false;
     180}
     181
    171182router.get('/ap/users/:slug/followers', (req, res) => {
    172183  const auth = OAuth.verifyBearer(req.headers.authorization);
     
    176187  if (owner) {
    177188    const uris = db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ? ORDER BY created_at').all(site.slug).map((r) => r.actor_uri);
    178     const items = uris.map((u) => AP.buildActorRef(site.slug, u));   // name + avatar (shaer-aa3)
     189    // Default = bare references; enrich only when the client asks (FEP-9876).
     190    const items = wantsEnriched(req, res) ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
    179191    return AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, items.length, items));
    180192  }
     
    190202  if (!site) return res.status(404).end();
    191203  if (owner) {
     204    const enrich = wantsEnriched(req, res);   // FEP-9876 opt-in
    192205    let items = [];
    193     try { items = db.prepare("SELECT actor_uri FROM ap_following WHERE slug = ? AND status = 'accepted' ORDER BY created_at").all(site.slug).map((r) => AP.buildActorRef(site.slug, r.actor_uri)); } catch { /* table may not exist */ }
     206    try {
     207      const uris = db.prepare("SELECT actor_uri FROM ap_following WHERE slug = ? AND status = 'accepted' ORDER BY created_at").all(site.slug).map((r) => r.actor_uri);
     208      items = enrich ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
     209    } catch { /* table may not exist */ }
    194210    return AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, items.length, items));
    195211  }
  • src/services/ActivityPubService.js

    r5143ccf r30871c1  
    667667  } catch { /* ignore */ }
    668668  return { name: null, handle: deriveHandle(uri), icon: null };
     669}
     670
     671// FEP-9876: does this `Prefer` header ask for enriched (embedded) members?
     672// Pure and testable; the route sets the response headers around it.
     673export function prefersEnriched(preferHeader) {
     674  return /(^|[,;\s])return=representation($|[,;\s])/i.test(String(preferHeader || ''));
    669675}
    670676
     
    30703076  linkifyBody, bakePostContent, bakePostContentWithMentions, listFollowers, removeFollower, listConnections,
    30713077  noteVisibility, isRejectedObject, rejectInteraction, interactionReportTarget,
    3072   getMessages, notificationsSeenAt, ingestOutboxActivity, c2sVisibility, actorDisplay, buildActorRef,
     3078  getMessages, notificationsSeenAt, ingestOutboxActivity, c2sVisibility, actorDisplay, buildActorRef, prefersEnriched,
    30733079};
  • test/c2s-contacts.test.js

    r5143ccf r30871c1  
    4444  assert.equal(ref.name, 'Dana');
    4545});
     46
     47test('prefersEnriched detects the Prefer opt-in (FEP-9876)', () => {
     48  assert.equal(AP.prefersEnriched('return=representation'), true);
     49  assert.equal(AP.prefersEnriched('return=representation; handling=lenient'), true);
     50  assert.equal(AP.prefersEnriched('foo, return=representation'), true);
     51  assert.equal(AP.prefersEnriched('return=minimal'), false);
     52  assert.equal(AP.prefersEnriched('returnx=representation'), false);
     53  assert.equal(AP.prefersEnriched(''), false);
     54  assert.equal(AP.prefersEnriched(undefined), false);
     55});
Note: See TracChangeset for help on using the changeset viewer.