diff --git a/backend/services/attendees.service.js b/backend/services/attendees.service.js index 63ee04d..d6a7c01 100644 --- a/backend/services/attendees.service.js +++ b/backend/services/attendees.service.js @@ -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}`); } } } diff --git a/backend/services/public-intent.js b/backend/services/public-intent.js new file mode 100644 index 0000000..da0832d --- /dev/null +++ b/backend/services/public-intent.js @@ -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 }; diff --git a/backend/services/public-intent.test.js b/backend/services/public-intent.test.js new file mode 100644 index 0000000..eb75286 --- /dev/null +++ b/backend/services/public-intent.test.js @@ -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)); + } +}); diff --git a/backend/services/public-read.service.js b/backend/services/public-read.service.js new file mode 100644 index 0000000..53c9a9c --- /dev/null +++ b/backend/services/public-read.service.js @@ -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}`); + } + } + } +}; diff --git a/src/shared/refine/providers/activitypods.test.ts b/src/shared/refine/providers/activitypods.test.ts index 68f4d13..446e83c 100644 --- a/src/shared/refine/providers/activitypods.test.ts +++ b/src/shared/refine/providers/activitypods.test.ts @@ -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). diff --git a/src/shared/refine/providers/activitypods.ts b/src/shared/refine/providers/activitypods.ts index d42f3e0..190aebe 100644 --- a/src/shared/refine/providers/activitypods.ts +++ b/src/shared/refine/providers/activitypods.ts @@ -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 = { 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 = { 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,