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:
@@ -1,15 +1,19 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
|
||||
const CONFIG = require('../config/config');
|
||||
const { hasPublicIntent } = require('./public-intent');
|
||||
|
||||
// Visibility decision: the attendees collection is readable by ANY visitor, including
|
||||
// unauthenticated ones (acl:agentClass foaf:Agent). A meeting point's participant count and
|
||||
// its list of participants must be visible to non-contacts — the app shows outsiders the
|
||||
// anonymous participants plus the total counter, and that is a product feature, not a leak.
|
||||
// Member URIs are not secret; the anonymity guarantee lives in each profile's own WAC (a
|
||||
// participant whose profile is not shared simply renders as anonymous), not here. The
|
||||
// reference welcometomyplace grants this to its announces group, but Festipod has no
|
||||
// announces concept, so the grant is public.
|
||||
// Attaches an `apods:attendees` collection (and a WAC group of the same members) to every
|
||||
// `as:Event` created in a user's pod that this backend learns of through the outbox watcher —
|
||||
// plain events as well as meeting points (observed 2026-10-12). A join adds the actor to it.
|
||||
//
|
||||
// Who may read the collection: the pod creates it world-readable (`acl:Read` for
|
||||
// `foaf:Agent`) — a platform default, observed 2026-10-12, not a choice made here. Whether
|
||||
// attendee lists SHOULD be world-readable is an open product question: the functional-domain
|
||||
// doctrine places participation in the protected scope. This service does not settle it. On a
|
||||
// resource carrying Festipod's public intent it also applies `public-read.grant` to the
|
||||
// collection, which finds it already world-readable and adds nothing; on any other resource the
|
||||
// backend grants nothing on the collection.
|
||||
module.exports = {
|
||||
name: 'attendees',
|
||||
mixins: [PodCollectionsHandlerMixin],
|
||||
@@ -22,28 +26,57 @@ module.exports = {
|
||||
},
|
||||
createWacGroup: true
|
||||
},
|
||||
dependencies: ['public-read'],
|
||||
actions: {
|
||||
async givePublicReadPermission(ctx) {
|
||||
const { collectionUri, actorUri } = ctx.params;
|
||||
// Replaces the mixin's action of the same name (`PodCollectionsHandlerMixin`,
|
||||
// @activitypods/app 2.3.2) with one difference. `pod-collections.createAndAttach` returns
|
||||
// undefined when it attaches nothing — the resource already carries an `apods:attendees`
|
||||
// value (written by its creator or by another app), or the pod refused to create the
|
||||
// collection — and the mixin then derives a WAC group slug from that undefined URI:
|
||||
// `new URL(undefined)` throws. The webhook call that runs the watcher is not awaited by the
|
||||
// platform, so that throw ended the whole backend process (observed 2026-10-12, an event
|
||||
// POSTed with `apods:attendees` already set). Here, nothing attached means no group to create.
|
||||
createAndAttach: {
|
||||
async handler(ctx) {
|
||||
const { resourceUri, actorUri } = ctx.params;
|
||||
const { attachPredicate, collectionOptions, createWacGroup } = this.settings;
|
||||
|
||||
await ctx.call('pod-permissions.add', {
|
||||
uri: collectionUri,
|
||||
agentPredicate: 'acl:agentClass',
|
||||
agentUri: 'http://xmlns.com/foaf/0.1/Agent',
|
||||
mode: 'acl:Read',
|
||||
actorUri
|
||||
});
|
||||
const collectionUri = await ctx.call('pod-collections.createAndAttach', {
|
||||
resourceUri,
|
||||
attachPredicate,
|
||||
collectionOptions,
|
||||
actorUri
|
||||
});
|
||||
if (!collectionUri) {
|
||||
this.logger.info(`No attendees collection attached to ${resourceUri} (already carries one, or creation refused)`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (createWacGroup) {
|
||||
await ctx.call('pod-wac-groups.create', {
|
||||
groupSlug: this.getGroupSlugFromCollectionUri(collectionUri),
|
||||
actorUri
|
||||
});
|
||||
}
|
||||
return collectionUri;
|
||||
}
|
||||
}
|
||||
},
|
||||
hooks: {
|
||||
after: {
|
||||
async createAndAttach(ctx, res) {
|
||||
const { actorUri } = ctx.params;
|
||||
const collectionUri = res;
|
||||
|
||||
await this.actions.givePublicReadPermission({ collectionUri, actorUri }, { parentCtx: ctx });
|
||||
|
||||
return res;
|
||||
methods: {
|
||||
// Replaces the mixin's `onCreate`. The watcher runs the handlers of one `Create` in sequence
|
||||
// and does not catch, in an order that is not fixed (attendees ran before public-read and
|
||||
// catalog in the runs observed 2026-10-12): a throw here would skip whichever handlers come
|
||||
// after it, and — the webhook call being un-awaited — end the process. So a failure is
|
||||
// logged, never thrown.
|
||||
async onCreate(ctx, resource, actorUri) {
|
||||
const resourceUri = resource.id || resource['@id'];
|
||||
try {
|
||||
const collectionUri = await this.actions.createAndAttach({ resourceUri, actorUri }, { parentCtx: ctx });
|
||||
if (collectionUri && hasPublicIntent(resource)) {
|
||||
await ctx.call('public-read.grant', { resourceUri: collectionUri, actorUri });
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(`Could not set up the attendees collection of ${resourceUri}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// Festipod's PUBLIC intent, as the backend reads it on a created resource.
|
||||
//
|
||||
// The signal is ActivityStreams public addressing: the ActivityStreams Public collection in
|
||||
// `to` or `cc`. Festipod's create path writes it on every event and meeting point it creates
|
||||
// (`src/shared/refine/providers/activitypods.ts`, the `toValues` of those two bindings), and
|
||||
// the pod stores it on the object as written (observed 2026-10-12: stored as `"to": "as:Public"`;
|
||||
// the outbox `Create` the pod generates for a container POST carries no addressing at all).
|
||||
//
|
||||
// What it is NOT: provenance. The decision does not depend on which client created the
|
||||
// resource, only on whether the resource says it is public. An as:Event without public
|
||||
// addressing — created by another app, posted directly on the pod, addressed to chosen
|
||||
// recipients only, or a future Festipod record in a protected or private scope — never matches.
|
||||
|
||||
const AS_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
|
||||
|
||||
// The three equivalent spellings of the Public collection: the full IRI and the two compacted
|
||||
// forms JSON-LD compaction against the ActivityStreams context can produce.
|
||||
const PUBLIC_FORMS = new Set([AS_PUBLIC, 'as:Public', 'Public']);
|
||||
|
||||
// `to` and `cc`, under the compacted names and their prefixed and full-IRI spellings (a
|
||||
// representation compacted against a context that does not define the short terms).
|
||||
const ADDRESSING_KEYS = [
|
||||
'to',
|
||||
'cc',
|
||||
'as:to',
|
||||
'as:cc',
|
||||
'https://www.w3.org/ns/activitystreams#to',
|
||||
'https://www.w3.org/ns/activitystreams#cc'
|
||||
];
|
||||
|
||||
const listOf = value => (value === undefined || value === null ? [] : Array.isArray(value) ? value : [value]);
|
||||
|
||||
/** The address a `to`/`cc` entry names: a bare IRI string, or a node carrying `id`/`@id`. */
|
||||
const addressOf = entry => {
|
||||
if (typeof entry === 'string') return entry;
|
||||
if (entry && typeof entry === 'object') {
|
||||
const id = entry.id || entry['@id'];
|
||||
return typeof id === 'string' ? id : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* True when this ONE JSON-LD resource (an object or an activity) is addressed to the
|
||||
* ActivityStreams Public collection in `to` or `cc`. Anything else is false: no addressing,
|
||||
* addressing to actors or collections only, Public in `bto`/`bcc`/`audience`, a non-object input.
|
||||
*/
|
||||
const isAddressedToPublic = resource => {
|
||||
if (!resource || typeof resource !== 'object') return false;
|
||||
return ADDRESSING_KEYS.some(key => listOf(resource[key]).some(entry => PUBLIC_FORMS.has(addressOf(entry))));
|
||||
};
|
||||
|
||||
/**
|
||||
* hasPublicIntent(object, activity?) → boolean.
|
||||
*
|
||||
* `object` is the created resource as stored on the pod (what the app's activity watcher hands
|
||||
* to `onCreate`: the dereferenced `object` of the `Create`). `activity` is optional: the `Create`
|
||||
* itself, when the caller holds it. True when either one is addressed to the Public collection
|
||||
* (see `isAddressedToPublic`); false when neither is — a missing argument counts as not addressed.
|
||||
*
|
||||
* This is the ONLY gate in front of making a resource world-readable or listing it publicly:
|
||||
* a caller that grants or publishes on a creation must ask this first.
|
||||
*/
|
||||
const hasPublicIntent = (object, activity) => isAddressedToPublic(object) || isAddressedToPublic(activity);
|
||||
|
||||
// ── LISTED public: what a public index may list ───────────────────────────────────────────────
|
||||
//
|
||||
// ActivityStreams addressing distinguishes two public audiences by WHERE the Public collection is
|
||||
// named, the convention fediverse servers follow: in `to`, the resource is public AND listed — meant
|
||||
// to show up in public timelines and indexes; in `cc` only, it is public but UNLISTED — anyone who
|
||||
// has its link may read it, but nothing should advertise it. The public-read grant serves both
|
||||
// (`hasPublicIntent`); a public index — Festipod's catalog — may list only the first.
|
||||
|
||||
// `to` only, under the same three spellings as above.
|
||||
const TO_KEYS = ['to', 'as:to', 'https://www.w3.org/ns/activitystreams#to'];
|
||||
|
||||
/**
|
||||
* True when this ONE JSON-LD resource names the ActivityStreams Public collection in `to` (any of
|
||||
* its spellings, as a bare IRI or a node). Public in `cc` only — unlisted — is false, as is
|
||||
* everything `isAddressedToPublic` refuses.
|
||||
*/
|
||||
const isListedToPublic = resource => {
|
||||
if (!resource || typeof resource !== 'object') return false;
|
||||
return TO_KEYS.some(key => listOf(resource[key]).some(entry => PUBLIC_FORMS.has(addressOf(entry))));
|
||||
};
|
||||
|
||||
/**
|
||||
* hasListedPublicIntent(object, activity?) → boolean.
|
||||
*
|
||||
* Public intent (`hasPublicIntent`, same arguments) AND listed: the Public collection in `to` of
|
||||
* the object or of the activity. Built on `hasPublicIntent`, so it can never be true where that one
|
||||
* is false: nothing is listed that the public-read gate would not have accepted.
|
||||
*/
|
||||
const hasListedPublicIntent = (object, activity) =>
|
||||
hasPublicIntent(object, activity) && (isListedToPublic(object) || isListedToPublic(activity));
|
||||
|
||||
module.exports = { AS_PUBLIC, isAddressedToPublic, hasPublicIntent, isListedToPublic, hasListedPublicIntent };
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { PodResourcesHandlerMixin } = require('@activitypods/app');
|
||||
const { arrayOf } = require('@semapps/ldp');
|
||||
const CONFIG = require('../config/config');
|
||||
const { hasPublicIntent } = require('./public-intent');
|
||||
|
||||
const ACL = 'http://www.w3.org/ns/auth/acl#';
|
||||
const FOAF_AGENT = 'http://xmlns.com/foaf/0.1/Agent';
|
||||
|
||||
// The pod compacts ACL documents with the `acl:` and `foaf:` prefixes; accept both forms.
|
||||
const expand = value => {
|
||||
const id = typeof value === 'object' && value !== null ? value['@id'] || value.id : value;
|
||||
if (typeof id !== 'string') return id;
|
||||
if (id.startsWith('acl:')) return ACL + id.slice('acl:'.length);
|
||||
if (id.startsWith('foaf:')) return 'http://xmlns.com/foaf/0.1/' + id.slice('foaf:'.length);
|
||||
return id;
|
||||
};
|
||||
|
||||
/** True when one of the ACL's authorizations already lets any agent (foaf:Agent) read. */
|
||||
const grantsPublicRead = authorizations =>
|
||||
arrayOf(authorizations).some(
|
||||
authorization =>
|
||||
arrayOf(authorization['acl:mode']).map(expand).includes(ACL + 'Read') &&
|
||||
arrayOf(authorization['acl:agentClass']).map(expand).includes(FOAF_AGENT)
|
||||
);
|
||||
|
||||
// Festipod creates its events and meeting points public: their bindings declare visibility
|
||||
// 'public', and its create path addresses each one to the ActivityStreams Public collection
|
||||
// (`src/shared/refine/providers/activitypods.ts`). This service owns the one WAC shape that makes
|
||||
// a pod resource world-readable — `acl:Read` for `acl:agentClass foaf:Agent`, nothing more — and
|
||||
// applies it, acting as the app actor, to an `as:Event` a user creates ONLY when the created
|
||||
// object carries that public intent (`backend/services/public-intent.js`). It learns of creations
|
||||
// through the same channel as the catalog: the app's watcher on each user's outbox, which calls
|
||||
// `onCreate` for every `Create` of an `as:Event` — whichever client created it and whatever its
|
||||
// audience, which is why the intent check, not the channel, decides.
|
||||
module.exports = {
|
||||
name: 'public-read',
|
||||
mixins: [PodResourcesHandlerMixin],
|
||||
settings: {
|
||||
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event')
|
||||
},
|
||||
dependencies: ['pod-permissions'],
|
||||
actions: {
|
||||
/**
|
||||
* Idempotent: reads the resource's ACL first and adds nothing when a public-read
|
||||
* authorization is already there. Resolves `true` when it added the grant, `false` when the
|
||||
* resource was already public; throws when the pod refuses the grant.
|
||||
*/
|
||||
grant: {
|
||||
params: {
|
||||
resourceUri: { type: 'string' },
|
||||
actorUri: { type: 'string' }
|
||||
},
|
||||
async handler(ctx) {
|
||||
const { resourceUri, actorUri } = ctx.params;
|
||||
|
||||
const authorizations = await ctx.call('pod-permissions.get', { uri: resourceUri, actorUri });
|
||||
if (authorizations && grantsPublicRead(authorizations)) {
|
||||
this.logger.info(`${resourceUri} is already publicly readable`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const added = await ctx.call('pod-permissions.add', {
|
||||
uri: resourceUri,
|
||||
agentPredicate: 'acl:agentClass',
|
||||
agentUri: FOAF_AGENT,
|
||||
mode: 'acl:Read',
|
||||
actorUri
|
||||
});
|
||||
if (!added) throw new Error(`The pod refused the public-read grant on ${resourceUri}`);
|
||||
|
||||
this.logger.info(`Granted public read on ${resourceUri}`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async onCreate(ctx, resource, actorUri) {
|
||||
const resourceUri = resource.id || resource['@id'];
|
||||
if (!hasPublicIntent(resource)) {
|
||||
this.logger.info(`${resourceUri} is not addressed to the Public collection: no public read`);
|
||||
return;
|
||||
}
|
||||
// The watcher runs every matching handler of an activity in sequence and does not catch: a
|
||||
// throw here would skip the handlers after this one, and the platform does not await the
|
||||
// webhook call that runs the watcher, so the rejection would also end the process. Log, never throw.
|
||||
try {
|
||||
await this.actions.grant({ resourceUri, actorUri }, { parentCtx: ctx });
|
||||
} catch (e) {
|
||||
this.logger.error(`Could not grant public read on ${resourceUri}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -124,6 +124,9 @@ test('event toValues composes the datetimes and never stores the derived fields'
|
||||
// text value resolved into a garbage IRI.
|
||||
location: { name: 'La Maison du Vélo, Lyon' },
|
||||
image: 'https://example.org/atelier.jpg',
|
||||
// Public intent, written on create: the backend grants anonymous read only to a
|
||||
// created as:Event addressed to the Public collection.
|
||||
to: 'https://www.w3.org/ns/activitystreams#Public',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,14 +142,22 @@ test('event toValues keeps a date-only end and drops a time-only start', () => {
|
||||
location: '',
|
||||
participantCount: 0,
|
||||
}),
|
||||
).toEqual({ name: 'T', content: '', startTime: '2026-03-01', endTime: '2026-03-03', location: { name: '' } });
|
||||
).toEqual({
|
||||
name: 'T',
|
||||
content: '',
|
||||
startTime: '2026-03-01',
|
||||
endTime: '2026-03-03',
|
||||
location: { name: '' },
|
||||
to: 'https://www.w3.org/ns/activitystreams#Public',
|
||||
});
|
||||
expect(
|
||||
event.toValues({ title: 'T', description: '', date: '', startTime: '09:00', location: '', participantCount: 0 }),
|
||||
).toEqual({ name: 'T', content: '', location: { name: '' } });
|
||||
).toEqual({ name: 'T', content: '', location: { name: '' }, to: 'https://www.w3.org/ns/activitystreams#Public' });
|
||||
});
|
||||
|
||||
test('event toPatch sends only what changed', () => {
|
||||
const { event } = getBackend('activitypods').bindings;
|
||||
// No addressing either: it is written on create, and the merge keeps it on an edit.
|
||||
expect(event.toPatch({ title: 'Nouveau titre' })).toEqual({ name: 'Nouveau titre' });
|
||||
});
|
||||
|
||||
@@ -206,6 +217,8 @@ test('meetingPoint toValues writes the context and the named location, never org
|
||||
content: 'Sous l’horloge',
|
||||
location: { name: 'Café de la Place' },
|
||||
'as:context': 'http://localhost:3000/alice/data/event-1',
|
||||
// A meeting point is public too: created addressed to the Public collection.
|
||||
to: 'https://www.w3.org/ns/activitystreams#Public',
|
||||
});
|
||||
// The host is the Pod-stamped creator; nothing here writes an `organizer` term
|
||||
// the platform context does not define (it would not round-trip).
|
||||
|
||||
@@ -367,11 +367,24 @@ function attendeesFromRecord(record: unknown): { members: unknown[]; total?: num
|
||||
return { members, total: total ?? members.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* PUBLIC ADDRESSING, WRITTEN ON CREATE. Events and meeting points are public
|
||||
* (`visibility: 'public'` on both bindings), and on this backend the intent travels
|
||||
* as ActivityStreams addressing: each is created addressed `to` the Public
|
||||
* collection. `create` sends every variable verbatim, and the pod stores the
|
||||
* addressing on the record (`"to": "as:Public"`). Festipod's backend reads it on the
|
||||
* created record (`backend/services/public-intent.js`) and grants anonymous read ONLY to an
|
||||
* `as:Event` that carries it — one created without it stays private to its owner,
|
||||
* whichever client created it. Create only: `update` merges onto the stored record,
|
||||
* so the addressing survives an edit without being re-sent.
|
||||
*/
|
||||
const PUBLIC_AUDIENCE = { to: 'https://www.w3.org/ns/activitystreams#Public' } as const;
|
||||
|
||||
const eventBinding: RecordBinding<FpEventData> = {
|
||||
recordKey: recordId,
|
||||
domainId: recordId,
|
||||
toDomain: eventFromRecord,
|
||||
toValues: (data) => eventValues(data),
|
||||
toValues: (data) => ({ ...eventValues(data), ...PUBLIC_AUDIENCE }),
|
||||
toPatch: (updates) => eventValues(updates),
|
||||
// Events and meeting points share the as/Event container; an `as:context` link
|
||||
// is what makes a record a MEETING point, so its absence is the event test.
|
||||
@@ -385,7 +398,7 @@ const meetingPointBinding: RecordBinding<FpMeetingPointData> = {
|
||||
recordKey: recordId,
|
||||
domainId: recordId,
|
||||
toDomain: meetingPointFromRecord,
|
||||
toValues: (data) => meetingPointValues(data),
|
||||
toValues: (data) => ({ ...meetingPointValues(data), ...PUBLIC_AUDIENCE }),
|
||||
toPatch: (updates) => meetingPointValues(updates),
|
||||
// The distinction lives in the record, not the container — see the event binding.
|
||||
isInstance: (record) => linkId(asRecord(record)?.['as:context']) !== undefined,
|
||||
|
||||
Reference in New Issue
Block a user