source: Klonkt/test/activitypub-as2.test.js@ 7df47b6

main
Last change on this file since 7df47b6 was 7df47b6, checked in by Robin <roboburr@…>, 5 weeks ago

Playlist-collecties zijn vindbaar (shaer-ayc, stap 2)

Drie wegen, drie vragen:

streams op de actor 'wat heeft deze site?' -- AS2-kern, letterlijk

'supplementary Collections which may be of
interest', dus geen eigen vocabulaire nodig

GET .../playlists de lijst: kaal URI's, verrijkt stubs op verzoek

(FEP-9876, zelfde conventie als followers)

Link-tag op de Note 'wat zit er in deze post?' -- Mastodon parseert

alleen Mention/Hashtag/Emoji en negeert een Link

De poortregel loopt overal door: ook een stub in de verrijkte lijst telt
alleen het open deel, en de gated titel komt nergens in de JSON voor.

De Link-tag checkt de site: playlist-ids zijn een globale primary key, dus
zonder die check zou een post van site A naar de collectie van site B wijzen.

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

  • Property mode set to 100644
File size: 5.6 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 // ActivityPub §4.1: supplementary collections on the actor — Klonkt wijst
35 // ermee naar de playlist-lijst (shaer-ayc).
36 'streams',
37 // FEP-633c (Guardians): the owner-only dashboard queues on the actor; the
38 // sub-keys are the daemon-contract collection names the Shaer clients read.
39 // `guardians` is the availability queue (3.6.1: never public, owner-only).
40 // `outgoingFollows` is §5.3 turned around: the ward's own follow requests,
41 // waiting for the guardians (shaer-p729).
42 'shaer:queues', 'offers', 'follows', 'outgoingFollows', 'wards', 'guardians',
43 // ActivityPub §4.1 `endpoints` vocabulary (same category as sharedInbox), used for C2S.
44 'oauthAuthorizationEndpoint', 'oauthTokenEndpoint', 'uploadMedia',
45 'publicKey', 'owner', 'publicKeyPem',
46 'Note', 'Person', 'Create', 'Update', 'Delete', 'Tombstone', 'Announce', 'Like', 'Follow',
47 'Accept', 'Reject', 'Undo', 'Add', 'Remove', 'Flag', 'Document', 'Image', 'Audio', 'Video',
48 'Mention', 'Link', 'Collection', 'OrderedCollection', 'OrderedCollectionPage',
49]);
50
51// The extension terms = exactly the keys declared in AP_CONTEXT's term-definition object.
52const ctxTerms = new Set();
53for (const part of AP.AP_CONTEXT) if (part && typeof part === 'object') for (const k of Object.keys(part)) ctxTerms.add(k);
54const allowed = new Set([...AS2, ...ctxTerms]);
55
56// Collect every property key + every `type` string value, recursively.
57function collect(obj, keys = new Set()) {
58 if (Array.isArray(obj)) { for (const x of obj) collect(x, keys); return keys; }
59 if (obj && typeof obj === 'object') {
60 for (const [k, v] of Object.entries(obj)) {
61 keys.add(k);
62 if (k === 'type' && typeof v === 'string') keys.add(v);
63 if (/Map$/.test(k)) continue; // a @language map (contentMap/…): its keys are BCP-47 tags, not vocab terms
64 collect(v, keys);
65 }
66 }
67 return keys;
68}
69function assertValid(obj, label) {
70 const undeclared = [...collect(obj)].filter((k) => !allowed.has(k));
71 assert.deepEqual(undeclared, [],
72 `${label}: undeclared AS2/JSON-LD term(s) — declare in AP_CONTEXT (extension) or the AS2 allowlist: ${undeclared.join(', ')}`);
73}
74
75// Seed one site that exercises the extension-heavy actor fields (profile links → PropertyValue,
76// photo → icon, primary → featured).
77db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
78db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, profile_links, profile_photo) VALUES (?,?,?,?,?,?,?)')
79 .run('s1', 'demo', 'Demo', 'u1', 1, JSON.stringify([{ platform: 'website', url: 'https://x.test' }]), '/media/x.png');
80const site = db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
81site.primary_slug = 'demo';
82
83// Kitchen-sink note: nsfw (→ sensitive + summary), a hashtag (→ Hashtag), a cover (→ attachment).
84const post = {
85 id: 'p1', slug: 'hello', title: 'Hi', content: '<p>hello #music</p>',
86 nsfw: 1, content_warning: 'cw', tags: JSON.stringify(['mood']),
87 cover_image_url: '/media/c.webp', cover_alt: 'A cover', language: 'en',
88 published_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
89};
90
91test('actor is valid AS2 (every term declared)', () => assertValid(AP.buildActor(BASE, site), 'actor'));
92test('create+note is valid AS2 (every term declared)', () => assertValid(AP.buildCreate(BASE, site, post), 'create/note'));
93test('outbox/followers/featured collections are valid AS2', () => {
94 assertValid(AP.buildOutbox(BASE, site, [post]), 'outbox');
95 assertValid(AP.buildFollowers(BASE, site, 3), 'followers');
96 assertValid(AP.buildFollowing(BASE, site, 2), 'following');
97 assertValid(AP.buildFeatured(BASE, site, [post]), 'featured');
98});
99test('AP_CONTEXT declares every extension term we rely on', () => {
100 for (const t of ['sensitive', 'Hashtag', 'manuallyApprovesFollowers', 'discoverable', 'featured', 'PropertyValue', 'embedUrl'])
101 assert.ok(ctxTerms.has(t), `AP_CONTEXT must declare "${t}"`);
102});
Note: See TracBrowser for help on using the repository browser.