// AS2 / JSON-LD validity guard. // // Every property key and `type` value our ActivityPub objects emit MUST be either an AS2-core // (or security/v1) term OR declared in the federation @context (AP_CONTEXT). A new feature that // emits an undeclared term fails this test → declare it in AP_CONTEXT (extensions) or add it to // the AS2 allowlist below. This keeps Klonkt's output valid AS2/JSON-LD forever — not just // "Mastodon tolerates it". No extra deps; in-memory SQLite. Run: npm test import { test } from 'node:test'; import assert from 'node:assert/strict'; process.env.DATABASE_PATH = ':memory:'; process.env.PUBLIC_BASE_URL = 'https://test.example'; const dbMod = await import('../src/config/database.js'); const db = dbMod.default; dbMod.initializeDatabase(); const AP = (await import('../src/services/ActivityPubService.js')).default; const BASE = 'https://test.example'; // AS2-core + security/v1 vocabulary Klonkt uses (stable — only extend when AS2/security itself // adds a term we adopt). JSON-LD keywords included. const AS2 = new Set([ '@context', '@id', '@type', 'id', 'type', 'actor', 'object', 'target', 'to', 'cc', 'content', 'name', 'summary', 'url', 'href', 'mediaType', 'published', 'updated', 'attributedTo', 'inReplyTo', 'replies', 'attachment', 'tag', 'icon', 'image', 'duration', 'contentMap', 'nameMap', 'summaryMap', // AS2 @language-map counterparts of content/name/summary 'totalItems', 'orderedItems', 'items', 'first', 'last', 'partOf', 'next', 'prev', 'preferredUsername', 'inbox', 'outbox', 'followers', 'following', 'endpoints', 'sharedInbox', // ActivityPub §5.6: the private blocked collection (owner-only GET). 'blocked', // FEP-633c (Guardians): the owner-only dashboard queues on the actor; the // sub-keys are the daemon-contract collection names the Shaer clients read. // `guardians` is the availability queue (3.6.1: never public, owner-only). 'shaer:queues', 'offers', 'follows', 'wards', 'guardians', // ActivityPub §4.1 `endpoints` vocabulary (same category as sharedInbox), used for C2S. 'oauthAuthorizationEndpoint', 'oauthTokenEndpoint', 'uploadMedia', 'publicKey', 'owner', 'publicKeyPem', 'Note', 'Person', 'Create', 'Update', 'Delete', 'Tombstone', 'Announce', 'Like', 'Follow', 'Accept', 'Reject', 'Undo', 'Add', 'Remove', 'Flag', 'Document', 'Image', 'Audio', 'Video', 'Mention', 'Link', 'Collection', 'OrderedCollection', 'OrderedCollectionPage', ]); // The extension terms = exactly the keys declared in AP_CONTEXT's term-definition object. const ctxTerms = new Set(); for (const part of AP.AP_CONTEXT) if (part && typeof part === 'object') for (const k of Object.keys(part)) ctxTerms.add(k); const allowed = new Set([...AS2, ...ctxTerms]); // Collect every property key + every `type` string value, recursively. function collect(obj, keys = new Set()) { if (Array.isArray(obj)) { for (const x of obj) collect(x, keys); return keys; } if (obj && typeof obj === 'object') { for (const [k, v] of Object.entries(obj)) { keys.add(k); if (k === 'type' && typeof v === 'string') keys.add(v); if (/Map$/.test(k)) continue; // a @language map (contentMap/…): its keys are BCP-47 tags, not vocab terms collect(v, keys); } } return keys; } function assertValid(obj, label) { const undeclared = [...collect(obj)].filter((k) => !allowed.has(k)); assert.deepEqual(undeclared, [], `${label}: undeclared AS2/JSON-LD term(s) — declare in AP_CONTEXT (extension) or the AS2 allowlist: ${undeclared.join(', ')}`); } // Seed one site that exercises the extension-heavy actor fields (profile links → PropertyValue, // photo → icon, primary → featured). db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god'); db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, profile_links, profile_photo) VALUES (?,?,?,?,?,?,?)') .run('s1', 'demo', 'Demo', 'u1', 1, JSON.stringify([{ platform: 'website', url: 'https://x.test' }]), '/media/x.png'); const site = db.prepare('SELECT * FROM sites WHERE id = ?').get('s1'); site.primary_slug = 'demo'; // Kitchen-sink note: nsfw (→ sensitive + summary), a hashtag (→ Hashtag), a cover (→ attachment). const post = { id: 'p1', slug: 'hello', title: 'Hi', content: '
hello #music
', nsfw: 1, content_warning: 'cw', tags: JSON.stringify(['mood']), cover_image_url: '/media/c.webp', cover_alt: 'A cover', language: 'en', published_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z', }; test('actor is valid AS2 (every term declared)', () => assertValid(AP.buildActor(BASE, site), 'actor')); test('create+note is valid AS2 (every term declared)', () => assertValid(AP.buildCreate(BASE, site, post), 'create/note')); test('outbox/followers/featured collections are valid AS2', () => { assertValid(AP.buildOutbox(BASE, site, [post]), 'outbox'); assertValid(AP.buildFollowers(BASE, site, 3), 'followers'); assertValid(AP.buildFollowing(BASE, site, 2), 'following'); assertValid(AP.buildFeatured(BASE, site, [post]), 'featured'); }); test('AP_CONTEXT declares every extension term we rely on', () => { for (const t of ['sensitive', 'Hashtag', 'manuallyApprovesFollowers', 'discoverable', 'featured', 'PropertyValue', 'embedUrl']) assert.ok(ctxTerms.has(t), `AP_CONTEXT must declare "${t}"`); });