source: Klonkt/test/activitypub-as2.test.js@ fa33214

main
Last change on this file since fa33214 was fa33214, checked in by Bart <bart@…>, 5 weeks ago

FEP-633c §5.3 andersom: een ward vraagt eerst of het iemand mag volgen

Uitgaande follows gingen ongehinderd de deur uit; de guardians kregen achteraf
een bericht (1a2f206). Dat is informeren, niet gaten — de deur staat al open als
het bericht aankomt. Bead shaer-p729, ontwerp in
docs/ward-outbound-follows-design.md.

De regel: per geval goedkeuring, met twee uitzonderingen die geen gunst zijn
maar dezelfde beslissing die al genomen is. Je eigen guardian volgen is geen
vraag. En iemand die de ward al volgt DOOR DE POORT heen is door een guardian
bij naam goedgekeurd; die vraag nog eens stellen leert mensen alleen om de vraag
niet meer te lezen.

Daarvoor moet je weten wie er door de poort kwam, dus ap_followers krijgt
gate_approved, gezet bij acceptGatedFollow. Iedereen die al volgde toen die
kolom erbij kwam wordt eenmalig gegrandfatherd (Barts besluit): exact vanaf nu,
in plaats van met terugwerkende kracht wantrouwig tegen wat er al was.

Eigen tabel, want ap_pending_follows is gesleuteld met de ward als DOEL. Eigen
wachtrij (outgoingFollows), want een guardian moet "iemand wil je ward volgen"
kunnen onderscheiden van "je ward wil iemand volgen" — de AS2-test ving netjes
dat de nieuwe term aangemeld moest worden. En een tegengehouden follow reist als
derde uitkomst naar de app (state: awaiting_guardian), zodat Shaer "wacht op
toestemming" kan tonen in plaats van een tegel die er al volgend uitziet.

Co-Authored-By: Claude Opus 5 <claude@…>

  • Property mode set to 100644
