9 Commits

Author SHA1 Message Date
Sylvain Duchesne 9671345634 Wait for the event and meeting point counts together in the direct sign-up tests 2026-11-06 23:30:05 +01:00
Sylvain Duchesne ebcf5f4266 Run the direct sign-up e2e scenarios on disposable accounts deleted after each scenario 2026-11-06 23:24:57 +01:00
Sylvain Duchesne 9169a5817c Report a Join on an event without an attendees list as not recorded, and trim redundant comments 2026-11-06 23:21:38 +01:00
Sylvain Duchesne 5540d361c7 Implement direct sign-up to an event with registration derived from meeting point memberships 2026-11-06 23:16:29 +01:00
Sylvain Duchesne af0cba6920 Adapt the failing direct sign-up tests to the declarer notification decision 2026-11-06 23:09:48 +01:00
Sylvain Duchesne 9cbe78948b Record direct sign-up to an event as a decision, with the declarer notified and confidentiality unchanged, and amend the doctrine that forbade it 2026-11-06 23:02:36 +01:00
Sylvain Duchesne 4abab5a55a Write the failing tests for direct sign-up to an event 2026-11-06 22:57:46 +01:00
Sylvain Duchesne f45fc564ec Design direct sign-up to an event, with registration derived from meeting point memberships 2026-11-06 22:45:52 +01:00
Sylvain Duchesne 9e2df2478b Deliver Join and Leave to the app's inbox instead of an unaddressed outbox post
A participation activity is now addressed to the Festipod app actor alone and
handled on reception in the app's inbox, the way the ActivityPods developer
advised: use the ActivityPub protocol rather than a side channel. Any
compatible sender may deliver; the inbox accepts only the activity's own
signer, and the backend still requires the activity to lie in that actor's pod
and acts on the stored copy.

The meeting point is read again in its creator's pod before anything is
written: read through the participant's pod, a foreign server's proxy chose
the attendee list and the notification's name.

