Make events and meeting points readable by anyone only when Festipod marks them public
Festipod's create path now addresses events and meeting points to the ActivityStreams Public collection (to: as:Public), and the backend grants acl:Read to foaf:Agent on a created as:Event only when it carries that public intent (one predicate, hasPublicIntent, in backend/services/public-intent.js). Before this, every as:Event written to a user's outbox would have been made world-readable, including other apps' events and events addressed to a single friend. The grant is idempotent (it reads the ACL first) and never throws into the webhook path. The attendees service overrides the collections mixin so that an event posted with apods:attendees already attached no longer reaches new URL(undefined) and takes the whole backend process down. Attendee lists stay world-readable as the pod creates them; whether they should is an open product question.
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
const { test, expect } = require('bun:test');
|
||||
const { AS_PUBLIC, isAddressedToPublic, hasPublicIntent } = require('./public-intent');
|
||||
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const FRIEND = 'http://localhost:3000/bob';
|
||||
|
||||
test('an object addressed to the Public collection matches, in each of its three spellings', () => {
|
||||
// The pod's own representation (observed): `"to": "as:Public"`.
|
||||
expect(hasPublicIntent({ type: 'Event', to: 'as:Public' })).toBe(true);
|
||||
expect(hasPublicIntent({ type: 'Event', to: AS_PUBLIC })).toBe(true);
|
||||
expect(hasPublicIntent({ type: 'Event', to: 'Public' })).toBe(true);
|
||||
});
|
||||
|
||||
test('public addressing matches in cc, in a list, and as a node', () => {
|
||||
expect(hasPublicIntent({ to: [FRIEND], cc: 'as:Public' })).toBe(true);
|
||||
expect(hasPublicIntent({ to: [FRIEND, AS_PUBLIC] })).toBe(true);
|
||||
expect(hasPublicIntent({ to: { id: AS_PUBLIC } })).toBe(true);
|
||||
expect(hasPublicIntent({ to: [{ '@id': 'as:Public' }] })).toBe(true);
|
||||
expect(hasPublicIntent({ 'as:to': 'as:Public' })).toBe(true);
|
||||
expect(hasPublicIntent({ 'https://www.w3.org/ns/activitystreams#cc': { '@id': AS_PUBLIC } })).toBe(true);
|
||||
});
|
||||
|
||||
test('an object with no addressing never matches — whoever created it', () => {
|
||||
// A private event posted directly on the pod, or created by another app: no `to`/`cc`.
|
||||
expect(hasPublicIntent({ id: `${ALICE}/data/1`, type: 'Event', name: 'Dîner privé' })).toBe(false);
|
||||
// A meeting point without addressing is not public either: the signal, not the shape, decides.
|
||||
expect(hasPublicIntent({ type: 'Event', 'as:context': `${ALICE}/data/0` })).toBe(false);
|
||||
});
|
||||
|
||||
test('addressing to chosen recipients only never matches', () => {
|
||||
expect(hasPublicIntent({ type: 'Event', to: [FRIEND] })).toBe(false);
|
||||
expect(hasPublicIntent({ type: 'Event', to: FRIEND, cc: `${ALICE}/followers` })).toBe(false);
|
||||
// A protected-scope record addressed to the creator's contacts collection.
|
||||
expect(hasPublicIntent({ type: 'Event', to: `${ALICE}/contacts` })).toBe(false);
|
||||
});
|
||||
|
||||
test('Public anywhere but to/cc does not match', () => {
|
||||
expect(hasPublicIntent({ bto: AS_PUBLIC })).toBe(false);
|
||||
expect(hasPublicIntent({ bcc: 'as:Public' })).toBe(false);
|
||||
expect(hasPublicIntent({ audience: AS_PUBLIC })).toBe(false);
|
||||
// The IRI as a plain value of another predicate is not addressing.
|
||||
expect(hasPublicIntent({ name: AS_PUBLIC, content: 'as:Public' })).toBe(false);
|
||||
});
|
||||
|
||||
test('look-alike addresses do not match', () => {
|
||||
expect(hasPublicIntent({ to: 'https://www.w3.org/ns/activitystreams#PublicX' })).toBe(false);
|
||||
expect(hasPublicIntent({ to: 'public' })).toBe(false);
|
||||
expect(hasPublicIntent({ to: `${ALICE}/Public` })).toBe(false);
|
||||
expect(hasPublicIntent({ to: { name: 'as:Public' } })).toBe(false);
|
||||
});
|
||||
|
||||
test('the creating activity counts when the caller holds it', () => {
|
||||
const privateObject = { type: 'Event', to: [FRIEND] };
|
||||
expect(hasPublicIntent(privateObject, { type: 'Create', to: AS_PUBLIC })).toBe(true);
|
||||
expect(hasPublicIntent(privateObject, { type: 'Create', to: [FRIEND] })).toBe(false);
|
||||
// The Create the pod generates for a container POST carries no addressing (observed).
|
||||
expect(hasPublicIntent({ type: 'Event' }, { type: 'Create', actor: ALICE, object: `${ALICE}/data/1` })).toBe(false);
|
||||
});
|
||||
|
||||
test('missing or non-object input is not public', () => {
|
||||
expect(hasPublicIntent(undefined)).toBe(false);
|
||||
expect(hasPublicIntent(null, undefined)).toBe(false);
|
||||
expect(hasPublicIntent(`${ALICE}/data/1`)).toBe(false);
|
||||
expect(isAddressedToPublic({ to: null, cc: [] })).toBe(false);
|
||||
});
|
||||
|
||||
// ── Listed public: what the catalog may list ─────────────────────────────────────────────────
|
||||
|
||||
const { isListedToPublic, hasListedPublicIntent } = require('./public-intent');
|
||||
|
||||
test('Public in `to` is listed, in each spelling and shape', () => {
|
||||
// The pod's own representation of a Festipod event (observed): `"to": "as:Public"`.
|
||||
expect(hasListedPublicIntent({ type: 'Event', to: 'as:Public' })).toBe(true);
|
||||
expect(hasListedPublicIntent({ type: 'Event', to: AS_PUBLIC })).toBe(true);
|
||||
expect(hasListedPublicIntent({ type: 'Event', to: 'Public' })).toBe(true);
|
||||
expect(hasListedPublicIntent({ to: [FRIEND, AS_PUBLIC], cc: `${ALICE}/followers` })).toBe(true);
|
||||
expect(hasListedPublicIntent({ to: { id: AS_PUBLIC } })).toBe(true);
|
||||
expect(hasListedPublicIntent({ 'as:to': [{ '@id': 'as:Public' }] })).toBe(true);
|
||||
expect(hasListedPublicIntent({ 'https://www.w3.org/ns/activitystreams#to': AS_PUBLIC })).toBe(true);
|
||||
});
|
||||
|
||||
test('Public in `cc` only is public but UNLISTED: readable, never listed', () => {
|
||||
const unlisted = { type: 'Event', to: [FRIEND], cc: 'as:Public' };
|
||||
expect(hasPublicIntent(unlisted)).toBe(true);
|
||||
expect(hasListedPublicIntent(unlisted)).toBe(false);
|
||||
expect(hasListedPublicIntent({ cc: AS_PUBLIC })).toBe(false);
|
||||
expect(hasListedPublicIntent({ 'as:cc': { '@id': 'as:Public' } })).toBe(false);
|
||||
});
|
||||
|
||||
test('no addressing, chosen recipients, bto/bcc/audience and look-alikes are never listed', () => {
|
||||
for (const resource of [
|
||||
{ id: `${ALICE}/data/1`, type: 'Event', name: 'Dîner privé' },
|
||||
{ type: 'Event', to: [FRIEND] },
|
||||
{ type: 'Event', to: `${ALICE}/contacts` },
|
||||
{ bto: AS_PUBLIC },
|
||||
{ bcc: 'as:Public' },
|
||||
{ audience: AS_PUBLIC },
|
||||
{ name: AS_PUBLIC, content: 'as:Public' },
|
||||
{ to: 'https://www.w3.org/ns/activitystreams#PublicX' },
|
||||
{ to: 'public' },
|
||||
{ to: `${ALICE}/Public` },
|
||||
{ to: { name: 'as:Public' } },
|
||||
undefined,
|
||||
null,
|
||||
`${ALICE}/data/1`
|
||||
]) {
|
||||
expect(hasListedPublicIntent(resource)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('the creating activity counts the same way: `to` lists, `cc` does not', () => {
|
||||
const noAddressing = { type: 'Event' };
|
||||
expect(hasListedPublicIntent(noAddressing, { type: 'Create', to: AS_PUBLIC })).toBe(true);
|
||||
expect(hasListedPublicIntent(noAddressing, { type: 'Create', cc: AS_PUBLIC })).toBe(false);
|
||||
expect(hasListedPublicIntent(noAddressing, { type: 'Create', to: [FRIEND] })).toBe(false);
|
||||
});
|
||||
|
||||
test('listed implies public: never true where hasPublicIntent is false', () => {
|
||||
const addresses = [undefined, AS_PUBLIC, 'as:Public', 'Public', FRIEND, [FRIEND, 'as:Public'], { id: AS_PUBLIC }, 'public'];
|
||||
const shapes = [];
|
||||
for (const to of addresses) for (const cc of addresses) shapes.push({ to, cc });
|
||||
for (const object of shapes) {
|
||||
for (const activity of [undefined, ...shapes.slice(0, 8)]) {
|
||||
if (hasListedPublicIntent(object, activity)) expect(hasPublicIntent(object, activity)).toBe(true);
|
||||
}
|
||||
expect(isListedToPublic(object)).toBe(hasListedPublicIntent(object));
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user