File size: 5.4 KB
Line 
1// AS2 / JSON-LD validity guard.
2//
3// Every property key and `type` value our ActivityPub objects emit MUST be either an AS2-core
4// (or security/v1) term OR declared in the federation @context (AP_CONTEXT). A new feature that
5// emits an undeclared term fails this test → declare it in AP_CONTEXT (extensions) or add it to
6// the AS2 allowlist below. This keeps Klonkt's output valid AS2/JSON-LD forever — not just
7// "Mastodon tolerates it". No extra deps; in-memory SQLite. Run: npm test
8import { test } from 'node:test';
9import assert from 'node:assert/strict';
10
11process.env.DATABASE_PATH = ':memory:';
12process.env.PUBLIC_BASE_URL = 'https://test.example';
13
14const dbMod = await import('../src/config/database.js');
15const db = dbMod.default;
16dbMod.initializeDatabase();
17const AP = (await import('../src/services/ActivityPubService.js')).default;
18
19const BASE = 'https://test.example';
20
21// AS2-core + security/v1 vocabulary Klonkt uses (stable — only extend when AS2/security itself
22// adds a term we adopt). JSON-LD keywords included.
23const AS2 = new Set([
24 '@context', '@id', '@type',
25 'id', 'type', 'actor', 'object', 'target', 'to', 'cc',
26 'content', 'name', 'summary', 'url', 'href', 'mediaType',
27 'published', 'updated', 'attributedTo', 'inReplyTo', 'replies',
28 'attachment', 'tag', 'icon', 'image', 'duration',
29 'contentMap', 'nameMap', 'summaryMap', // AS2 @language-map counterparts of content/name/summary
30 'totalItems', 'orderedItems', 'items', 'first', 'last', 'partOf', 'next', 'prev',
31 'preferredUsername', 'inbox', 'outbox', 'followers', 'following', 'endpoints', 'sharedInbox',
32 // ActivityPub §5.6: the private blocked collection (owner-only GET).
33 '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 // `guardians` is the availability queue (3.6.1: never public, owner-only).
37 // `outgoingFollows` is §5.3 turned around: the ward's own follow requests,
38 // waiting for the guardians (shaer-p729).
39 'shaer:queues', 'offers', 'follows', 'outgoingFollows', 'wards', 'guardians',
40 // ActivityPub §4.1 `endpoints` vocabulary (same category as sharedInbox), used for C2S.
41 'oauthAuthorizationEndpoint', 'oauthTokenEndpoint', 'uploadMedia',
42 'publicKey', 'owner', 'publicKeyPem',
43 'Note', 'Person', 'Create', 'Update', 'Delete', 'Tombstone', 'Announce', 'Like', 'Follow',
44 'Accept', 'Reject', 'Undo', 'Add', 'Remove', 'Flag', 'Document', 'Image', 'Audio', 'Video',
45 'Mention', 'Link', 'Collection', 'OrderedCollection', 'OrderedCollectionPage',
46]);
47
48// The extension terms = exactly the keys declared in AP_CONTEXT's term-definition object.
49const ctxTerms = new Set();
50for (const part of AP.AP_CONTEXT) if (part && typeof part === 'object') for (const k of Object.keys(part)) ctxTerms.add(k);
51const allowed = new Set([...AS2, ...ctxTerms]);
52
53// Collect every property key + every `type` string value, recursively.
54function collect(obj, keys = new Set()) {
55 if (Array.isArray(obj)) { for (const x of obj) collect(x, keys); return keys; }
56 if (obj && typeof obj === 'object') {
57 for (const [k, v] of Object.entries(obj)) {
58 keys.add(k);
59 if (k === 'type' && typeof v === 'string') keys.add(v);
60 if (/Map$/.test(k)) continue; // a @language map (contentMap/…): its keys are BCP-47 tags, not vocab terms
61 collect(v, keys);
62 }
63 }
64 return keys;
65}
66function assertValid(obj, label) {
67 const undeclared = [...collect(obj)].filter((k) => !allowed.has(k));
68 assert.deepEqual(undeclared, [],
69 `${label}: undeclared AS2/JSON-LD term(s) — declare in AP_CONTEXT (extension) or the AS2 allowlist: ${undeclared.join(', ')}`);
70}
71
72// Seed one site that exercises the extension-heavy actor fields (profile links → PropertyValue,
73// photo → icon, primary → featured).
74db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
75db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, profile_links, profile_photo) VALUES (?,?,?,?,?,?,?)')
76 .run('s1', 'demo', 'Demo', 'u1', 1, JSON.stringify([{ platform: 'website', url: 'https://x.test' }]), '/media/x.png');
77const site = db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
78site.primary_slug = 'demo';
79
80// Kitchen-sink note: nsfw (→ sensitive + summary), a hashtag (→ Hashtag), a cover (→ attachment).
81const post = {
82 id: 'p1', slug: 'hello', title: 'Hi', content: '<p>hello #music</p>',
83 nsfw: 1, content_warning: 'cw', tags: JSON.stringify(['mood']),
84 cover_image_url: '/media/c.webp', cover_alt: 'A cover', language: 'en',
85 published_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
86};
87
88test('actor is valid AS2 (every term declared)', () => assertValid(AP.buildActor(BASE, site), 'actor'));
89test('create+note is valid AS2 (every term declared)', () => assertValid(AP.buildCreate(BASE, site, post), 'create/note'));
90test('outbox/followers/featured collections are valid AS2', () => {
91 assertValid(AP.buildOutbox(BASE, site, [post]), 'outbox');
92 assertValid(AP.buildFollowers(BASE, site, 3), 'followers');
93 assertValid(AP.buildFollowing(BASE, site, 2), 'following');
94 assertValid(AP.buildFeatured(BASE, site, [post]), 'featured');
95});
96test('AP_CONTEXT declares every extension term we rely on', () => {
97 for (const t of ['sensitive', 'Hashtag', 'manuallyApprovesFollowers', 'discoverable', 'featured', 'PropertyValue', 'embedUrl'])
98 assert.ok(ctxTerms.has(t), `AP_CONTEXT must declare "${t}"`);
99});
Note: See TracBrowser for help on using the repository browser.