Confidentiality is unchanged: the Join stays readable by its author and the
app only. Recorded in decision_2026-11-03_participation-delivered-to-the-app,
a named exception to festipod-only-reference-app-patterns.
2026-11-03 18:31:24 +01:00
46 changed files with 2147 additions and 300 deletions
@@ -8,7 +8,7 @@ against: 66616484b96fb2227cd8cdf02321aacfb4811365
Festipod is a mobile-first web application: users create **meeting points** grafted onto public events, and sign up to them. Its entire persistence goes through Refine, behind a **backend seam** that exists so the app can support several Refine backends and let the user switch between them at runtime. This package is one of the two backends targeted; the other is a Refine provider over NextGraph plus its polyfill. The seam's own contract — what a backend must answer before it can be added — lives in code, in `src/shared/refine/providers/types.ts`.
The seam is wired since 2026-10-09 (T04.e): this backend is the registered default, and Festipod reads and writes through it — `useList` over the shared `as:Event` container (split into events and meeting points by the bindings' `isInstance`), creates and updates through the data provider, and the embedded `apods:attendees` collection through the participation binding's `membersOf`. Joins/leaves are the one write beside Refine (the package implements no `custom`): an `as:Join`/`as:Leave` posted to the user's own outbox by `src/shared/data/outbox.ts`, naming the meeting point with no addressee, so that only its author and the app can read it. What the list below declares is therefore measured where a runtime check exists and declared elsewhere; `against:` names the observation's commit, and the package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped.
The seam is wired since 2026-10-09 (T04.e): this backend is the registered default, and Festipod reads and writes through it — `useList` over the shared `as:Event` container (split into events and meeting points by the bindings' `isInstance`), creates and updates through the data provider, and the embedded `apods:attendees` collection through the participation binding's `membersOf`. Joins/leaves are the one write beside Refine (the package implements no `custom`): an `as:Join`/`as:Leave` posted to the user's own outbox by `src/shared/data/outbox.ts`, naming the meeting point and addressed to the Festipod app actor alone, so that only its author and the app can read it; the pod delivers it to the app's inbox. What the list below declares is therefore measured where a runtime check exists and declared elsewhere; `against:` names the observation's commit, and the package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped.
## Consumed surface
@@ -17,7 +17,7 @@ last_checked: 2026-08-17
| `FpNotificationData` | SDK (Notification shape) | recipient, type, ref, payload, timestamp, isRead |
| `FpFriendshipData` | **derived** on ActivityPods (mirror of the Pod's `apods:contacts`, see [[decision_2026-10-21_contacts-are-the-pod-social-graph]]); absent elsewhere | userId + friendId |
**On ActivityPods the participation is not a document of its own.** A sign-up is a member URN in the meeting point's `apods:attendees` collection plus the meeting point's URI in the person's participations collection (linked from the profile as `http://festipod.org/participations`); the person's `http://festipod.org/participationSecret` on the profile is what lets their contacts recognise the URN. No shape describes any of these — see [[decision_2026-10-29_participation-privacy-mechanism]] and `src/shared/data/participationPrivacy.ts` (verified in code).
**On ActivityPods the participation is not a document of its own.** A sign-up is a member URN in the `apods:attendees` collection of its target — a meeting point, or the event itself for a direct sign-up (concept `functional-domain`, [[decision_2026-11-06_direct-event-registration]]) — plus the target's URI in the person's participations collection (linked from the profile as `http://festipod.org/participations`); the person's `http://festipod.org/participationSecret` on the profile is what lets their contacts recognise the URN. No shape describes any of these — see [[decision_2026-10-29_participation-privacy-mechanism]] and `src/shared/data/participationPrivacy.ts` (verified in code).
**An event has no host.** `hostName`/`hostInitials` are gone from the type and the shape alike — the event is only the anchor, and the host lives one level down on the meeting point (`FpMeetingPointData.hostId`, SHEX `fp:MeetingPoint.host`). See concept `functional-domain`, [[knowledge_actors-and-concepts]].
@@ -14,7 +14,7 @@ Festipod's **functional domain**: what the product promises and the business voc
## Pivotal idea
Festipod lets users create **meeting points** that *graft* themselves onto existing **public events**. The event (festival, conference…) is only a *pretext* and a space-time anchor; the value produced is the meeting point. **You sign up to a meeting point, never to an event.**
Festipod lets users create **meeting points** that *graft* themselves onto existing **public events**. The event (festival, conference…) is only a *pretext* and a space-time anchor; the value produced is the meeting point. **You sign up to a meeting point, or directly to an event; being in one of an event's meeting points registers you to that event** ([[decision_2026-11-06_direct-event-registration]]).
## Scope & confidentiality
@@ -29,3 +29,4 @@ The product model of **who sees what** — personal data reserved to one's netwo
- [[decision_2026-10-29_participation-privacy-mechanism]] — the mechanism enforcing it on ActivityPods (private Join, hashed member URNs, contacts-readable participations collection)
- [[knowledge_roadmap]] — current features vs upcoming evolutions
- [[brief_2026-06-15_event-deduplication]] — open challenge of deduplicating events in P2P
- [[decision_2026-11-06_direct-event-registration]] — direct sign-up to an event (FP-001): registration derived from it or from a meeting point membership, anonymous notification of the declarer, confidentiality unchanged, confirmed cascade on leaving the event
@@ -29,4 +29,6 @@ The rule states the **what**, never the **how** — and that is intended. Which
- [[knowledge_data-scopes-and-discovery]] — the scopes model this rule tightens (participation: public as an object, the identity link is protected)
- [[decision_2026-10-29_participation-privacy-mechanism]] — the mechanism enforcing this rule on ActivityPods (private Join, hashed member URNs, contacts-readable participations collection)
- [[decision_2026-11-06_direct-event-registration]] — a direct sign-up to an event is a participation under this rule, unchanged
- [[decision_2026-11-03_participation-delivered-to-the-app]] — how that private Join reaches the backend: addressed to the app actor alone, delivered to the app's inbox
- [[decision_2026-10-21_contacts-are-the-pod-social-graph]] — what "contact" means (the Pod's bilateral `apods:contacts`)
@@ -5,7 +5,7 @@ summary: How sign-ups stay unlinkable on ActivityPods — a private Join handled
# Decision (2026-10-29): the participation privacy mechanism
User-validated 2026-10-29. Enforces [[behavior_participation-listing-is-contacts-only]] on the ActivityPods backend, using only patterns of the reference apps (project rule `festipod-only-reference-app-patterns`). ActivityPods itself is never modified.
User-validated 2026-10-29. Widened by [[decision_2026-11-06_direct-event-registration]]: a sign-up may also name an event itself, under this mechanism unchanged (its own list, the same URN, the event URI in the participations collection, its declarer notified anonymously). Enforces [[behavior_participation-listing-is-contacts-only]] on the ActivityPods backend, using only patterns of the reference apps (project rule `festipod-only-reference-app-patterns`). ActivityPods itself is never modified.
## What it protects: two links
@@ -0,0 +1,34 @@
---
type: decision
summary: Join/Leave are addressed to the Festipod app actor alone and handled on reception in the app's inbox, no longer picked up unaddressed by an outbox webhook; any compatible sender may send, authorship rests on the signature and the pod, the meeting point is read in its creator's pod
---
# Decision (2026-11-03): participation activities are delivered to the app
User-validated 2026-11-03, on the advice of the ActivityPods developer: an activity meant for an actor who acts on it is addressed to that actor and reaches it through its inbox — the ActivityPub protocol rather than a side channel. This replaces steps 1 and 2 (the unaddressed sign-up, "the backend acts on emission") of [[decision_2026-10-29_participation-privacy-mechanism]]; every other step of that mechanism (member URN, secret, participations collection, recognition, anonymous notification) and all its residuals stand.
## The delivery
- The participant posts the `as:Join` (withdrawal: `as:Leave`) to their own outbox, naming the meeting point, with `to` = the Festipod app actor (`<backend>/as/actor/app`, which is also the Solid-OIDC client id) and no other addressee. Their pod delivers it to the app's inbox.
- The backend handles it with `onReceive` of `@semapps/activitypub`'s `ActivitiesHandlerMixin` (`registration.service.js`), the path `@activitypods/app` itself uses for `Create interop:ApplicationRegistration`. No reference app receives user activities in the app's inbox: this is a named exception to the project rule `festipod-only-reference-app-patterns`, justified by the platform developer's advice and by the framework's own use of that path.
- The outbox webhook (`apods:ReadOutbox`) stays: the catalog, the contacts and the attendee lists still follow the user's own resources there, which have no actor to address.
## Who may send: anyone, nobody for someone else
- **No "app installed" gate** (user decision): interoperability is the aim of the ActivityPods architecture, and a Join sent by another compatible app is legitimate. The processing filters by what it needs: a sender whose profile secret the backend cannot read is refused, nothing written.
- **Authorship.** The inbox accepts a delivery only when it is signed by the activity's `actor` (semapps 1.2.7 `inbox.post` refuses any other actor, `INVALID_ACTOR`; read in code 2026-11-03). The handler further requires the activity to lie in that actor's pod and its stored copy to name them, and acts on the stored copy (`verifiedDelivery` in `emitted-activity.js`). A pod refuses writes under another actor's path, so nobody can join or leave for someone else.
- **The meeting point is read in its creator's pod.** Read through the participant's pod, a resource of another pod goes through the proxy the participant's own actor document declares: for a participant on another server, an answer that server chooses — another meeting point's attendee list, a forged name (reproduced by the adversarial review 2026-11-03 against mocked platform calls). The handler reads the meeting point again through its creator's access and uses that copy alone (`meetingPointOf`).
## Confidentiality: unchanged
The Join stays readable by its author and its only addressee, the app; the creator's inbox receives nothing. The rights matrix lines on the Join's read rights pass unchanged (148/148 hard lines, run 2026-11-03 with the change as committed, creator re-read included). The backend learns the participant's identity as before.
## Costs accepted
- The inbox POST awaits the processing (semapps `processInbox` waits for its job), so each Join/Leave delivery holds the participant's pod's request for the whole handling: 2.6–6.2 s over the 9 deliveries of a full validation run (measured 2026-11-03 on the local stack, creator re-read included, from the queue's job timestamps), against 0.7–1.5 s for the app's other inbox jobs. The app's inbox queue runs 5 jobs at a time, shared with app registrations. Whether the pod provider serializes its outgoing deliveries is not verified.
- The app's inbox stores a copy of every Join/Leave in the backend's dataset — the person ↔ meeting point link the attendance ledger already holds. An anonymous GET of the app's inbox answers 404 (observed 2026-11-03).
- Known exposure, not introduced here and not settled: the backend issues app-signed GETs to URIs a sender chooses — the meeting point it names, its own documents — as the framework's app-registration handler already does with the registration URI a delivery names. Festipod filters no private or internal address; whether the framework does is not verified.
## Open
How to welcome a participant from another app, whose secret the backend cannot read — refused today. Leads: count them under a member URN only the backend can compute (counted, recognised by nobody); agree with the ActivityPods developer on a shared vocabulary for the secret; another policy. A product decision, not taken.
@@ -0,0 +1,214 @@
---
type: decision
summary: A user signs up to a meeting point or directly to an event; registration to an event = direct sign-up OR membership of one of its meeting points, derived at read and never stored; a direct sign-up notifies the declarer anonymously and follows the participation confidentiality rules unchanged; leaving an event with meeting points asks for confirmation and withdraws them too
---
# Decision (2026-11-06): direct sign-up to an event (FP-001)
User-validated 2026-11-06 (ticket FP-001). It **amends the pivotal sentence** of the product model: the old "you sign up to a meeting point, never to an event" becomes **"you sign up to a meeting point, or directly to an event; being in one of an event's meeting points registers you to that event"** — [[knowledge_business-model]], the `functional-domain` overview, [[knowledge_actors-and-concepts]] and `AGENTS.md` carry the new sentence. The meeting point stays the **unit of value**; an event can now be joined on its own — "I'll be there" before, or without, any meeting point.
Every rule of the participation mechanism stands ([[decision_2026-10-29_participation-privacy-mechanism]], [[decision_2026-11-03_participation-delivered-to-the-app]]); only the **target** of the act widens from "a meeting point" to "a meeting point or an event". The confidentiality rules apply **without change** ([[behavior_participation-listing-is-contacts-only]]): a direct sign-up is a participation like any other.
## The request
- sign-up to a meeting point ⇒ registered to its event;
- withdrawal from a meeting point ⇒ still registered iff in another meeting point of that event **or** signed up to it directly (user-confirmed);
- withdrawal from an event ⇒ if the user is in meeting points of it, ask for confirmation, saying those memberships go too;
- a direct sign-up counts in no meeting point (user-confirmed);
- a direct sign-up **notifies the event's declarer** (user decision, replacing the "no notification" first proposed).
## Model: two acts stored, one status derived
| Fact | Stored? | Where |
|---|---|---|
| **Direct sign-up** to event E | yes — an act of the user | an `as:Join` naming **E itself**, through the same channel as a meeting point Join |
| **Membership** of meeting point M | yes — unchanged | the existing `as:Join` naming M |
| **Registered to E** | **no — derived at read** | `direct(E) ∨ ∃ M of E : member(M)` |
Deriving the registration, rather than writing an event-level Join as a side effect of every meeting point Join, is the core choice:
- R2 holds by construction — no second write that can fail, lag or drift from the first.
- R3 needs no decision logic at withdrawal: after the last meeting point is left, the status falls back to whatever the direct sign-up says.
- **Existing data needs no migration**: every registration stored today is a meeting point membership, hence "derived, not direct" — after the change each user is registered to exactly the events they are registered to now.
Rejected: *writing an event-level Join on every meeting point Join* (two non-atomic writes per act; on withdrawal of the last meeting point the app could no longer tell a direct sign-up from an automatic one without storing a flag — re-creating the derived status by hand). Rejected: *a "direct" flag on the participation* (no participation document exists on ActivityPods; the act is the record).
### On ActivityPods (the active backend)
- **The act.** `as:Join` / `as:Leave` posted to the user's outbox, `to` = the app actor alone, `object` = the event URI, delivered to the app's inbox ([[decision_2026-11-03_participation-delivered-to-the-app]]). Same privacy: readable by its author and the app only.
- **The backend's target resolution** (`registration.service.js`, `meetingPointTarget` / `meetingPointOf`) accepts a stored `as:Event` **without** `as:context` as an event target whose anchor is itself. Everything else applies as is: re-read in the declarer's pod, `dc:creator` must own that pod, serialized per target, newer-act-wins ledger keyed by (target, participant), both collections read back before the act counts.
- **The member URN is the same** as in the event's meeting points: `sha256("<WebID> <event URI> <secret>")` with event URI = the event itself. One person carries one URN across the event's own list and all its meeting points' lists — the count stays "distinct URNs", publicly computable.
- **The event's own `apods:attendees` collection** already exists: `attendees.service.js` attaches one to every `as:Event` created, plain events included (observed 2026-10-12). It receives the direct sign-ups' URNs, under the same public-read reconciliation as a meeting point's list (readable anonymously while the event is public, closed when it is switched private).
- **The participations collection** receives the event URI on a direct Join (removed on its Leave). Its audience is unchanged (the participant's `/contacts` group).
- **The declarer is notified**, exactly as a meeting point's host is: by `notifyCreator` (`pod-notifications.send`), recipient = the event's `dc:creator`, on a direct sign-up **and** on its withdrawal, only when the ledger state flips (a repeated or stale act notifies no one), **always anonymous** — no name, no WebID, no activity, the participant's contacts list never read. The notification names the event (template parameter, not the participant) and its link opens the event. A notification failure does not fail the act. Notifying gives the declarer no read right: they stay "anyone else" towards the participant unless a contact.
### Reads (`refineDerive.ts`, `refineData.tsx`)
- `deriveContainer` reads the attendee list of **events** too; a direct sign-up becomes a participation keyed by the event id.
- `eventParticipantCount` = distinct member values over the event's own list **∪** its meeting points' lists.
- "Mine": my participations collection holds meeting point URIs **and** event URIs; an event URI there is my direct sign-up. `getUserEvents(me)` unions both; a contact's `participatedEventsByContact` likewise.
- Recognition of contacts on an event (`getEventParticipants`) includes the event's own list — with the contacts' secrets only, as for meeting points.
- Pending acts / read-your-writes (`PendingActs`, `settleMembershipActs`, `countsWithOverlay`) are keyed by **target id** instead of meeting point id.
### Local demo and the suspended NextGraph path
- `LocalDataProvider`: the seed's `seedParticipations` are keyed by **event** id — they read as direct sign-ups; mutations stay no-ops.
- `NgDataProvider` (suspended): out of scope; the new mutations **reject** with an explicit "not supported on the suspended NextGraph path" error, never a silent success.
## Screen-facing contract (`festipodDataBase.ts`)
```ts
interface EventRegistration {
direct: boolean; // signed up to the event itself
meetingPointIds: string[]; // the event's meeting points I am a member of
registered: boolean; // direct || meetingPointIds.length > 0 (R2, R3)
}
getEventRegistration(eventId: string): EventRegistration;
joinEvent(eventId: string): Promise<void>; // direct sign-up (R1), idempotent
leaveEvent(eventId: string): Promise<void>; // withdraws the direct sign-up AND every membership of the event's meeting points (R4)
```
- The **cascade belongs to the data context**, the **confirmation to the screen**.
- `leaveEvent` posts one Leave per held target (the event if `direct`, each joined meeting point), in parallel; it resolves only when all were accepted and **rejects naming the targets that failed** (R8).
- `registered` replaces the screens' local `joinedMeetingPointIds.size > 0` (which gates « Inviter »).
## UI (`EventDetailScreen`, Home)
- Event detail: an event-level toggle under the info block — « S'inscrire à l'événement » when not registered; « ✓ Inscrit à l'événement » + « Se désinscrire de l'événement » when registered (direct or derived).
- Withdrawal with memberships ⇒ a confirmation dialog: title « Se désinscrire de l'événement ? », body « Vous êtes inscrit aux points de rencontre suivants : <titres>. En confirmant, vous en serez aussi désinscrit. », actions « Confirmer la désinscription » / « Annuler ». Without memberships: no dialog, immediate withdrawal.
- A shared `ConfirmDialog` in `src/shared/components/sketchy/` (none exists today).
- Meeting point toggles are unchanged (no confirmation when leaving a meeting point — R3).
- Home's « mes événements » comes from `getUserEvents(me)`: directly joined events appear there with no screen change.
- The participants shown on an event remain the viewer's **contacts** only, whatever their path (direct or meeting point); anyone else is counted, never shown.
## Business rules
- **R1 — Direct sign-up.** A user can sign up to an event directly, whether or not it has meeting points. Signing up twice changes nothing (idempotent).
- **R2 — Meeting point ⇒ event.** A member of at least one meeting point of an event is registered to that event, without any further act.
- **R3 — Leaving a meeting point.** Leaving a meeting point never asks for confirmation and never touches the other memberships. Afterwards the user is still registered to the event iff they are a member of another of its meeting points **or** signed up to it directly; otherwise they are no longer registered.
- **R4 — Leaving an event.** If the user is a member of at least one of its meeting points, a confirmation names those meeting points and says they will be left too; confirming withdraws the direct sign-up (if any) and every such membership; cancelling changes nothing. Without memberships, the withdrawal is immediate, with no confirmation.
- **R5 — One person counts once.** The event's participant count is the number of distinct persons registered to it, directly or through meeting points; a meeting point's count is its own members only — a direct sign-up counts in no meeting point.
- **R6 — The declarer is notified, anonymously.** A direct sign-up and its withdrawal notify the event's declarer, as a meeting point's sign-up notifies its host: once per change of state, never naming the participant (no name, no WebID). Leaving an event through the cascade notifies the declarer (if the sign-up was direct) and each left meeting point's host, each once. A failed notification does not fail the act.
- **R7 — Same confidentiality, unchanged.** A direct sign-up is a participation and follows exactly the rules of a meeting point participation ([[behavior_participation-listing-is-contacts-only]], [[decision_2026-10-29_participation-privacy-mechanism]]):
- **R7a — The act is private**: readable by its author and the app only — not by the declarer, not by anyone else.
- **R7b — Event → participants**: the event's own list holds opaque member URNs, never a WebID or a name; anyone reads a count; only a **contact** of the participant recognises them among the event's participants.
- **R7c — Person → events**: listing someone's events, direct sign-ups included, is reserved to the person and their contacts; anyone else — **the declarer included** — is refused.
- **R7d — No particular right for the declarer**: declaring an event grants no read on who signed up to it, as creating a meeting point grants none to its host; the notification is their only signal, and it is anonymous.
- **R7e — No cross-event correlation**: the URN of one person differs from one event to another; the same person on the event and on its meeting points carries one URN (counted once — R5).
- **R8 — Nothing succeeds in silence.** A refused or failed act shows an error; the displayed status follows the data (a partially failed withdrawal leaves the user shown as registered through what remains).
- **R9 — Withdrawal is authoritative.** After a withdrawal (from a meeting point or an event) and a real reload, the user is not registered back ([[caveat_participation-deletion]]).
## Acceptance criteria
| # | Given | When | Then | Rules |
|---|---|---|---|---|
| AC1 | not registered to E (E with or without meeting points) | taps « S'inscrire à l'événement » | shown « ✓ Inscrit à l'événement »; E in Home's events; E's count +1 | R1, R5 |
| AC2 | signed up directly to E | signs up again (API) | no second entry; count unchanged; no second notification | R1, R6 |
| AC3 | not registered to E | joins meeting point M of E | shown registered to E, with no event-level act sent | R2 |
| AC4 | member of M1 and M2 of E, not direct | leaves M1 | still registered to E; no dialog | R3 |
| AC5 | member of M1 only, not direct | leaves M1 | no longer registered to E; E leaves Home's events | R3 |
| AC6 | member of M1 only, **and** direct | leaves M1 | still registered to E | R3 |
| AC7 | member of M1 and M2 of E | taps « Se désinscrire de l'événement » | dialog lists M1 and M2 and warns they will be left | R4 |
| AC8 | AC7's dialog | « Annuler » | nothing sent, still member of M1, M2, still registered | R4 |
| AC9 | AC7's dialog | « Confirmer la désinscription » | not registered to E, member of neither M1 nor M2 | R4 |
| AC10 | signed up directly, no membership | taps « Se désinscrire de l'événement » | no dialog; no longer registered | R4 |
| AC11 | direct + member of M1, same person | — | E's count counts them once; M1's count counts them once | R5 |
| AC12 | D declared E, A is not D's contact | A signs up directly to E | D is notified of a new participant to E, without A's name or WebID | R6, R7d |
| AC13 | AC12 | A withdraws from E | D is notified of a withdrawal, anonymously | R6 |
| AC14 | A direct + member of M1 (host H) | A confirms leaving E | D notified once (leave), H notified once (leave), both anonymous | R6 |
| AC15 | the notification cannot be sent | A signs up directly | the sign-up stands | R6 |
| AC16 | direct sign-up by A | anyone reads A's Join | refused, the declarer included | R7a |
| AC17 | direct sign-up by A | B, not A's contact, opens E / reads E's list | B sees a count and opaque URNs, never A | R7b |
| AC18 | direct sign-up by A | B, not A's contact (declarer D included), reads A's participations collection | refused | R7c, R7d |
| AC19 | A's contact C | opens E and A's profile | C recognises A among E's participants and sees E in A's events | R7b, R7c |
| AC20 | A direct to E and to another event E' | anyone reads both lists | the two URNs differ | R7e |
| AC21 | the Join/Leave is refused (e.g. no session) | any act | an error toast; the status shown is unchanged | R8 |
| AC22 | after AC5, AC9 or AC10 | real reload | still not registered | R9 |
| AC23 | data stored before the change (memberships only) | first load after | every user registered to exactly the same events as before; nobody shown as directly registered | migration |
## Test plan
The `@data` and `@e2e` suites of the NextGraph broker are **suspended** (`@suspendu`); on the active backend the data layer is exercised by `@e2e-activitypods` and by the rights matrix (`bun run test:e2e-rights`). Mapping of [[rule_test-layer-contracts]]: **`@ui`** = rendering over the seed; **data** = unit tests (`bun test`) of the derivations and the backend handler, plus `@e2e-activitypods` scenarios; **`@e2e`** = one `@e2e-activitypods @browser` journey. All runs obey [[rule_tests-never-break-the-dev-environment]]: throwaway accounts only, cleaned up; nothing resets the stack; a down stack means "not run", never started by the test.
### Already written (commit 4abab5a) and still valid
- `@ui` (`us-7-inscription-evenement.feature`): « Voir le bouton d'inscription à l'événement », « Un inscrit direct voit son inscription à l'événement », « Être inscrit à un point de rencontre affiche l'inscription à l'événement », « Se désinscrire d'un événement avec points de rencontre demande confirmation », « La confirmation de désinscription précise les points de rencontre quittés », « Annuler la confirmation ne change rien », « Se désinscrire d'un événement sans point de rencontre ne demande pas de confirmation », « Quitter un point de rencontre ne demande pas de confirmation », « Le bouton Inviter suit l'inscription à l'événement, directe ou par un point de rencontre ».
- `@e2e-activitypods` (`e2e-activitypods.feature`): « Rejoindre puis quitter un événement met à jour sa collection apods:attendees », « L'inscription directe et l'inscription à un point de rencontre partagent la même URN de membre », « Quitter le dernier point de rencontre sans inscription directe désinscrit de l'événement », « Quitter le dernier point de rencontre garde l'inscription directe », « Se désinscrire de l'événement retire l'inscription directe et celles des points de rencontre », and the journey « Parcours : s'inscrire à un événement, rejoindre un point de rencontre, se désinscrire de l'événement avec confirmation ».
- Units: `refineDerive.test.ts`, `festipodDataBase.test.ts`, `refineData.test.tsx` as written; in `registration.service.test.js` the direct sign-up block **except** its notification test (below).
- Rights matrix group `direct sign-up to E`: `direct.join`, `direct.non-contact-refused`, `direct.contact-lists`, `direct.leave`.
### Delta for the QA — to modify
| Where | Test | Change | Rules |
|---|---|---|---|
| `backend/services/registration.service.test.js` | `a direct sign-up notifies nobody: the declarer of an event is not its host` | **replace** by `a direct sign-up notifies the event's declarer, anonymously`: one notification, recipient = the event's `dc:creator`, join wording, no name / WebID / activity / `participantName`, the participant's contacts list never read, the event named by template parameter, the link opening the event | R6, R7d |
### Delta for the QA — to add
**Backend units** (`registration.service.test.js`, in the direct sign-up block):
- `a Leave naming the event notifies the declarer, anonymously` — leave wording, same anonymity checks (R6).
- `a repeated direct Join notifies once; a Leave of someone never signed up notifies no one` — ledger-driven, as for meeting points (R1, R6).
- `a failed declarer notification does not fail the direct sign-up` (R6).
- `leaving the event and its meeting point notifies the declarer and the host, each once` — Leave on E then on M1: two anonymous notifications, one per recipient (R6).
- `a direct Join's URN on one event differs from the same person's URN on another event` (R7e).
**Rights matrix** (`scripts/e2e-rights-matrix.ts`, group `direct sign-up to E`; E's declarer must **not** be Alice's contact for the anonymity lines — Carol's event `CE` fits, or a declarer created for the group):
- `direct.notify-anonymous` — the declarer is notified of the direct Join in their own inbox, naming neither her name nor her WebID (R6, R7d).
- `direct.leave-notify-anonymous` — the same for the Leave (R6).
- `direct.activity-declarer-refused` — the declarer GETs Alice's Join naming E (URI handed out of band): refused 403 (R7a).
- `direct.list-opaque` — E's own attendee list (anonymous read) holds Alice's member URN — the one computed from her secret with E as anchor — and no WebID (R7b).
- `direct.declarer-refused-participations` — the declarer, not her contact, is refused Alice's participations collection although it lists E (R7c, R7d).
**`@ui`** (`src/modules/event/features/us-7-inscription-evenement.feature`, seed: a contact of Marie directly registered to an event, no meeting point membership):
- « Un contact inscrit directement apparaît parmi les participants de l'événement » (R7b).
- « Un participant inscrit directement qui n'est pas un contact n'est que compté » — counted, not shown (R7b).
**`@ui`** (`src/modules/user/features/us-10-profil-participant.feature`):
- « Le profil d'un contact liste les événements auxquels il est inscrit directement » (R7c).
**`@e2e-activitypods`** (`e2e-activitypods.feature`, `@inscription-evenement`):
- « Une inscription directe notifie le déclarant de l'événement sans nommer le participant » — the declarer's inbox holds a join notification about E, anonymous; then the Leave's (R6). Only if the declarer is another throwaway account; otherwise the rights matrix lines above suffice and this scenario is dropped.
### Traceability
| Rule | Scenarios |
|---|---|
| R1 | « Voir le bouton d'inscription à l'événement », « Un inscrit direct voit son inscription à l'événement », « Rejoindre puis quitter un événement met à jour sa collection apods:attendees », journey, repeated-Join unit |
| R2 | « Être inscrit à un point de rencontre affiche l'inscription à l'événement », « Le bouton Inviter suit l'inscription… », `getEventRegistration` unit, journey |
| R3 | « Quitter un point de rencontre ne demande pas de confirmation », « Quitter le dernier point de rencontre sans inscription directe désinscrit de l'événement », « Quitter le dernier point de rencontre garde l'inscription directe », `getEventRegistration` unit |
| R4 | « Se désinscrire d'un événement avec points de rencontre demande confirmation », « La confirmation… précise les points de rencontre quittés », « Annuler la confirmation ne change rien », « Se désinscrire d'un événement sans point de rencontre ne demande pas de confirmation », « Se désinscrire de l'événement retire l'inscription directe et celles des points de rencontre », journey |
| R5 | « L'inscription directe et l'inscription à un point de rencontre partagent la même URN de membre », `refineDerive` units |
| R6 | the 5 notification units, `direct.notify-anonymous`, `direct.leave-notify-anonymous`, « Une inscription directe notifie le déclarant… » |
| R7 | `direct.activity-declarer-refused` (a), `direct.list-opaque` + the two `@ui` participants scenarios (b), `direct.non-contact-refused` + `direct.declarer-refused-participations` + `direct.contact-lists` + « Le profil d'un contact liste… » (c, d), URN-per-event unit (e) |
| R8 | `refineData` partial-failure unit, journey |
| R9 | « Rejoindre puis quitter un événement… », « Se désinscrire de l'événement retire… », journey (reload) |
## Files to touch
- **Doctrine** (done with this decision): this file, [[knowledge_business-model]], [[knowledge_actors-and-concepts]], [[knowledge_data-scopes-and-discovery]], the `functional-domain` overview, `AGENTS.md`.
- **Code comments carrying the old sentence** (for the dev): the header of `src/shared/context/festipodDataBase.ts` (l. 5), `src/modules/event/screens/EventDetailScreen.tsx` (l. 45–46), `src/modules/event/steps/ui/event.steps.ts` (l. 80–82); `README.md §Modèle fonctionnel` (l. 24) says the same.
- **Contract & queries**: `src/shared/context/festipodDataBase.ts`, `src/shared/context/FestipodDataContext.tsx`, `src/shared/context/ngData.tsx`.
- **ActivityPods reads/writes**: `src/shared/context/refineDerive.ts`, `src/shared/context/refineData.tsx`, `src/shared/data/outbox.ts`, `src/shared/refine/providers/activitypods.ts`.
- **Backend**: `backend/services/registration.service.js` (event target; `notifyCreator` for an event target: recipient the declarer, template naming the event, link to the event).
- **UI**: `EventDetailScreen.tsx`, `HomeScreen.tsx`, new `src/shared/components/sketchy/ConfirmDialog.tsx` + export, stories.
## Risks
- **Events without an attendee list.** A direct Join is skipped ("No attendees collection") on an event whose list was never attached. Check on the local stack before implementing; if such events exist, attach the list on demand in the handler.
- **Partial cascade.** `leaveEvent` is N independent acts; mitigated by R8 — not atomic, as every act already is.
- **Read cost.** One more collection read per event (its own list).
- **Notification volume for the declarer.** Every direct sign-up of a popular event reaches its declarer, who may be a mere referencer. Accepted by the user; a per-event mute belongs to the notification settings (`us-18`), not designed here.
- **Product dilution.** A direct sign-up lets an event be joined with no meeting point at all. Accepted by the request; keep meeting points forward in the UI after a direct sign-up.
- **Delivery latency.** 2.6–6.2 s per act ([[decision_2026-11-03_participation-delivered-to-the-app]] §Costs); a cascade multiplies the acts.
## Settled with the user (2026-11-06)
- R3 default: stays registered after the last meeting point only if signed up directly.
- A direct sign-up counts in no meeting point (R5).
- A direct sign-up notifies the declarer (R6) — the first proposal said no notification.
- Confidentiality applies unchanged (R7).
@@ -13,16 +13,17 @@ Vocabulary reference. Every actor is a specialization of an authenticated **user
|---|---|
| **User** | Anyone with an account (a NextGraph wallet). The root of all the others. |
| **Connection ("friend")** | Another user I am connected to. Used to scope lists ("my friends who are attending…") and trust. Bilateral (accepted on both sides). |
| **Declarer of an event** | The user who inserted the event into Festipod. *Not necessarily the real organizer*: just whoever references it. **There is NO notion of "event host"** — and this now holds all the way down: the event carries no host field at all, in the shape or in the app type (`data-layer` → [[knowledge_entities]]). The event is public, merely flagged by its declarer, who **is NOT required to attend**: at creation no participation is written, the counter starts at 0, and the declarer signs up and withdraws through the same path as anyone else. The declarer is nonetheless the event document's **owner**, hence its only writer (`app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]]). The "host" is an actor at the **meeting point** level (next row), never at the event level. |
| **Declarer of an event** | The user who inserted the event into Festipod. *Not necessarily the real organizer*: just whoever references it. **There is NO notion of "event host"** — and this now holds all the way down: the event carries no host field at all, in the shape or in the app type (`data-layer` → [[knowledge_entities]]). The event is public, merely flagged by its declarer, who **is NOT required to attend**: at creation no participation is written, the counter starts at 0, and the declarer signs up and withdraws through the same path as anyone else. The declarer is nonetheless the event document's **owner**, hence its only writer (`app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]]). The "host" is an actor at the **meeting point** level (next row), never at the event level. The declarer **is notified, anonymously,** of direct sign-ups to the event and their withdrawals, and gains no read right over who signed up ([[decision_2026-11-06_direct-event-registration]]). |
| **Host of a meeting point** | The user who created a meeting point attached to an event. |
| **Participant in a meeting point** | A user signed up to a meeting point; in effect they become an attendee of the parent event. |
| **Participant in a meeting point** | A user signed up to a meeting point; in effect they become registered to the parent event. |
| **Participant in an event** | A user registered to an event: signed up to it directly, or a participant in one of its meeting points (derived, never stored — [[decision_2026-11-06_direct-event-registration]]). A direct sign-up notifies the declarer anonymously and follows the same confidentiality rules as a meeting point participation. |
| **Member of an interest community** | A user subscribed to a community in order to discover the events it references. |
## Business concepts
| Concept | Definition |
|---|---|
| **Meeting point** | *The app's unit of value.* A moment of meeting proposed by a host at a given place and time, grafted onto a public event. It is what you sign up to. |
| **Meeting point** | *The app's unit of value.* A moment of meeting proposed by a host at a given place and time, grafted onto a public event. It is what you mainly sign up to — an event can also be joined directly. |
| **Event** | The anchor. A real public event referenced in Festipod to serve as a support for meeting points. A mere pretext (title, dates, location, themes). |
| **Interest community** | A thematic grouping of users. Used mainly to discover events (through subscription) and to delimit referencing scopes. |
| **Curated list** | An editorialized list of events (by a user or a community), distinct from "the events I declared". Allows organizing/recommending. |
@@ -1,6 +1,6 @@
---
type: knowledge
summary: The meeting point is the unit of value, grafted onto an event that is only a pretext; you sign up to the meeting point, not to the event
summary: The meeting point is the unit of value, grafted onto an event that is only a pretext; you sign up to a meeting point or directly to the event, and a meeting point registers you to its event
---
# Business model: the grafted meeting point
@@ -13,7 +13,7 @@ The **public event** (festival, conference, trade show, exhibition…) is **only
Direct consequences for modelling:
- **You sign up to a meeting point, not to an event.** Without meeting points, a Festipod event is of no interest whatsoever.
- **You sign up to a meeting point, or directly to an event.** Being in one of an event's meeting points registers you to that event; a direct sign-up says "I'll be there" before, or without, any meeting point, and counts in no meeting point ([[decision_2026-11-06_direct-event-registration]]). The meeting point stays the value: without meeting points, a Festipod event brings little more than a head count.
- The **declarer** of an event is *not* (necessarily) its real organizer — just someone who inserted the reference into Festipod so that others can attach meeting points to it.
- The **host** of a meeting point is whoever created it; the act of creating makes you the host. Likewise the act of declaring an event makes you its declarer.
@@ -16,7 +16,7 @@ Every entity is stored in the **scope** matching who must be able to read it:
| Event (the anchor) | **public** | everyone |
| Meeting point | **public** | everyone |
| Network profile (name, avatar, bio, city, interests) | **protected** | the holder + their connections |
| Participation / sign-up to a meeting point | **public object, private link** | everyone sees *that* (and how many) participate; *who* participates is contacts-only — [[behavior_participation-listing-is-contacts-only]] |
| Participation / sign-up to a meeting point or directly to an event | **public object, private link** | everyone sees *that* (and how many) participate; *who* participates is contacts-only — [[behavior_participation-listing-is-contacts-only]] |
| Connection index | **protected** | the holder + their connections |
| Private profile (settings, email, preferences) | **private** | the holder alone |
| Connection A↔B (bilateral link, + future messaging) | **dialog** | both users |
@@ -40,7 +40,7 @@ The intent is the same on both backends — every public event is findable by ev
- **ActivityPods (the default backend): the Festipod backend's catalog.** An event or meeting point is public when Festipod creates it addressed to the ActivityStreams Public collection (`to: as:Public`). Such a record is readable by anyone at its URI, and the Festipod backend lists it in its catalog at once — no curation. The listing follows the record: the owner's edits update it; a deletion, or a switch to private, removes it and makes the record and its attendee list unreadable anonymously again. Only a record's owner can change its listing. Découvrir shows the user's own events plus the catalog's; opening another user's event reads that event, its meeting points and their attendee counts anonymously, by URI. Costs, accepted: a record created while the backend was not receiving the pod's notifications is never listed (no backfill), and the catalog lists meeting points too, so its own total is not an event count. Verified end to end by `bun run test:e2e-rights` (groups catalog, discovery, visibility).
- **NextGraph (backend suspended): a curated shared-index document** — [[decision_2026-08-17_discovery-through-a-shared-index]] (concept `data-layer`), no code consumes it yet. An ordinary public document the app reaches by a reference it hardcodes; anyone may deposit a reference to their event, only the index's owner curates deposits into visible entries, ordered by start date. Its accepted costs: an event is findable only once **someone curates** the index (an operator role, no fixed schedule); an event declared before its document could carry the indexed field never becomes findable; a withdrawn or corrected event **stays listed**, so a reader must tolerate a reference that no longer resolves or resolves to something changed; an entry's position is **frozen at curation**.
> **Sign-up notification.** Signing up to a meeting point, and withdrawing, notifies its host. The intent is "identified if the participant is one of the host's connections, unnamed otherwise"; on ActivityPods it is **always unnamed** for now — the host has no read right over who signed up, and the app has no trustworthy contacts signal to decide otherwise ([[decision_2026-10-29_participation-privacy-mechanism]]).
> **Sign-up notification.** Signing up to a meeting point, and withdrawing, notifies its host; signing up directly to an event, and withdrawing, notifies its declarer the same way ([[decision_2026-11-06_direct-event-registration]]). The intent is "identified if the participant is one of the host's connections, unnamed otherwise"; on ActivityPods it is **always unnamed** for now — the host has no read right over who signed up, and the app has no trustworthy contacts signal to decide otherwise ([[decision_2026-10-29_participation-privacy-mechanism]]).
## Settled: the event write model is OWNER-ONLY
@@ -12,4 +12,4 @@ created: 2026-10-28
**Why:** La validation live du 2026-10-28 a montré que tout chemin de plateforme non exercé par une app de référence est un terrain miné : le provisioning holder-side navigateur (création de conteneur dans `as/collection` + PATCH `/_acl` avec un token de session Solid-OIDC `azp`) a révélé deux bugs fournisseurs (500 après création effective du conteneur ; hang sans écriture dans `webacl.resource.addRights`), alors que les chemins utilisés par les apps de référence fonctionnent en production. Aucune app de référence ne provisionne d'infrastructure depuis le frontend : toute écriture pod hors données (conteneur, ACL, groupe, collection) se fait **backend-side, signée app-actor** via `signature.proxy.query`.
**How to apply:** Références canoniques (2026-10-28, les mieux maintenues, ajoutées au dossier) : **`welcometomyplace`, `mutual-aid.app`, `portejunes`** — en priorité welcometomyplace, seule à exercer les patterns sociaux avancés. Secondaires : `mastopod` (access needs simples), `refine-providers/examples/antd-app` (frontend), et les services du app-framework (`activitypods/app-framework/app`). Avant toute écriture pod, vérifier le pattern dans ces sources ; s'il est absent des trois canoniques, ne pas coder : chercher une composition de patterns existants, et à défaut demander la décision utilisateur en nommant l'exception. Patterns welcometomyplace que Festipod réutilise : traiter un Join/Leave sur `onEmit` de l'outbox du participant (`registration.service.js`) ; collection lisible par un groupe = `pod-permissions.add({agentPredicate: 'acl:agentGroup', mode: 'acl:Read'})` sur une collection `pod-collections` (`attendees.service.js`), le groupe `/contacts` d'un utilisateur trouvé par `pod-wac-groups.getUriFromCollectionUri({collectionUri: apods:contacts})` — jamais un droit par élément de la liste `apods:contacts`, falsifiable par un `Accept` forgé ; notifier = `pod-notifications.send`, qui exige un backend en WebACL activé sur un dataset sécurisé, comme les trois apps de référence. Ne JAMAIS reprendre leur `addMember` de l'organisateur dans le groupe `/contacts` du participant : c'est un choix produit de welcometomyplace (événements privés), une fuite d'identité sous la règle de Festipod (`behavior_participation-listing-is-contacts-only`). Couvre l'usage de la plateforme, pas les choix produit propres à Festipod.
**How to apply:** Références canoniques (2026-10-28, les mieux maintenues, ajoutées au dossier) : **`welcometomyplace`, `mutual-aid.app`, `portejunes`** — en priorité welcometomyplace, seule à exercer les patterns sociaux avancés. Secondaires : `mastopod` (access needs simples), `refine-providers/examples/antd-app` (frontend), et les services du app-framework (`activitypods/app-framework/app`). Avant toute écriture pod, vérifier le pattern dans ces sources ; s'il est absent des trois canoniques, ne pas coder : chercher une composition de patterns existants, et à défaut demander la décision utilisateur en nommant l'exception. Patterns welcometomyplace que Festipod réutilise : traiter une activité sur `onEmit` de l'outbox de son émetteur (catalogue, contacts, listes de participants) ; exception nommée (2026-11-03, sur avis du développeur d'ActivityPods, `decision_2026-11-03_participation-delivered-to-the-app`) : un Join/Leave de participation est adressé à l'acteur de l'app et traité à sa réception dans l'inbox de l'app (`onReceive` de l'`ActivitiesHandlerMixin` de semapps, le chemin du framework pour l'installation d'une app, `registration.service.js`) ; collection lisible par un groupe = `pod-permissions.add({agentPredicate: 'acl:agentGroup', mode: 'acl:Read'})` sur une collection `pod-collections` (`attendees.service.js`), le groupe `/contacts` d'un utilisateur trouvé par `pod-wac-groups.getUriFromCollectionUri({collectionUri: apods:contacts})` — jamais un droit par élément de la liste `apods:contacts`, falsifiable par un `Accept` forgé ; notifier = `pod-notifications.send`, qui exige un backend en WebACL activé sur un dataset sécurisé, comme les trois apps de référence. Ne JAMAIS reprendre leur `addMember` de l'organisateur dans le groupe `/contacts` du participant : c'est un choix produit de welcometomyplace (événements privés), une fuite d'identité sous la règle de Festipod (`behavior_participation-listing-is-contacts-only`). Couvre l'usage de la plateforme, pas les choix produit propres à Festipod.
+1 -1
View File
@@ -1,6 +1,6 @@
# Festipod
Web app mobile-first où les utilisateurs créent des **points de rencontre** qui se *greffent* sur des **événements publics** existants, pour favoriser les rencontres. L'événement n'est qu'un prétexte/ancrage ; la valeur, c'est le point de rencontre — **on s'inscrit à un point de rencontre, pas à un événement**. Stack : Bun + React + NextGraph (P2P, local-first, chiffré).
Web app mobile-first où les utilisateurs créent des **points de rencontre** qui se *greffent* sur des **événements publics** existants, pour favoriser les rencontres. L'événement n'est qu'un prétexte/ancrage ; la valeur, c'est le point de rencontre — **on s'inscrit à un point de rencontre ou directement à un événement ; être inscrit à un de ses points de rencontre inscrit à l'événement**. Stack : Bun + React + NextGraph (P2P, local-first, chiffré).
## Invariants à toujours garder
+1 -1
View File
@@ -21,7 +21,7 @@ Tous les utilisateurs sont authentifiés — il n'y a pas d'accès anonyme à l'
### Concepts métier
- **Point de rencontre** — *l'unité de valeur de l'app*. Un moment de rencontre proposé par un hôte à un endroit et à un horaire donnés, greffé sur un événement public. C'est ce à quoi on s'inscrit (on ne s'inscrit pas à un événement). Sans points de rencontre, un événement Festipod n'a pas d'intérêt.
- **Point de rencontre** — *l'unité de valeur de l'app*. Un moment de rencontre proposé par un hôte à un endroit et à un horaire donnés, greffé sur un événement public. On s'inscrit à un point de rencontre ou directement à un événement ; être inscrit à un de ses points de rencontre inscrit à l'événement. Sans points de rencontre, un événement Festipod n'a pas d'intérêt.
- **Événement** — l'ancrage. Un événement public réel (festival, conférence, salon, exposition…) référencé dans Festipod pour servir de support à des points de rencontre. C'est simplement un *prétexte* (titre, dates, lieu, thèmes) ; le déclarant n'est pas l'organisateur officiel de l'événement, juste celui qui l'a inscrit dans Festipod.
- **Communauté d'intérêt** — un groupement thématique d'utilisateurs. Sert principalement à découvrir des événements (via abonnement) et à délimiter les périmètres de référencement.
- **Liste curated** — une liste d'événements éditorialisée (par un utilisateur ou une communauté), distincte de « les événements que j'ai déclarés » ou « les événements de la communauté ». Permet d'organiser/recommander.
+5 -2
View File
@@ -14,8 +14,11 @@
// `pim:storage` (verified 2026-10-12: PATCH → 204, the profile then lists both) — so it proves
// nothing about who owns a resource.
//
// `actorUri` must come from the server side — the actor whose outbox the webhook fired on — never
// from the activity's own `actor` field, which the emitting client writes.
// `actorUri` must come from the server side — the actor whose outbox a webhook fired on, or the
// signer the app's inbox verified for a delivered activity — never from a field nothing vouches for,
// such as a webhook body's `actor`, which the emitting client writes. One use takes an unvouched
// value as a HINT only: registration's `meetingPointOf` reads a meeting point in the pod its first
// copy names, and believes that pod only once the copy it serves names the same creator.
const WEB_PROTOCOLS = new Set(['http:', 'https:']);
+57 -10
View File
@@ -1,5 +1,7 @@
// What the backend may believe about an activity its watcher reports — the check in front of every
// Festipod handler fed by that watcher (public-read, catalog, attendees, contacts, registration).
// What the backend may believe about an activity its watcher reports, or a server delivers to the
// app's inbox — the check in front of every Festipod handler fed by that watcher (public-read,
// catalog, attendees, contacts) or by that inbox (registration). A delivered body, like a webhook
// body, is only a pointer.
//
// THE WEBHOOK IS A HINT, NOT A SOURCE. The webhook route (`/.webhooks/:uuid`) is unauthenticated: the
// listener only matches the uuid, the watcher derives the box owner from the body's `target`, and an
@@ -16,7 +18,11 @@
// activity must lie in that owner's pod and the stored activity must name that owner as `actor`;
// - an INBOX notification does not (`verifiedReceive`): the emitter is the stored activity's own
// `actor`, believed only because the activity lies in THAT actor's pod — someone else's claim
// about an actor cannot be stored there.
// about an actor cannot be stored there;
// - a DELIVERY to the app's own inbox names its emitter (`verifiedDelivery`): the inbox accepts it
// only signed by the activity's `actor` (@semapps/activitypub 1.2.7 `inbox.post` refuses an actor
// other than the signer, INVALID_ACTOR). Nothing else in the body is believed: the activity must
// lie in that actor's pod and the copy stored there name them.
//
// An activity is accepted only when all of this holds:
// 1. it is identified by a URI (an embedded activity without an id was never stored);
@@ -87,13 +93,14 @@ const ANY_POD = Symbol('any pod');
* { ok: true, activity, emitterUri, objectUri, object } (object: the stored object; undefined for a Delete)
* { ok: false, reason }
*
* `activity` is what the watcher handed over (a URI or a node — only its id is used). `type` is the
* activity type the handler expects. `emitterUri` is the owner of the outbox the notification names,
* or `null` for an inbox notification (the emitter is then the stored activity's actor). The object
* must lie in the pod of `objectOwnerUri`, or anywhere with `ANY_POD`. `objectType` is the type the
* stored object must declare — an ActivityStreams name or a full IRI; not checked for a Delete.
* `read(uri)` reads a resource as the box owner and resolves `{ status, body }` (`status` 0 when the
* request itself failed). Never throws for a refusal; a throwing `read` propagates.
* `activity` is what the watcher or the app's inbox handed over (a URI or a node — only its id is
* used). `type` is the activity type the handler expects. `emitterUri` is the owner of the outbox the
* notification names, the `actor` the app's inbox verified as the signer of a delivery, or `null` for
* an inbox notification (the emitter is then the stored activity's actor). The object must lie in the
* pod of `objectOwnerUri`, or anywhere with `ANY_POD`. `objectType` is the type the stored object
* must declare — an ActivityStreams name or a full IRI; not checked for a Delete. `read(uri)` reads a
* resource as the box owner — for a delivery, as its emitter — and resolves `{ status, body }`
* (`status` 0 when the request itself failed). Never throws for a refusal; a throwing `read` propagates.
*/
async function verifyActivity({ activity, type, emitterUri, objectOwnerUri, objectType, read }) {
if (!ACTIVITY_TYPES.has(type)) throw new Error(`Unsupported activity type ${type}`);
@@ -227,6 +234,45 @@ function verifiedReceive(type, handle, { objectType } = {}) {
);
}
/**
* An `onReceive` for the APP's own inbox (`@semapps/activitypub` `ActivitiesHandlerMixin`, whose
* third argument is the inbox owner, the app): runs `handle(ctx, verdict, emitterUri)` only for an
* activity `verifyActivity` accepts as emitted by the delivered activity's `actor`, the signer the
* inbox verified. Before anything is read, the activity's URI must lie in that actor's pod; the
* activity and its object are then read through that actor's pod (`podReader`), the stored activity
* having to name that same actor. `objectType` and `objectIn` are those of `verifiedEmit`. With
* `objectIn: 'any'`, an object outside the actor's pod is read through the proxy THEIR actor document
* declares — for an actor on another server, an answer that server chooses: a handler acting on such
* an object reads it again where it lies (registration's `meetingPointOf`).
*/
function verifiedDelivery(type, handle, { objectType, objectIn = 'emitter' } = {}) {
if (objectIn !== 'emitter' && objectIn !== 'any') throw new Error(`Unknown objectIn ${objectIn}`);
return async function (ctx, activity, inboxOwnerUri) {
try {
const claimed = activity && typeof activity === 'object' ? singleUriOf(activity.actor) : undefined;
if (!claimed) {
this.logger.warn(`${this.name} ignored a ${type} delivered to ${inboxOwnerUri}: it names no single actor`);
return;
}
const verdict = await verifyActivity({
activity,
type,
emitterUri: claimed,
objectOwnerUri: objectIn === 'any' ? ANY_POD : claimed,
objectType,
read: podReader(ctx, claimed)
});
if (!verdict.ok) {
this.logger.warn(`${this.name} ignored a ${type} delivered to ${inboxOwnerUri} as from ${claimed}: ${verdict.reason}`);
return;
}
await handle.call(this, ctx, verdict, verdict.emitterUri);
} catch (e) {
this.logger.error(`${this.name} could not process a ${type} delivered to ${inboxOwnerUri}: ${e.message}`);
}
};
}
module.exports = {
verifyActivity,
verifyEmittedActivity,
@@ -234,6 +280,7 @@ module.exports = {
neverThrowingMatcher,
verifiedEmit,
verifiedReceive,
verifiedDelivery,
uriOf,
singleUriOf,
hasAsType,
+93 -2
View File
@@ -1,5 +1,5 @@
const { test, expect, describe } = require('bun:test');
const { verifyEmittedActivity, verifyActivity, verifiedEmit, verifiedReceive, neverThrowingMatcher, ANY_POD } = require('./emitted-activity');
const { verifyEmittedActivity, verifyActivity, verifiedEmit, verifiedReceive, verifiedDelivery, neverThrowingMatcher, ANY_POD } = require('./emitted-activity');
const ALICE = 'http://localhost:3000/alice';
const BOB = 'http://localhost:3000/bob';
@@ -265,7 +265,7 @@ describe('verifiedReceive / verifiedEmit with objectIn "any"', () => {
});
});
describe('verifiedEmit — a Join from a participant\'s outbox (the registration flow)', () => {
describe('verifiedEmit — a Join from a participant\'s outbox', () => {
const MP = `${ALICE}/data/events/mp1`;
const JOIN = `${BOB}/outbox/join-1`;
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP };
@@ -298,3 +298,94 @@ describe('verifiedEmit — a Join from a participant\'s outbox (the registration
expect(service.logger.w).toContain('not attributed to');
});
});
describe("verifiedDelivery — a Join delivered to the app's inbox: the body's actor is a claim", () => {
const APP = 'http://localhost:3005/as/actor/app';
const MALLORY = 'http://localhost:3000/mallory';
const MP = `${ALICE}/data/events/mp1`;
const JOIN = `${BOB}/data/activities/join-1`;
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP, to: APP };
const meetingPoint = { id: MP, type: 'Event', context: `${ALICE}/data/events/e1` };
/** A pod answering `stored` (uri → body), recording every read as [uri, actorUri]. */
function setup(stored = { [JOIN]: join, [MP]: meetingPoint }) {
const reads = [];
const ctx = {
call: async (_action, { resourceUri, actorUri }) => {
reads.push([resourceUri, actorUri]);
const body = stored[resourceUri];
return body ? { status: 200, body } : { status: 404 };
}
};
const logs = { warn: [], error: [] };
const service = { name: 'test', logger: { info() {}, warn: m => logs.warn.push(m), error: m => logs.error.push(m) } };
const ran = [];
const deliver = (body, options = { objectType: 'Event', objectIn: 'any' }, handle = async (_ctx, verdict, emitterUri) => ran.push([emitterUri, verdict.object])) =>
verifiedDelivery('Join', handle, options).call(service, ctx, body, APP);
return { reads, logs, ran, deliver };
}
test("the normal case: the claimed actor's pod holds the activity naming them — the handler runs for them, on the STORED object, everything read through their pod", async () => {
const w = setup();
await w.deliver({ ...join, object: { id: `${ALICE}/data/events/other`, type: 'Event' } });
expect(w.ran).toEqual([[BOB, meetingPoint]]);
expect(w.reads).toEqual([[JOIN, BOB], [MP, BOB]]);
expect(w.logs.warn).toEqual([]);
});
test("a body claiming another actor for an activity of Bob's pod is refused before anything is read", async () => {
const w = setup();
await w.deliver({ ...join, actor: MALLORY });
expect(w.ran).toEqual([]);
expect(w.reads).toEqual([]);
expect(w.logs.warn.some(m => m.includes(`not in the pod of ${MALLORY}`))).toBe(true);
});
test("an activity held in Mallory's pod claiming Bob as its actor is refused before anything is read", async () => {
const forged = `${MALLORY}/data/activities/join-1`;
const w = setup({ [forged]: { ...join, id: forged }, [MP]: meetingPoint });
await w.deliver({ ...join, id: forged });
expect(w.ran).toEqual([]);
expect(w.reads).toEqual([]);
expect(w.logs.warn.some(m => m.includes(`not in the pod of ${BOB}`))).toBe(true);
});
test("an activity of Bob's pod whose stored copy names someone else is refused", async () => {
const w = setup({ [JOIN]: { ...join, actor: ALICE }, [MP]: meetingPoint });
await w.deliver(join);
expect(w.ran).toEqual([]);
expect(w.logs.warn.some(m => m.includes('not attributed to'))).toBe(true);
});
test('a body naming no single actor, or no body at all, is refused without a read', async () => {
for (const body of [{ ...join, actor: undefined }, { ...join, actor: [BOB, ALICE] }, JOIN, undefined]) {
const w = setup();
await w.deliver(body);
expect(w.ran).toEqual([]);
expect(w.reads).toEqual([]);
expect(w.logs.warn.some(m => m.includes('names no single actor'))).toBe(true);
}
});
test("an activity the claimed actor's pod does not hold is refused", async () => {
const w = setup({ [MP]: meetingPoint });
await w.deliver(join);
expect(w.ran).toEqual([]);
expect(w.logs.warn.some(m => m.includes('does not return the activity'))).toBe(true);
});
test('by default the object must lie in the emitter\'s pod; objectIn "any" lets the handler check it', async () => {
const w = setup();
await w.deliver(join, { objectType: 'Event' });
expect(w.ran).toEqual([]);
expect(w.logs.warn.some(m => m.includes(`not in the pod of ${BOB}`))).toBe(true);
});
test('a failing handler or read is logged, never thrown', async () => {
const w = setup();
await w.deliver(join, { objectType: 'Event', objectIn: 'any' }, async () => {
throw new Error('boom');
});
expect(w.logs.error.some(m => m.includes('boom'))).toBe(true);
});
});
+9 -4
View File
@@ -85,7 +85,7 @@ const MEMBER = 'http://festipod.org/attendanceMember';
* A collection (a key of `state.items`) answers a GET as the pod serves an unpaged collection: its
* `items` inline.
*/
function world({ stored = {}, items = {}, acls = {}, ledger = {}, delays = {}, fail = {}, timeoutMs } = {}) {
function world({ stored = {}, items = {}, acls = {}, ledger = {}, delays = {}, fail = {}, proxied = {}, timeoutMs } = {}) {
const calls = [];
const logs = { info: [], warn: [], error: [] };
const logger = { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) };
@@ -137,6 +137,11 @@ function world({ stored = {}, items = {}, acls = {}, ledger = {}, delays = {}, f
return { id: APP };
case 'pod-resources.get':
await wait(delays[`read:${params.resourceUri}`]);
// `proxied[reader][uri]`: what the reader's OWN server answers for a resource outside the
// reader's pod — the platform reads it through the proxy the reader's actor document declares.
if (proxied[params.actorUri] && params.resourceUri in proxied[params.actorUri] && !params.resourceUri.startsWith(`${params.actorUri}/`)) {
return { ok: true, status: 200, body: proxied[params.actorUri][params.resourceUri] };
}
return read(params.resourceUri);
case 'pod-collections.getItems': {
// As the platform does: the body's items, whatever the status — `[]` on a failed read.
@@ -263,10 +268,10 @@ function world({ stored = {}, items = {}, acls = {}, ledger = {}, delays = {}, f
}
for (const [name, fn] of Object.entries(registration.methods)) registrationSvc[name] = fn.bind(registrationSvc);
/** Notifies the registration service of an activity emitted in `actorUri`'s outbox. */
const emit = (key, hint, actorUri) => registration.activities[key].onEmit.call(registrationSvc, ctx, hint, actorUri);
/** Delivers an activity to the app's inbox, as the participant's pod does: the body is all the handler gets. */
const deliver = (key, body) => registration.activities[key].onReceive.call(registrationSvc, ctx, body, APP);
return { ctx, calls, state, logs, registration: registrationSvc, participations: participationsSvc, emit };
return { ctx, calls, state, logs, registration: registrationSvc, participations: participationsSvc, deliver };
}
module.exports = {
+84 -56
View File
@@ -1,11 +1,10 @@
const { ACTIVITY_TYPES, matchActivity } = require('@semapps/activitypub');
const { PodActivitiesHandlerMixin } = require('@activitypods/app');
const { ACTIVITY_TYPES, ActivitiesHandlerMixin, matchActivity } = require('@semapps/activitypub');
const { arrayOf } = require('@semapps/ldp');
const CONFIG = require('../config/config');
const { isInActorPod } = require('./actor-pod');
const { createKeyedSerializer } = require('./keyed-serializer');
const { isNewerAct, lastAttendance, persistMember, recordAttendance } = require('./attendance-ledger');
const { neverThrowingMatcher, verifiedEmit, singleUriOf, uriOf } = require('./emitted-activity');
const { neverThrowingMatcher, verifiedDelivery, singleUriOf, uriOf, hasAsType } = require('./emitted-activity');
const { participationSecretOf, memberUrn } = require('./member-urn');
const { readItems, expectItem } = require('./collection-items');
@@ -36,14 +35,13 @@ const firstStringOf = value =>
.find(v => typeof v === 'string' && v.trim().length > 0);
/**
* What a Join/Leave targets: the stored object must be a meeting point — an `as:Event` (checked by
* `verifiedEmit`) anchored to an event by `as:context` — whose creator is its `dc:creator`, believed
* only because the meeting point lies in that creator's pod (the pod stamps the creator, and nobody
* can store a resource in someone else's pod). Resolves `{ ok, eventUri, creatorUri }` or a refusal.
* What a Join/Leave targets: an `as:Event` whose `dc:creator` owns the pod it lies in. A meeting point
* is anchored to its event by `as:context`; an event signed up to directly is its own anchor. Only on
* the copy the creator's pod serves (`meetingPointOf`) is that creator believed; on the copy read
* through the participant's pod, it only names where to read that one. Resolves `{ ok, eventUri, creatorUri }` or a refusal.
*/
function meetingPointTarget(meetingPoint, meetingPointUri) {
const eventUri = linkOf(meetingPoint, 'context', 'as:context', `${AS}context`);
if (!eventUri) return { ok: false, reason: `the meeting point ${meetingPointUri} names no single as:context (its event)` };
const eventUri = linkOf(meetingPoint, 'context', 'as:context', `${AS}context`) ?? meetingPointUri;
const creatorUri = linkOf(meetingPoint, 'dc:creator', DC_CREATOR);
if (!creatorUri) return { ok: false, reason: `the meeting point ${meetingPointUri} names no single dc:creator` };
if (!isInActorPod(meetingPointUri, creatorUri)) {
@@ -53,10 +51,10 @@ function meetingPointTarget(meetingPoint, meetingPointUri) {
}
/**
* The notification template for the creator of a meeting point (`pod-notifications.send`, rendered in
* the creator's language): always ANONYMOUS, the meeting point named through `meetingPointName`, the
* link to its event's meeting points (`eventUri`). Every value travels as a template PARAMETER, never
* inside the template, so no user text is compiled.
* The notification template for the creator of a target (`pod-notifications.send`, rendered in the
* creator's language): always ANONYMOUS, linking to the event, or to its meeting points when the target
* is one. Every value travels as a template PARAMETER, never inside the template, so no user text is
* compiled.
*
* Never names the participant: naming would need a trustworthy signal that the creator is one of the
* participant's contacts, and the only one the app can read — the participant's `apods:contacts` list —
@@ -64,27 +62,26 @@ function meetingPointTarget(meetingPoint, meetingPointUri) {
* request checked; platform defect reported upstream). Until that list can be trusted, every
* notification is anonymous.
*/
function notificationTemplate(change) {
const title =
change === 'join'
? {
en: 'A participant joined your meeting point "{{{meetingPointName}}}"',
fr: 'Un participant a rejoint votre point de rencontre « {{{meetingPointName}}} »'
}
: {
en: 'A participant left your meeting point "{{{meetingPointName}}}"',
fr: 'Un participant a quitté votre point de rencontre « {{{meetingPointName}}} »'
};
return {
title,
actions: [{ caption: { en: 'View', fr: 'Voir' }, link: '/events/{{encodeUri eventUri}}/meeting-points' }]
function notificationTemplate(change, isMeetingPoint) {
const joined = change === 'join';
const nameParam = isMeetingPoint ? 'meetingPointName' : 'eventName';
const subject = isMeetingPoint
? { en: 'your meeting point', fr: 'votre point de rencontre' }
: { en: 'your event', fr: 'votre événement' };
const title = {
en: `A participant ${joined ? 'joined' : 'left'} ${subject.en} "{{{${nameParam}}}}"`,
fr: `Un participant a ${joined ? 'rejoint' : 'quitté'} ${subject.fr} « {{{${nameParam}}}} »`
};
const link = isMeetingPoint ? '/events/{{encodeUri eventUri}}/meeting-points' : '/events/{{encodeUri eventUri}}';
return { title, actions: [{ caption: { en: 'View', fr: 'Voir' }, link }] };
}
// Participation (decision_2026-10-29_participation-privacy-mechanism): a person joins (leaves) a
// meeting point with an `as:Join` (`as:Leave`) posted to their OWN outbox, naming the meeting point,
// addressed to no one — so only its author and the app read it. The creator's inbox receives nothing.
// The backend acts on EMISSION (`onEmit`, the welcometomyplace registration pattern), and records:
// Participation (decision_2026-10-29_participation-privacy-mechanism; its delivery:
// decision_2026-11-03_participation-delivered-to-the-app): a person joins (leaves) a meeting point with an `as:Join`
// (`as:Leave`) posted to their OWN outbox, naming the meeting point, addressed to the app actor alone —
// so only its author and the app read it. Their pod delivers it to the app's inbox; the creator's inbox
// receives nothing. The backend acts on RECEPTION in the app's inbox (`onReceive` of the semapps
// `ActivitiesHandlerMixin`, as the app framework handles an app registration), and records:
// - in the meeting point's `apods:attendees` collection (the creator's pod, acting as the creator):
// the participant's MEMBER URN, never their WebID — `member-urn.js`, computed from the secret in
// their profile. A Join whose author's profile carries no usable secret is refused, nothing written;
@@ -95,11 +92,15 @@ function notificationTemplate(change) {
// contact of the participant" signal, and the only one the app can read (the participant's
// `apods:contacts` list) is forgeable (platform defect reported upstream, see `notificationTemplate`).
// The creator has no other right on the participant: they learn a count and opaque URNs.
// The webhook that reports the act is a hint (emitted-activity.js): the handler acts only on the
// activity and the meeting point as the pod stores them, and only for the people they really name:
// - the participant is the owner of the outbox the webhook fired on, and the stored activity's actor;
// - the creator is the meeting point's `dc:creator`, accepted only when the meeting point lies in
// that creator's pod (`meetingPointTarget`);
// The delivery that reports the act is a pointer (emitted-activity.js, `verifiedDelivery`): the handler
// acts only on the activity and the meeting point as the pods store them, and only for the people they
// really name:
// - the participant is the delivered activity's `actor` — the app's inbox accepts a delivery only
// signed by its actor — and the activity must lie in that actor's pod, its stored copy naming them:
// any server may deliver, nobody acts for someone else;
// - the meeting point is the copy its creator's pod serves (`meetingPointOf`), never the one read
// through the participant's server; the creator is its `dc:creator`, accepted only when the meeting
// point lies in that creator's pod (`meetingPointTarget`);
// - the collections written to are the ones the STORED meeting point and the participant's STORED
// profile name, and must lie in the pod of whoever owns them.
// Joins and leaves of one meeting point are processed one at a time (keyed-serializer.js), and the
@@ -115,24 +116,27 @@ function notificationTemplate(change) {
// (the two writes are not atomic).
module.exports = {
name: 'registration',
mixins: [PodActivitiesHandlerMixin],
mixins: [ActivitiesHandlerMixin],
dependencies: ['participations'],
created() {
this.serialize = createKeyedSerializer();
},
async started() {
this.logger.info('Registration watcher subscribed');
this.logger.info("Registration listens to the Joins and Leaves delivered to the app's inbox");
},
methods: {
/**
* Handles one verified Join/Leave (`change`: 'join' | 'leave') of `participantUri`, the outbox
* owner: resolves the meeting point and the participant, applies the act, then notifies the
* Handles one verified Join/Leave (`change`: 'join' | 'leave') of `participantUri`, its verified
* emitter: resolves the meeting point and the participant, applies the act, then notifies the
* creator when the act changed the participant's state. A refusal is logged; nothing is written.
*/
async onParticipation(ctx, change, { activity, objectUri: meetingPointUri, object: meetingPoint }, participantUri) {
async onParticipation(ctx, change, { activity, objectUri: meetingPointUri, object: claimedMeetingPoint }, participantUri) {
const refuse = reason => this.logger.warn(`${this.name} refused the ${change} ${uriOf(activity)} of ${participantUri}: ${reason}`);
const target = meetingPointTarget(meetingPoint, meetingPointUri);
const claimed = meetingPointTarget(claimedMeetingPoint, meetingPointUri);
if (!claimed.ok) return refuse(claimed.reason);
const target = await this.meetingPointOf(ctx, meetingPointUri, claimed.creatorUri);
if (!target.ok) return refuse(target.reason);
const { meetingPoint } = target;
const participant = await this.participantOf(ctx, participantUri);
if (!participant.ok) return refuse(participant.reason);
const outcome = await this.updateAttendance(ctx, {
@@ -158,6 +162,28 @@ module.exports = {
});
}
},
/**
* The meeting point as the pod that holds it serves it. The verification read it through the
* participant's pod — for a participant on another server, through the proxy THEIR actor document
* declares, which answers what it wants (another meeting point's list, another name). Only the
* creator's pod speaks for the meeting point: it is read again there, through the creator's access
* (a GET to that pod, signed by the app), and the act uses that copy alone. It must still be an
* `as:Event` anchored by `as:context` whose `dc:creator` is that same creator. Resolves
* `{ ok, meetingPoint, eventUri, creatorUri }` or a refusal.
*/
async meetingPointOf(ctx, meetingPointUri, creatorUri) {
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: meetingPointUri, actorUri: creatorUri });
if (status !== 200 || !body || uriOf(body) !== meetingPointUri) {
return { ok: false, reason: `the pod of ${creatorUri} does not return the meeting point ${meetingPointUri} (HTTP ${status})` };
}
if (!hasAsType(body, 'Event')) return { ok: false, reason: `the meeting point ${meetingPointUri} its pod returns is not an Event` };
const target = meetingPointTarget(body, meetingPointUri);
if (!target.ok) return target;
if (target.creatorUri !== creatorUri) {
return { ok: false, reason: `the meeting point ${meetingPointUri} names ${target.creatorUri} as its creator in its pod, not ${creatorUri}` };
}
return { ok: true, meetingPoint: body, eventUri: target.eventUri, creatorUri };
},
/**
* The participant as their pod describes them, read as the participant: their actor document
* (its `url` — the `as:Profile`, which must lie in their pod — and `apods:contacts`), then the
@@ -262,7 +288,7 @@ module.exports = {
}
const collectionUri = await this.attendeesCollectionOf(ctx, meetingPoint, meetingPointUri, creatorUri);
if (!collectionUri) {
this.logger.warn(`No attendees collection on ${meetingPointUri}, skipping ${change}`);
this.logger.error(`${meetingPointUri} has no attendees collection: the ${change} ${act.activityUri} of ${participantUri} is NOT recorded, the next act retries`);
return undefined;
}
if (signal.aborted) return undefined;
@@ -333,25 +359,27 @@ module.exports = {
await expectItem(ctx, { collectionUri, itemUri: meetingPointUri, actorUri: participantUri, present: joining });
},
/**
* Tells the creator of the meeting point that a participant joined or left it — ALWAYS anonymously.
* Tells the creator of the target (a meeting point or an event) that a participant joined or left
* it — ALWAYS anonymously.
* The participant is never named: the only membership signal the app can read (the participant's
* `apods:contacts` list) is forgeable, so naming cannot be trusted (platform defect reported
* upstream, see `notificationTemplate`). The notification carries no `activity`: given one,
* `pod-notifications.send` fetches the emitter's profile. A creator acting on their own meeting
* point is not notified. A failure is logged, never thrown: the act stands.
* point or event is not notified. A failure is logged, never thrown: the act stands.
*/
async notifyCreator(ctx, { change, meetingPointUri, meetingPoint, eventUri, creatorUri, participantUri }) {
const isMeetingPoint = eventUri !== meetingPointUri;
try {
if (creatorUri === participantUri) {
this.logger.info(`${participantUri} acted on their own meeting point ${meetingPointUri}: no notification`);
this.logger.info(`${participantUri} acted on their own ${meetingPointUri}: no notification`);
return;
}
this.logger.info(`Notifying ${creatorUri} of a ${change} on ${meetingPointUri}, anonymously`);
await ctx.call('pod-notifications.send', {
template: notificationTemplate(change),
template: notificationTemplate(change, isMeetingPoint),
recipientUri: creatorUri,
context: meetingPointUri,
meetingPointName: firstStringOf(meetingPoint.name ?? meetingPoint['as:name'] ?? meetingPoint[`${AS}name`]) ?? '',
[isMeetingPoint ? 'meetingPointName' : 'eventName']: firstStringOf(meetingPoint.name ?? meetingPoint['as:name'] ?? meetingPoint[`${AS}name`]) ?? '',
eventUri
});
} catch (e) {
@@ -364,12 +392,12 @@ module.exports = {
match: neverThrowingMatcher(function (activity, fetcher) {
return matchActivity(JOIN_PATTERN, activity, fetcher);
}),
// The participant's outbox: `actorUri` is the participant; the meeting point may lie in any pod
// (its creator's — checked by `meetingPointTarget`).
onEmit: verifiedEmit(
// Delivered to the app's inbox: the participant is the verified emitter; the meeting point may lie
// in any pod (its creator's — checked by `meetingPointTarget`).
onReceive: verifiedDelivery(
'Join',
async function (ctx, verdict, actorUri) {
await this.onParticipation(ctx, 'join', verdict, actorUri);
async function (ctx, verdict, participantUri) {
await this.onParticipation(ctx, 'join', verdict, participantUri);
},
{ objectType: 'Event', objectIn: 'any' }
)
@@ -378,10 +406,10 @@ module.exports = {
match: neverThrowingMatcher(function (activity, fetcher) {
return matchActivity(LEAVE_PATTERN, activity, fetcher);
}),
onEmit: verifiedEmit(
onReceive: verifiedDelivery(
'Leave',
async function (ctx, verdict, actorUri) {
await this.onParticipation(ctx, 'leave', verdict, actorUri);
async function (ctx, verdict, participantUri) {
await this.onParticipation(ctx, 'leave', verdict, participantUri);
},
{ objectType: 'Event', objectIn: 'any' }
)
+265 -22
View File
@@ -45,8 +45,8 @@ function setup({ acts = [], stored = {}, ...options } = {}) {
return world({ stored: { ...MEETING_POINTS, ...Object.fromEntries(acts.map(a => [a.id, a])), ...stored }, ...options });
}
const emitJoin = (w, act, actorUri = act.actor) => w.emit('join', act, actorUri);
const emitLeave = (w, act, actorUri = act.actor) => w.emit('leave', act, actorUri);
const emitJoin = (w, act) => w.deliver('join', act);
const emitLeave = (w, act) => w.deliver('leave', act);
const attendees = (w, mp = MP1) => w.state.items[attendeesOf(mp)] || [];
const participationsUri = (w, who = BOB) => {
const link = w.state.stored[profileOf(who)][FP_PARTICIPATIONS];
@@ -84,7 +84,7 @@ const protectedAcl = (uri, group = groupOf(BOB)) => [
{ '@id': `http://localhost:3000/_acl${new URL(uri).pathname}#Control`, 'acl:mode': 'acl:Control', 'acl:agent': APP }
];
describe("registration — a Join emitted in the participant's outbox", () => {
describe("registration — a Join delivered to the app's inbox", () => {
test('the normal case: the member URN joins the list, the meeting point joins the participant\'s collection, the act is recorded', async () => {
const j = join(MP1);
const w = setup({ acts: [j] });
@@ -105,20 +105,59 @@ describe("registration — a Join emitted in the participant's outbox", () => {
expect(w.logs.error).toEqual([]);
});
test('the webhook body is a hint: its actor and its embedded object are ignored', async () => {
test('the delivered body is a pointer: its embedded object is ignored, the stored meeting point is used', async () => {
const j = join(MP1);
const w = setup({ acts: [j] });
await emitJoin(w, { ...j, actor: MALLORY, object: { id: MP2, type: 'Event', 'apods:attendees': `${MALLORY}/data/c` } }, BOB);
await emitJoin(w, { ...j, object: { id: MP2, type: 'Event', 'apods:attendees': `${MALLORY}/data/c` } });
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(attendees(w, MP2)).toEqual([]);
expect(w.state.items[`${MALLORY}/data/c`]).toBeUndefined();
});
test('the act is handled on emission only: the creator\'s inbox has no handler', () => {
expect(registration.activities.join.onReceive).toBeUndefined();
expect(registration.activities.leave.onReceive).toBeUndefined();
expect(typeof registration.activities.join.onEmit).toBe('function');
expect(typeof registration.activities.leave.onEmit).toBe('function');
test("nobody joins or leaves for someone else: a delivery claiming Mallory for Bob's activity, or Bob for Mallory's, writes nothing", async () => {
const j = join(MP1);
const forged = { ...join(MP1, T0, MALLORY), actor: BOB };
const w = setup({ acts: [j, forged] });
await emitJoin(w, { ...j, actor: MALLORY });
await emitJoin(w, forged);
expect(writes(w.calls)).toEqual([]);
expect(attendees(w)).toEqual([]);
});
test("the meeting point is the one its creator's pod serves, not the copy the participant's server hands over", async () => {
// Bob's server answers the reads made through him: for MP1 (in Alice's pod), a forged copy
// naming MP2's attendee list and a name of its choosing.
const j = join(MP1);
const forged = meetingPoint(MP1, { name: 'Forged title', 'apods:attendees': attendeesOf(MP2) });
const w = setup({ acts: [j], proxied: { [BOB]: { [MP1]: forged } } });
await emitJoin(w, j);
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(attendees(w, MP2)).toEqual([]);
expect(w.state.notifications.map(n => n.meetingPointName)).toEqual(['Rendez-vous mp1']);
expect(w.calls.some(c => c.action === 'pod-resources.get' && c.params.resourceUri === MP1 && c.params.actorUri === ALICE)).toBe(true);
});
test("a meeting point its creator's pod does not serve, or serves under another creator, writes nothing", async () => {
const j = join(MP1);
for (const options of [
{ fail: { read: { [MP1]: 404 } }, proxied: { [BOB]: { [MP1]: meetingPoint(MP1) } }, contains: `does not return the meeting point ${MP1}` },
{ stored: { [MP1]: meetingPoint(MP1, { 'dc:creator': CAROL }) }, proxied: { [BOB]: { [MP1]: meetingPoint(MP1) } }, contains: `not in the pod of its creator ${CAROL}` },
{ stored: { [MP1]: meetingPoint(MP1, { type: 'Note' }) }, proxied: { [BOB]: { [MP1]: meetingPoint(MP1) } }, contains: 'is not an Event' }
]) {
const { contains, stored = {}, ...rest } = options;
const w = setup({ acts: [j], stored, ...rest });
await emitJoin(w, j);
expect(writes(w.calls)).toEqual([]);
expect(w.logs.warn.some(m => m.includes(contains))).toBe(true);
}
});
test("the act is handled on reception in the app's inbox only: no outbox handler", () => {
expect(registration.mixins).toContain(require('@semapps/activitypub').ActivitiesHandlerMixin);
expect(registration.activities.join.onEmit).toBeUndefined();
expect(registration.activities.leave.onEmit).toBeUndefined();
expect(typeof registration.activities.join.onReceive).toBe('function');
expect(typeof registration.activities.leave.onReceive).toBe('function');
});
});
@@ -146,11 +185,6 @@ describe('registration — refusals write nothing and record nothing', () => {
await refused(setup({ acts: [j], stored: { [MP1]: { ...meetingPoint(MP1), type: 'Note' } } }), j, { contains: 'is not a Event' });
});
test('an Event with no as:context (no event to anchor the URN)', async () => {
const j = join(MP1);
await refused(setup({ acts: [j], stored: { [MP1]: meetingPoint(MP1, { context: undefined }) } }), j, { contains: 'as:context' });
});
test('a meeting point not in the pod of its dc:creator, or naming no creator', async () => {
const j = join(MP1);
await refused(setup({ acts: [j], stored: { [MP1]: meetingPoint(MP1, { 'dc:creator': MALLORY }) } }), j, { contains: 'not in the pod of its creator' });
@@ -661,7 +695,7 @@ describe('registration — a participant who rewrites their profile secret betwe
// THE DEFECT the sticky member fixes: a Join whose list add lands but a later step fails is left
// unrecorded; the participant rewrites their secret and joins again — that must not list a second URN.
const onEmitWith = (w, ctx, key, act) => registration.activities[key].onEmit.call(w.registration, ctx, act, act.actor);
const deliverWith = (w, ctx, key, act) => registration.activities[key].onReceive.call(w.registration, ctx, act, APP);
test('partial failure (the pod refuses the participations write after the list add) + secret rewrite + Join: ONE URN; a Leave empties the list', async () => {
const j1 = join(MP1, T0, BOB, 1);
@@ -697,13 +731,13 @@ describe('registration — a participant who rewrites their profile secret betwe
return w.ctx.call(action, params);
}
};
await onEmitWith(w, ctx, 'join', j1);
await deliverWith(w, ctx, 'join', j1);
expect(attendees(w)).toEqual([urnOf(BOB)]);
expect(ledgerOf(w).activityUri).toBeUndefined();
rotate(w, BOB, secretN(5));
await onEmitWith(w, ctx, 'join', j2);
await deliverWith(w, ctx, 'join', j2);
expect(attendees(w)).toEqual([urnOf(BOB)]);
await onEmitWith(w, ctx, 'leave', l);
await deliverWith(w, ctx, 'leave', l);
expect(attendees(w)).toEqual([]);
});
@@ -722,15 +756,15 @@ describe('registration — a participant who rewrites their profile secret betwe
return w.ctx.call(action, params);
}
};
const first = onEmitWith(w, ctx, 'join', j1); // abandoned at 80 ms, its member write still in flight
const first = deliverWith(w, ctx, 'join', j1); // abandoned at 80 ms, its member write still in flight
await new Promise(resolve => setTimeout(resolve, 100));
rotate(w, BOB, secretN(6));
await onEmitWith(w, ctx, 'join', j2); // persists, lists and records the NEW secret's URN
await deliverWith(w, ctx, 'join', j2); // persists, lists and records the NEW secret's URN
await first;
await new Promise(resolve => setTimeout(resolve, 150));
expect(attendees(w)).toEqual([urnWith(BOB, secretN(6))]);
expect(ledgerOf(w).member).toBe(urnWith(BOB, secretN(6))); // the late write was a no-op
await onEmitWith(w, ctx, 'leave', l);
await deliverWith(w, ctx, 'leave', l);
expect(attendees(w)).toEqual([]);
});
@@ -808,3 +842,212 @@ describe('attendance-ledger — the order of acts', () => {
expect(() => lastAttendanceQuery(MP1, BOB)).not.toThrow();
});
});
describe('registration — a direct sign-up: a Join naming an event itself', () => {
const DAVE = ALICE.replace('/alice', '/dave');
const PLAIN = `${ALICE}/data/events/plain`;
const plainEvent = (uri, extra = {}) => meetingPoint(uri, { context: undefined, name: `Evénement ${uri.split('/').pop()}`, ...extra });
const plainWorld = (acts, options = {}) => setup({ acts, stored: { [PLAIN]: plainEvent(PLAIN) }, ...options });
const plainUrn = (who = BOB, event = PLAIN) => memberUrn({ participantUri: who, eventUri: event, secret: SECRETS[who] });
test("the member URN, computed with the event as anchor, joins the event's own list and the event joins the participant's collection", async () => {
const j = join(PLAIN);
const w = plainWorld([j]);
await emitJoin(w, j);
expect(attendees(w, PLAIN)).toEqual([plainUrn()]);
expect(attendees(w, PLAIN)).not.toContain(BOB);
const add = w.calls.find(c => c.action === 'attendees.add');
expect(add.params).toEqual({ collectionUri: attendeesOf(PLAIN), itemUri: plainUrn(), actorUri: ALICE });
expect(participations(w)).toEqual([PLAIN]);
const added = w.calls.find(c => c.action === 'pod-collections.add');
expect(added.params).toEqual({ collectionUri: participationsUri(w), itemUri: PLAIN, actorUri: BOB });
expect(ledgerOf(w, PLAIN)).toEqual({ activityUri: j.id, published: T0, change: 'join', member: plainUrn() });
expect(w.logs.error).toEqual([]);
});
test('a Join on an event with no attendees collection is NOT recorded, loudly logged — nothing written, nothing notified', async () => {
const j = join(PLAIN);
const w = setup({ acts: [j], stored: { [PLAIN]: plainEvent(PLAIN, { 'apods:attendees': undefined }) } });
await emitJoin(w, j);
expect(w.logs.error.some(m => m.includes('NOT recorded') && m.includes('no attendees collection'))).toBe(true);
expect(writes(w.calls)).toEqual([]);
expect(ledgerOf(w, PLAIN)).toBeUndefined();
expect(participations(w)).toEqual([]);
});
const readsOf = (w, uri) => w.calls.filter(c => ['pod-resources.get', 'pod-collections.getItems'].includes(c.action) && (c.params.resourceUri ?? c.params.collectionUri) === uri);
const expectAnonymous = (w, n) => {
expect(n.recipientUri).toBe(ALICE);
expect(JSON.stringify(n.template)).not.toContain('participantName');
expect(n).not.toHaveProperty('participantName');
expect(n).not.toHaveProperty('activity');
expect(JSON.stringify(n)).not.toContain(BOB);
expect(readsOf(w, contactsOf(BOB))).toEqual([]);
};
test("a direct sign-up notifies the event's declarer, anonymously", async () => {
const j = join(PLAIN);
const w = plainWorld([j]);
await emitJoin(w, j);
expect(attendees(w, PLAIN)).toEqual([plainUrn()]);
expect(w.state.notifications).toHaveLength(1);
const [n] = w.state.notifications;
expectAnonymous(w, n);
expect(n.template.title.fr).toContain('Un participant a rejoint');
expect(n.template.title.en).toContain('A participant joined');
expect(n.eventUri).toBe(PLAIN);
expect(Object.values(n)).toContain('Evénement plain');
expect(JSON.stringify(n.template)).not.toContain('Evénement plain');
expect(n.template.title.fr).toContain('{{{');
expect(n.template.actions).toHaveLength(1);
expect(n.template.actions[0].link).toContain('/events/{{encodeUri eventUri}}');
expect(n.template.actions[0].link).not.toContain('meeting-points');
});
test('a Leave naming the event notifies the declarer, anonymously', async () => {
const j = join(PLAIN);
const l = leave(PLAIN);
const w = plainWorld([j, l]);
await emitJoin(w, j);
await emitLeave(w, l);
expect(w.state.notifications).toHaveLength(2);
const n = w.state.notifications[1];
expectAnonymous(w, n);
expect(n.template.title.fr).toContain('Un participant a quitté');
expect(n.template.title.en).toContain('A participant left');
expect(n.eventUri).toBe(PLAIN);
});
test('a repeated direct Join notifies once; a Leave of someone never signed up notifies no one', async () => {
const first = join(PLAIN, T0, BOB, 1);
const again = join(PLAIN, T1, BOB, 2);
const stray = leave(PLAIN, T1, CAROL, 1);
const w = plainWorld([first, again, stray]);
await emitJoin(w, first);
await emitJoin(w, again);
expect(w.state.notifications).toHaveLength(1);
await emitLeave(w, stray);
expect(w.state.notifications).toHaveLength(1);
});
test('a failed declarer notification does not fail the direct sign-up', async () => {
const j = join(PLAIN);
const w = plainWorld([j], { fail: { notify: true } });
await emitJoin(w, j);
expect(ledgerOf(w, PLAIN).change).toBe('join');
expect(attendees(w, PLAIN)).toEqual([plainUrn()]);
expect(participations(w)).toEqual([PLAIN]);
expect(w.logs.error.some(m => m.includes('Could not notify'))).toBe(true);
});
test('leaving the event and its meeting point notifies the declarer and the host, each once', async () => {
const DAVE_EVENT = `${DAVE}/data/events/festival`;
const acts = [join(DAVE_EVENT), join(MP1), leave(DAVE_EVENT, T1), leave(MP1, T1)];
const w = setup({
acts,
stored: {
[DAVE_EVENT]: plainEvent(DAVE_EVENT, { 'dc:creator': DAVE }),
[MP1]: meetingPoint(MP1, { context: DAVE_EVENT })
}
});
await emitJoin(w, acts[0]);
await emitJoin(w, acts[1]);
w.state.notifications.length = 0;
await emitLeave(w, acts[2]);
await emitLeave(w, acts[3]);
expect(w.state.notifications).toHaveLength(2);
expect(w.state.notifications.map(n => n.recipientUri).sort()).toEqual([ALICE, DAVE].sort());
for (const n of w.state.notifications) {
expect(n.template.title.fr).toContain('Un participant a quitté');
expect(n).not.toHaveProperty('participantName');
expect(JSON.stringify(n)).not.toContain(BOB);
}
});
test('a declarer signing up to, then leaving, their own event is not notified', async () => {
const j = join(PLAIN, T0, ALICE);
const l = leave(PLAIN, T1, ALICE);
const w = plainWorld([j, l]);
await emitJoin(w, j);
await emitLeave(w, l);
expect(attendees(w, PLAIN)).toEqual([]);
expect(ledgerOf(w, PLAIN, ALICE).change).toBe('leave');
expect(w.state.notifications).toEqual([]);
});
test('the same person carries a different URN on another event (R7e)', async () => {
const OTHER = `${ALICE}/data/events/other`;
const a = join(PLAIN, T0, BOB, 1);
const b = join(OTHER, T0, BOB, 2);
const w = setup({ acts: [a, b], stored: { [PLAIN]: plainEvent(PLAIN), [OTHER]: plainEvent(OTHER) } });
await emitJoin(w, a);
await emitJoin(w, b);
expect(attendees(w, PLAIN)).toEqual([plainUrn(BOB, PLAIN)]);
expect(attendees(w, OTHER)).toEqual([plainUrn(BOB, OTHER)]);
expect(attendees(w, PLAIN)[0]).not.toBe(attendees(w, OTHER)[0]);
});
test("the participant carries the SAME URN in the event's own list and in its meeting points' lists (R5, R6)", async () => {
const festival = plainEvent(EVENT1, { 'dc:creator': DAVE });
const acts = [join(EVENT1), join(MP1), join(MP2)];
const w = setup({ acts, stored: { [EVENT1]: festival } });
for (const a of acts) await emitJoin(w, a);
expect(attendees(w, EVENT1)).toEqual([urnOf(BOB, MP1)]);
expect(attendees(w, MP1)).toEqual(attendees(w, EVENT1));
expect(attendees(w, MP2)).toEqual(attendees(w, EVENT1));
expect(participations(w).sort()).toEqual([EVENT1, MP1, MP2].sort());
});
test('signing up twice leaves one entry in the list and one in the collection (R1)', async () => {
const first = join(PLAIN, T0, BOB, 1);
const again = join(PLAIN, T1, BOB, 2);
const w = plainWorld([first, again]);
await emitJoin(w, first);
await emitJoin(w, again);
expect(attendees(w, PLAIN)).toEqual([plainUrn()]);
expect(participations(w)).toEqual([PLAIN]);
expect(w.logs.error).toEqual([]);
});
test("a Leave naming the event removes the URN from its list and the event from the participant's collection (R8)", async () => {
const j = join(PLAIN);
const l = leave(PLAIN);
const w = plainWorld([j, l]);
await emitJoin(w, j);
await emitLeave(w, l);
expect(attendees(w, PLAIN)).toEqual([]);
expect(participations(w)).toEqual([]);
expect(ledgerOf(w, PLAIN).change).toBe('leave');
});
test("leaving the event leaves the participant's meeting point memberships alone, and the other way round", async () => {
const acts = [join(PLAIN), join(MP1), leave(PLAIN, T1)];
const w = setup({ acts, stored: { [PLAIN]: plainEvent(PLAIN) } });
await emitJoin(w, acts[0]);
await emitJoin(w, acts[1]);
expect(participations(w).sort()).toEqual([PLAIN, MP1].sort());
await emitLeave(w, acts[2]);
expect(attendees(w, MP1)).toEqual([urnOf(BOB, MP1)]);
expect(participations(w)).toEqual([MP1]);
});
test('a stored object that is not an Event stays refused', async () => {
const j = join(PLAIN);
const w = plainWorld([j], { stored: { [PLAIN]: { ...plainEvent(PLAIN), type: 'Note' } } });
await emitJoin(w, j);
expect(writes(w.calls)).toEqual([]);
expect(w.logs.warn.length).toBeGreaterThan(0);
});
test("an event not in the pod of its dc:creator, or naming no creator, stays refused", async () => {
const j = join(PLAIN);
for (const extra of [{ 'dc:creator': MALLORY }, { 'dc:creator': undefined }]) {
const w = plainWorld([j], { stored: { [PLAIN]: plainEvent(PLAIN, extra) } });
await emitJoin(w, j);
expect(writes(w.calls)).toEqual([]);
expect(attendees(w, PLAIN)).toEqual([]);
expect(w.logs.warn.length).toBeGreaterThan(0);
}
});
});
+103 -10
View File
@@ -12,8 +12,9 @@
* meeting point through her app and Bob joins it by tapping (the forged-contact
* group) — so EVERY participation goes the way the app does it: the app writes
* the participant's secret on their profile with their app-session token (the
* data provider's update), then posts the Join to their own outbox with no
* addressee (decision_2026-10-29_participation-privacy-mechanism). Carol (a
* data provider's update), then posts the Join to their own outbox, addressed
* to the app actor alone (decision_2026-10-29_participation-privacy-mechanism,
* decision_2026-11-03_participation-delivered-to-the-app). Carol (a
* non-contact) and Bob (the creator) read the event through their own app.
* Direct HTTP with each user's own token for the access-control lines (faster,
* and precise about statuses). Carol installs the app programmatically (the
@@ -117,12 +118,14 @@ import { tmpdir } from 'node:os';
import { join as joinPath } from 'node:path';
import { chromium, type Browser, type Page } from 'playwright';
import {
appActorUri,
attendeesCollectionUri,
linkId,
MEMBER_URN_PREFIX,
memberUrnOf,
participationSecretOf,
participationsCollectionOf,
postOutboxActivity,
recordUri,
asRecord,
type ApodsSession,
@@ -781,9 +784,14 @@ async function ownActivity(session: ApodsSession, type: 'Join' | 'Leave', object
return items.find(a => typeIs(a, type) && linkId(a['object']) === object);
}
/** The addressing fields an activity carries — a participation activity must carry none. */
/** The addressees an activity names, as `field=uri` — a participation activity names the app actor alone, in `to`. */
const ADDRESSING = ['to', 'cc', 'bto', 'bcc', 'audience', 'as:to', 'as:cc', 'as:bto', 'as:bcc', 'as:audience'];
const addressingOf = (activity: JsonRecord): string[] => ADDRESSING.filter(k => activity[k] !== undefined);
const addressingOf = (activity: JsonRecord): string[] =>
ADDRESSING.flatMap(k => arrayOf(activity[k]).map(v => `${k.replace(/^as:/, '')}=${linkId(v) ?? JSON.stringify(v)}`));
const addressedToTheAppAlone = (activity: JsonRecord): boolean => {
const addressees = addressingOf(activity);
return addressees.length === 1 && addressees[0] === `to=${BACKEND}/as/actor/app`;
};
/**
* The pod's refusal of a COLLECTION: 401/403, or its 404 — which counts as one
@@ -910,15 +918,14 @@ async function join(): Promise<void> {
return { ok: !!back && own === sent, actual: `${reads.length} app-token read(s) after the PUT; secret returned: ${!!back} under the key ${JSON.stringify(key)}; her own read: ${own === sent ? 'same secret' : own ? 'ANOTHER secret' : 'none'}` };
});
// THE JOIN: in her own outbox, addressed to no one; only she reads it.
await m.hard('join.activity-shape', G, "Alice reads her own outbox: the Join names M and is addressed to NO ONE (not public, not the creator)", 'one Join, actor Alice, object M, no to/cc/bto/bcc/audience', async () => {
// THE JOIN: in her own outbox, addressed to the app actor alone; only she and the app read it.
await m.hard('join.activity-shape', G, "Alice reads her own outbox: the Join names M and is addressed to the app actor ALONE (not public, not the creator)", 'one Join, actor Alice, object M, to = the app actor, no other addressee', async () => {
const alice = need(S.alice, 'Alice');
const M = need(S.M, 'M');
const act = await poll(() => ownActivity(alice, 'Join', M), v => !!v, 15000);
if (!act) return { ok: false, actual: `no Join naming M in her outbox` };
S.aliceJoinM = recordUri(act);
const addressed = addressingOf(act);
return { ok: linkId(act['actor']) === alice.webId && addressed.length === 0 && !!S.aliceJoinM, actual: `${S.aliceJoinM}: actor ${linkId(act['actor'])}, object ${linkId(act['object'])}, addressing fields: ${addressed.join(',') || 'none'}` };
return { ok: linkId(act['actor']) === alice.webId && addressedToTheAppAlone(act) && !!S.aliceJoinM, actual: `${S.aliceJoinM}: actor ${linkId(act['actor'])}, object ${linkId(act['object'])}, addressees: ${addressingOf(act).join(',') || 'none'}` };
});
await m.hard('join.activity-author-reads', G, 'CONTROL: Alice (the author) GETs her Join', '200', async () => {
const r = await http('GET', need(S.aliceJoinM, "Alice's Join (join.activity-shape)"), { token: need(S.alice, 'Alice').token });
@@ -1101,9 +1108,9 @@ async function leave(): Promise<void> {
await mpCard(page, TITLE_M).getByRole('button', { name: /✓ Inscrit/ }).waitFor({ timeout: 20000 }).catch(() => {});
return tapMeetingPoint(page, TITLE_M, /✓ Inscrit/, /^S'inscrire$/);
});
await m.hard('leave.activity-shape', G, "Alice reads her own outbox: the Leave names M and is addressed to no one", 'a Leave, object M, no addressing field', async () => {
await m.hard('leave.activity-shape', G, "Alice reads her own outbox: the Leave names M and is addressed to the app actor alone", 'a Leave, object M, to = the app actor, no other addressee', async () => {
const act = await poll(() => ownActivity(need(S.alice, 'Alice'), 'Leave', need(S.M, 'M')), v => !!v, 15000);
return { ok: !!act && addressingOf(act).length === 0, actual: act ? `${recordUri(act)}: addressing fields: ${addressingOf(act).join(',') || 'none'}` : 'no Leave naming M in her outbox' };
return { ok: !!act && addressedToTheAppAlone(act), actual: act ? `${recordUri(act)}: addressees: ${addressingOf(act).join(',') || 'none'}` : 'no Leave naming M in her outbox' };
});
await m.hard('leave.collection', G, "Alice's member URN disappears from M's attendee list", 'items ∌ her URN within 30 s (read 200)', async () => {
need(S.joined.M, 'the join on M landed (join.collection)');
@@ -1286,6 +1293,90 @@ async function leaveAfterContact(): Promise<void> {
});
}
/**
* A direct sign-up to the event itself (FP-001): Alice posts a Join naming E, the way
* the app does; her participations collection then lists E, under the same audience as
* a meeting point membership — her /contacts group only.
*/
async function directSignup(): Promise<void> {
const G = 'direct sign-up to E (Alice; Bob her contact, Carol not)';
const E = (): string => need(S.E, 'E');
const listsE = async (): Promise<{ ok: boolean; items: string[] }> => {
const alice = need(S.alice, 'Alice');
const uri = need(await ownParticipations(alice), 'her participations collection');
const r = await refIn(uri, E(), true, alice.token);
return { ok: r.ok, items: r.items };
};
await m.hard('direct.join', G, 'Alice posts a Join naming E, to the app actor alone; her own participations collection lists E', 'items ∋ E within 30 s (her read)', async () => {
await postOutboxActivity(need(S.alice, 'Alice'), 'Join', E(), appActorUri(config));
const r = await listsE();
return { ok: r.ok, actual: `items=${JSON.stringify(r.items)}` };
});
await m.hard('direct.non-contact-refused', G, "a direct sign-up is not listable by a non-contact: Carol is refused Alice's participations collection although it now lists E (URI handed out of band)", 'refused (403, or the pod\'s 404 while Alice reads it 200)', async () => {
const alice = need(S.alice, 'Alice');
const listed = await listsE();
need(listed.ok, 'the direct sign-up landed (direct.join)');
const refusal = await collectionRefusal(need(await ownParticipations(alice), 'her participations collection'), need(S.carol, 'Carol').token, alice, AUTHN);
return { ok: refusal.ok, actual: refusal.actual };
});
await m.hard('direct.contact-lists', G, "a contact lists the direct sign-ups: Bob, her contact, reads Alice's participations collection (linked from her profile) and finds E", '200, items ∋ E', async () => {
need(S.contacts, 'the contact pair (contact.accepted)');
const profile = need(await profileUriOf(need(S.alice, 'Alice').webId), "the profile Alice's public actor document links");
const link = need(participationsCollectionOf(parseJson((await http('GET', profile, { token: need(S.bob, 'Bob').token })).text)), "the participations link on Alice's profile, read by Bob");
const r = await poll(() => http('GET', link, { token: need(S.bob, 'Bob').token }), v => v.status === 200 && itemUris(parseJson(v.text)).includes(E()), 30000);
const items = itemUris(parseJson(r.text));
return { ok: r.status === 200 && items.includes(E()), actual: `HTTP ${r.status} items=${JSON.stringify(items)}` };
});
await m.hard('direct.leave', G, 'Alice posts a Leave naming E: her participations collection no longer lists it (the group leaves the event as it found it)', 'items ∌ E within 30 s (her read)', async () => {
const alice = need(S.alice, 'Alice');
await postOutboxActivity(alice, 'Leave', E(), appActorUri(config));
const uri = need(await ownParticipations(alice), 'her participations collection');
const r = await refIn(uri, E(), false, alice.token);
return { ok: r.ok, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` };
});
}
/**
* A direct sign-up to an event whose declarer is NOT the participant's contact
* (FP-001, R6/R7): Alice signs up to Carol's event CE, Carol being no contact of
* hers. The declarer is told, anonymously, and gains no right to read the act, the
* list or the participations. Runs once CE exists (forged.setup-carol-meeting-point).
*/
async function directSignupToDeclared(): Promise<void> {
const G = "direct sign-up to CE (Alice; Carol declared CE and is not Alice's contact)";
const alice = (): ApodsSession => need(S.alice, 'Alice');
const carol = (): ApodsSession => need(S.carol, 'Carol');
const CE = (): string => need(S.CE, "Carol's event CE");
const noticed = (change: 'join' | 'leave'): Promise<CheckOutcome> =>
notifiedLine(CE(), change, false, { creator: carol(), participant: alice(), name: USERS.alice });
await m.hard('direct.notify-anonymous', G, 'Alice posts a Join naming CE: Carol, its declarer, is notified in her own inbox, naming neither her name nor her WebID', 'a join notification about CE naming neither the display name nor the WebID, within 30 s', async () => {
await postOutboxActivity(alice(), 'Join', CE(), appActorUri(config));
return noticed('join');
});
await m.hard('direct.activity-declarer-refused', G, "Carol — CE's declarer, not her contact — GETs Alice's Join naming CE (its URI handed out of band)", 'refused 403', async () => {
const act = need(await poll(() => ownActivity(alice(), 'Join', CE()), v => !!v, 15000), "Alice's Join naming CE");
const r = await http('GET', need(recordUri(act), "the Join's URI"), { token: carol().token });
return { ok: refused(r.status, AUTHN), actual: brief(r).slice(0, 80) };
});
await m.hard('direct.list-opaque', G, "CE's own attendee list (anonymous read) holds Alice's member URN, computed from her secret with CE as anchor, and no WebID", 'her URN ∈ items, her WebID ∉ items', async () => {
const list = need(await attachedCollection(CE(), carol()), "CE's attendees collection");
const urn = await ownMemberUrn(alice(), CE());
const r = await refIn(list, urn, true);
return { ok: r.ok && !r.items.includes(alice().webId), actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` };
});
await m.hard('direct.declarer-refused-participations', G, "Carol, the declarer and no contact of Alice, is refused Alice's participations collection although it lists CE (URI handed out of band)", 'refused (403, or the pod\'s 404 while Alice reads it 200)', async () => {
const uri = need(await ownParticipations(alice()), 'her participations collection');
const listed = await refIn(uri, CE(), true, alice().token);
need(listed.ok, 'the direct sign-up landed (direct.notify-anonymous)');
const refusal = await collectionRefusal(uri, carol().token, alice(), AUTHN);
return { ok: refusal.ok, actual: refusal.actual };
});
await m.hard('direct.leave-notify-anonymous', G, 'Alice posts a Leave naming CE (the group leaves the event as it found it): Carol is notified of the withdrawal, anonymously', 'a leave notification about CE naming neither the display name nor the WebID, within 30 s', async () => {
await postOutboxActivity(alice(), 'Leave', CE(), appActorUri(config));
return noticed('leave');
});
}
/**
* Who reads a person's participations is her /contacts WAC group, through ONE
@@ -1840,8 +1931,10 @@ async function cleanupAccounts(): Promise<void> {
await contactChannel();
await afterContact();
await leaveAfterContact();
await directSignup();
await visibility();
await forgedContact();
await directSignupToDeclared();
await contactChanges();
} finally {
await uiHygiene();
@@ -32,8 +32,8 @@ Fonctionnalité: Smoke e2e de la pile ActivityPods locale
# L'app s'installe sur le Pod (consentement de l'agent d'autorisation, ou pour un
# scénario sans navigateur l'endpoint d'enregistrement — voir l'étape
# « installée sur le Pod »). Une fois installée, l'activité `as:Join` postée sur
# l'outbox de l'utilisateur, sans destinataire, est traitée par le backend à
# l'émission : il inscrit dans la collection `apods:attendees` du point de
# l'outbox de l'utilisateur, adressée à l'acteur de l'app seul, est livrée dans
# l'inbox de l'app et traitée par le backend à sa réception : il inscrit dans la collection `apods:attendees` du point de
# rencontre l'URN de membre dérivée du secret de participation du profil
# (écrit avant le Join, comme le fait l'app) — jamais le WebID
# (decision_2026-10-29_participation-privacy-mechanism). Le point de rencontre est créé dans le
@@ -59,3 +59,96 @@ Fonctionnalité: Smoke e2e de la pile ActivityPods locale
Quand la session rejoint le point de rencontre
Alors le compteur du point de rencontre compte cette inscription
Et la liste des participants rend ce participant comme anonyme
# --- Inscription directe à un événement (FP-001) ---
#
# Un `as:Join` dont l'objet est l'événement lui-même suit le même canal qu'un Join de
# point de rencontre : l'URN de membre (ancrée sur l'événement) entre dans la
# collection `apods:attendees` de l'événement, et l'URI de l'événement dans la
# collection des participations de la session. L'inscription à l'événement est
# dérivée : directe, ou membre d'un de ses points de rencontre. Chaque scénario tourne sur un compte
# jetable, créé puis supprimé (Pod compris) même en cas d'échec : rien ne reste dans
# le pod d'un compte persistant.
@inscription-evenement
Scénario: Rejoindre puis quitter un événement met à jour sa collection apods:attendees
Étant donné une session Pod jetable avec le mot de passe "Test1test"
Et l'application Festipod est installée sur le Pod de la session
Et un événement "Concert direct" avec sa collection apods:attendees
Quand la session s'inscrit à l'événement
Alors la collection apods:attendees de l'événement contient la session
Et la collection des participations de la session contient l'événement
Quand la session se désinscrit de l'événement
Alors la collection apods:attendees de l'événement ne contient plus la session
Et la collection des participations de la session ne contient plus l'événement
Et une relecture ultérieure ne réinscrit pas la session
@inscription-evenement
Scénario: L'inscription directe et l'inscription à un point de rencontre partagent la même URN de membre
Étant donné une session Pod jetable avec le mot de passe "Test1test"
Et l'application Festipod est installée sur le Pod de la session
Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees
Et l'événement a aussi sa collection apods:attendees
Quand la session s'inscrit à l'événement
Et la session rejoint le point de rencontre
Alors la collection apods:attendees de l'événement contient la session
Et la collection apods:attendees contient la session
Et l'URN de la session est la même dans la liste de l'événement et dans celle du point de rencontre
@inscription-evenement
Scénario: Quitter le dernier point de rencontre sans inscription directe désinscrit de l'événement
Étant donné une session Pod jetable avec le mot de passe "Test1test"
Et l'application Festipod est installée sur le Pod de la session
Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees
Quand la session rejoint le point de rencontre
Alors la session est inscrite à l'événement
Quand la session quitte le point de rencontre
Alors la session n'est plus inscrite à l'événement
Et la collection des participations de la session ne contient pas l'événement
@inscription-evenement
Scénario: Quitter le dernier point de rencontre garde l'inscription directe
Étant donné une session Pod jetable avec le mot de passe "Test1test"
Et l'application Festipod est installée sur le Pod de la session
Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees
Et l'événement a aussi sa collection apods:attendees
Quand la session s'inscrit à l'événement
Et la session rejoint le point de rencontre
Et la session quitte le point de rencontre
Alors la collection des participations de la session ne contient plus le point de rencontre
Et la session est inscrite à l'événement
Et la collection apods:attendees de l'événement contient la session
@inscription-evenement
Scénario: Se désinscrire de l'événement retire l'inscription directe et celles des points de rencontre
Étant donné une session Pod jetable avec le mot de passe "Test1test"
Et l'application Festipod est installée sur le Pod de la session
Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees
Et l'événement a aussi sa collection apods:attendees
Quand la session s'inscrit à l'événement
Et la session rejoint le point de rencontre
Et la session quitte l'événement et chacun de ses points de rencontre
Alors la session n'est plus inscrite à l'événement
Et la collection apods:attendees de l'événement ne contient plus la session
Et la collection apods:attendees ne contient plus la session
Et une relecture ultérieure ne réinscrit pas la session
@browser @inscription-evenement
Scénario: Parcours : s'inscrire à un événement, rejoindre un point de rencontre, se désinscrire de l'événement avec confirmation
Étant donné une session Pod jetable avec le mot de passe "Test1test"
Et l'application Festipod est installée sur le Pod de la session
Et un point de rencontre "Rdv parcours" rattaché à un événement, avec sa collection apods:attendees
Et l'événement a aussi sa collection apods:attendees
Et la session est connectée dans l'application avec le mot de passe "Test1test"
Quand dans l'application, j'ouvre l'événement
Et dans l'application, j'appuie sur "S'inscrire à l'événement"
Alors l'application affiche "✓ Inscrit à l'événement"
Quand dans l'application, j'appuie sur "S'inscrire" du point de rencontre "Rdv parcours"
Et dans l'application, j'appuie sur "Se désinscrire de l'événement"
Alors l'application affiche "Se désinscrire de l'événement ?"
Et l'application affiche "Rdv parcours"
Quand dans l'application, j'appuie sur "Confirmer la désinscription"
Alors l'application affiche "S'inscrire à l'événement"
Quand je recharge l'application
Alors l'application affiche "S'inscrire à l'événement"
Et la session n'est plus inscrite à l'événement
@@ -15,9 +15,72 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
Étant donné que je suis sur la page "détail événement"
Alors l'écran affiche les informations de l'événement
Scénario: Voir le bouton d'inscription sur l'écran
Étant donné que je suis sur la page "détail événement"
Alors je peux m'inscrire à l'événement
Scénario: Voir le bouton d'inscription à l'événement
Étant donné que je suis sur le détail de l'événement "Formation CNV"
Alors je vois le bouton "S'inscrire à l'événement"
Et je ne vois pas le bouton "Se désinscrire de l'événement"
Scénario: Un inscrit direct voit son inscription à l'événement
Étant donné que je suis sur le détail de l'événement "Atelier low-tech"
Alors je vois le bouton "Se désinscrire de l'événement"
Et l'écran affiche "✓ Inscrit à l'événement"
Et je ne vois pas le bouton "S'inscrire à l'événement"
Scénario: Être inscrit à un point de rencontre affiche l'inscription à l'événement
Étant donné que je suis sur le détail de l'événement "Rencontre des Colibris"
Alors l'écran affiche "✓ Inscrit à l'événement"
Et je vois le bouton "Se désinscrire de l'événement"
Et je ne vois pas le bouton "S'inscrire à l'événement"
Scénario: Se désinscrire d'un événement avec points de rencontre demande confirmation
Étant donné que je suis sur le détail de l'événement "Rencontre des Colibris"
Quand j'appuie sur "Se désinscrire de l'événement"
Alors une fenêtre de confirmation "Se désinscrire de l'événement ?" est ouverte
Et la fenêtre de confirmation propose "Confirmer la désinscription" et "Annuler"
Scénario: La confirmation de désinscription précise les points de rencontre quittés
Étant donné que je suis sur le détail de l'événement "Rencontre des Colibris"
Quand j'appuie sur "Se désinscrire de l'événement"
Alors la fenêtre de confirmation liste les points de rencontre "Covoiturage depuis la gare" et "Départ à pied de la place"
Et la fenêtre de confirmation avertit qu'ils seront aussi quittés
Scénario: Annuler la confirmation ne change rien
Étant donné que je suis sur le détail de l'événement "Rencontre des Colibris"
Quand j'appuie sur "Se désinscrire de l'événement"
Et j'appuie sur "Annuler"
Alors aucune fenêtre de confirmation n'est ouverte
Et l'écran affiche "✓ Inscrit à l'événement"
Et je suis toujours inscrit aux points de rencontre "Covoiturage depuis la gare" et "Départ à pied de la place"
Scénario: Se désinscrire d'un événement sans point de rencontre ne demande pas de confirmation
Étant donné que je suis sur le détail de l'événement "Atelier low-tech"
Quand j'appuie sur "Se désinscrire de l'événement"
Alors aucune fenêtre de confirmation n'est ouverte
Scénario: Quitter un point de rencontre ne demande pas de confirmation
Étant donné que je suis sur le détail de l'événement "Rencontre des Colibris"
Quand j'appuie sur le bouton "✓ Inscrit" d'un point de rencontre
Alors aucune fenêtre de confirmation n'est ouverte
Et je vois le bouton "Se désinscrire de l'événement"
Scénario: Un contact inscrit directement apparaît parmi les participants de l'événement
Étant donné que je suis sur le détail de l'événement "Résidence Reconnexion"
Alors la liste des participants nomme "Jean Durand"
Scénario: Un participant inscrit directement qui n'est pas un contact n'est que compté
Étant donné que je suis sur le détail de l'événement "Formation CNV"
Alors la liste des participants compte des participants inconnus
Et la liste des participants ne nomme pas "user-hors-reseau"
Plan du Scénario: Le bouton Inviter suit l'inscription à l'événement, directe ou par un point de rencontre
Étant donné que je suis sur le détail de l'événement "<événement>"
Alors le bouton "Inviter" est <visibilité>
Exemples:
| événement | visibilité |
| Rencontre des Colibris | affiché |
| Atelier low-tech | affiché |
| Formation CNV | absent |
Scénario: Rechercher un événement existant
Étant donné que je suis sur la page "découvrir"
+78 -20
View File
@@ -1,6 +1,6 @@
import { useLayoutEffect } from 'react';
import { useLayoutEffect, useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Button, Avatar, EventCover, EventMeetingPoints, showToast, Text, type MeetingPointData } from '../../../shared/components/sketchy';
import { Button, Avatar, ConfirmDialog, EventCover, EventMeetingPoints, showToast, Text, type MeetingPointData } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { eventPath, useNavigate, useParams, userPath } from '../../../app/router';
@@ -11,8 +11,11 @@ export function EventDetailScreen() {
const {
getEvent,
isParticipating,
getEventRegistration,
joinMeetingPoint,
leaveMeetingPoint,
joinEvent,
leaveEvent,
getEventParticipants,
getEventMeetingPoints,
getEventOwnership,
@@ -42,15 +45,17 @@ export function EventDetailScreen() {
participantCount: mp.participantCount,
}));
// SIGNING UP IS PER MEETING POINT — the product model (on s'inscrit à un
// point de rencontre, pas à un événement). There is no event-level join on
// this screen anymore: "joined" means a member of at least one of this
// event's meeting points, and it gates the Invite affordance the way the
// old event-level membership did.
// Signing up is per meeting point, or directly to the event: the registration
// to the event is derived by the data layer from both, and it gates the
// Invite affordance.
const joinedMeetingPointIds = new Set(
meetingPointsRaw.filter(mp => isParticipating(mp.id)).map(mp => String(mp.id)),
);
const joined = joinedMeetingPointIds.size > 0;
const registration = eventId ? getEventRegistration(eventId) : undefined;
const registered = registration?.registered ?? false;
const [confirmingLeave, setConfirmingLeave] = useState(false);
const [joining, setJoining] = useState(false);
const [leaving, setLeaving] = useState(false);
const handleToggleMeetingPoint = (id: string, _title: string, willJoin: boolean) => {
const failed = (message: string) => (err: unknown) => {
@@ -66,6 +71,40 @@ export function EventDetailScreen() {
}
};
const handleJoinEvent = () => {
if (!eventId || joining) return;
setJoining(true);
Promise.resolve(joinEvent(eventId))
.catch((err: unknown) => {
console.error('[EventDetail] event sign-up failed:', err);
showToast("L'inscription à l'événement n'a pas pu être enregistrée", 'error');
})
.finally(() => setJoining(false));
};
const withdrawFromEvent = () => {
if (!eventId) return;
setLeaving(true);
Promise.resolve(leaveEvent(eventId))
.catch((err: unknown) => {
console.error('[EventDetail] event withdrawal failed:', err);
showToast("La désinscription de l'événement n'a pas pu être enregistrée", 'error');
})
.finally(() => {
setLeaving(false);
setConfirmingLeave(false);
});
};
const handleLeaveEvent = () => {
if (registration && registration.meetingPointIds.length > 0) setConfirmingLeave(true);
else withdrawFromEvent();
};
const leftMeetingPointTitles = meetingPointsRaw
.filter(mp => registration?.meetingPointIds.includes(mp.id))
.map(mp => mp.title || mp.location);
// EDITING IS OWNING. Only a document's owner writes to it, and nothing delegates
// a write, so "may I edit this event" is answered entirely by whether this
// session owns the event's document — no permission call, no extra read.
@@ -165,19 +204,26 @@ export function EventDetailScreen() {
)}
</div>
{/* SIGNING UP IS PER MEETING POINT: no event-level join button here. The
join affordances live on the meeting points below; Invite follows
membership, the way the old event-level "Je participe" did. */}
{joined && (
<div style={{ margin: '4px 16px 16px', display: 'flex', gap: 8 }}>
<Button
onClick={() => navigate(eventPath(event.id, 'invite'))}
style={{ flex: 1, padding: '12px 0' }}
>
Inviter
<div style={{ margin: '4px 16px 16px', display: 'flex', flexDirection: 'column', gap: 8 }}>
{registered ? (
<>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--app-accent-dark)' }}>✓ Inscrit à l'événement</div>
<Button onClick={handleLeaveEvent} disabled={leaving} style={{ padding: '12px 0' }}>
Se désinscrire de l'événement
</Button>
<Button
onClick={() => navigate(eventPath(event.id, 'invite'))}
style={{ padding: '12px 0' }}
>
Inviter
</Button>
</>
) : (
<Button variant="primary" onClick={handleJoinEvent} disabled={joining} style={{ padding: '12px 0' }}>
S'inscrire à l'événement
</Button>
</div>
)}
)}
</div>
{meetingPoints.length > 0 && (
<div style={{ padding: '0 16px 8px' }}>
@@ -244,6 +290,18 @@ export function EventDetailScreen() {
)}
</div>
</div>
{confirmingLeave && (
<ConfirmDialog
title="Se désinscrire de l'événement ?"
confirmLabel="Confirmer la désinscription"
cancelLabel="Annuler"
onConfirm={withdrawFromEvent}
onCancel={() => setConfirmingLeave(false)}
busy={leaving}
>
Vous êtes inscrit aux points de rencontre suivants : {leftMeetingPointTitles.join(', ')}. En confirmant, vous en serez aussi désinscrit.
</ConfirmDialog>
)}
</div>
);
}
@@ -14,6 +14,7 @@ import {
participationSecretOf,
podLogin,
postOutboxActivity,
appActorUri,
readAttendees,
readResource,
recordUri,
@@ -279,15 +280,16 @@ When('la session rejoint le point de rencontre', async function (this: FestipodW
// decision_2026-10-29_participation-privacy-mechanism): the participation
// secret on my profile first (the backend refuses a Join whose author's
// profile carries none), then an as:Join naming the meeting point, posted to
// my OWN outbox with NO addressee — not public, not addressed to the creator.
// my OWN outbox and addressed to the app actor alone — not public, not
// addressed to the creator (decision_2026-11-03_participation-delivered-to-the-app).
await ensureParticipationSecret(pod);
await postOutboxActivity(pod, 'Join', mpUri);
await postOutboxActivity(pod, 'Join', mpUri, appActorUri(apodsStackConfig()));
});
When('la session quitte le point de rencontre', async function (this: FestipodWorld) {
const pod = session(this);
const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');
await postOutboxActivity(pod, 'Leave', mpUri);
await postOutboxActivity(pod, 'Leave', mpUri, appActorUri(apodsStackConfig()));
});
/**
@@ -0,0 +1,255 @@
import { After, Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
import type { ApodsSession } from '../../../../shared/support/activitypodsStack';
import {
apodsStackConfig,
appActorUri,
attendeesCollectionUri,
ensureParticipationSecret,
linkId,
memberUrnOf,
participationSecretOf,
participationsCollectionOf,
podLogin,
postOutboxActivity,
readAttendees,
readResource,
} from '../../../../shared/support/activitypodsStack';
import { driveLogin, type DriveContext } from '../../../../../scripts/e2e-rights-matrix-support';
const JSON_CONTEXT = 'https://activitypods.org/context.json';
const POLL_MS = 500;
function session(world: FestipodWorld): ApodsSession {
if (!world.apods.session) throw new Error('No Pod session — a "session Pod locale" step must run first');
return world.apods.session;
}
function required(value: string | undefined, what: string): string {
if (!value) throw new Error(`${what} is unset — a prior step must have created it`);
return value;
}
const pause = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
async function until<T>(read: () => Promise<T>, done: (value: T) => boolean, timeoutMs = 20000): Promise<T> {
const end = Date.now() + timeoutMs;
let value = await read();
while (!done(value) && Date.now() < end) {
await pause(POLL_MS);
value = await read();
}
return value;
}
async function attachedCollection(pod: ApodsSession, resourceUri: string): Promise<string> {
const found = await until(
async () => attendeesCollectionUri(await readResource(pod, resourceUri)),
uri => uri !== undefined,
);
if (!found) throw new Error(`${resourceUri} never gained an apods:attendees collection`);
return found;
}
async function memberUrnAtEvent(world: FestipodWorld): Promise<string> {
const pod = session(world);
const profileUri = required(linkId((await readResource(pod, pod.webId))['url']), "the session's profile");
const secret = required(participationSecretOf(await readResource(pod, profileUri)), "the session's participation secret");
return memberUrnOf(pod.webId, required(world.apods.anchorUri, 'event URI'), secret);
}
async function membersOf(pod: ApodsSession, collectionUri: string): Promise<string[]> {
return (await readAttendees(pod, collectionUri)).map(linkId).filter((id): id is string => typeof id === 'string');
}
async function participationsOf(pod: ApodsSession): Promise<string[]> {
const profileUri = linkId((await readResource(pod, pod.webId))['url']);
const collection = profileUri ? participationsCollectionOf(await readResource(pod, profileUri)) : undefined;
return collection ? membersOf(pod, collection) : [];
}
function targets(world: FestipodWorld): string[] {
return [world.apods.anchorUri, world.apods.meetingPointUri].filter((uri): uri is string => uri !== undefined);
}
async function expectMember(world: FestipodWorld, collectionUri: string, urn: string, present: boolean): Promise<void> {
const pod = session(world);
const members = await until(() => membersOf(pod, collectionUri), found => found.includes(urn) === present);
expect(members.includes(urn), present ? 'the member URN is listed' : 'the member URN is gone').to.equal(present);
expect(members, 'a list never holds a WebID').to.not.include(pod.webId);
}
async function expectParticipation(world: FestipodWorld, uri: string, present: boolean): Promise<void> {
const pod = session(world);
const items = await until(() => participationsOf(pod), found => found.includes(uri) === present);
expect(items.includes(uri), present ? `${uri} is in my participations` : `${uri} is not in my participations`).to.equal(present);
}
async function registered(world: FestipodWorld): Promise<boolean> {
const items = await participationsOf(session(world));
return targets(world).some(uri => items.includes(uri));
}
/**
* The scenarios run on a throwaway account the run creates and deletes: nothing they
* write (events, meeting points, activities, secrets) may stay in a persistent
* account's pod (rule_tests-never-break-the-dev-environment). Deleting the account
* is the cleanup — the platform drops its dataset — and it runs whatever failed.
*/
const POD_PASSWORD = 'Test1test';
async function podApi(path: string, init: RequestInit): Promise<Response> {
return fetch(`${apodsStackConfig().podProviderUrl}${path}`, { ...init, signal: AbortSignal.timeout(30000) });
}
async function deleteAccount(username: string, token: string | undefined): Promise<void> {
let bearer = token;
if (!bearer) {
// The sign-up may have succeeded before the login failed: log in again to get a token.
const login = await podApi('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password: POD_PASSWORD }),
}).catch(() => undefined);
if (!login?.ok) return;
bearer = ((await login.json().catch(() => ({}))) as { token?: string }).token;
}
if (!bearer) return;
await podApi(`/.account/${username}`, { method: 'DELETE', headers: { Authorization: `Bearer ${bearer}` } }).catch(() => undefined);
}
Given('une session Pod jetable avec le mot de passe {string}', async function (this: FestipodWorld, password: string) {
const username = `fpjet${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
expect(password, 'the cleanup logs in with the shared test password').to.equal(POD_PASSWORD);
this.apods.username = username; // recorded before the sign-up so the After hook deletes it even if the login fails
this.apods.session = await podLogin(apodsStackConfig(), username, password);
});
After({ tags: '@inscription-evenement', timeout: 120000 }, async function (this: FestipodWorld) {
const username = this.apods.username;
if (!username) return;
await deleteAccount(username, this.apods.session?.token);
});
Given('un événement {string} avec sa collection apods:attendees', async function (this: FestipodWorld, title: string) {
const pod = session(this);
const container = `${pod.storage}/as/event`;
const response = await fetch(container, {
method: 'POST',
headers: { 'Content-Type': 'application/ld+json', Accept: 'application/ld+json', Authorization: `Bearer ${pod.token}` },
body: JSON.stringify({ '@context': JSON_CONTEXT, type: 'Event', name: title, startTime: '2026-11-01T18:00:00Z' }),
});
const location = response.headers.get('Location') ?? response.headers.get('location');
if (!response.ok || !location) throw new Error(`Create in ${container} failed: HTTP ${response.status}`);
this.apods.anchorUri = location;
this.apods.eventCollectionUri = await attachedCollection(pod, location);
});
Given("l'événement a aussi sa collection apods:attendees", async function (this: FestipodWorld) {
this.apods.eventCollectionUri = await attachedCollection(session(this), required(this.apods.anchorUri, 'event URI'));
});
When("la session s'inscrit à l'événement", async function (this: FestipodWorld) {
const pod = session(this);
await ensureParticipationSecret(pod);
await postOutboxActivity(pod, 'Join', required(this.apods.anchorUri, 'event URI'), appActorUri(apodsStackConfig()));
});
When("la session se désinscrit de l'événement", async function (this: FestipodWorld) {
await postOutboxActivity(session(this), 'Leave', required(this.apods.anchorUri, 'event URI'), appActorUri(apodsStackConfig()));
});
When("la session quitte l'événement et chacun de ses points de rencontre", async function (this: FestipodWorld) {
const pod = session(this);
const to = appActorUri(apodsStackConfig());
await Promise.all(targets(this).map(uri => postOutboxActivity(pod, 'Leave', uri, to)));
});
Then("la collection apods:attendees de l'événement contient la session", async function (this: FestipodWorld) {
await expectMember(this, required(this.apods.eventCollectionUri, "the event's attendees collection"), await memberUrnAtEvent(this), true);
});
Then("la collection apods:attendees de l'événement ne contient plus la session", async function (this: FestipodWorld) {
await expectMember(this, required(this.apods.eventCollectionUri, "the event's attendees collection"), await memberUrnAtEvent(this), false);
});
Then("la collection des participations de la session contient l'événement", async function (this: FestipodWorld) {
await expectParticipation(this, required(this.apods.anchorUri, 'event URI'), true);
});
Then(/^la collection des participations de la session ne contient (?:plus|pas) l'événement$/, async function (this: FestipodWorld) {
await expectParticipation(this, required(this.apods.anchorUri, 'event URI'), false);
});
Then('la collection des participations de la session ne contient plus le point de rencontre', async function (this: FestipodWorld) {
await expectParticipation(this, required(this.apods.meetingPointUri, 'meeting point URI'), false);
});
Then("la session est inscrite à l'événement", async function (this: FestipodWorld) {
const found = await until(() => registered(this), value => value);
expect(found, 'the session is registered to the event, directly or through a meeting point').to.be.true;
});
Then("la session n'est plus inscrite à l'événement", async function (this: FestipodWorld) {
const found = await until(() => registered(this), value => !value);
expect(found, 'the session is registered to neither the event nor any of its meeting points').to.be.false;
});
Then("l'URN de la session est la même dans la liste de l'événement et dans celle du point de rencontre", async function (this: FestipodWorld) {
const pod = session(this);
const urn = await memberUrnAtEvent(this);
const inEvent = await membersOf(pod, required(this.apods.eventCollectionUri, "the event's attendees collection"));
const inMeetingPoint = await membersOf(pod, required(this.apods.collectionUri, "the meeting point's attendees collection"));
expect(inEvent).to.include(urn);
expect(inMeetingPoint).to.include(urn);
});
Then('une relecture ultérieure ne réinscrit pas la session', async function (this: FestipodWorld) {
await pause(3000);
expect(await registered(this), 'a later read does not register the session back').to.be.false;
const urn = await memberUrnAtEvent(this);
const pod = session(this);
expect(await membersOf(pod, required(this.apods.eventCollectionUri, "the event's attendees collection"))).to.not.include(urn);
});
// ── The journey, in the real app ───────────────────────────────────────────
const stackUrl = (value: string | undefined, fallback: string) => (value || fallback).replace(/\/+$/, '');
Given('la session est connectée dans l\'application avec le mot de passe {string}', async function (this: FestipodWorld, password: string) {
const username = required(this.apods.username, 'the throwaway account name');
const ctx: DriveContext = {
app: required(this.apods.appUrl, 'app URL'),
podFront: stackUrl(process.env.POD_FRONTEND_URL, 'http://localhost:5000'),
hops: [],
errors: [],
consoleErrors: [],
pageErrors: [],
deadline: Date.now() + 5 * 60 * 1000,
};
const outcome = await driveLogin(this.page!, username, username, password, ctx);
expect(outcome.ok, outcome.detail).to.be.true;
});
When('dans l\'application, j\'ouvre l\'événement', async function (this: FestipodWorld) {
const url = `${required(this.apods.appUrl, 'app URL')}/events/${encodeURIComponent(required(this.apods.anchorUri, 'event URI'))}`;
await this.page!.goto(url, { waitUntil: 'domcontentloaded' });
});
When('dans l\'application, j\'appuie sur {string}', async function (this: FestipodWorld, label: string) {
await this.page!.getByRole('button', { name: label, exact: true }).first().click({ timeout: 30000 });
});
When('dans l\'application, j\'appuie sur {string} du point de rencontre {string}', async function (this: FestipodWorld, label: string, title: string) {
const card = this.page!.locator('div', { hasText: title }).filter({ has: this.page!.getByRole('button', { name: label, exact: true }) }).last();
await card.getByRole('button', { name: label, exact: true }).click({ timeout: 30000 });
});
Then('l\'application affiche {string}', async function (this: FestipodWorld, text: string) {
await this.page!.getByText(text, { exact: false }).first().waitFor({ state: 'visible', timeout: 30000 });
});
When("je recharge l'application", async function (this: FestipodWorld) {
await this.page!.reload({ waitUntil: 'domcontentloaded' });
});
+2 -3
View File
@@ -77,9 +77,8 @@ Then('les événements affichent leur lieu', async function (this: FestipodWorld
'Event cards should display a location (text or 📍 icon)').to.be.true;
});
// The product model signs up to a MEETING POINT, not to the event (arbitration
// of the event-join removal, T04.e): the signup affordance on this screen is the
// per-meeting-point "S'inscrire" toggle, not an event-level join button.
// Signing up is per meeting point or directly to the event: the detail screen
// exposes the event-level button and the per-meeting-point toggles.
Then('je peux m\'inscrire à l\'événement', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('event-detail');
const doc = this.renderedDoc;
@@ -0,0 +1,107 @@
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
import { renderScreen } from '../../../../shared/test-harness/renderHelper';
import { seedEvents } from '../../../../shared/data/seedData';
const DIALOG_TITLE = "Se désinscrire de l'événement ?";
const settle = () => new Promise<void>(resolve => setTimeout(resolve, 50));
function buttons(world: FestipodWorld): HTMLButtonElement[] {
expect(world.renderedDoc, 'Screen should be rendered').to.not.be.null;
return Array.from(world.renderedDoc!.querySelectorAll('button')) as HTMLButtonElement[];
}
function buttonLabelled(world: FestipodWorld, label: string): HTMLButtonElement | undefined {
return buttons(world).find(b => (b.textContent ?? '').trim() === label);
}
function dialogOpen(world: FestipodWorld): boolean {
return world.getDomText().includes(DIALOG_TITLE);
}
Given('je suis sur le détail de l\'événement {string}', async function (this: FestipodWorld, title: string) {
const event = seedEvents.find(e => e.title === title);
expect(event, `No seed event titled "${title}"`).to.not.be.undefined;
this.currentScreenId = 'event-detail';
this.renderedDoc = await renderScreen('event-detail', `/events/${event!.id}`);
});
When('j\'appuie sur {string}', async function (this: FestipodWorld, label: string) {
const button = buttonLabelled(this, label);
expect(button, `Button "${label}" should be on screen`).to.not.be.undefined;
button!.click();
await settle();
});
When('j\'appuie sur le bouton {string} d\'un point de rencontre', async function (this: FestipodWorld, label: string) {
const button = buttonLabelled(this, label);
expect(button, `Meeting point button "${label}" should be on screen`).to.not.be.undefined;
button!.click();
await settle();
});
Then('je vois le bouton {string}', async function (this: FestipodWorld, label: string) {
expect(buttonLabelled(this, label), `Button "${label}" should be on screen`).to.not.be.undefined;
});
Then('je ne vois pas le bouton {string}', async function (this: FestipodWorld, label: string) {
expect(buttonLabelled(this, label), `Button "${label}" should not be on screen`).to.be.undefined;
});
Then('le bouton {string} est affiché', async function (this: FestipodWorld, label: string) {
expect(buttonLabelled(this, label), `Button "${label}" should be on screen`).to.not.be.undefined;
});
Then('le bouton {string} est absent', async function (this: FestipodWorld, label: string) {
expect(buttonLabelled(this, label), `Button "${label}" should not be on screen`).to.be.undefined;
});
Then('l\'écran affiche {string}', async function (this: FestipodWorld, text: string) {
expect(this.getDomText(), `Screen should show "${text}"`).to.include(text);
});
Then('une fenêtre de confirmation {string} est ouverte', async function (this: FestipodWorld, title: string) {
expect(this.getDomText(), `Confirmation "${title}" should be open`).to.include(title);
});
Then('la fenêtre de confirmation propose {string} et {string}', async function (this: FestipodWorld, confirm: string, cancel: string) {
expect(buttonLabelled(this, confirm), `Button "${confirm}" should be in the dialog`).to.not.be.undefined;
expect(buttonLabelled(this, cancel), `Button "${cancel}" should be in the dialog`).to.not.be.undefined;
});
Then('la fenêtre de confirmation liste les points de rencontre {string} et {string}', async function (this: FestipodWorld, first: string, second: string) {
expect(dialogOpen(this), 'The confirmation should be open').to.be.true;
const text = this.getDomText();
expect(text, 'The dialog should name the first meeting point').to.include(`suivants : ${first}`);
expect(text, 'The dialog should name the second meeting point').to.include(second);
});
Then('la fenêtre de confirmation avertit qu\'ils seront aussi quittés', async function (this: FestipodWorld) {
expect(this.getDomText()).to.include('En confirmant, vous en serez aussi désinscrit.');
});
Then('aucune fenêtre de confirmation n\'est ouverte', async function (this: FestipodWorld) {
expect(dialogOpen(this), 'No confirmation should be open').to.be.false;
});
Then('je suis toujours inscrit aux points de rencontre {string} et {string}', async function (this: FestipodWorld, first: string, second: string) {
const text = this.getDomText();
expect(text).to.include(first);
expect(text).to.include(second);
const joined = buttons(this).filter(b => (b.textContent ?? '').trim() === '✓ Inscrit');
expect(joined.length, 'Both meeting points should still show as joined').to.equal(2);
});
Then('la liste des participants nomme {string}', async function (this: FestipodWorld, name: string) {
expect(this.getDomText(), `The participants should name "${name}"`).to.include(name);
});
Then('la liste des participants ne nomme pas {string}', async function (this: FestipodWorld, name: string) {
expect(this.getDomText(), `The participants should not name "${name}"`).to.not.include(name);
});
Then('la liste des participants compte des participants inconnus', async function (this: FestipodWorld) {
expect(this.getDomText(), 'Someone who is not a contact should be counted, not shown').to.include('Participant inconnu');
});
@@ -17,6 +17,10 @@ Fonctionnalité: US-10 Visualiser la fiche/le profil d'un participant
Étant donné que je suis sur la page "profil utilisateur"
Alors je peux voir les événements auxquels l'utilisateur a participé
Scénario: Le profil d'un contact liste les événements auxquels il est inscrit directement
Étant donné que je suis sur le profil de "Jean Durand"
Alors le profil liste l'événement "Résidence Reconnexion"
Scénario: Voir la localisation des événements
Étant donné que je suis sur la page "profil utilisateur"
Alors les événements affichent leur localisation et distance
+14
View File
@@ -1,6 +1,8 @@
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
import { renderScreen } from '../../../../shared/test-harness/renderHelper';
import { seedUsers } from '../../../../shared/data/seedData';
When('je clique sur un participant', async function (this: FestipodWorld) {
await this.navigateTo('#/demo/user-profile');
@@ -96,3 +98,15 @@ function expectProfileDom(
expect((avatar?.textContent ?? '').trim(), `Avatar should show initials "${expected.initials}"`)
.to.equal(expected.initials);
}
Given('je suis sur le profil de {string}', async function (this: FestipodWorld, name: string) {
const user = seedUsers.find(u => u.name === name);
expect(user, `No seed user named "${name}"`).to.not.be.undefined;
this.currentScreenId = 'user-profile';
this.renderedDoc = await renderScreen('user-profile', `/users/${user!.id}`);
await new Promise<void>(resolve => setTimeout(resolve, 50));
});
Then('le profil liste l\'événement {string}', async function (this: FestipodWorld, title: string) {
expect(this.getDomText(), `The profile should list the event "${title}"`).to.include(title);
});
@@ -0,0 +1,41 @@
import type { ReactNode } from 'react';
import { Button } from './Button';
interface ConfirmDialogProps {
title: string;
children: ReactNode;
confirmLabel: string;
cancelLabel: string;
onConfirm: () => void;
onCancel: () => void;
busy?: boolean;
}
export function ConfirmDialog({ title, children, confirmLabel, cancelLabel, onConfirm, onCancel, busy = false }: ConfirmDialogProps) {
return (
<div
role="dialog"
aria-modal="true"
aria-label={title}
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.4)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
zIndex: 900,
}}
>
<div style={{ background: '#fff', borderRadius: 14, padding: 20, maxWidth: 360, width: '100%', display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ fontSize: 17, fontWeight: 700 }}>{title}</div>
<div style={{ fontSize: 14, color: '#555', lineHeight: 1.5 }}>{children}</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<Button variant="primary" onClick={onConfirm} disabled={busy}>{confirmLabel}</Button>
<Button onClick={onCancel} disabled={busy}>{cancelLabel}</Button>
</div>
</div>
</div>
);
}
+1
View File
@@ -15,5 +15,6 @@ export { EventCover } from './EventCover';
export { ImagePicker } from './ImagePicker';
export { EventMeetingPoints } from './EventMeetingPoints';
export type { MeetingPointData } from './EventMeetingPoints';
export { ConfirmDialog } from './ConfirmDialog';
export { Divider } from './Divider';
export { Spinner } from './Spinner';
+7 -1
View File
@@ -108,6 +108,12 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
const leaveMeetingPoint = useCallback((_meetingPointId: string) => {
console.log(`${logPrefix} leaveMeetingPoint (local, no-op)`);
}, [logPrefix]);
const joinEvent = useCallback(async (_eventId: string) => {
console.log(`${logPrefix} joinEvent (local, no-op)`);
}, [logPrefix]);
const leaveEvent = useCallback(async (_eventId: string) => {
console.log(`${logPrefix} leaveEvent (local, no-op)`);
}, [logPrefix]);
const addMeetingPoint = useCallback((_mp: Omit<FpMeetingPointData, 'id'>) => {
console.log(`${logPrefix} addMeetingPoint (local, no-op)`);
// Nothing is stored in the disconnected demo: say so instead of letting the
@@ -145,7 +151,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
selectedUserId, setSelectedUserId, selectedUser,
...queries,
getEventOwnership,
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint,
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint, joinEvent, leaveEvent,
addMeetingPoint, addFriend, inviteToEvent, updateProfile, loadTestData,
};
}
@@ -183,3 +183,81 @@ describe('safeUserUsername', () => {
expect(safeUserUsername(user(''))).toBe('');
});
});
describe('getEventRegistration', () => {
const E = 'event-1';
const M1 = 'mp-1';
const M2 = 'mp-2';
const events: FpEventData[] = [{ id: E, title: 'Concert', description: '', date: '2026-10-10', location: 'Lyon', participantCount: 0 }];
const users: FpUserData[] = [{ id: 'user-1', name: 'Marie Dupont', initials: 'MD', username: '@mariedupont' }];
const meetingPoints: FpMeetingPointData[] = [
{ id: M1, eventId: E, location: 'Café', time: '' },
{ id: M2, eventId: E, location: 'Gare', time: '' },
];
const part = (target: string, userId = 'user-1'): FpParticipationData => ({ id: `${target}|${userId}`, eventId: target, userId, isConfirmed: true });
const registration = (participations: FpParticipationData[]) =>
buildQueries(events, users, participations, meetingPoints, [] as FpFriendshipData[], 'user-1').getEventRegistration(E);
test('signed up to nothing: not registered', () => {
expect(registration([])).toEqual({ direct: false, meetingPointIds: [], registered: false });
});
test('signed up directly: registered, through no meeting point (R1)', () => {
expect(registration([part(E)])).toEqual({ direct: true, meetingPointIds: [], registered: true });
});
test('a member of one meeting point: registered without any direct sign-up (R2)', () => {
expect(registration([part(M1)])).toEqual({ direct: false, meetingPointIds: [M1], registered: true });
});
test('a member of two meeting points lists both (R2)', () => {
const found = registration([part(M1), part(M2)]);
expect(found.direct).toBe(false);
expect([...found.meetingPointIds].sort()).toEqual([M1, M2]);
expect(found.registered).toBe(true);
});
test('direct and a member of a meeting point: both facts are kept', () => {
expect(registration([part(E), part(M1)])).toEqual({ direct: true, meetingPointIds: [M1], registered: true });
});
test('leaving one of two meeting points keeps the registration (R3)', () => {
expect(registration([part(M2)])).toEqual({ direct: false, meetingPointIds: [M2], registered: true });
});
test('leaving the last meeting point without a direct sign-up ends the registration (R3)', () => {
expect(registration([])).toEqual({ direct: false, meetingPointIds: [], registered: false });
});
test('leaving the last meeting point while signed up directly keeps the registration (R3)', () => {
expect(registration([part(E)])).toEqual({ direct: true, meetingPointIds: [], registered: true });
});
test("someone else's sign-ups never count as mine", () => {
expect(registration([part(E, 'user-2'), part(M1, 'user-2')])).toEqual({ direct: false, meetingPointIds: [], registered: false });
});
test('a meeting point of another event never registers me to this one', () => {
const other: FpMeetingPointData[] = [{ id: 'mp-9', eventId: 'event-2', location: 'Ailleurs', time: '' }];
const found = buildQueries(events, users, [part('mp-9')], [...meetingPoints, ...other], [] as FpFriendshipData[], 'user-1').getEventRegistration(E);
expect(found.registered).toBe(false);
});
});
describe('getUserEvents with direct sign-ups and meeting point memberships', () => {
const events: FpEventData[] = [
{ id: 'event-1', title: 'Direct', description: '', date: '', location: '', participantCount: 0 },
{ id: 'event-2', title: 'Par un point', description: '', date: '', location: '', participantCount: 0 },
{ id: 'event-3', title: 'Aucun', description: '', date: '', location: '', participantCount: 0 },
];
const meetingPoints: FpMeetingPointData[] = [{ id: 'mp-2', eventId: 'event-2', location: 'Café', time: '' }];
const participations: FpParticipationData[] = [
{ id: 'p1', eventId: 'event-1', userId: 'user-1', isConfirmed: true },
{ id: 'p2', eventId: 'mp-2', userId: 'user-1', isConfirmed: true },
];
const queries = buildQueries(events, [], participations, meetingPoints, [] as FpFriendshipData[], 'user-1');
test("my events are those I signed up to directly and those of the meeting points I belong to (R1, R2)", () => {
expect(queries.getUserEvents('user-1').map(e => e.id).sort()).toEqual(['event-1', 'event-2']);
});
});
+59 -13
View File
@@ -2,10 +2,13 @@
* FestipodDataContext — the screen-facing data contract, and the shared pieces
* every provider implementation builds on.
*
* The app signs up to a MEETING POINT, not to an event (product model): an
* event is only the public anchor meeting points graft onto. So the
* participation surface is `joinMeetingPoint` / `leaveMeetingPoint`; there is
* no event-level join. Provider implementations:
* The app signs up to a MEETING POINT, or directly to an event (product
* model): an event is the public anchor meeting points graft onto, and being in
* one of its meeting points registers you to it. So the participation surface
* is `joinMeetingPoint` / `leaveMeetingPoint` for a meeting point and
* `joinEvent` / `leaveEvent` for the event itself; the registration to an
* event (`getEventRegistration`) is derived from both, never stored.
* Provider implementations:
*
* - `LocalDataProvider` (`FestipodDataContext.tsx`) — the disconnected demo,
* seed fixtures, mutations as no-ops. What the `@ui` layer renders against.
@@ -32,6 +35,17 @@ import { normalizeIdentifier } from '../utils/identifier';
// Context interface
// ============================================================================
/**
* How the current user is registered to an event: `direct` (signed up to the
* event itself) and `meetingPointIds` (the event's meeting points they belong
* to). `registered` is derived — one of the two — never stored.
*/
export interface EventRegistration {
direct: boolean;
meetingPointIds: string[];
registered: boolean;
}
/**
* Whether this session may WRITE an event's document — asked the same way on
* every backend: only a record's owner writes it. `'unknown'` is a REAL third
@@ -82,6 +96,8 @@ export interface FestipodDataContextValue {
getUserEvents(userId: string): FpEventData[];
/** Membership test against a MEETING POINT id (defaults to the current user). */
isParticipating(meetingPointId: string, userId?: string): boolean;
/** How the current user is registered to an event — see {@link EventRegistration}. */
getEventRegistration(eventId: string): EventRegistration;
getFriends(userId?: string): FpUserData[];
getEventMeetingPoints(eventId: string): FpMeetingPointData[];
/**
@@ -131,6 +147,18 @@ export interface FestipodDataContextValue {
joinMeetingPoint(meetingPointId: string, userId?: string): Promise<void> | void;
/** Withdraw from a meeting point — the symmetric fact to `joinMeetingPoint`. */
leaveMeetingPoint(meetingPointId: string, userId?: string): Promise<void> | void;
/**
* Sign up directly to an event, whether or not it has meeting points.
* Idempotent; rejects when the act was refused or could not be sent.
*/
joinEvent(eventId: string): Promise<void>;
/**
* Withdraw from an event: the direct sign-up AND every membership of the
* event's meeting points. The confirmation the user owes before losing those
* memberships belongs to the screen. Resolves once every act was accepted;
* rejects naming the targets that failed (the status then follows the data).
*/
leaveEvent(eventId: string): Promise<void>;
/**
* Create a meeting point on an event. Same contract as `addFriend`: resolves
* once the meeting point was created and persisted — rejects when nothing
@@ -262,12 +290,11 @@ export function safeUserInitials(user: FpUserData | undefined): string {
* that provider's arrays. Cross-entity derivations only: nothing here touches
* a backend.
*
* THE `participations` ARRAY'S `eventId` IS THE JOINED TARGET'S ID. On the
* local/NextGraph providers that is an event; on the ActivityPods provider it
* is a meeting point. `isParticipating` and `getUserEvents` filter on it
* as-is, so a provider whose participations are keyed differently (the
* ActivityPods reader derives event-level participants from the meeting
* points) overrides those two queries and reuses the rest.
* THE `participations` ARRAY'S `eventId` IS THE JOINED TARGET'S ID: an event
* (a direct sign-up) or a meeting point. `getUserEvents`, `isParticipating`
* and `getEventRegistration` read it as such; a provider whose participations
* are not the whole truth about the current user (the ActivityPods reader
* overlays pending acts) overrides those queries and reuses the rest.
*/
export function buildQueries(
events: FpEventData[],
@@ -296,8 +323,9 @@ export function buildQueries(
};
const getUserEvents = (userId: string) => {
const partEventIds = participations.filter(p => p.userId === userId).map(p => p.eventId);
return events.filter(e => partEventIds.includes(e.id));
const joined = new Set(participations.filter(p => p.userId === userId).map(p => p.eventId));
const viaMeetingPoints = new Set(meetingPoints.filter(mp => joined.has(mp.id)).map(mp => mp.eventId));
return events.filter(e => joined.has(e.id) || viaMeetingPoints.has(e.id));
};
const isParticipating = (meetingPointId: string, userId?: string) => {
@@ -305,6 +333,10 @@ export function buildQueries(
return participations.some(p => p.eventId === meetingPointId && p.userId === uid);
};
const getEventRegistration = (eventId: string): EventRegistration =>
registrationOf(eventId, meetingPoints, target =>
participations.some(p => p.eventId === target && p.userId === currentUserId));
const getFriends = (userId?: string) => {
const uid = userId || currentUserId;
const friendIds = friendships
@@ -323,13 +355,27 @@ export function buildQueries(
events.some(e => e.id === id) ? 'ready' : 'unavailable';
return {
getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getFriends, getEventMeetingPoints,
getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getEventRegistration, getFriends, getEventMeetingPoints,
openEvent: openEventInCompleteReadSet,
openUserEvents: openUserEventsInCompleteReadSet,
getEventReadState,
};
}
/**
* The registration to an event, given who holds which target: registered iff
* signed up to the event itself or a member of one of its meeting points.
*/
export function registrationOf(
eventId: string,
meetingPoints: readonly FpMeetingPointData[],
holds: (targetId: string) => boolean,
): EventRegistration {
const direct = holds(eventId);
const meetingPointIds = meetingPoints.filter(mp => mp.eventId === eventId && holds(mp.id)).map(mp => mp.id);
return { direct, meetingPointIds, registered: direct || meetingPointIds.length > 0 };
}
/**
* `openEvent` for a provider whose read set is already everything it can read
* (the local fixtures, the suspended NextGraph path): nothing to bring in. One
+10 -1
View File
@@ -997,6 +997,15 @@ function useNgData(): FestipodDataContextValue {
await Promise.all(persists).catch(err => console.error(`${logPrefix} persist event update failed:`, err));
}, []);
const joinEvent = useCallback(
() => Promise.reject(new Error('joinEvent: not supported on the suspended NextGraph path')),
[],
);
const leaveEvent = useCallback(
() => Promise.reject(new Error('leaveEvent: not supported on the suspended NextGraph path')),
[],
);
const joinMeetingPoint = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log(`${logPrefix} joinMeetingPoint (NG):`, eventId, 'user:', uid);
@@ -1267,7 +1276,7 @@ function useNgData(): FestipodDataContextValue {
selectedUserId, setSelectedUserId, selectedUser,
...queries,
getEventOwnership,
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint,
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint, joinEvent, leaveEvent,
addMeetingPoint, addFriend, inviteToEvent, updateProfile, loadTestData,
};
}
+143 -7
View File
@@ -60,6 +60,8 @@ const CAROL = 'http://carol.pod.test/carol';
const E = 'http://bob.pod.test/bob/data/events/concert';
const M = 'http://bob.pod.test/bob/data/events/concert-bar';
const C = 'http://bob.pod.test/bob/data/events/concert-bar/attendees';
/** CE: the event's OWN attendee list — where the direct sign-ups are listed. */
const CE = 'http://bob.pod.test/bob/data/events/concert/attendees';
/** M2: the event's SECOND meeting point, with its own attendee list C2. */
const M2 = 'http://bob.pod.test/bob/data/events/concert-terrasse';
const C2 = 'http://bob.pod.test/bob/data/events/concert-terrasse/attendees';
@@ -80,6 +82,7 @@ const CAROL_URN_E = urnOf(CAROL, E, CAROL_SECRET);
const DAVE_URN_E = urnOf(DAVE, E, DAVE_SECRET);
const EVENT_RECORD = { id: E, type: 'Event', name: 'Concert', startTime: '2026-11-01T20:00:00', 'dc:creator': BOB, to: 'as:Public' };
const EVENT_RECORD_LISTED = { ...EVENT_RECORD, 'apods:attendees': CE };
const MP_RECORD = { id: M, type: 'Event', location: 'Au bar', 'as:context': E, 'dc:creator': BOB, 'apods:attendees': C, to: 'as:Public' };
const MP2_RECORD = { id: M2, type: 'Event', location: 'En terrasse', 'as:context': E, 'dc:creator': BOB, 'apods:attendees': C2, to: 'as:Public' };
/** E2: an event Bob DECLARED — known to this session only through the catalog's owner filter. */
@@ -106,6 +109,11 @@ const server = {
/** Attendee lists of M and M2, and how many reads of either were served. */
members: [] as string[],
members2: [] as string[],
/** The event's own attendee list, and whether the event is served with its link. */
eventMembers: [] as string[],
eventListed: false,
/** Objects whose Join/Leave the outbox refuses. */
refused: [] as string[],
reads: 0,
/** Alice's own list (`own`, with M's list link once `ownLinked`). */
own: [] as JsonRecord[],
@@ -140,6 +148,8 @@ function json(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/ld+json' } });
}
const eventRecord = () => (server.eventListed ? EVENT_RECORD_LISTED : EVENT_RECORD);
async function scriptedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (init?.signal?.aborted) throw new DOMException('aborted', 'AbortError');
@@ -147,6 +157,7 @@ async function scriptedFetch(input: RequestInfo | URL, init?: RequestInit): Prom
if (method === 'POST') {
if (url === ALICE_OUTBOX) {
const body = JSON.parse(String(init?.body)) as JsonRecord;
if (server.refused.includes(String(body['object']))) return new Response(null, { status: 500 });
server.posted.push(body);
server.log.push(String(body['type']));
return new Response(null, { status: 201 });
@@ -158,7 +169,7 @@ async function scriptedFetch(input: RequestInfo | URL, init?: RequestInit): Prom
const owner = new URL(url).searchParams.get('owner');
const items = owner
? (owner === BOB ? server.bobDeclared : [])
: (server.secondMp ? [EVENT_RECORD, MP_RECORD, MP2_RECORD] : [EVENT_RECORD, MP_RECORD]);
: (server.secondMp ? [eventRecord(), MP_RECORD, MP2_RECORD] : [eventRecord(), MP_RECORD]);
return json({ type: 'OrderedCollection', items });
}
switch (url) {
@@ -177,7 +188,8 @@ async function scriptedFetch(input: RequestInfo | URL, init?: RequestInit): Prom
case BOB_PARTICIPATIONS:
server.bobParticipationsReads++;
return json({ id: BOB_PARTICIPATIONS, type: 'Collection', items: [...server.bobParticipations] });
case E: return json(EVENT_RECORD);
case E: return json(eventRecord());
case CE: return json({ id: CE, type: 'Collection', items: [...server.eventMembers] });
case E2: return json(E2_RECORD);
case M: return json(server.mpLinked ? MP_RECORD : MP_RECORD_UNLINKED);
case M2: return json(MP2_RECORD);
@@ -224,7 +236,7 @@ const alicePod = {
* collection — created and linked from her profile at her first Join.
*/
function backendProcesses(op: 'join' | 'leave', mp: string): void {
const list = mp === M ? 'members' : 'members2';
const list = mp === E ? 'eventMembers' : mp === M ? 'members' : 'members2';
if (op === 'join') {
if (!server[list].includes(ALICE_URN_E)) server[list] = [...server[list], ALICE_URN_E];
if (!server.myParticipations.includes(mp)) server.myParticipations = [...server.myParticipations, mp];
@@ -323,11 +335,20 @@ async function settledView(match: (v: ReturnType<typeof view>) => boolean, timeo
return current;
}
type Registration = { direct: boolean; meetingPointIds: string[]; registered: boolean };
const registration = (): Registration => {
const found = latest?.getEventRegistration(E);
return found ? { ...found, meetingPointIds: [...found.meetingPointIds].sort() } : { direct: false, meetingPointIds: [], registered: false };
};
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
interface Scenario {
members?: string[];
members2?: string[];
/** The event's own attendee list; the event is served with its link only when this is given. */
eventMembers?: string[];
refused?: string[];
own?: JsonRecord[];
mpLinked?: boolean;
secondMp?: boolean;
@@ -345,6 +366,9 @@ interface Scenario {
function render(s: Scenario = {}): void {
server.members = s.members ?? [];
server.members2 = s.members2 ?? [];
server.eventMembers = s.eventMembers ?? [];
server.eventListed = s.eventMembers !== undefined;
server.refused = s.refused ?? [];
server.reads = 0;
server.own = s.own ?? [];
server.ownLinked = false;
@@ -400,7 +424,7 @@ const isSecret = (value: unknown) => typeof value === 'string' && /^[0-9a-f]{64}
// --- joining: the secret first, then a Join naming the meeting point --------------
test("the first join: a secret is generated and written to my profile BEFORE the Join is posted; the Join names the meeting point with no addressee; my participations collection, linked once the backend processed it, confirms it", async () => {
test("the first join: a secret is generated and written to my profile BEFORE the Join is posted; the Join names the meeting point, addressed to the app actor alone; my participations collection, linked once the backend processed it, confirms it", async () => {
await mount();
const served = server.reads;
@@ -412,8 +436,8 @@ test("the first join: a secret is generated and written to my profile BEFORE the
expect(server.profileWrites).toHaveLength(1);
expect(Object.keys(server.profileWrites[0]!)).toEqual([FP_SECRET]);
expect(isSecret(server.profileWrites[0]![FP_SECRET])).toBe(true);
// Exactly these keys: no `to`, no `cc`.
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Join', actor: ALICE, object: M }]);
// Exactly these keys: `to` the app actor (the client id the backend URL derives), no `cc`.
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Join', actor: ALICE, object: M, to: `${BACKEND}/as/actor/app` }]);
// The membership shows at once (overlay) while the backend has processed
// nothing: my profile links no collection yet, the list is empty.
@@ -493,7 +517,7 @@ test("a leave is never undone by a stale read: the membership is my participatio
const served = server.reads;
await latest!.leaveMeetingPoint(M);
const leftAt = Date.now();
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Leave', actor: ALICE, object: M }]);
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Leave', actor: ALICE, object: M, to: `${BACKEND}/as/actor/app` }]);
expect(server.profileWrites).toEqual([]);
// The leave shows at once (overlay). The re-reads now land on STALE data —
// the list still holds her URN, her collection still lists M: the count
@@ -729,3 +753,115 @@ test('a contact whose profile links no participations collection: asking for the
expect(latest!.getUserEvents(BOB)).toEqual([]);
expect(server.bobParticipationsReads).toBe(0);
}, 30000);
// --- signing up to the event itself -------------------------------------------------
const NOT_REGISTERED: Registration = { direct: false, meetingPointIds: [], registered: false };
test('joining an event directly: the secret first, then ONE Join naming the event, addressed to the app actor alone; the event shows on my home (R1)', async () => {
await mount({ eventMembers: [] });
await latest!.joinEvent(E);
expect(server.log).toEqual(['profile-write', 'Join']);
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Join', actor: ALICE, object: E, to: `${BACKEND}/as/actor/app` }]);
await until(() => registration().direct, 'the direct sign-up reflected at once');
expect(registration()).toEqual({ direct: true, meetingPointIds: [], registered: true });
backendProcesses('join', E);
await until(() => view().event === 1 && view().mp === 0, 'the event counting my sign-up, M listed with no member');
expect(view().mp).toBe(0);
expect(registration()).toEqual({ direct: true, meetingPointIds: [], registered: true });
await until(() => latest?.getUserEvents(ALICE).some(e => e.id === E) === true, 'the event on my home');
}, 30000);
test('a refused direct Join rejects, and the status shown stays "not registered" (R7)', async () => {
await mount({ eventMembers: [], secret: ALICE_SECRET, myParticipations: [], refused: [E] });
await expect(latest!.joinEvent(E)).rejects.toThrow();
await land();
expect(server.posted).toEqual([]);
expect(registration()).toEqual(NOT_REGISTERED);
}, 30000);
test('an event joined only through a meeting point: registered with no direct sign-up and no act naming the event (R2)', async () => {
render({ eventMembers: [], members: [ALICE_URN_E], secret: ALICE_SECRET, myParticipations: [M] });
await until(() => registration().registered, 'registered through the meeting point');
expect(registration()).toEqual({ direct: false, meetingPointIds: [M], registered: true });
expect(server.posted).toEqual([]);
}, 30000);
test("another member's direct sign-up in the event's list counts at the event and in no meeting point (R5)", async () => {
render({ eventMembers: [BOB_URN_E, CAROL_URN_E], members: [CAROL_URN_E] });
await until(() => view().event === 2 && view().mp === 1, 'the event counting both persons and M counting its one member');
expect(view()).toEqual({ event: 2, mp: 1, joined: false });
}, 30000);
test("a contact signed up directly is recognised in the event's own list, and a contact's participations naming the event list it among their events (R6)", async () => {
render({
eventMembers: [BOB_URN_E],
contacts: [BOB],
contactProfiles: bobProfile({ secret: BOB_SECRET, participations: BOB_PARTICIPATIONS }),
bobParticipations: [E],
});
await until(() => latest?.getEventParticipants(E).some(u => u.id === BOB) === true, 'Bob recognised from the event list');
latest!.openUserEvents(BOB);
await until(() => latest?.getUserEvents(BOB).some(e => e.id === E) === true, "the event among Bob's events");
}, 30000);
// --- leaving the event: the cascade belongs to the data context -----------------------
async function mountRegistered(s: Scenario): Promise<void> {
render({ eventMembers: [ALICE_URN_E], members: [ALICE_URN_E], members2: [ALICE_URN_E], secondMp: true, secret: ALICE_SECRET, myParticipations: [E, M, M2], ...s });
await until(() => registration().direct && registration().meetingPointIds.length === 2, 'registered directly and through both meeting points');
}
test('leaving an event posts a Leave for the direct sign-up and one for each meeting point joined, and shows the user no longer registered (R4)', async () => {
await mountRegistered({});
await latest!.leaveEvent(E);
expect(server.posted.map(body => body['type'])).toEqual(['Leave', 'Leave', 'Leave']);
expect(server.posted.map(body => body['object']).sort()).toEqual([E, M, M2].sort());
expect(server.profileWrites).toEqual([]);
await until(() => !registration().registered, 'the status following the withdrawal');
expect(registration()).toEqual(NOT_REGISTERED);
}, 30000);
test('leaving an event held only through meeting points posts no Leave naming the event (R4)', async () => {
render({ eventMembers: [], members: [ALICE_URN_E], members2: [ALICE_URN_E], secondMp: true, secret: ALICE_SECRET, myParticipations: [M, M2] });
await until(() => registration().meetingPointIds.length === 2, 'registered through both meeting points');
await latest!.leaveEvent(E);
expect(server.posted.map(body => body['object']).sort()).toEqual([M, M2].sort());
}, 30000);
test('a partly failed withdrawal rejects naming the targets that failed, and the status shows what remains (R7)', async () => {
await mountRegistered({ refused: [M2] });
const failure = await latest!.leaveEvent(E).then(() => undefined, (error: unknown) => error as Error);
expect(failure).toBeInstanceOf(Error);
expect(failure!.message).toContain(M2);
expect(failure!.message).not.toContain(M);
expect(server.posted.map(body => body['object']).sort()).toEqual([E, M].sort());
backendProcesses('leave', E);
backendProcesses('leave', M);
await until(() => !registration().direct && registration().meetingPointIds.length === 1, 'only M2 remaining');
expect(registration()).toEqual({ direct: false, meetingPointIds: [M2], registered: true });
}, 30000);
test('leaving an event the user holds nothing of rejects and posts nothing', async () => {
await mount({ eventMembers: [] });
await expect(latest!.leaveEvent(E)).rejects.toThrow();
expect(server.posted).toEqual([]);
}, 30000);
+112 -54
View File
@@ -65,12 +65,12 @@
*
* WHAT LIVES BESIDE REFINE. A participation is not a CRUD write and the
* refine-providers package implements no `custom` method: joining or leaving
* a meeting point POSTs an `as:Join`/`as:Leave` naming it, with no addressee,
* to the user's own outbox (`data/outbox.ts`). The FIRST join ensures the
* a meeting point POSTs an `as:Join`/`as:Leave` naming it, addressed to the app
* actor alone, to the user's own outbox (`data/outbox.ts`). The FIRST join ensures the
* participation secret on my profile beforehand — generated when absent and
* written through the data provider's `update` — since the backend refuses a
* Join whose author has none. The backend handles the activity on emission
* and updates the attendee list and my participations collection
* Join whose author has none. The backend handles the activity on its delivery
* to the app's inbox and updates the attendee list and my participations collection
* asynchronously — so the join/leave reflects in an optimistic overlay here,
* confirmed by re-reading my participations collection (see
* `settleMembershipActs` in `refineDerive.ts`), with a bounded schedule of
@@ -103,7 +103,7 @@ import { fetchCatalogEvents, fetchCatalogMeetingPointIds } from '../data/catalog
import { postJoinActivity, postLeaveActivity } from '../data/outbox';
import { contactsCollectionOf, fetchCollectionMemberIds, postContactRequest, postEventInvitation } from '../data/contacts';
import { useCurrentSession } from '../utils/currentSession';
import { getBackendUrl } from '../utils/runtimeConfig';
import { getBackendUrl, resolveActivityPodsAuthConfig } from '../utils/runtimeConfig';
import { useCurrentPrincipal } from '../utils/currentPrincipal';
import type { BootstrapResult } from '../utils/ngBootstrap';
import { RefineProvider, useBackend } from '../refine/RefineProvider';
@@ -112,6 +112,7 @@ import {
FestipodDataContext,
buildQueries,
nextId,
registrationOf,
type FestipodDataContextValue,
type EventOwnership,
type EventReadState,
@@ -162,11 +163,14 @@ function inertDataValue(): FestipodDataContextValue {
selectedEventId: '', selectedEvent: undefined, selectedUserId: '', selectedUser: undefined,
getEvent: () => undefined, getUser: () => undefined,
getEventParticipants: () => [], getUserEvents: () => [], isParticipating: () => false,
getEventRegistration: () => ({ direct: false, meetingPointIds: [], registered: false }),
getFriends: () => [], getEventMeetingPoints: () => [], getEventOwnership: (): EventOwnership => 'unknown',
openEvent: () => {}, openUserEvents: () => {}, getEventReadState: (): EventReadState => 'unavailable',
setSelectedEventId: () => {}, setSelectedUserId: () => {},
createEvent: async (event: Omit<FpEventData, 'id'>) => ({ ...event, id: nextId('event') }),
updateEvent: () => {}, joinMeetingPoint: () => {}, leaveMeetingPoint: () => {},
joinEvent: () => Promise.reject(new Error('joinEvent: no data provider')),
leaveEvent: () => Promise.reject(new Error('leaveEvent: no data provider')),
addMeetingPoint: () => Promise.reject(new Error('addMeetingPoint: no data provider')), addFriend: () => Promise.reject(new Error('addFriend: no data provider')), inviteToEvent: () => Promise.reject(new Error('inviteToEvent: no data provider')), updateProfile: () => {}, loadTestData: async () => emptyBootstrap(),
};
return none;
@@ -187,6 +191,11 @@ interface MyParticipationsRead {
}
const NO_PARTICIPATIONS_READ: MyParticipationsRead = { generation: 0, attempted: 0, ids: [] };
/** The Festipod app actor, the only addressee of a Join or a Leave: the Solid-OIDC client id, which is the actor's URI. */
function appActorUri(): string {
return resolveActivityPodsAuthConfig(window.location.origin).clientId;
}
/** What a contact's profile gives this session beyond their display fields. */
interface ContactKeys {
/** Their participation secret — what recognising them in an attendee list takes. */
@@ -196,6 +205,18 @@ interface ContactKeys {
}
/** The memo key of one contact's member URN at one event. */
/** The event a joined target is anchored to: its own event for a meeting point, itself for an event. */
function anchorEventIdOf(bindings: RefineBackend['bindings'], targetId: string, record: unknown): string | undefined {
if (bindings.meetingPoint.isInstance?.(record) ?? false) {
try {
return bindings.meetingPoint.toDomain(record).eventId || undefined;
} catch {
return undefined;
}
}
return bindings.event.isInstance?.(record) ?? true ? targetId : undefined;
}
function memberUrnKey(contactId: string, eventId: string, secret: string): string {
return `${contactId}\n${eventId}\n${secret}`;
}
@@ -246,6 +267,8 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
);
const ownMeetingPointIdsRef = useRef(ownMeetingPointIds);
ownMeetingPointIdsRef.current = ownMeetingPointIds;
const ownEventIdsRef = useRef(ownEventIds);
ownEventIdsRef.current = ownEventIds;
// --- MY PROFILE (the app's own entity id space) ------------------------------
// The actor (WebID) document links the `as:Profile` record via `url`; the
@@ -354,7 +377,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
useEffect(() => {
if (myProfileRead) readMyParticipations();
}, [myProfileRead, myParticipationsUri, readMyParticipations]);
const joinedMeetingPointIds = myParticipations.ids;
const joinedTargetIds = myParticipations.ids;
// The events each CONTACT DECLARES, keyed by their actor URI — read from the
// catalog with `?owner=<their WebID>` (the header's first contact channel):
// the declaration is public by design, and the catalog item IS the event
@@ -424,8 +447,8 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
// to every derivation. An own point (on an own event, or one I proposed on
// another's) is already in the own list and never re-read by URI.
const joinedForeignMeetingPointIds = useMemo(
() => joinedMeetingPointIds.filter(id => !ownMeetingPointIds.has(id)),
[joinedMeetingPointIds, ownMeetingPointIds],
() => joinedTargetIds.filter(id => !ownMeetingPointIds.has(id) && !ownEventIds.has(id)),
[joinedTargetIds, ownMeetingPointIds, ownEventIds],
);
const foreignMeetingPointIds = useMemo(() => {
const ids = new Set<string>();
@@ -452,13 +475,9 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const ids = new Set<string>();
for (const mpId of joinedForeignMeetingPointIds) {
const record = foreignMeetingPointsRead.records.get(mpId);
if (record === undefined || !(bindings.meetingPoint.isInstance?.(record) ?? false)) continue;
try {
const eventId = bindings.meetingPoint.toDomain(record).eventId;
if (eventId) ids.add(eventId);
} catch {
// Unreadable by the binding — the pick below drops the record too.
}
if (record === undefined) continue;
const eventId = anchorEventIdOf(bindings, mpId, record);
if (eventId) ids.add(eventId);
}
return [...ids].filter(id => !ownEventIds.has(id));
}, [joinedForeignMeetingPointIds, foreignMeetingPointsRead.records, bindings, ownEventIds]);
@@ -494,7 +513,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
foreignMeetingPointsRead.records, bindings]);
// --- ATTENDEE LISTS, read by URI and embedded --------------------------------
// Every meeting point's linked collection, own and foreign alike — public by
// Every meeting point's and event's linked collection, own and foreign alike — public by
// design, read by URI with the same token policy, keyed by the URI the record
// links (never by what the fetched document claims). The members are member
// URNs: the read embeds them for the COUNT derivation and for recognising
@@ -504,11 +523,11 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const collectionOf = useMemo(() => {
const links = new Map<string, string>(); // meeting point id → collection URI
for (const record of readRecords) {
if (!(bindings.meetingPoint.isInstance?.(record) ?? false)) continue;
const link = attendeesCollectionLink(record);
if (!link) continue;
const kind = (bindings.meetingPoint.isInstance?.(record) ?? false) ? bindings.meetingPoint : bindings.event;
try {
links.set(bindings.meetingPoint.domainId(record), link);
links.set(kind.domainId(record), link);
} catch {
// No identifier: the derive skips this record too.
}
@@ -575,28 +594,28 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
useEffect(() => {
if (pendingActs.size === 0 || !webId) return;
setPendingActs(prev => settleMembershipActs(prev, mpId => {
if (!derived.meetingPoints.some(mp => mp.id === mpId)) return undefined;
if (!derived.meetingPoints.some(mp => mp.id === mpId) && !derived.events.some(e => e.id === mpId)) return undefined;
return {
member: joinedMeetingPointIds.includes(mpId),
member: joinedTargetIds.includes(mpId),
generation: myParticipations.generation,
attempted: myParticipations.attempted,
count: derived.totals.get(mpId),
};
}));
}, [pendingActs, derived, joinedMeetingPointIds, myParticipations, webId]);
}, [pendingActs, derived, joinedTargetIds, myParticipations, webId]);
// MY meeting points as this session sees them: the collection's, with the
// pending acts on top (an act asserts the state between the POST and the read
// that confirms it; while it is pending it wins over the last read, in either
// direction).
const myMeetingPointIds = useMemo(() => {
const ids = new Set(joinedMeetingPointIds);
const myTargetIds = useMemo(() => {
const ids = new Set(joinedTargetIds);
for (const [mpId, op] of overlay) {
if (op === 'join') ids.add(mpId);
else ids.delete(mpId);
}
return ids;
}, [joinedMeetingPointIds, overlay]);
}, [joinedTargetIds, overlay]);
// --- PARTICIPANT RESOLUTION (the Alice/Bob rule) ------------------------------
// WHO gets resolved: CONTACTS plus self only — never anyone else. A contact
@@ -731,7 +750,10 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
// with the meeting points whose list holds it (one person carries the same
// URN in every list of one event).
const listedMembersByEvent = useMemo(() => {
const eventOf = new Map(derived.meetingPoints.map(mp => [mp.id, mp.eventId]));
const eventOf = new Map([
...derived.events.map(e => [e.id, e.id] as const),
...derived.meetingPoints.map(mp => [mp.id, mp.eventId] as const),
]);
const byEvent = new Map<string, Map<string, string[]>>(); // event → URN → meeting points
for (const p of derived.participations) {
const eventId = eventOf.get(p.eventId);
@@ -776,7 +798,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
// Per contact, the meeting points whose list holds their URN — a set lookup
// per (contact, event): O(contacts + participations) per event. A contact
// whose profile carries no usable secret is never recognised.
const recognisedMeetingPoints = useMemo(() => {
const recognisedTargets = useMemo(() => {
const byContact = new Map<string, Set<string>>();
for (const [eventId, members] of listedMembersByEvent) {
for (const [contactId, keys] of contactKeys) {
@@ -829,16 +851,12 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const contactMpUris = useMemo(() => [...new Set([...contactListedMps.values()].flat())], [contactListedMps]);
const contactMpsRead = usePublicRecords(contactMpUris, { retry: userEventsAsks, tokenOf });
const contactMpEventIds = useMemo(() => {
const ids = new Map<string, string>(); // meeting point URI → anchor event URI
const ids = new Map<string, string>(); // listed target URI → anchor event URI
for (const uri of contactMpUris) {
const record = contactMpsRead.records.get(uri);
if (record === undefined || !(bindings.meetingPoint.isInstance?.(record) ?? false)) continue;
try {
const eventId = bindings.meetingPoint.toDomain(record).eventId;
if (eventId) ids.set(uri, eventId);
} catch {
// Not a readable meeting point — the binding drops it, as everywhere.
}
if (record === undefined) continue;
const eventId = anchorEventIdOf(bindings, uri, record);
if (eventId) ids.set(uri, eventId);
}
return ids;
}, [contactMpUris, contactMpsRead.records, bindings]);
@@ -912,23 +930,23 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
// WHO is at an event: myself per my own meeting points, a contact per the
// recognition above (their URN found in one of the event's lists). Nobody
// else is ever identified — the count on the event stays the total.
const mpIds = new Set(mpIdsOf(eventId));
const targetIds = [eventId, ...mpIdsOf(eventId)];
const ids = new Set<string>();
for (const u of users) {
const theirs = u.id === webId ? myMeetingPointIds : recognisedMeetingPoints.get(u.id);
if (theirs && [...mpIds].some(id => theirs.has(id))) ids.add(u.id);
const theirs = u.id === webId ? myTargetIds : recognisedTargets.get(u.id);
if (theirs && targetIds.some(id => theirs.has(id))) ids.add(u.id);
}
return users.filter(u => ids.has(u.id));
}, [allMeetingPoints, users, webId, myMeetingPointIds, recognisedMeetingPoints]);
}, [allMeetingPoints, users, webId, myTargetIds, recognisedTargets]);
const getUserEvents = useCallback((userId: string) => {
if (!webId) return [];
if (userId === currentUserId) {
// Mine: my meeting points (my participations collection, with the pending
// acts on top), mapped to the events through the meeting points the read
// set carries.
const joinedEventIds = new Set<string>();
for (const mpId of myMeetingPointIds) {
// Mine: my direct sign-ups and my meeting points (my participations
// collection, with the pending acts on top), the latter mapped to their
// events through the meeting points the read set carries.
const joinedEventIds = new Set<string>(myTargetIds);
for (const mpId of myTargetIds) {
const mp = allMeetingPoints.find(m => m.id === mpId);
if (mp) joinedEventIds.add(mp.eventId);
}
@@ -945,7 +963,12 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
for (const event of declaredEventsByContact.get(userId) ?? []) byId.set(event.id, event);
for (const event of participatedEventsByContact.get(userId) ?? []) byId.set(event.id, event);
return [...byId.values()];
}, [currentUserId, webId, contactIdSet, declaredEventsByContact, participatedEventsByContact, myMeetingPointIds, allMeetingPoints, allEvents]);
}, [currentUserId, webId, contactIdSet, declaredEventsByContact, participatedEventsByContact, myTargetIds, allMeetingPoints, allEvents]);
const getEventRegistration = useCallback(
(eventId: string) => registrationOf(eventId, allMeetingPoints, id => myTargetIds.has(id)),
[allMeetingPoints, myTargetIds],
);
// Own participation — my meeting points, pending acts included. Another user:
// a contact participates iff recognised in this meeting point's list; anyone
@@ -953,10 +976,10 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const isParticipating = useCallback((meetingPointId: string, userId?: string) => {
if (userId !== undefined && userId !== currentUserId && userId !== webId) {
if (!contactIdSet.has(userId)) return false;
return recognisedMeetingPoints.get(userId)?.has(meetingPointId) ?? false;
return recognisedTargets.get(userId)?.has(meetingPointId) ?? false;
}
return myMeetingPointIds.has(meetingPointId);
}, [currentUserId, webId, contactIdSet, recognisedMeetingPoints, myMeetingPointIds]);
return myTargetIds.has(meetingPointId);
}, [currentUserId, webId, contactIdSet, recognisedTargets, myTargetIds]);
// --- Ownership: editing is owning ------------------------------------------------
// The list is own-pod scoped, so membership in it IS ownership. A failed
@@ -1020,6 +1043,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
actTimers.current.clear();
actBaseTotals.current.clear();
}, []);
const isOwnTarget = (id: string) => ownMeetingPointIdsRef.current.has(id) || ownEventIdsRef.current.has(id);
const afterMembershipAct = useCallback((mpId: string, op: MembershipOp) => {
const invalidateOwnList = () => void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
// The count: re-read the list the backend updates. With no list linked
@@ -1027,7 +1051,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const readCountAgain = (): void => {
const collection = collectionOfRef.current.get(mpId);
if (collection !== undefined) { readCollectionAgain(collection); return; }
if (ownMeetingPointIdsRef.current.has(mpId)) invalidateOwnList();
if (isOwnTarget(mpId)) invalidateOwnList();
else readMeetingPointAgain(mpId);
};
readCountAgain();
@@ -1038,7 +1062,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
const confirmFrom = readMyParticipations(true);
actBaseTotals.current.set(mpId, totalsRef.current.get(mpId));
setPendingActs(prev => recordMembershipAct(prev, mpId, op, confirmFrom, totalsRef.current.get(mpId)));
if (ownMeetingPointIdsRef.current.has(mpId)) invalidateOwnList();
if (isOwnTarget(mpId)) invalidateOwnList();
for (const timer of actTimers.current.get(mpId) ?? []) clearTimeout(timer);
const last = MEMBERSHIP_REREAD_DELAYS_MS.length - 1;
actTimers.current.set(mpId, MEMBERSHIP_REREAD_DELAYS_MS.map((delay, step) => setTimeout(() => {
@@ -1178,7 +1202,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
console.log(`${logPrefix} joinMeetingPoint (refine):`, meetingPointId);
// The secret first: without it the backend records nothing.
await ensureParticipationSecret();
await postJoinActivity({ meetingPointUri: mp.id, session });
await postJoinActivity({ meetingPointUri: mp.id, session, appActorUri: appActorUri() });
afterMembershipAct(mp.id, 'join');
}, [logPrefix, allMeetingPoints, session, webId, ensureParticipationSecret, afterMembershipAct]);
@@ -1195,15 +1219,48 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
// The membership check is MY meeting points' word — my participations
// collection with the pending acts on top, so a join still unconfirmed can
// be withdrawn.
if (!myMeetingPointIds.has(mp.id)) {
if (!myTargetIds.has(mp.id)) {
const msg = `leaveMeetingPoint refused: no membership of mine on ${mp.id} — nothing was withdrawn.`;
console.error(`${logPrefix} ${msg}`);
throw new Error(msg);
}
console.log(`${logPrefix} leaveMeetingPoint (refine):`, meetingPointId);
await postLeaveActivity({ meetingPointUri: mp.id, session });
await postLeaveActivity({ meetingPointUri: mp.id, session, appActorUri: appActorUri() });
afterMembershipAct(mp.id, 'leave');
}, [logPrefix, allMeetingPoints, myMeetingPointIds, session, webId, afterMembershipAct]);
}, [logPrefix, allMeetingPoints, myTargetIds, session, webId, afterMembershipAct]);
const joinEvent = useCallback(async (eventId: string) => {
const event = allEvents.find(e => e.id === eventId);
if (!event || !session) {
throw new Error(`joinEvent refused: ${eventId} is not in the read set ` +
'(or there is no session) — the write would target a reference this session cannot see.');
}
if (myTargetIds.has(event.id)) return;
console.log(`${logPrefix} joinEvent (refine):`, eventId);
await ensureParticipationSecret();
await postJoinActivity({ meetingPointUri: event.id, session, appActorUri: appActorUri() });
afterMembershipAct(event.id, 'join');
}, [logPrefix, allEvents, session, myTargetIds, ensureParticipationSecret, afterMembershipAct]);
const leaveEvent = useCallback(async (eventId: string) => {
if (!allEvents.some(e => e.id === eventId) || !session) {
throw new Error(`leaveEvent refused: ${eventId} is not in the read set (or there is no session).`);
}
const { direct, meetingPointIds } = getEventRegistration(eventId);
const targets = direct ? [eventId, ...meetingPointIds] : meetingPointIds;
if (targets.length === 0) {
throw new Error(`leaveEvent refused: no registration of mine on ${eventId} — nothing was withdrawn.`);
}
console.log(`${logPrefix} leaveEvent (refine):`, eventId, targets.length);
const outcomes = await Promise.allSettled(targets.map(async target => {
await postLeaveActivity({ meetingPointUri: target, session, appActorUri: appActorUri() });
afterMembershipAct(target, 'leave');
}));
const failed = targets.filter((_, i) => outcomes[i]?.status === 'rejected');
if (failed.length > 0) {
throw new Error(`leaveEvent: the withdrawal was refused for ${failed.join(', ')}`);
}
}, [logPrefix, allEvents, session, getEventRegistration, afterMembershipAct]);
// --- The rest of the contract -------------------------------------------------------
// A contact request, not a friendship: the Pod's social graph is mutual and
@@ -1252,13 +1309,14 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
getEventParticipants,
getUserEvents,
isParticipating,
getEventRegistration,
getFriends: shared.getFriends,
getEventMeetingPoints: shared.getEventMeetingPoints,
getEventOwnership,
openEvent,
openUserEvents,
getEventReadState,
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint,
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint, joinEvent, leaveEvent,
addMeetingPoint, addFriend, inviteToEvent, updateProfile, loadTestData,
};
}
+67
View File
@@ -380,3 +380,70 @@ test('the re-read schedule is finite and increasing', () => {
const previous = [0, ...MEMBERSHIP_REREAD_DELAYS_MS];
expect(MEMBERSHIP_REREAD_DELAYS_MS.every((delay, i) => delay > (previous[i] ?? delay))).toBe(true);
});
const eventList = (items: unknown[]) => ({ id: `${EVENT}/attendees`, totalItems: items.length, items });
test("the event's own attendee list becomes participations keyed by the event", () => {
const aliceUrn = URN('a'.repeat(64));
const derived = deriveContainer(
[{ id: EVENT, type: 'Event', name: 'Concert', 'apods:attendees': eventList([aliceUrn]) }],
bindings,
);
expect(derived.participations).toEqual([{ id: `${EVENT}|${aliceUrn}`, eventId: EVENT, userId: aliceUrn, isConfirmed: true }]);
expect(derived.totals.get(EVENT)).toBe(1);
});
test("the event's count is the distinct URNs over its own list and its meeting points' lists", () => {
const mp2 = 'http://localhost:3000/alice/data/mp-2';
const aliceUrn = URN('a'.repeat(64));
const bobUrn = URN('b'.repeat(64));
const carolUrn = URN('c'.repeat(64));
const derived = deriveContainer(
[
{ id: EVENT, type: 'Event', name: 'Concert', 'apods:attendees': eventList([aliceUrn, carolUrn]) },
{ id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 2, items: [aliceUrn, bobUrn] } },
{ id: mp2, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 1, items: [bobUrn] } },
],
bindings,
);
expect(derived.events[0]?.participantCount).toBe(3);
});
test('a person signed up directly and in a meeting point counts once, at the event and in the meeting point', () => {
const aliceUrn = URN('a'.repeat(64));
const bobUrn = URN('b'.repeat(64));
const derived = deriveContainer(
[
{ id: EVENT, type: 'Event', name: 'Concert', 'apods:attendees': eventList([aliceUrn, bobUrn]) },
{ id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 1, items: [aliceUrn] } },
],
bindings,
);
expect(derived.events[0]?.participantCount).toBe(2);
expect(derived.meetingPoints[0]?.participantCount).toBe(1);
});
test("a direct sign-up counts at the event but in no meeting point", () => {
const aliceUrn = URN('a'.repeat(64));
const derived = deriveContainer(
[
{ id: EVENT, type: 'Event', name: 'Concert', 'apods:attendees': eventList([aliceUrn]) },
{ id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': { totalItems: 0, items: [] } },
],
bindings,
);
expect(derived.events[0]?.participantCount).toBe(1);
expect(derived.meetingPoints[0]?.participantCount).toBe(0);
});
test('a pending direct sign-up adds one to the event and shows a participation keyed by the event', () => {
const derived = deriveContainer(
[{ id: EVENT, type: 'Event', name: 'Concert', 'apods:attendees': eventList([]) }],
bindings,
);
const pending: PendingActs = recordMembershipAct(new Map(), EVENT, 'join', 0, derived.totals.get(EVENT));
const adjusted = countsWithOverlay(derived, pending, ALICE);
expect(adjusted.events[0]?.participantCount).toBe(1);
const overlaid = applyMembershipOverlay(derived.participations, overlayOfPendingActs(pending), ALICE);
expect(overlaid).toEqual([{ id: `${EVENT}|${ALICE}`, eventId: EVENT, userId: ALICE, isConfirmed: true }]);
});
+44 -34
View File
@@ -7,17 +7,20 @@
* here, and they are the two places the ActivityPods model meets the app's:
*
* 1. `deriveContainer` SPLITS one `as/Event` container into events and meeting
* points and reads each meeting point's `apods:attendees` collection. Events
* and meeting points share the container (the bindings' `isInstance` is the
* only distinction), so the split has to happen exactly once, at read time.
* The event's `participantCount` is the number of DISTINCT member values
* across its meeting points' memberships — never a stored, incremented
* counter, and never the number of RECOGNISED people: the members are the
* member URNs of decision_2026-10-29_participation-privacy-mechanism, one
* per (person, EVENT), so the same person in two meeting points of one event
* carries the SAME URN in both collections and counts ONCE — the product
* points and reads the `apods:attendees` collection of each — a meeting
* point's members are its memberships, an event's own members are its direct
* sign-ups. Events and meeting points share the container (the bindings'
* `isInstance` is the only distinction), so the split has to happen exactly
* once, at read time. The event's `participantCount` is the number of
* DISTINCT member values across its own list and its meeting points' lists —
* never a stored, incremented counter, and never the number of RECOGNISED
* people: the members are the member URNs of
* decision_2026-10-29_participation-privacy-mechanism, one per (person,
* EVENT), so the same person directly signed up and in meeting points of one
* event carries the SAME URN in every list and counts ONCE — the product
* rule is publicly computable from opaque data, with no recognition and no
* backend.
* backend. Being registered to an event is never stored: it is a direct
* sign-up or a membership of one of its meeting points.
*
* 2. `applyMembershipOverlay` reflects a join/leave made beside Refine (through
* the outbox) before the backend's update of the collections is read. `countsWithOverlay`
@@ -40,14 +43,15 @@ export interface DerivedContainer {
events: FpEventData[];
meetingPoints: FpMeetingPointData[];
/**
* Participations keyed BY MEETING POINT: `eventId` carries the meeting point
* id and `userId` the attendee-list member — the member URN of
* Participations keyed BY TARGET: `eventId` carries the id of the meeting
* point — or of the event itself for a direct sign-up — whose list holds the
* member, and `userId` the attendee-list member — the member URN of
* decision_2026-10-29_participation-privacy-mechanism (one per (person,
* EVENT)), which only a holder of the person's secret recognises (see
* `refineData.tsx`).
*/
participations: FpParticipationData[];
/** Per-meeting-point collection totals, keyed by meeting point id. */
/** Per-list collection totals, keyed by meeting point or event id. */
totals: Map<string, number>;
}
@@ -55,10 +59,10 @@ export interface DerivedContainer {
* Split a container's raw records with the backend's own bindings and derive
* the participations and the participant counts.
*
* A meeting point's attendees collection travels inside its record; each member
* is mapped to a participation stamped with the HOST meeting point's id (a
* member carries no link back to what it attends), and the collection's total is
* kept as-is — a member the reader cannot resolve still counts.
* An attendees collection travels inside its record; each member is mapped to a
* participation stamped with the HOST record's id (a member carries no link back
* to what it attends), and the collection's total is kept as-is — a member the
* reader cannot resolve still counts.
*/
export function deriveContainer(
records: readonly unknown[],
@@ -72,26 +76,32 @@ export function deriveContainer(
// pods too (events opened by URI, their meeting points, the collections those
// link to): one that the bindings reject is skipped with a warning — it never
// takes the whole read down with it.
const readMembers = (record: unknown, targetId: string): number | undefined => {
const attendees = bindings.participation.membersOf?.(record);
if (!attendees) return undefined;
for (const member of attendees.members) {
try {
const mapped = bindings.participation.toDomain(member);
participations.push({ ...mapped, id: `${targetId}|${mapped.userId}`, eventId: targetId });
} catch (error) {
console.warn(`[refine] skipped an unreadable attendee of ${targetId}`, error);
}
}
return attendees.total ?? attendees.members.length;
};
for (const record of records) {
try {
if (bindings.meetingPoint.isInstance?.(record) ?? false) {
const mp = bindings.meetingPoint.toDomain(record);
meetingPoints.push(mp);
const attendees = bindings.participation.membersOf?.(record);
const members = attendees?.members ?? [];
for (const member of members) {
try {
const mapped = bindings.participation.toDomain(member);
participations.push({ ...mapped, id: `${mp.id}|${mapped.userId}`, eventId: mp.id });
} catch (error) {
console.warn(`[refine] skipped an unreadable attendee of ${mp.id}`, error);
}
}
const total = attendees?.total ?? members.length;
const total = readMembers(record, mp.id) ?? 0;
totals.set(mp.id, total);
mp.participantCount = total;
} else if (bindings.event.isInstance?.(record) ?? true) {
events.push(bindings.event.toDomain(record));
const event = bindings.event.toDomain(record);
events.push(event);
const total = readMembers(record, event.id);
if (total !== undefined) totals.set(event.id, total);
}
} catch (error) {
console.warn('[refine] skipped a record the bindings cannot read', error);
@@ -105,11 +115,11 @@ export function deriveContainer(
/**
* The event's count: the number of DISTINCT member values among the
* participations of its meeting points — derived at read time, never a stored
* participations of its own list and of its meeting points — derived at read time, never a stored
* counter. The members are the member URNs of
* decision_2026-10-29_participation-privacy-mechanism: one URN per (person, EVENT),
* so a person in two of the event's meeting points carries the same URN in both
* collections and counts ONCE — distinct PERSONS, publicly computable from
* so a person in several of the event's lists carries the same URN in each and
* counts ONCE — distinct PERSONS, publicly computable from
* opaque data, while each meeting point's own count stays the collection total
* (its card shows per-point inscriptions).
*/
@@ -118,10 +128,10 @@ function eventParticipantCount(
meetingPoints: readonly FpMeetingPointData[],
participations: readonly FpParticipationData[],
): number {
const mpIds = new Set(meetingPoints.filter(mp => mp.eventId === eventId).map(mp => mp.id));
const targetIds = new Set([eventId, ...meetingPoints.filter(mp => mp.eventId === eventId).map(mp => mp.id)]);
const userIds = new Set<string>();
for (const p of participations) {
if (mpIds.has(p.eventId)) userIds.add(p.userId);
if (targetIds.has(p.eventId)) userIds.add(p.userId);
}
return userIds.size;
}
+1 -1
View File
@@ -1028,7 +1028,7 @@ export const parsedFeatures: ParsedFeature[] = [
}
],
"filePath": "src/modules/event/features/e2e-activitypods.feature",
"rawContent": "# language: fr\n@EVENT @priority-1 @e2e-activitypods\nFonctionnalité: Smoke e2e de la pile ActivityPods locale\n En tant que développeur validant le branchement ActivityPods\n Je pilote la pile locale réelle (pod provider + backend Festipod + app servie)\n Afin de prouver que les flux du modèle passent réellement par le Pod.\n\n # PÉRIMÈTRE. Cette couche est DISTINCTE des suites `@data`/`@e2e`/`@multibrowser`\n # suspendues avec le workstream NextGraph : elle ne signe aucun wallet et ne monte\n # aucun broker. Elle n'existe que si la pile locale tourne — son mode d'emploi\n # reproductible est le concept `data-layer`,\n # `cookbook_local-activitypods-platform.md` (pod provider :3000, backend Festipod\n # :3005 par défaut). Le chargement de l'app reprend le motif `@e2e` du\n # repo (serveur réel + Playwright), pas le wallet partagé.\n\n Scénario: La pile locale est joignable et le backend sert son application\n Alors le pod provider local répond sur son endpoint OpenID\n Et le backend Festipod sert la description de son application\n\n @browser\n Scénario: L'application démarre en variante ActivityPods et propose la connexion au Pod\n Quand l'application Festipod est chargée depuis la pile locale\n Alors l'entrée de connexion au Pod est proposée\n Et aucune erreur runtime n'a été émise au chargement\n\n Scénario: Un événement et un point de rencontre rattaché par as:context sont créés sur le Pod\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Quand je crée un événement \"Concert smoke\" et un point de rencontre \"Rdv entrée\" rattaché par as:context\n Alors la lecture du point de rencontre expose son lien as:context vers l'événement\n Et l'événement et le point de rencontre partagent le conteneur as:Event\n\n # L'app s'installe sur le Pod (consentement de l'agent d'autorisation, ou pour un\n # scénario sans navigateur l'endpoint d'enregistrement — voir l'étape\n # « installée sur le Pod »). Une fois installée, l'activité `as:Join` postée sur\n # l'outbox de l'utilisateur, sans destinataire, est traitée par le backend à\n # l'émission : il inscrit dans la collection `apods:attendees` du point de\n # rencontre l'URN de membre dérivée du secret de participation du profil\n # (écrit avant le Join, comme le fait l'app) — jamais le WebID\n # (decision_2026-10-29_participation-privacy-mechanism). Le point de rencontre est créé dans le\n # conteneur enregistré (`as/event`) ; sa collection `apods:attendees` est attachée\n # par le backend lui-même, à la création — le scénario la lit, il ne la crée pas.\n Scénario: Rejoindre puis quitter un point de rencontre met à jour la collection apods:attendees\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et l'application Festipod est installée sur le Pod de la session\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors la collection apods:attendees contient la session\n Quand la session quitte le point de rencontre\n Alors la collection apods:attendees ne contient plus la session\n\n # Dépend du scénario précédent pour la mise en place, mais lit la collection\n # elle-même : le compteur est le total de la collection (jamais les profils\n # résolus) et une URN de membre, que rien ne résout, reste comptée et rendue\n # anonyme.\n Scénario: Le compteur et la liste des participants tiennent une référence non résolue\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et l'application Festipod est installée sur le Pod de la session\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors le compteur du point de rencontre compte cette inscription\n Et la liste des participants rend ce participant comme anonyme\n",
"rawContent": "# language: fr\n@EVENT @priority-1 @e2e-activitypods\nFonctionnalité: Smoke e2e de la pile ActivityPods locale\n En tant que développeur validant le branchement ActivityPods\n Je pilote la pile locale réelle (pod provider + backend Festipod + app servie)\n Afin de prouver que les flux du modèle passent réellement par le Pod.\n\n # PÉRIMÈTRE. Cette couche est DISTINCTE des suites `@data`/`@e2e`/`@multibrowser`\n # suspendues avec le workstream NextGraph : elle ne signe aucun wallet et ne monte\n # aucun broker. Elle n'existe que si la pile locale tourne — son mode d'emploi\n # reproductible est le concept `data-layer`,\n # `cookbook_local-activitypods-platform.md` (pod provider :3000, backend Festipod\n # :3005 par défaut). Le chargement de l'app reprend le motif `@e2e` du\n # repo (serveur réel + Playwright), pas le wallet partagé.\n\n Scénario: La pile locale est joignable et le backend sert son application\n Alors le pod provider local répond sur son endpoint OpenID\n Et le backend Festipod sert la description de son application\n\n @browser\n Scénario: L'application démarre en variante ActivityPods et propose la connexion au Pod\n Quand l'application Festipod est chargée depuis la pile locale\n Alors l'entrée de connexion au Pod est proposée\n Et aucune erreur runtime n'a été émise au chargement\n\n Scénario: Un événement et un point de rencontre rattaché par as:context sont créés sur le Pod\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Quand je crée un événement \"Concert smoke\" et un point de rencontre \"Rdv entrée\" rattaché par as:context\n Alors la lecture du point de rencontre expose son lien as:context vers l'événement\n Et l'événement et le point de rencontre partagent le conteneur as:Event\n\n # L'app s'installe sur le Pod (consentement de l'agent d'autorisation, ou pour un\n # scénario sans navigateur l'endpoint d'enregistrement — voir l'étape\n # « installée sur le Pod »). Une fois installée, l'activité `as:Join` postée sur\n # l'outbox de l'utilisateur, adressée à l'acteur de l'app seul, est livrée dans\n # l'inbox de l'app et traitée par le backend à sa réception : il inscrit dans la collection `apods:attendees` du point de\n # rencontre l'URN de membre dérivée du secret de participation du profil\n # (écrit avant le Join, comme le fait l'app) — jamais le WebID\n # (decision_2026-10-29_participation-privacy-mechanism). Le point de rencontre est créé dans le\n # conteneur enregistré (`as/event`) ; sa collection `apods:attendees` est attachée\n # par le backend lui-même, à la création — le scénario la lit, il ne la crée pas.\n Scénario: Rejoindre puis quitter un point de rencontre met à jour la collection apods:attendees\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et l'application Festipod est installée sur le Pod de la session\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors la collection apods:attendees contient la session\n Quand la session quitte le point de rencontre\n Alors la collection apods:attendees ne contient plus la session\n\n # Dépend du scénario précédent pour la mise en place, mais lit la collection\n # elle-même : le compteur est le total de la collection (jamais les profils\n # résolus) et une URN de membre, que rien ne résout, reste comptée et rendue\n # anonyme.\n Scénario: Le compteur et la liste des participants tiennent une référence non résolue\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et l'application Festipod est installée sur le Pod de la session\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors le compteur du point de rencontre compte cette inscription\n Et la liste des participants rend ce participant comme anonyme\n",
"screenIds": []
},
{
+10 -5
View File
@@ -5,12 +5,15 @@ import type { OutboxActivityInput } from './outbox';
const ACTOR = 'http://localhost:3000/alice';
const OUTBOX = 'http://localhost:3000/alice/outbox';
// What a Join names (decision_2026-10-29_participation-privacy-mechanism): the
// meeting point itself, with no addressee.
// meeting point itself; its only addressee is the app actor
// (decision_2026-11-03_participation-delivered-to-the-app).
const MEETING_POINT = 'http://localhost:3000/bob/data/events/concert-bar';
const APP_ACTOR = 'http://localhost:3005/as/actor/app';
const input: OutboxActivityInput = {
meetingPointUri: MEETING_POINT,
session: { webId: ACTOR, token: 'tok-1' },
appActorUri: APP_ACTOR,
};
/** Installs a scripted fetch double, returning the calls it saw. */
@@ -38,7 +41,7 @@ function jsonResponse(body: unknown, status = 200): Response {
});
}
test('postJoinActivity discovers the outbox from the actor document and posts a Join naming the meeting point, with no addressee', async () => {
test('postJoinActivity discovers the outbox from the actor document and posts a Join naming the meeting point, addressed to the app actor alone', async () => {
const double = installFetch((url) => {
if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX, url: `${ACTOR}/as/profile` });
return jsonResponse({}, 201);
@@ -53,20 +56,21 @@ test('postJoinActivity discovers the outbox from the actor document and posts a
const headers = postCall!.init.headers as Record<string, string>;
expect(headers['Authorization']).toBe('Bearer tok-1');
expect(headers['Content-Type']).toBe('application/ld+json');
// Exactly these keys: no `to`, no `cc` — the activity stays readable by its
// author and the app only.
// Exactly these keys: `to` the app actor, no `cc`, not public — the activity
// stays readable by its author and the app only.
expect(JSON.parse(String(postCall!.init.body))).toEqual({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Join',
actor: ACTOR,
object: MEETING_POINT,
to: APP_ACTOR,
});
} finally {
double.restore();
}
});
test('postLeaveActivity posts a Leave naming the meeting point, with no addressee', async () => {
test('postLeaveActivity posts a Leave naming the meeting point, addressed to the app actor alone', async () => {
const double = installFetch((url) => {
if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX });
return jsonResponse({}, 201);
@@ -78,6 +82,7 @@ test('postLeaveActivity posts a Leave naming the meeting point, with no addresse
type: 'Leave',
actor: ACTOR,
object: MEETING_POINT,
to: APP_ACTOR,
});
} finally {
double.restore();
+17 -13
View File
@@ -1,5 +1,5 @@
/**
* Outbox posting — how a user joins or leaves a meeting point.
* Outbox posting — how a user joins or leaves a meeting point or an event.
*
* WHY THIS LIVES BESIDE REFINE. The refine-providers package implements no
* `custom` method: there is no escape hatch through the data provider, and a
@@ -7,13 +7,14 @@
* posted to the USER'S OWN outbox with the user's token, a withdrawal an
* `as:Leave` on the same path (decision_2026-10-29_participation-privacy-mechanism).
*
* THE ACTIVITY. Its `object` is the meeting point itself, and it carries no
* `to`/`cc`: not public, not addressed to the meeting point's creator. A
* non-public activity is readable only by its author and the app (the app reads
* the outbox through `apods:ReadOutbox`), so the target stays hidden from
* everyone else. The Festipod backend handles it on emission — it records the
* author's member URN in the meeting point's attendee list and the meeting
* point in the author's participations collection.
* THE ACTIVITY. Its `object` is the meeting point — or the event, for a direct
* sign-up — itself, and its only addressee is the Festipod app actor (`to`):
* not public, not addressed to the target's creator. A non-public activity is
* readable only by its author and its addressees, so the target stays hidden
* from everyone else. The user's pod delivers it to the app's inbox, where the
* Festipod backend handles it — it records the author's member URN in the
* target's attendee list and the target in the author's participations collection
* (decision_2026-11-03_participation-delivered-to-the-app).
*
* THE FLOW. The outbox URI is read off the user's own actor (WebID) document
* (`outbox` predicate) at post time — the reference integration resolves it
@@ -33,24 +34,27 @@ import { fetchActorDocument } from './actor';
const ACTIVITY_CONTEXT = 'https://www.w3.org/ns/activitystreams';
export interface OutboxActivityInput {
/** URI of the meeting point the activity names. */
/** URI of the target the activity names: a meeting point, or an event for a direct sign-up. */
meetingPointUri: string;
/** The authenticated session — whose outbox receives the activity. */
session: CurrentSession;
/** URI of the Festipod app actor, the activity's only addressee (the Solid-OIDC client id). */
appActorUri: string;
}
async function postActivity(type: 'Join' | 'Leave', input: OutboxActivityInput): Promise<void> {
const { meetingPointUri, session } = input;
const { meetingPointUri, session, appActorUri } = input;
const actor = await fetchActorDocument(session.webId, session.token);
if (!actor.outbox) {
throw new Error(`actor document for ${session.webId} carries no outbox — cannot post ${type}`);
}
// No addressee: the activity stays readable by its author and the app only.
// The app actor alone: the activity stays readable by its author and the app only.
const activity: Record<string, unknown> = {
'@context': ACTIVITY_CONTEXT,
type,
actor: session.webId,
object: meetingPointUri,
to: appActorUri,
};
const response = await fetch(actor.outbox, {
@@ -67,12 +71,12 @@ async function postActivity(type: 'Join' | 'Leave', input: OutboxActivityInput):
}
}
/** Post an `as:Join` activity to the user's outbox — signing up to a meeting point. */
/** Post an `as:Join` activity to the user's outbox — signing up to a meeting point or an event. */
export function postJoinActivity(input: OutboxActivityInput): Promise<void> {
return postActivity('Join', input);
}
/** Post an `as:Leave` activity to the user's outbox — leaving a meeting point. */
/** Post an `as:Leave` activity to the user's outbox — leaving a meeting point or an event. */
export function postLeaveActivity(input: OutboxActivityInput): Promise<void> {
return postActivity('Leave', input);
}
+19
View File
@@ -196,6 +196,11 @@ export const seedParticipations: FpParticipationData[] = [
{ id: 'part-4', eventId: 'event-1', userId: 'user-2', isConfirmed: true },
// Thomas participates in event 1
{ id: 'part-5', eventId: 'event-1', userId: 'user-7', isConfirmed: true },
// Marie is a member of both meeting points of event 5, and not signed up to it directly
{ id: 'part-6', eventId: 'mp-3', userId: 'user-1', isConfirmed: true },
{ id: 'part-7', eventId: 'mp-4', userId: 'user-1', isConfirmed: true },
// Someone Marie cannot read signed up directly to event 4: counted, never shown
{ id: 'part-8', eventId: 'event-4', userId: 'user-hors-reseau', isConfirmed: true },
];
export const seedMeetingPoints: FpMeetingPointData[] = [
@@ -211,6 +216,20 @@ export const seedMeetingPoints: FpMeetingPointData[] = [
location: 'Station de métro Bellecour',
time: '15h30',
},
{
id: 'mp-3',
eventId: 'event-5',
title: 'Covoiturage depuis la gare',
location: 'Gare Part-Dieu',
time: '18h00',
},
{
id: 'mp-4',
eventId: 'event-5',
title: 'Départ à pied de la place',
location: 'Place Carnot',
time: '18h30',
},
];
export const seedFriendships: FpFriendshipData[] = [
+16 -16
View File
@@ -435,77 +435,77 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"keyword": "Then",
"file": "activitypods.steps.ts",
"sourceCode": "Then(\"le pod provider local répond sur son endpoint OpenID\", async function (this: FestipodWorld) {\n const { podProviderUrl } = apodsStackConfig();\n const status = await urlStatus(`${podProviderUrl}/.well-known/openid-configuration`);\n expect(status, `GET ${podProviderUrl}/.well-known/openid-configuration`).to.equal(200);\n});",
"lineNumber": 95
"lineNumber": 100
},
{
"pattern": "le backend Festipod sert la description de son application",
"keyword": "Then",
"file": "activitypods.steps.ts",
"sourceCode": "Then(\"le backend Festipod sert la description de son application\", async function (this: FestipodWorld) {\n const { backendUrl } = apodsStackConfig();\n const description = await readResource(undefined, `${backendUrl}/app`);\n // The description IS the OIDC client the app registers with: it must carry\n // the interop access-need group the Pod grants, not merely answer 200.\n expect(JSON.stringify(description.type)).to.contain('Application');\n expect(linkId(description['interop:hasAccessNeedGroup']), 'access-need group').to.be.a('string');\n});",
"lineNumber": 101
"lineNumber": 106
},
{
"pattern": "l'application Festipod est chargée depuis la pile locale",
"keyword": "When",
"file": "activitypods.steps.ts",
"sourceCode": "When(\"l'application Festipod est chargée depuis la pile locale\", async function (this: FestipodWorld) {\n const appUrl = requires(this, this.apods.appUrl, 'app URL (set by the @browser Before hook)');\n expect(this.page, 'a Playwright page (set by the @browser Before hook)').to.not.be.null;\n await this.page!.goto(appUrl, { waitUntil: 'domcontentloaded' });",
"lineNumber": 112
"lineNumber": 117
},
{
"pattern": "l'entrée de connexion au Pod est proposée",
"keyword": "Then",
"file": "activitypods.steps.ts",
"sourceCode": "Then(\"l'entrée de connexion au Pod est proposée\", async function (this: FestipodWorld) {\n const { podProviderUrl } = apodsStackConfig();\n const host = new URL(podProviderUrl).host;\n await this.page!.waitForSelector(`text=Se connecter avec ${host}`, { timeout: 30000 });",
"lineNumber": 120
"lineNumber": 125
},
{
"pattern": "aucune erreur runtime n'a été émise au chargement",
"keyword": "Then",
"file": "activitypods.steps.ts",
"sourceCode": "Then(\"aucune erreur runtime n'a été émise au chargement\", function (this: FestipodWorld) {\n expect(\n this.pageErrors,\n `Des erreurs runtime ont été émises au chargement de l'app :\\n` +\n this.pageErrors.map((e, i) => ` [${i + 1}] ${e}`).join('\\n'),\n ).to.be.empty;\n});",
"lineNumber": 126
"lineNumber": 131
},
{
"pattern": "l'application Festipod est installée sur le Pod de la session",
"keyword": "Given",
"file": "activitypods.steps.ts",
"sourceCode": "Given(\"l'application Festipod est installée sur le Pod de la session\", async function (this: FestipodWorld) {\n const pod = session(this);\n const config = apodsStackConfig();\n // The registered agent IS the Solid-OIDC client_id: the app actor URI the\n // backend serves as both the actor and its Client ID Document. Registering\n // against `/app` (the alias) is refused — the agent must be the actor URI.\n const description = await readResource(undefined, `${config.backendUrl}/app`);\n const appUri = requires(this, recordUri(description), 'app actor URI (the id of GET /app)');\n const groupUri = requires(this, linkId(description['interop:hasAccessNeedGroup']), 'access-need group URI');\n const group = await readResource(undefined, groupUri);\n const acceptedAccessNeeds = arrayOf(group['interop:hasAccessNeed']);\n const acceptedSpecialRights = arrayOf(group['apods:hasSpecialRights']);\n\n const response = await fetch(`${config.podProviderUrl}/.auth-agent/app-registrations/register`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${pod.token}` },\n body: JSON.stringify({ appUri, acceptedAccessNeeds, acceptedSpecialRights }),\n });",
"lineNumber": 143
"lineNumber": 148
},
{
"pattern": "la session rejoint le point de rencontre",
"keyword": "When",
"file": "activitypods.steps.ts",
"sourceCode": "When('la session rejoint le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n // The app's own path (src/shared/data/outbox.ts): as:Join to the user's own\n // outbox, `object` = the meeting point URI and `to` = the HOST (the record's\n // dc:creator). Without the addressee the backend silently drops the activity;\n // and an activity carrying no routing target never reaches the host.\n const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId;\n await postOutboxActivity(pod, 'Join', mpUri, host);\n});",
"lineNumber": 267
"sourceCode": "When('la session rejoint le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n // The app's own sequence (refineData.tsx, src/shared/data/outbox.ts —\n // decision_2026-10-29_participation-privacy-mechanism): the participation\n // secret on my profile first (the backend refuses a Join whose author's\n // profile carries none), then an as:Join naming the meeting point, posted to\n // my OWN outbox and addressed to the app actor alone — not public, not\n // addressed to the creator (decision_2026-11-03_participation-delivered-to-the-app).\n await ensureParticipationSecret(pod);\n await postOutboxActivity(pod, 'Join', mpUri, appActorUri(apodsStackConfig()));\n});",
"lineNumber": 276
},
{
"pattern": "la session quitte le point de rencontre",
"keyword": "When",
"file": "activitypods.steps.ts",
"sourceCode": "When('la session quitte le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId;\n await postOutboxActivity(pod, 'Leave', mpUri, host);\n});",
"lineNumber": 278
"sourceCode": "When('la session quitte le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n await postOutboxActivity(pod, 'Leave', mpUri, appActorUri(apodsStackConfig()));\n});",
"lineNumber": 289
},
{
"pattern": "la collection apods:attendees contient la session",
"keyword": "Then",
"file": "activitypods.steps.ts",
"sourceCode": "Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));\n expect(members.map(linkId)).to.include(pod.webId);\n});",
"lineNumber": 285
"sourceCode": "Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const urn = await ownMemberUrn(this);\n const members = (await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(urn))).map(linkId);\n expect(members, 'the member URN derived from my secret is listed').to.include(urn);\n expect(members, 'the list never holds a WebID').to.not.include(pod.webId);\n});",
"lineNumber": 308
},
{
"pattern": "le compteur du point de rencontre compte cette inscription",
"keyword": "Then",
"file": "activitypods.steps.ts",
"sourceCode": "Then('le compteur du point de rencontre compte cette inscription', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));\n const collection = await readResource(pod, collectionUri);\n // The count is the collection total — kept as-is, never the number of\n // RESOLVED profiles, so it stays total for a reader who resolves nobody.\n const raw = collection.totalItems ?? collection['as:totalItems'];\n const total = raw === undefined ? members.length : Number(raw);\n expect(total, 'the count reflects the joined session').to.be.greaterThan(0);\n expect(members.map(linkId), 'the counted session is the one listed').to.include(pod.webId);\n});",
"lineNumber": 304
"sourceCode": "Then('le compteur du point de rencontre compte cette inscription', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const urn = await ownMemberUrn(this);\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(urn));\n const collection = await readResource(pod, collectionUri);\n // The count is the collection total — kept as-is, never the number of\n // RESOLVED profiles, so it stays total for a reader who resolves nobody.\n const raw = collection.totalItems ?? collection['as:totalItems'];\n const total = raw === undefined ? members.length : Number(raw);\n expect(total, 'the count reflects the joined session').to.be.greaterThan(0);\n expect(members.map(linkId), 'the counted session is the one listed (its member URN)').to.include(urn);\n});",
"lineNumber": 331
},
{
"pattern": "la liste des participants rend ce participant comme anonyme",
"keyword": "Then",
"file": "activitypods.steps.ts",
"sourceCode": "Then('la liste des participants rend ce participant comme anonyme', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));\n const joined = members.find(member => linkId(member) === pod.webId);\n expect(joined, 'the joined session is listed').to.not.be.undefined;\n // \"Anonymous\" is the read-side fact: the member travels as a REFERENCE with no\n // profile the reader can resolve — the list has nothing but the URI to render,\n // which is exactly what the app shows for a participant it cannot dereference.\n const profileFields = ['name', 'preferredUsername', 'as:name', 'nickname'];\n const hasProfile =\n typeof joined === 'object' && joined !== null && profileFields.some(field => (joined as Record<string, unknown>)[field] !== undefined);\n expect(hasProfile, 'a listed participant carries no resolved profile (rendered anonymous)').to.be.false;\n});",
"lineNumber": 317
"sourceCode": "Then('la liste des participants rend ce participant comme anonyme', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const urn = await ownMemberUrn(this);\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(urn));\n const joined = members.find(member => linkId(member) === urn);\n expect(joined, 'the joined session is listed').to.not.be.undefined;\n // \"Anonymous\" is the read-side fact: the member is a member URN — a value in\n // no pod, no WebID, nothing to dereference — so a reader who cannot compute\n // it from a contact's secret has nothing but the count to render.\n expect(members.map(linkId).every(id => typeof id === 'string' && id.startsWith(MEMBER_URN_PREFIX)), 'every member is a member URN').to.be.true;\n const profileFields = ['name', 'preferredUsername', 'as:name', 'nickname'];\n const hasProfile =\n typeof joined === 'object' && joined !== null && profileFields.some(field => (joined as Record<string, unknown>)[field] !== undefined);\n expect(hasProfile, 'a listed participant carries no resolved profile (rendered anonymous)').to.be.false;\n});",
"lineNumber": 345
},
{
"pattern": "le portefeuille contient des données de test",
+10 -4
View File
@@ -46,6 +46,11 @@ export function apodsStackConfig(): ApodsStackConfig {
};
}
/** The Festipod app actor of a stack: the only addressee of a Join or a Leave. */
export function appActorUri(config: ApodsStackConfig): string {
return `${config.backendUrl}/as/actor/app`;
}
/** An authenticated Pod session, plus the actor references the flows need. */
export interface ApodsSession {
webId: string;
@@ -203,11 +208,12 @@ export const PARTICIPATIONS = 'http://festipod.org/participations';
/**
* Post a participation activity (`Join` / `Leave`) to the session's own outbox,
* the shape the app posts (`src/shared/data/outbox.ts`): `object` = the meeting
* point, NO `to`/`cc` — not public, addressed to no one, so only its author and
* the app read it. Returns the stored activity's URI (`Location`).
* point, `to` = the app actor alone (`appActorUri`) — not public, not addressed to
* the creator, so only its author and the app read it; the pod delivers it to the
* app's inbox. Returns the stored activity's URI (`Location`).
*/
export async function postOutboxActivity(session: ApodsSession, type: 'Join' | 'Leave', object: string): Promise<string | undefined> {
const activity: JsonRecord = { '@context': AS_CONTEXT, type, actor: session.webId, object };
export async function postOutboxActivity(session: ApodsSession, type: 'Join' | 'Leave', object: string, to: string): Promise<string | undefined> {
const activity: JsonRecord = { '@context': AS_CONTEXT, type, actor: session.webId, object, to };
const response = await request(session.outbox, {
method: 'POST',
headers: { 'Content-Type': 'application/ld+json' },
+4
View File
@@ -16,9 +16,13 @@ export interface ApodsScenarioState {
session?: ApodsSession;
/** The app server URL the `@browser` scenario loads (set by the hook). */
appUrl?: string;
/** Name of the throwaway account a scenario created (deleted by its After hook). */
username?: string;
anchorUri?: string;
meetingPointUri?: string;
collectionUri?: string;
/** The `apods:attendees` collection of the anchor event itself (direct sign-ups). */
eventCollectionUri?: string;
}
export interface FestipodWorld extends World {