| 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
|
|---|
| 8 | import { test } from 'node:test';
|
|---|
| 9 | import assert from 'node:assert/strict';
|
|---|
| 10 |
|
|---|
| 11 | process.env.DATABASE_PATH = ':memory:';
|
|---|
| 12 | process.env.PUBLIC_BASE_URL = 'https://test.example';
|
|---|
| 13 |
|
|---|
| 14 | const dbMod = await import('../src/config/database.js');
|
|---|
| 15 | const db = dbMod.default;
|
|---|
| 16 | dbMod.initializeDatabase();
|
|---|
| 17 | const AP = (await import('../src/services/ActivityPubService.js')).default;
|
|---|
| 18 |
|
|---|
| 19 | const 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.
|
|---|
| 23 | const 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.
|
|---|
| 52 | const ctxTerms = new Set();
|
|---|
| 53 | for (const part of AP.AP_CONTEXT) if (part && typeof part === 'object') for (const k of Object.keys(part)) ctxTerms.add(k);
|
|---|
| 54 | const allowed = new Set([...AS2, ...ctxTerms]);
|
|---|
| 55 |
|
|---|
| 56 | // Collect every property key + every `type` string value, recursively.
|
|---|
| 57 | function 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 | }
|
|---|
| 69 | function 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).
|
|---|
| 77 | db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)').run('u1', 'u1', 'u1@test', 'x', 'god');
|
|---|
| 78 | db.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');
|
|---|
| 80 | const site = db.prepare('SELECT * FROM sites WHERE id = ?').get('s1');
|
|---|
| 81 | site.primary_slug = 'demo';
|
|---|
| 82 |
|
|---|
| 83 | // Kitchen-sink note: nsfw (→ sensitive + summary), a hashtag (→ Hashtag), a cover (→ attachment).
|
|---|
| 84 | const 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 |
|
|---|
| 91 | test('actor is valid AS2 (every term declared)', () => assertValid(AP.buildActor(BASE, site), 'actor'));
|
|---|
| 92 | test('create+note is valid AS2 (every term declared)', () => assertValid(AP.buildCreate(BASE, site, post), 'create/note'));
|
|---|
| 93 | test('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 | });
|
|---|
| 99 | test('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 | });
|
|---|