Keep sign-ups unlinkable on ActivityPods, and land the pending coherence-pass work

Participation privacy (decision_2026-10-29_participation-privacy-mechanism):
a sign-up is a private Join naming the meeting point, addressed to nobody,
handled by the backend on emission. Attendee lists hold member URNs hashed
from (WebID, event, a secret kept in the participant's profile), so counts
stay public and only contacts recognise anyone; the creator has no read
right and is notified anonymously. Each person's participations collection,
created by the backend at the first sign-up, is readable by their /contacts
WAC group only, never world-readable. The member URN is sticky per
(meeting point, participant) and every collection write is verified by
reading back, so a refused write leaves the act unrecorded and retryable.
The EventRef mechanism and its browser-side provisioning are gone.

The backend now runs with WebACL on, as the reference apps do, on its own
secure datasets festipod / settings-festipod instead of the pod provider's
settings dataset; pod-notifications needs it. The compose header and the
deployment leaf say how to reset a stack whose datasets predate this.

The rights matrix covers the new mechanism live (148 hard lines), including
a forged contact Accept between throwaway accounts: the stranger enters the
contacts list (platform defect, reported upstream) but is refused the profile
and the participations. Tests may attack as long as they never break the dev
environment (rule_tests-never-break-the-dev-environment).

Also lands the coherence-pass work that was still uncommitted: contacts as
the Pod's social graph and invite links, image upload to the Pod, the
meeting-point form, profile completion gate, and the doctrine for them.
Superseded briefs and decisions are deleted rather than annotated.
This commit is contained in:
Sylvain Duchesne
2026-10-30 17:06:38 +01:00
parent f90651eb9a
commit 908f7ea973
138 changed files with 8347 additions and 3679 deletions
+2 -2
View File
@@ -54,7 +54,7 @@ SEMAPPS_FRONT_URL=http://localhost:4000
SEMAPPS_SPARQL_ENDPOINT=http://localhost:3030/
# Dataset Jena/Fuseki principal utilisé par le backend.
SEMAPPS_MAIN_DATASET=settings
SEMAPPS_MAIN_DATASET=festipod
# Identifiants Jena/Fuseki.
SEMAPPS_JENA_USER=admin
@@ -65,7 +65,7 @@ SEMAPPS_REDIS_CACHE_URL=redis://localhost:6390/3
SEMAPPS_QUEUE_SERVICE_URL=redis://localhost:6390/4
# Dataset utilisé par le service auth.account.
SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME=settings
SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME=settings-festipod
# Dépôt de shapes/shapetrees utilisé pour déclarer les besoins d'accès.
SEMAPPS_SHAPE_REPOSITORY_URL=https://shapes.activitypods.org/
@@ -0,0 +1,15 @@
---
type: knowledge
summary: Signed in on a Pod, the app is held behind ProfileCompletionGate — a profile with an empty name shows the profile form in onboarding mode, a Pod with no readable profile shows an explanatory message; the only completion flag is the presence of a name
---
# Profile completion gate
`FestipodDataContextValue.profileStatus` (`'loading' | 'ready' | 'missing'`, absent on providers where it does not apply) is computed by the refine provider from the actor document and the profile read. `ProfileCompletionGate` (in the app shell, inside the data provider) uses it:
- `loading` → nothing is rendered yet;
- `missing` → a blocking, honest message: the Pod links no readable profile and Festipod cannot create or link one (outside the pinned contract);
- `ready` with an empty `currentUser.name` → `UpdateProfileScreen` in `onboarding` mode (explanation, no way out until saved, no pre-filled fiction);
- otherwise the routed screen.
The flag is the name itself — nothing is stored locally, so the form disappears for good once a name is saved and the profile re-read.
@@ -39,6 +39,7 @@ export function MyScreen() { // named function, NEVER any props
- **Zero props, no exception left**: every registered screen receives nothing; everything comes from context/hooks (`useFestipodData`, `useNavigate`, `useParams`). `WelcomeScreen` does not use `useFestipodData` (intro) — but still takes no props. (`LoginScreen`/`ConnexionScreen`/`AccessGateScreen` no longer exist — Festipod renders no access screen of its own; see [[knowledge_screens]] and [[knowledge_app-shell]].)
- **Identity: the current user may not be there yet.** `currentUserId` is the NURI of the profile document this session **owns**, so it is **empty until that document resolves** — see [[caveat_identity-ids-in-screens]] before keying anything on it.
- **The confirmation FOLLOWS the write.** Mutations reject rather than returning quietly, so a screen must `await` (or `.then`) before announcing anything: success toast and navigation on resolve, an error toast on reject, and the user kept on their edits. A toast fired beside the call announces a write that may never have happened — the pattern that had to be corrected on the event and profile edit screens.
- **Reading what the session has not read yet is asked for from an effect.** A screen about one event calls `openEvent(eventId)`, a screen about someone else's events calls `openUserEvents(userId)` (`UserProfileScreen`); both are state changes, so they run in a `useEffect` keyed on the id, never during render, and the getters (`getEvent`, `getUserEvents`) answer with what has landed. They are idempotent and are no-ops on providers whose read set is already complete (local fixtures); `openUserEvents` is also a no-op for oneself and for anyone who is not a contact. Verified in `festipodDataBase.ts` (the context contract), `refineData.tsx` (the ActivityPods implementation).
- **A write affordance is decided by ownership, in three states.** Both the control that *offers* the write and the screen that *performs* it consult the same answer (`data-layer` → [[knowledge_write-rights-are-ownership]]). `unknown` is never folded into either side: showing the form lets a non-owner edit on a guess, and bouncing them out tells a genuine owner their thing is not theirs. Render it as its own pending state.
- **Layout**: full-height flex column; `Header` at the top, content at `flex:1; overflow:auto`, `BottomNav` at the bottom **only for hub screens** (Home, Events, Profile, Friends). Flow screens (creation, editing, detail) have no `BottomNav`.
- **Feedback**: `showToast(message, 'success'|'info'|'error')` (`ToastContainer` mechanism exported by `sketchy/`).
@@ -0,0 +1,12 @@
---
type: rule
summary: Tests are free in what they exercise — attacks, forged or spoofed activities included — with one restriction: nothing a test does may break the dev environment (pod provider and its datasets, the backend's datasets, Redis, persistent test accounts)
---
# Tests never break the dev environment
**Rule:** (user directive 2026-10-30) A test may exercise anything, attacks included — a forged `Accept`, a spoofed or malformed activity, a crafted write — as long as it cannot break the dev environment. Concretely: an attack runs only between the throwaway accounts the run creates, and the run still deletes them; a test never writes another user's data, never alters a persistent account (the smoke's `alice`) beyond what the app itself does, never deletes, recreates or flushes a Fuseki dataset or Redis, and leaves the stack as it found it.
**Why:** the local stack is the shared state every suite and every manual session rely on; a broken pod provider, dataset or cache costs a reset (slow sign-ups, lost app registrations). An earlier reading — "no forged activity is ever sent to the stack" — was stricter than intended and left real attack paths unproven: the forged-contact line of the rights matrix is the only live proof that the participations audience resists a forged contact ([[decision_2026-10-29_participation-privacy-mechanism]]).
**How to apply:** before adding an attack line, check its blast radius: it touches only accounts created in this run, and its cleanup still runs when it fails. A MANUAL-CHECK line that is manual only because it would forge an activity may be promoted to a live line under the same condition. Read through the user's rules-allow-judgment: what is protected is the environment's integrity, not a list of forbidden activity types.
@@ -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`, per the platform flow recorded in the 2026-10-09 brief. 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 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.
## Consumed surface
@@ -32,6 +32,8 @@ Four resources, one per persisted entity: **events**, **meeting points**, **part
Through Refine's hooks only, never the provider directly: `useList` → `getList`, `useCreate` → `create`, `useUpdate` → `update`, `useDelete` → `deleteOne`. `getOne` is reached by Refine's cache plumbing rather than an explicit call. The app mounts Refine with `liveMode: 'auto'` and `mutationMode: 'optimistic'`.
`update` of the own profile is also how the participation secret is stored: before the first Join the app re-reads its profile explicitly (`getOne`, refetched, never trusting a cached copy), and when no secret is there writes `http://festipod.org/participationSecret` through `update` with the **full IRI** as the key, then reads the stored value back from the answer. The pod stores and returns such a predicate under the full IRI (verified live 2026-10-29 with the app's token), whatever the `fp` prefix registration.
`create` of an event or a meeting point carries `to: as:Public` among its variables: Festipod's declaration that the record is public, on which the Festipod backend's public read and discovery listing depend. See constraint 10.
### Record handling
@@ -51,7 +53,7 @@ What must keep holding for this backend to serve the app.
1. **Festipod's own vocabulary, not ActivityStreams.** The four entities are Festipod domain types defined by SHEX shapes this repo holds; none is an ActivityStreams type. A caller-supplied `jsonContext` and caller-supplied `types` are what make that possible, and both must stay caller-supplied.
2. **The identifier a record is addressed by must stay the identifier other records reference.** The seam carries the two separately because they differ on the other targeted backend; here they coincide, and the app relies on that. Splitting them later would break every cross-entity reference silently.
3. **A partial update must stay partial.** The app sends only changed fields. A move to replacing the resource would drop every field omitted, with no error.
4. **Every participation must be readable by every user.** The participant count is the number of participations of an event, counted with no filtering. What is confidential is the **link between a participation and a person** — only a connection can read the profile a participation points at and recognise who it is — never the participation record itself. The provider alone cannot satisfy it (need 1).
4. **Every participation must be readable by every user.** The participant count is the number of participations of an event, counted with no filtering. What is confidential is the **link between a participation and a person**: an attendee list holds opaque member URNs, and only a connection, who reads the secret on the person's profile, can recognise who a URN is — never the list itself. The provider alone cannot satisfy it (need 1).
5. **The count is never a stored, incremented field.** Reading a counter, adding one and writing it back derives each write from a read of the previous one and races itself through any data provider. This is measured, not theoretical, and it is why the app derives the count from the participation list at read time.
6. **Changes must be pushed, not polled.** Neither the app nor its BDD suites has a polling loop, and adding one is ruled out by project doctrine. `liveMode: 'auto'` is mounted on that basis.
7. **No process-global state that outlives a remount.** Two backends coexist in one page and the user switches between them at runtime; switching remounts Refine to discard the cache of the backend being left. Building this backend's providers must have no side effect that accumulates across switches — see friction 1, which is where this currently bites.
@@ -65,7 +67,7 @@ What must keep holding for this backend to serve the app.
2. **`getList` fetches the whole container on every call.** Filtering, sorting and pagination run in memory afterwards, so the cost of listing an event's participations grows with the user's total number of participations rather than with the event. No workaround; it is why a filter on a `getList` buys nothing beyond convenience.
3. **A list subscription reports membership only.** A container channel delivers `Add`/`Remove`, not a member's own fields changing, so a list of events does not refresh when an event's title, place or time is edited. Watching each record instead would open one channel per row. The event list is exactly the screen where this is visible.
4. **`types` takes precedence over `shapeTreeUri`, the opposite of what the configuration type documents.** The declared behaviour is that `types` is "only used as a fallback when `shapeTreeUri` is not set"; a resource setting both is in fact resolved from `types`. Establishing which one actually wins cost a read of the resolution path. Configuring exactly one avoids it, but the documentation should say which wins.
5. **The member shape of an `apods:attendees` collection is not pinned anywhere Festipod may read at work time.** The reconstruction documents the container discovery, not what a membership record is: a bare actor URI, or an object carrying event/participant/status. The project reconnaissance records only that a join "adds the actor to the collection" and that members come back "as full objects for a reader who is allowed, and as URIs otherwise". The ActivityPods backend's participation binding is designed against that: a member maps to `{ id, userId, isConfirmed: true }` (membership IS the attendance), with `eventId` supplied by the reader — the event the collection was read from — because a member carries no event link. The first runtime read against the local platform (T04.g) must confirm or correct this mapping.
5. **The member shape of an `apods:attendees` collection is not pinned anywhere Festipod may read at work time.** The reconstruction documents the container discovery, not what a membership record is. Read against a local platform (verified 2026-10-12), the collection is its own resource, linked from the meeting point by URI and never embedded; its items are, in Festipod, opaque member URNs recorded by the Festipod backend, and the participation binding maps each to `{ id, userId, isConfirmed: true }` (membership IS the attendance), `eventId` being supplied by the reader — the meeting point the collection was read from. The `userId` of such a record is a member URN, not a person: a person is recognised only by hashing (see constraint 4).
6. **The live provider's `subscribe` rejects with an uncaught `Forbidden` when the Pod refuses its push channel.** On this platform the channel creation (`POST /.notifications/WebSocketChannel2023`) answers 403 for the app's session, and each refusal surfaces as one unhandled rejection in the page — raised inside the provider, where the app cannot catch it; Festipod's end-to-end checks recognise it by its throw site. Reporting the refusal through the subscription's error path would let the app handle it.
@@ -1,97 +0,0 @@
---
type: brief
summary: Target model for sign-ups — a Participation READABLE by everyone (event ref + `active` boolean + a key-less reference to the participant's profile), deposited into the event's inbox; the creator processes the inbox, dedups WITHOUT knowing who, files the reference into a Set on the event and PURGES the cancelled ones; count = Set.size with no filtering (accepted upper bound); only connections can read the profile and recognize the person. Supersedes the mutated counter + plaintext userId.
---
# Brief (2026-07-20, revised 2026-07-27) — Set-based sign-ups
## The model
Laid down and refined by the PO on 2026-07-27. Everything is **keys and URLs** — no roles, no membership, no allow-list.
1. The participant creates a **Participation** object, **readable by everyone**, holding: the **reference to the event**, an **`active` boolean**, and a **reference to their *protected* profile that carries no key**. **Nothing else** — no description for now.
2. They deposit the **Participation's reference** into the **event's inbox**.
3. The **creator** processes their inbox **automatically**, as soon as they are online.
4. They **dedup** (see below) — **without knowing who the participant is**: they hold a name for the profile, not the key to read it.
5. They file a **reference** to the Participation into a **Set** carried by the event's document.
6. Anyone reads **`Set.size`** → the number of participants.
7. Someone **connected** to the participant holds their profile's cap, reads it, and **recognizes** the person.
**Withdrawal**: the participant sets `active` to false **on their own object**. The creator sees it when reading, and **purges** — removing the reference from the Set.
Three properties follow: **anonymous attendance by default** (even the creator cannot see who); **nobody modifies anyone else's sign-up** (only the participant holds the write key to their own object); **no plaintext `userId`** ever travels.
### The principle that holds it all together: the truth is in the object, messages are only hints
The object **controlled by the participant** is what counts. Any message — an inbox deposit, a purge notification — is only a **hint** that triggers a check, never an authority.
Consequence: **forgery becomes structurally harmless**. A fake « purge X » leads the creator to read X, find it still active, and do nothing. That is why inbox deposits **need not be signed** — which is just as well, since the contract promises no authenticated sender.
### Why a flag rather than a deletion
Without the read key, a **deletion** cannot be told apart from "nothing was ever there". A **readable** object carrying a **flag** transforms the problem: the cancellation no longer has to be *detected*, it is simply *read*. The blocker disappears instead of being worked around with a forgeable message.
### Why the identity pointer targets the existing profile
No need for a second document per participation: the participant's **protected profile** already plays that role, and their connections **already** hold the key to read it — that is the very definition of being connected. A third party sees an opaque reference.
The advantage over an encrypted field inside the Participation: **adding a connection rewrites nothing**. The profile is shared with a new connection once, durably (and irreversibly — the contract publishes no revocation). An encrypted field would require re-encrypting to N recipients and rewriting the Participation on every new connection.
## What this rests on
Two guarantees the contract publishes, and one thing it does not.
| What the model needs | Where it stands |
|---|---|
| A reference can **name without granting read access** | Published: *"A returned reference carries no key… A reference found inside a document yields a name, not a key."* |
| Sharing is **per document, durable and one-way** | Published: `inbox.share(doc, toUser)` — one act, no revocation, nothing per reader on a public document |
| **Anyone may deposit, only the owner reads** the inbox | Published: `inbox.postToDocument` / `inbox.read` |
| A **dedup key** letting the creator count distinct people without reading them | **NOT published.** See below — this is the open dependency. |
## The dedup: the requirement, and the gap
**The requirement, validated by the PO (2026-07-27)**: the creator must be able to tell two references from the *same* person apart from two references from *different* people, **without ever knowing who** — otherwise the count is not a count of people, and a participant could inflate it by creating several Participations.
**The contract publishes nothing that does this.** A reference "yields a name, not a key", and no call answers "do these two references belong to one person?". So the mechanism is **not Festipod's to specify**: it is a **gap to raise with the provider**, stated as a need — *a stable, per-person discriminator that can be compared without reading the referenced document*.
Design consequence, whatever the mechanism turns out to be: the Set is **keyed by that discriminator** — at most one reference per person. `Set.size` = the number of distinct people.
### The reservation that must outlive this brief
Any such discriminator is by construction a **pseudonym**: it does not say *who*, but it is comparable across contexts, so whoever collects references can link them. **Never present a Festipod action as "anonymous"** when it circulates one — the contract guarantees no anonymity, and making the Participation public widens the surface on which it is collected. Whether the pseudonym can be rotated, or scoped, is part of the gap above.
## Trade-offs deliberately accepted (PO, 2026-07-27)
- **No filtering on read.** The count is `Set.size`, **without** checking the `active` flags. We accept the **staleness risk**: a cancelled participation still counts until the creator has purged. `Set.size` is therefore an **upper bound**, exact once purged. *Rationale: keep reads simple and O(1).*
- **Purging is the creator's job.** No curator service, no catch-up by readers.
- **No description** in the Participation for now. *(To be reopened when the need arises: whatever we put there would become public.)*
- **Creator offline**: the Set does not move until they have processed their inbox. Accepted.
## What changes vs the current implementation
What exists today ([[knowledge_context-internals]] §participantCount) derives a `participantCount` **written by the owner** from inbox markers carrying the **plaintext `userId`**.
- **Drop the `userId`** from inbox deposits → only the **Participation's reference** remains.
- **Count distinct people** through the discriminator above, no longer `userId`s.
- **`event.participantCount` goes away**, replaced by `Set.size`.
- **Identity resolution** now goes through **reading the profile** (hence through being connected), no longer through the marker.
- **Withdrawal stops being a deletion** → `active` set to false + a purge by the creator. See [[caveat_participation-deletion]], whose requirement (« authoritative, must not come back ») still holds but changes mechanism.
Still valid as-is: **reactive reads**, **re-arming on reconnection**, and the **id-space fix** already shipped.
## Open points
- **Participation scope** — it becomes **public**, whereas current product doctrine places it in *protected* ([[knowledge_data-scopes-and-discovery]], concept `functional-domain`). That leaf describes **what is implemented**: do not change it until this brief has graduated, but **do update it at that point**.
- **Recognition by connections** (step 7) — and what happens to a broken connection: the contract publishes **no revocation**, so sharing a profile is permanent. Explicitly deferred to a second stage.
- **Public reads are not recursive** — this is the principle the whole model rests on, and it deserves to be stated on its own: *an item in the **public** store is public — whoever has the URL reads the content.* But **not recursively**: public content may **reference** private content, and **that is exactly our case**. So the creator reads the Participation (public) and **cannot** follow the reference to the profile (protected). That is what yields both readability by the creator and anonymity towards them — with no additional mechanism.
## Dependencies
- **Blocking — a contract gap**: no published way to **dedup without reading** (see above). Until the contract answers it, coding this model would produce a count that **claims** to be a count of people without being one. Raise it with the provider; do not emulate it here.
- **Parked**: **identity terminology** (wallet / user / profile) — see `.project/to-discuss.md`.
## Status: model settled, implementation gated
The model is **settled** (PO, 2026-07-27). What remains gated is the **implementation**, waiting on the dependency above. **Do not remove the current owner-derived counter** in the meantime ([[knowledge_context-internals]]).
Links: [[caveat_participation-deletion]], [[rule_document-per-entity]], [[rule_app-uses-sdk-surface-only]], app-security ([[brief_2026-05-18_authorization-matrix]], [[knowledge_trust-model]]).
@@ -1,100 +0,0 @@
---
type: brief
summary: One persisted shape holding on BOTH backends — NextGraph (per-document encryption) and ActivityPods (WAC) — references rather than embedded content, no stored counter, with the acl:Append behaviour and the deduplication gap left open
---
# Brief (2026-10-03) — One data shape for two rights models
Festipod puts all its persistence behind interchangeable Refine backends (the seam's own contract is in code, `src/shared/refine/providers/types.ts`). Two are targeted: NextGraph plus its polyfill, and `@activitypods/refine-providers`, whose reconstructed observation is pinned beside this leaf ([[contract_activitypods-providers]]). The question this brief answers: what structure survives both rights models unchanged.
## The primitives, side by side
| What is needed | ActivityPods | NextGraph |
|---|---|---|
| Unit of rights | LDP resource + WAC | **document** |
| List attached to a resource | `as:Collection` attached by a predicate, `semapps:dereferenceItems` | a Set inside the document |
| Let a third party contribute | `acl:Append`, or `apods:PostOutbox` to the recipient's inbox | `inbox.postToDocument` |
| Grant "my connections" | `acl:agentGroup` + a WAC group | per-document sharing, once per connection |
| Name without granting access | reference + WAC refuses the GET | reference carrying no key |
| Revoke | `pod-permissions.remove` exists | not published |
| Write offline | no (client-server) | yes (local-first) |
## The design rule that follows
**The common denominator is NextGraph**, whose model is the strict subset: no groups, no revocation, sharing wholesale per document. So structure for NextGraph and ActivityPods will serve that structure — more cheaply even, with groups. The converse does not hold: any structure that needs a server-side projection or a revocation is untenable on NextGraph.
## The structure
```
Event own document, public, written by its creator
└─ participations an attached list of REFERENCES (never embedded content)
Participation own document, owned by THE PARTICIPANT
├─ event → ref
├─ active → boolean
└─ participant → ref to the Profile
Profile own document, protected
```
## Why references and not embedded content — three facts
1. **Ownership.** The participation must be writable by its participant and by nobody else (withdrawal must be authoritative — [[caveat_participation-deletion]]). On NextGraph write access is granted per document, so the participation **must be** a document; embedded in the event it would be writable only by the event's creator.
2. **Per-reader variation is impossible inside a single document on NextGraph**, because encryption and sharing are per document. It is obtained by **indirection**: the reference is always present, and what varies is the ability to **follow** it.
3. [[rule_document-per-entity]] already says this, and says it for exactly this reason.
## The non-visibility mechanism differs — PO precision, 2026-10-03
On NextGraph the target is **undecipherable** for want of a key: nothing is absent, the reference itself is readable. On ActivityPods it is either inaccessible (WAC refuses the GET) or the reference is omitted by server-side projection. Both land on the same application state: **a reference one may or may not resolve.**
## Consequence on the domain types
The type models the NextGraph shape, where everything is present:
```ts
interface FpParticipationData {
id: string;
event: string;
active: boolean; // NOT optional — the participation is readable by everyone
participant: string; // NOT optional — a reference, which names without granting access
}
```
Resolution alone may fail: `resolveParticipant(ref)` yields `FpUserData | undefined`, where `undefined` means "not a connection". ActivityPods' omission is a **degradation the binding absorbs**: it reports "a participation whose participant does not resolve" — the same state as "I do not hold the key".
An error made in session and corrected here: `active?` and `participant?` were proposed first, which lifted the ActivityPods adaptation into the common model.
## The count
**No stored counter, ever.** The number of participants is `participations.length`. `knownParticipants` is the participations whose participant resolves — a presence test, not an access check. Withdrawal is `active: false` on one's own document.
## The three invariants that make the app platform-indifferent
1. One document per entity; never embedded content.
2. A reference may be unresolvable, and that is a **normal state** — neither an error nor an absence (the app shows "someone").
3. The list is an **upper bound that converges**; never assume it is complete at the instant (already documented by [[caveat_participant-count-one-connection-lag]]).
## Open points
### NOT VERIFIED — does `acl:Append` on a collection let a third party insert an item without being able to remove one?
WAC mode enforcement lives in `@semapps/webacl`, absent from the ActivityPods checkout consulted and not installed. The mode is real and genuinely used (`tests/apps/example.app.js` declares `accessMode: ['acl:Read', 'acl:Append']`), but its behaviour on a SparqlPatch `INSERT DATA` / `DELETE DATA` was not observed.
**The structure does not depend on the answer.** If Append works that way, the participant appends directly and the count is immediate. Otherwise the route is `apods:PostOutbox` to the creator's inbox, whose app adds the reference — which is exactly the NextGraph shape. Only the latency changes.
### OPEN — deduplication: neither platform counts people, and the implementation here does not either
[[brief_2026-07-20_attendance-set-model]] requires that the creator tell two references from the *same* person apart from two references from different people, **without knowing who** — otherwise the count is not a count of people and a participant can inflate it. **The code here does not do that** (verified 2026-10-05). A different problem is solved, and solved well: `materializeAttendance` (`src/shared/data/registration.ts`) keys its active set by a **per-deposit** `uid` minted from time plus randomness, so a broker re-sync replaying the same deposits converges instead of double-counting — that is **replay idempotence**, not person identity. The count is therefore a count of **deposits**: the same person joining twice counts twice. Three further findings about the current implementation:
- **The plaintext `userId` travels in the deposit**, and the cancellation fallback depends on it — a leave without a known `regUid` cancels the earliest still-active join carrying that `userId`, so N leaves cancel N joins. The attendance-set brief's own change *"drop the `userId` from inbox deposits"* was never made, so the creator **does** know who and the anonymity property does not hold today. The privacy-preserving discriminator was never needed because the privacy was never implemented.
- **Participations live in the protected scope**, not public, and withdrawal is still a deletion ([[caveat_participation-deletion]]) — so the target model is not in place either. The attendance-set brief's own status line, *model settled / implementation gated*, still holds.
- **The gap was never raised through the declared channel.** The five frictions in this app's polyfill declaration (`polyfill-surface/usage_festipod.md`) cover inbox idempotence, the absence of a push on one's own deposit, the reactive read not seeing one's own write, the impossibility of resetting the test wallet, and a wrong published signature. None asks for a per-person discriminator.
So the divergence is not "NextGraph has an upstream gap while ActivityPods does not": **both count deposits rather than people.** ActivityPods could arbitrate server-side because the server sees everything; NextGraph awaits a primitive. And the gap sits in **neither Refine provider** — it is in the polyfill on one side and the server on the other, which is what matters when deciding what to contribute where. The upper bound remains the common fallback position, already accepted by the PO.
## Two ActivityPods constraints not to rediscover later
- **The Refine providers cover CRUD only.** Creating the collection, setting WAC, creating a group, posting to the outbox are not in the data provider, which does not implement Refine's `custom` either — so there is no escape hatch through Refine and those calls happen beside it.
- **A custom ontology prefix must be registered at prefix.cc**, the Pod provider requiring every ontology to have a known prefix; this concerns our `fp:`. Shape trees must also sit at a public URL, one per entity, since they cannot reference other shapes — no sharing cascade.
## Reservation to keep alive
The reference to the profile is readable by everyone and therefore collectable: it is a **correlatable pseudonym, not anonymity**, and must never be presented as such. The reservation comes from the attendance-set-model brief, and the 2026-10-03 precision reactivates it.
@@ -18,26 +18,23 @@ Directives recorded at the resumption of the hand-off (`.project/handoff/refine-
Reading these at this stage is adoption-time reading, explicitly directed — the standing constraint against reading provider sources at work time is unaffected (`@activitypods/refine-providers` remains specified by its pinned reconstruction, `activitypods-providers/contract_activitypods-providers.md`).
- **Example apps** — `/home/sylvain/projects/reconnexion/welcometomyplace` and `/home/sylvain/projects/reconnexion/mutual-aid.app`: working integrations to mine for provider configuration, resources, and the on-behalf backend pattern.
- **Example apps** — the reference apps under `~/projects/activitypods/` (`welcometomyplace`, `mutual-aid.app`, `portejunes`), the only allowed source of pod-interaction patterns (project rule `festipod-only-reference-app-patterns`).
- **ActivityPods platform source** — `/home/sylvain/projects/activitypods/activitypods`; Semapps lives in a sibling folder.
- **Shapes** — `/home/sylvain/projects/activitypods/shapes`: readily usable, and **evolvable by us** if Festipod's entities require it (that repo is ours to adapt).
## Constraints already on record (see the 2026-10-03 brief and the reconstruction)
## Constraints on record
- `fp:` must be registered at prefix.cc; shape trees must sit at public URLs, one per entity, no sharing cascade.
- A Festipod CLASS with its own shape tree needs `fp:` registered at prefix.cc: the pod registers the ontology of every class it creates a data registration for, and refuses one whose prefix it cannot find (`registerOntologyFromClass` in the pod provider). Shape trees must sit at public URLs, one per entity, no sharing cascade. A Festipod PREDICATE on an existing resource does not need it: the pod stored and returned a `http://festipod.org/` predicate on a profile, `fp` unregistered (verified live 2026-10-29: written with the app's token, returned under the full IRI).
- The Refine providers cover CRUD only; `custom` is not implemented — no escape hatch through Refine.
- `getMany` silently drops failed fetches — it erases the "unreadable reference" state the model needs (a candidate contribution).
- `acl:Append` third-party insert-without-remove behaviour is NOT VERIFIED; the structure does not depend on the answer.
## Reconnaissance answers (2026-10-09)
- **On-behalf mechanism — ANSWERED.** An app backend is an ActivityPub actor (`@activitypods/app` `AppService`, Moleculer); it signs pod requests as the app actor and the pod impersonates the user after checking registration + grants. Frontend calls use the Solid-OIDC dual-identity token instead. No unattended per-user backend token exists.
- **Registration/Participation — production precedent found.** welcometomyplace persists NO registration resource: a join is an `as:Join` activity posted to the user's outbox, the backend service handles it and adds the actor to the event's `apods:attendees` collection (attached via `attachPredicate`, `PodCollectionsHandlerMixin`); a leave is `as:Leave`, same path in reverse. Membership is read via `pod-collections.getItems`. mutual-aid uses none of this (comments, likes, announces only).
- **Registration/Participation — production precedent found.** welcometomyplace persists NO registration resource: a join is an `as:Join` activity posted to the user's outbox, the backend service handles it and adds the actor to the event's `apods:attendees` collection (attached via `attachPredicate`, `PodCollectionsHandlerMixin`); a leave is `as:Leave`, same path in reverse. Membership is read via `pod-collections.getItems`. mutual-aid uses none of this (comments, likes, announces only). Festipod's adaptation, where participants must stay unidentifiable to non-contacts: [[decision_2026-10-29_participation-privacy-mechanism]].
- **Event-in-event — feasible without touching any shape.** The `as/Event` and `as/Profile` SHACL shapes are open (no `sh:closed`), and the generic ActivityStreams vocabulary already defines `as:context`, `as:partOf`, `as:inReplyTo`. A meeting point is a plain `as/Event` carrying an `as:context` link to its parent event — the presence of the link is what distinguishes it, nothing else. Shapes are consumed for access needs and container setup, not server-side resource validation, so nothing breaks.
## Open questions
- Participation topology — the one real divergence from the 2026-10-03 structure: welcometomyplace keeps the canonical list creator-side (an `apods:attendees` collection on the event, writes backend-mediated), where the two-rights-models structure makes each participation a participant-owned document. Both land on the same application state (a reference that may or may not resolve); the choice is who owns the write path. **Arbitration route decided 2026-10-09: the implementation plan evaluates both against the concrete screens (count, withdrawal, discovery, reference non-resolution) and submits an argued recommendation before anything is frozen.**
- `as:Join` has a shape but no shape tree: if participations must be addressable as resources in access needs, adding `shapetrees/as/Join.ttl` for an already-existing AS type is the minimal move — or the collection pattern makes it unnecessary.
- The backend's deployment shape (service alongside the app? a platform app?).
- Whether the four pointed-to provider contributions (from the hand-off) are still the right set once the backend exists — some may move from "contribution to the providers" to "our backend's job".
- Whether the three pointed-to provider contributions (from the hand-off) are still the right set once the backend exists — some may move from "contribution to the providers" to "our backend's job".
@@ -0,0 +1,16 @@
---
type: caveat
summary: Several coherence-pass features were built and unit-tested but never played against a real pod — invitations, image upload, bio/city/photo round trip, profile completion — plus a few known limits of the read side
last_checked: 2026-10-21
---
# Caveat: built but not verified on a real pod
Unit and UI-layer tests are green; none of the following has run against a live ActivityPods stack. Treat as unverified, and re-check at the first run on a pod:
- **Invitations**: that the `Announce` is delivered to the chosen contacts' Pods.
- **Image upload** ([[decision_2026-10-21_images-are-uploaded-to-the-pod]]): container resolution, the `Location` header, the public-read grant, and the re-consent for the File access need.
- **Profile round trip**: bio, city and photo (`image`) written and read back through the provider.
- **Profile completion**: the onboarding gate on a pod whose profile has an empty name; a pod linking no profile at all only gets a message, since creating and linking an `as:Profile` is outside the pinned contract.
Known limits of the read side, by design or by backend: catalogue entries carry no participant count (so cards of events never opened show none, and gain one once opened); an empty catalogue is indistinguishable from an unreachable one; another person's events are not listable (`getUserEvents` answers for the signed-in user only).
@@ -31,8 +31,20 @@ pod provider 3000 (OpenID + `/auth` signup/login), pod provider frontend 5000, F
On a fresh pod, an `as:Event` POST lands at the pod root (`/alice/data/<uuid>`): typed containers such as `/alice/data/as/event` exist only after a user installs an app whose access needs declare that shape tree. Do not expect typed containers before app registration.
## Data location (hard-won, 2026-10-26)
- **Fuseki's data dir must stay on the SSD** (`/home`, NVMe). Measured: a signup is ~10 s on the SSD, 150–200 s on the spinning HDD (`/hdd-home`) — TDB2 fsyncs per write and the seek time dominates. The HDD mount also hosts the bulk of the host's files; keep fuseki off it.
- **Never replace `data/fuseki` with a symlink after the container exists.** Docker resolves the bind source when the container is CREATED; moving the data away and leaving a link behind leaves the running container with a broken/empty mount (symptom: signup 500 « Error when creating secure dataset … FileNotFoundException: /fuseki/system_files/… »). `docker restart fuseki` re-resolves and heals — until the next behind-its-back move.
- The e2e suites delete the accounts they create at the end of every run (user decision 2026-10-26): `DELETE http://localhost:3000/.account/:username` with the user's token (a user may delete only their own account). The platform tombstones the account, sends the Delete activity, and drops the fuseki dataset on a delay — so test data no longer accumulates and the periodic full wipe is no longer needed. `diag-invite.ts` is the only suite left without cleanup (manual diagnostic).
## Backend fresh-boot note
The Festipod backend has its OWN datasets, `festipod` (main, created SECURE at first boot — WebACL is on) and `settings-festipod` (accounts), named like the sibling apps' (`welcometomyplace`/`settings-wtmp`…). Never point it at the pod provider's `settings` dataset, and never delete or recreate `settings`: it holds the pod provider's accounts. A backend dataset reset follows the compose file's header (delete the datasets and the secure dataset's files on disk, empty the backend's Redis cache database). After a reset, pod accounts that had installed the app must install it again: the new backend knows none of their registrations.
On a FRESH backend dataset (after a wipe), the app's actor and its access-need group are created in parallel by the mixin's boot and can race: the actor can end up without `interop:hasAccessNeedGroup` (symptom: `GET /app` links a group that 404s; consents register zero grants). `backend/services/app.service.js` heals this at boot by polling for the group (up to 60 s) and attaching it. If the backend is restarted while its dataset is wiped, wait for the heal before running consented flows — or simply re-check `GET /app` links a group that resolves 200.
## Teardown
`pkill -f moleculer-runner; pkill -f react-scripts`, then `docker compose down` in `pod-provider/`. Data persists in `pod-provider/data/` (gitignored); delete that directory to fully reset.
`pkill -f moleculer-runner; pkill -f react-scripts`, then `docker compose down` in `pod-provider/`. Data persists in `pod-provider/data/` (gitignored); delete that directory to fully reset. With the suites cleaning up after themselves (above), a full reset should now be a rare event, not a maintenance ritual.
Current stack test account: `alice` / `Test1test` on localhost:3000.
@@ -0,0 +1,26 @@
---
type: decision
summary: Festipod keeps no friend model; contacts are the Pod's own apods:contacts collection, a contact request is an Offer{Add{my profile}} posted from the browser to my own outbox, invitations are Announce{event} activities, and nothing is built for acceptance or push
---
# Decision (2026-10-21): contacts are the Pod's social graph
## Decision
- **No home-made friend model.** A user's network is the `apods:contacts` collection linked from their actor document (the app falls back to the profile record), managed by the Pod — mutual when both went through the request/accept flow, though not guaranteed (see the platform defects below). In the app, `friendships` is only a mirror of that collection and `getFriends` reads it. Contacts are resolved to profiles through the same path as event participants.
- **Contact request** (`addFriend`): an `Offer{ object: Add{ object: { id, type: 'Profile' } }, to, target }` addressed to the other person's actor, posted to the requester's **own outbox with their own token**, from the browser (`src/shared/data/contacts.ts`). The `type: 'Profile'` is embedded in the activity: the platform's `CONTACT_REQUEST` matcher requires it, and it must not dereference the profile — a protected profile 403s anonymously and a bare-URI object was silently ignored (fixed 2026-10-22). The recipient lands it in `apods:contactRequests` and accepts on their Pod provider's own network page. **Festipod builds no acceptance screen.**
- **Sharing my connection** (the primary flow — see [[decision_2026-10-22_connection-sharing-is-the-invite-capability]]): an **invite capability** (VerifiableCredential) issued by the Pod (`POST {webId}/vc/v0.3/credentials/issue`), shared as `{frontendUrl}/invite/{capability}`; the recipient's contact request carries a VerifiablePresentation of it and is **auto-accepted** by the inviter's Pod. Strictly better than the bare request: no manual acceptance, and the invitee sees the inviter's profile (name, photo) through the grant.
- **Invitation to an event** (`inviteToEvent`): an `Announce{ object: <event>, to: [chosen contacts] }` posted the same way. The invitable set is the real contacts only.
- **No backend service added.** A user-initiated request needs nothing the browser cannot do; a backend handler (notifications for people without the app) would be speculative without a pod to test it on. Push notifications stay the Pod provider's job.
- Providers with no Pod (the disconnected demo, the suspended NextGraph path) **reject** `addFriend` and `inviteToEvent` instead of simulating state.
## Channels
Participations are **public objects** (anyone sees that there are participants), but the **identity link** is protected — the profile is readable by contacts only, and the carved rule [[behavior_participation-listing-is-contacts-only]] reserves listing a person's events to their contacts, for every client. How contacts recognise participants and list a contact's events is [[decision_2026-10-29_participation-privacy-mechanism]]; the app reads no one else's outbox.
## Consequences
- "Demande envoyée" is session-local: the target's request collection is not readable by the sender, so after a reload the button offers the request again.
- There is no `removeFriend`.
- The `apods:contacts` list is a display, not a grant: the pod adds the sender of a received `Accept` of a contact request without checking a request was sent (a sender whose profile it cannot read is turned away; one who first sent a request of their own is added — verified live 2026-10-29), and keeps a removed contact in the `/contacts` WAC group (both platform defects reported upstream 2026-10-29). Festipod never derives a read right from the list — rights follow the group, see [[decision_2026-10-29_participation-privacy-mechanism]].
- The request/accept round trip is verified live by the rights matrix (2026-10-29): the request posted from the requester's outbox, its acceptance naming the stored `Offer`, both lists then naming each other.
@@ -0,0 +1,18 @@
---
type: decision
summary: Images (event cover, profile photo) are uploaded as binary LDP resources to the Pod's file container from the browser, then made publicly readable; the URL is stored in coverImage / photo; existing users must re-consent for the new File access need
---
# Decision (2026-10-21): images live in the user's Pod
## Decision
- `useUploadImage` (`src/shared/hooks/`) resolves the Pod's `file` container, POSTs the file binary with the user's token and reads the new URL from the `Location` header, then calls `grantPublicRead` (`src/shared/utils/`): a best-effort WAC ACL PATCH granting `foaf:Agent` read. Without the grant, an image nobody else can read is a dead feature. Without a session (the demo) the upload rejects.
- The URL is stored as `coverImage` on an event and `photo` on a profile. On ActivityPods the cover maps to the AS2 term `image` and the profile photo to `vcard:photo` — the term both reference apps read (mutual-aid, welcometomyplace), after verifying the `vcard:` prefix is present in the platform context. Interop cross-apps : une photo stockée sur un autre vocabulaire serait invisible partout.
- The app declares the `shapetrees/File` access need (read + write) in `backend/services/app.service.js`. **Users who registered the app before this change must re-consent** for uploads to be allowed.
- `resolveContainerUri` and `fetchJson` come from `@activitypods/refine-providers/utils`, which the pinned contract does not list: this use is outside the contract and must be declared in the usage declaration / raised to the provider.
- The event cover falls back to a neutral gradient; the old stock-photo map is gone.
## Consequences
Covers and avatars are visible to anyone who can read the event or profile record, as the `grantPublicRead` makes the file itself public. Never verified end to end on a real pod — see [[caveat_t06-unverified-on-a-real-pod]].
@@ -55,9 +55,6 @@ Mutations on the create/participate path **reject** rather than returning quietl
A `Participation` written **today** carries the profile document's NURI in `fp:user`, so the direct join `u.id === userId` matches. Participations written under the **earlier** scheme carry `urn:festipod:user:<normalized-handle>`, which matches nothing directly.
**`resolveParticipantUser`** is the single join point and tries, in order: (1) the direct id match — today's writes, and the demo seed's bare `user-1` space; (2) failing that, strip `USER_PRINCIPAL_PREFIX` and compare the remainder to `normalizeIdentifier(profile.username)`. Never join by direct comparison at a call site: getting it wrong renders every participant as « participant inconnu », which shipped once. `USER_PRINCIPAL_PREFIX` is **read-side only** — nothing mints it any more; it is not a shape to write against. The inbox deposit `uid` (`mint…`) is a third space that takes **no** part: it identifies a deposit for the counter, never a user.
> **Horizon.** The target model drops the plaintext `userId` and resolves identity by reading the profile — [[brief_2026-07-20_attendance-set-model]], gated. The id-space fix is noted there as still valid: do not undo it in anticipation.
## Reads = `watchShape`, writes = an optimistic overlay
Reads go through `useShapeQuery(shape, scope)` — three scoped reads (events/public, profiles/protected, participations/protected) mapped by `shapeAdapters.ts`; `ready` combines their `isSuccess` flags. The app resolves, lists and re-queries nothing ([[rule_document-per-entity]] §Reads).
@@ -90,6 +87,10 @@ Two measurement points are laid down **as a pair**: the owner's materializer log
The app settles its identity once, before anything renders, and offers no way to change it (`app-security` → [[decision_2026-08-10_the-barrier-names-no-identity]]). One page hosts exactly one identity for its whole life, so there is no identity-change reset: no `useEffect([identifier])`, no cap reset, no registry-cache reset. Do not reintroduce a reset for a transition that cannot happen. Cross-identity **isolation** is still a real requirement, but proving it needs two genuinely separate browser contexts (`bdd-testing` → [[rule_tests-validate-festipod-not-the-sdk]]).
## On ActivityPods the context is `refineData.tsx`, and read-your-writes is confirmed by a collection
The ActivityPods implementation of the same context (`src/shared/context/refineData.tsx`) holds a join or leave as a pending act on top of the reads and releases it only when a read issued after the act confirms it — the person's own participations collection for membership, the meeting point's attendee list for the count — or when a bounded re-read schedule runs out (verified in code). A read that merely shows the asserted state is not a confirmation: it may predate the act. Details: [[decision_2026-10-29_participation-privacy-mechanism]].
## Mutations are no-ops in local mode
In local/demo mode (`useLocalData`), `createEvent`/`joinEvent`/`leaveEvent`/`updateEvent` are **no-ops** (a log, no state change) — yet the screens still show a success toast. Misleading UX, unchanged. See [[knowledge_data-modes]].
@@ -1,6 +1,6 @@
---
type: knowledge
summary: The Fp* app types and their SHEX shapes — Event (no host), UserProfile, Participation, MeetingPoint and Notification are persisted, Friendship stays local-only; the generated ORM names carry NO Fp prefix and are aliased at the import sites
summary: The Fp* app types and their SHEX shapes — Event (no host), UserProfile, Participation, MeetingPoint and Notification are persisted, Friendship has no shape of its own; the generated ORM names carry NO Fp prefix and are aliased at the import sites
last_checked: 2026-08-17
---
@@ -11,11 +11,13 @@ last_checked: 2026-08-17
| Type | Persistence | Key fields |
|---|---|---|
| `FpEventData` | SDK (Event shape) | title, date, startDate, endDate, startTime, endTime, location, distance, participantCount, coverImage |
| `FpUserData` | SDK (UserProfile shape) | name, initials, username, role, isPublic |
| `FpUserData` | SDK (UserProfile shape) | name, initials, username, role, isPublic, bio, city, photo (all optional; on ActivityPods `photo` is the AS2 `image`) |
| `FpParticipationData` | SDK (Participation shape) | event + user + isConfirmed |
| `FpMeetingPointData` | SDK (MeetingPoint shape) | event, host, title, place, time |
| `FpNotificationData` | SDK (Notification shape) | recipient, type, ref, payload, timestamp, isRead |
| `FpFriendshipData` | **local-only** | userId + friendId |
| `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).
**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]].
@@ -2,7 +2,7 @@
type: _overview
summary: Festipod's product model — the meeting point grafted onto a public event as the unit of value, its actors, its business concepts, and the confidentiality scopes (public/protected/private) per entity
triggers:
keywords: [point de rencontre, rencontre, greffe, greffer, événement, déclarant, hôte, inscrit, inscription, communauté, connexion, festival, déduplication, découverte, périmètre, scope, public, protected, privé]
keywords: [point de rencontre, rencontre, greffe, greffer, événement, déclarant, hôte, inscrit, inscription, participation, participer, communauté, connexion, festival, déduplication, découverte, périmètre, scope, public, protected, privé]
paths: ["src/modules/*/features/**"]
---
@@ -18,12 +18,14 @@ Festipod lets users create **meeting points** that *graft* themselves onto exist
## Scope & confidentiality
The product model of **who sees what** — personal data reserved to one's network, public events/meeting points, identified-or-anonymous sign-up notification — is a business fact: see [[knowledge_data-scopes-and-discovery]]. The detailed authorization matrix (actor × verb) and its incubation live in the `app-security` concept ([[brief_2026-05-18_authorization-matrix]]).
The product model of **who sees what** — personal data reserved to one's network, public events/meeting points, an anonymous sign-up notification to the host — is a business fact: see [[knowledge_data-scopes-and-discovery]]. The detailed authorization matrix (actor × verb) and its incubation live in the `app-security` concept ([[brief_2026-05-18_authorization-matrix]]).
## Links
- [[knowledge_business-model]] — the event / meeting point inversion
- [[knowledge_actors-and-concepts]] — reference for actors and business concepts
- [[knowledge_data-scopes-and-discovery]] — public/protected/private scopes per entity + discovery
- [[behavior_participation-listing-is-contacts-only]] — the carved-in-stone rule: listing someone's events is contacts-only
- [[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
@@ -0,0 +1,32 @@
---
type: behavior
summary: The link between a person and the events they participate in is personal data — listing someone's events is reserved to their contacts; the person themself always can, anyone else never
---
# Participation listing is contacts-only
> **La règle (canonical statement, French):** *« On ne doit pouvoir lister les événements auxquels participe une personne que si on est en contact avec elle. »*
A **product invariant**, carved in stone (user decision 2026-10-23). It governs any feature, screen, query or data-layer mechanism that surfaces *which events a given person participates in* — whoever asks, whichever channel.
## What it protects
The set of events someone attends reveals where they go, when, and with whom — it is **personal data**, in the same class as the profile itself. The event and the meeting point are public; the *link person ↔ participation* is not. An ordinary user must learn nothing from it; only a **connection** (bilateral contact) may.
## The three outcomes
| Who asks | Answer |
|---|---|
| The person themself | always listable — their own acts are theirs |
| A contact (bilateral connection) | listable — being a contact IS the grant |
| Anyone else | **never** — no listing, no count of *their* events, no cross-event correlation |
## Deliberately mechanism-free
The rule states the **what**, never the **how** — and that is intended. Which resource carries the link, how contacts read it, what the data layer exposes: all free, and all **constrained**. Any implementation choice — an attendees collection, an outbox read, an index, a grant — is acceptable **only while** the rule holds for every client, not just the Festipod app. A mechanism that lets a foreign client enumerate one person's events (correlatable IDs, scannable public collections) violates the rule even if the app's own UI behaves.
## Related
- [[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-10-21_contacts-are-the-pod-social-graph]] — what "contact" means (the Pod's bilateral `apods:contacts`)
@@ -0,0 +1,12 @@
---
type: decision
summary: The meeting-point creation form carries a title (optional) and a place (required) only; free-text "when", duration and invitees were removed because the Pod vocabulary has no slot for them, and creation is announced only once it happened
---
# Decision (2026-10-21): the meeting-point form asks only what can be stored
- Fields: **title** (optional, the intent) and **place** (required, stored as `location`). Nothing is merged into another field any more.
- **When**, **duration** and **invitees** were removed from the form: ActivityStreams has no term for a free-text moment or a free-text duration (`startTime` and `duration` are structured values), and the app has no invitee model. A field that vanishes after submit is worse than no field. `time` stays unmapped on ActivityPods; screens show it only when a record carries one, and the invented "~60 min" is gone.
- Reopening this needs a slot offered by the provider (or a structured time/duration input), not a new free-text field.
- `addMeetingPoint` returns a promise: the refine provider propagates the write, the disconnected demo rejects. The screen toasts success only after resolution and an error otherwise.
- Titles may be absent: cards show the place alone, and the title line only when there is one.
@@ -0,0 +1,20 @@
---
type: decision
summary: Sharing my connection means creating an invite capability (VerifiableCredential issued by the Pod) and sharing the pod provider's /invite/<capability> page — the recipient's contact request carries a VerifiablePresentation of the capability and is auto-accepted; no QR, no profile-url page
---
# Decision (2026-10-22): connection sharing is the invite capability
## Decision
- **No QR code.** Neither the reference apps nor the platform need one (kept from the 2026-10-21 arbitration).
- **The shared link is the platform's invite page, not a profile page.** Festipod creates an **invite capability** — a `VerifiableCredential` issued by the user's own Pod at `POST {webId}/vc/v0.3/credentials/issue` (name "Invite Link"; `apods:hasActivityGrant` = the contact-request shape; `apods:hasAuthorization` = Read on the profile document) — and shares `{frontendUrl}/invite/{encodeURIComponent(capabilityUri)}`. Implementation: `src/shared/utils/inviteLink.ts`, `src/shared/hooks/useInviteLink.ts`.
- **Why this and not the `/network/@user@host` page**: a bare profile link fails exactly for the person it is shared with — the not-yet-contact, who gets an empty public view and a manual-accept round trip on the Pod provider. The invite page instead reads the inviter's profile **through the capability** (rich name/photo) and its Connect button posts the contact request **with a VerifiablePresentation of the capability**, which the inviter's Pod verifies and **auto-accepts** (`inviteLinkContactRequest` in the pod provider backend). The capability IS the proof of invitation.
- The `frontendUrl` (host of the invite page) is discovered via nodeinfo of the viewer's pod host, as before.
- The earlier `decision_2026-10-21_profile-link-not-qr` is **superseded by this one** (link target changed from the network page to the invite page).
## Consequences
- The ActivityPods pod-provider frontend fix of 2026-10-22 (public view of `/network` no longer fetching the private profile) is no longer on Festipod's contact path; it remains a standalone correctness fix there, uncommitted.
- Invite capabilities are created on demand; several valid ones may coexist (each grants the same profile read). Revocation is the Pod provider's job.
- Not yet verified end to end on a real pod pair — same caveat as the contacts decision.
@@ -0,0 +1,59 @@
---
type: decision
summary: How sign-ups stay unlinkable on ActivityPods — a private Join handled by the backend on emission; attendee lists of member URNs hashed with a secret kept in the profile, so only contacts recognise anyone; a participations collection per person readable by their contacts group; the creator notified anonymously
---
# 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.
## What it protects: two links
- **Event → participants.** From an event (always public) anyone can count the participations, but nobody learns *who* participates — no name, no WebID — unless a contact.
- **Person → events.** Nobody can list the events a person participates in, unless a contact. Nothing published on a person's behalf identifies an event.
The **creator of a meeting point has no particular read right**: for these two links they are "anyone else" unless a contact of the participant. The **Festipod backend** is not a person: it acts (records, notifies) and shows nothing to anyone.
"Contact" is the audience the platform itself shares the profile with: the person's `/contacts` WAC group. The pod puts someone in it only through the person's own acts — the target of a contact request they send, the requester of a request they accept, whoever presents an invite link they issued (accepted automatically, so a forwarded link admits its holder) — never through an activity a stranger sends.
| Who | From an event | From a person |
|---|---|---|
| The person themself | sees themself | lists their events |
| A contact (current or future) | recognises which of their contacts participate | lists the person's events, past ones included |
| Anyone else, the creator included | a count of participations, opaque member URNs | nothing |
## The mechanism
1. **Sign-up.** The participant posts an `as:Join` (withdrawal: `as:Leave`) to their own outbox, naming the **meeting point directly**, **not public and not addressed to anyone**. The outbox is filtered by read rights: a non-public activity is readable only by its recipients and its author (verified 2026-10-29 on the local pod — absent from the anonymous listing, direct GET refused 403), so the target stays hidden from strangers with no intermediate object.
2. **The backend acts on emission** — `onEmit` of the participant's outbox, the welcometomyplace registration pattern (`PodActivitiesHandlerMixin`); the app reads the outbox through `apods:ReadOutbox`. The creator's inbox receives no activity of the participant.
3. **The member URN.** `urn:festipod:userref:<lowercase-hex sha256("<participant WebID> <event URI> <secret>")>`, where the event URI is the meeting point's anchor (`as:context`). The backend computes it from the participant's secret and records it in the meeting point's `apods:attendees` collection (in the creator's pod). Properties: the same for one person across all meeting points of one event (the per-event count is the number of **distinct** URNs, publicly computable); unrelated from one event to another; computable only by whoever can read the secret — the person, their contacts, the app. A client never supplies a URN, and the backend's ledger assigns one URN per (meeting point, participant), persisted before any write: every later act on that pair — after a failure or a rewrite of the secret — uses it, so one person never holds two entries in one list. Every collection write is verified by reading the collection back: a refused write or a failed read leaves the act unrecorded and unnotified, so the next act retries it.
4. **The secret** lives in the participant's `as:Profile`, under the Festipod predicate `http://festipod.org/participationSecret` (32 random bytes, hex), so it has exactly the profile's audience: the app and the person's contacts. The participant's browser generates it at the first sign-up when absent and writes it through the ordinary profile update, before posting the Join. A Join whose author's profile carries no usable secret is refused by the backend (nothing recorded). Verified live 2026-10-29: the pod stores this predicate written with the app's token and returns it under its full IRI, although `fp` is not registered at prefix.cc.
5. **The participations collection** — the person-side record of where they participate, for themself and for their contacts. An `as:Collection` in the participant's pod, attached to their profile under `http://festipod.org/participations`, created by the backend the way welcometomyplace attaches `apods:attendees` to an event (`PodCollectionsHandlerMixin`), and readable by the participant's `/contacts` WAC group (`pod-permissions.add` with `acl:agentGroup`, the group found through `pod-wac-groups.getUriFromCollectionUri` on `apods:contacts`). The group is evaluated at read time: a future contact sees past participations. The pod creates such a collection **world-readable** (observed 2026-10-12 on the attendee lists): the backend takes that public read away, with the same revocation it applies to the list of a meeting point switched private — a participations collection readable by strangers would list the person's events to anyone. Its items are meeting point URIs, added on a Join and removed on a Leave by the backend. The backend creates the collection at the person's first sign-up when the profile links none (one creation at a time per person); a profile with no collection has no participation to list.
6. **Recognition on an event.** The viewer reads each contact's profile once per session (it is read for names anyway) and keeps the secrets in memory; for an event, it puts the URNs of all its meeting points' lists in a set, hashes each contact once and tests membership — O(contacts + participations) per event, no network beyond the lists. Measured 2026-10-29 (Bun, Web Crypto): 500 contacts / 2 000 participations in 3.4 ms, 5 000 / 20 000 in 28 ms.
7. **Notification of the creator** on every sign-up and withdrawal, sent by the backend with `pod-notifications.send` (the welcometomyplace call), **always anonymous** ("a participant…"); the backend never passes the participant's profile. Naming a participant to a creator who is their contact would need a trustworthy contacts signal the app can read: the `/contacts` group is not readable by the app (403, verified live 2026-10-29), and the `apods:contacts` list can be forged (below). `pod-notifications.send` posts from the app's own outbox, which requires the backend to run with WebACL on a secure main dataset, as the three reference apps do.
## Rejected alternatives
- **Addressing the Join to the creator** (welcometomyplace's choice, where events are private): the creator's inbox, and the participant's outbox as a recipient, show them the participant's WebID.
- **Addressing the Join to the contacts**: an activity's read rights are granted once, at emission, to each recipient individually (semapps `setRightsHandler`; `getRecipients` expands only the sender's followers) — future contacts would never see past participations.
- **One read grant per item of the `apods:contacts` list**, kept in sync with contact changes: the list is forgeable — on a received `Accept` of a contact request the pod adds its sender without checking that a request was ever sent (pod provider `contacts/request.ts`, `acceptContactRequest.onReceive`); it first caches the sender's profile, so a sender whose profile the recipient cannot read is not added, but a sender who first sends the person a contact request (which lets the person read the sender's profile) is added (both verified live 2026-10-29) — a stranger would then gain the person's participations, where the group grants them nothing (verified live the same day: the forged contact is refused the profile and the participations collection, and is notified anonymously).
- **Granting the contacts group read on each Join**: the app holds read only on activities (`apods:ReadOutbox` maps to a default read on the activities container), no control; the browser's token carries the app's rights.
- **A private object named by the Join** (an "event reference"): useless once the Join is non-public, and creating its container and ACL from the browser is not a reference-app path.
- **A secret readable by its owner only**: contacts could not recognise anyone from the event side, and resolving identities would mean reading every contact's participations on every event view.
- **The backend resolving identities for viewers**: it would turn the backend into the gate of identity on the main screen.
- **Participations stored in the profile itself**: the profile is read for every contact at session start and would grow with each person's history.
## Accepted residuals
- The target of a contact request is in the requester's `/contacts` group as soon as it is sent, before accepting: they see the requester's profile and participations (user-accepted 2026-10-29). Showing such pending requests in the contacts screen is to be discussed.
- **Platform defect, reported upstream (2026-10-29):** `removeContact` removes from the `apods:contacts` list only, never from the `/contacts` group nor from the profile's read authorisation (pod provider `contacts/manager.ts`) — a removed contact keeps seeing the profile and the participations until the platform fixes it.
- **Platform defect, reported upstream (2026-10-29):** the `apods:contacts` list accepts a forged `Accept` (above) — the reason notifications stay anonymous; the contacts screen, which displays that list, shows such a stranger as a contact.
- A person who rewrites their secret between two meeting points of one event is counted twice in that event — no worse than a second account.
- The outbox root, read anonymously, carries in its pagination link the URI (never the content) of the newest activity: it reveals that an activity exists.
- An act writes twice (the list in the creator's pod, the collection in the participant's pod), not atomically: a failed write leaves the two apart until the person's next act on that meeting point.
- Removing the app leaves the collection and its grant in place.
- If the pod keeps refusing to link a new collection to the profile, each retried Join leaves one more empty, unlinked collection in the person's pod (the act itself stays unrecorded and retryable).
## Not built: public users
A future per-person setting will make some users **public**: their Joins become public, their list entries hold their WebID instead of a URN, their participations collection becomes readable by everyone. Switching a person between the two modes converts their existing entries. Nothing in the mechanism above precludes it.
@@ -16,14 +16,14 @@ 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 | **protected** | the participant + 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]] |
| 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 |
**Deviation on the ActivityPods backend (user decision 2026-10-12, open):** a public meeting point's attendee list — its `apods:attendees` collection, whose members are WebIDs, which embed usernames — is **world-readable**, the pod's default, instead of protected. Moving it to the protected scope is a later task; until then the end-to-end rights matrix tracks it as a KNOWN-DEVIATION line, never as a positive assertion. When the meeting point is switched private, its attendee list stops being readable anonymously with it.
The attendee list of a public meeting point — its `apods:attendees` collection — is world-readable, and that is the **model, not a deviation**: participations are public objects. What is protected is the **identity link**: the list holds opaque member URNs that only a contact of the participant can recognise, and a person's events are listable by their contacts only; the meeting point's creator has no particular read right. The mechanism is [[decision_2026-10-29_participation-privacy-mechanism]]. When the meeting point is switched private, its attendee list stops being readable anonymously with it.
Guiding principle: **the "public" side (meeting point, event) and the "personal" side (profile, participations, connections) coexist within a single user.** Personal information is reserved to the **network** (bilateral connections), never visible to an ordinary user.
Guiding principle: **the "public" side (meeting point, event, the participation as an object) and the "personal" side (profile, the identity link of a participation, connections) coexist within a single user.** Personal information is reserved to the **network** (bilateral connections), never visible to an ordinary user.
- **Meeting points / events are universally public.** Any user can read them and subscribe; creating a meeting point makes you its host, declaring an event makes you its declarer (no prerequisite).
- **The host is the sole holder of write rights** on their meeting point; the declarer has no particular right over the meeting points grafted onto their event.
@@ -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 (product intent).** Signing up to a meeting point notifies its host: identified if the participant is one of the host's connections, **unnamed otherwise**. This "identified if known, unnamed otherwise" falls out of scope placement — the host can read the sign-up, but not the *protected* profile it points at unless they are connected. The app states the intent; it implements no filter of its own.
> **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]]).
## Settled: the event write model is OWNER-ONLY
@@ -15,7 +15,8 @@ last_checked: 2026-10-19
**The backend is base-path aware** (`backend/services/base-path.js`), because SemApps is: it mounts every route it adds under the base URL's path (LDP resources, the app actor and its inbox, public keys, access needs, webhooks, `/nodeinfo/2.1`) but keeps the `/.well-known` ones (`context.jsonld`, `nodeinfo`, `solid`, `void`) at the origin root. Festipod's own routes (`/app`, `/as/actor/app`, `/catalog`) are mounted under the base path the same way, the public-surface allow-list matches paths relative to it (the two `/.well-known` endpoints against the full path), and the Solid-OIDC `client_id` is the requested URI rebuilt from the base URL as written — so it equals, byte for byte, the actor URI the backend mints and the `${backendUrl}/as/actor/app` the front end derives. At the root (`http://localhost:3003`, local dev) every decision is identical to the pre-base-path code. To verify: `bun test backend/services` (with and without `SEMAPPS_HOME_URL=https://<host>/api/`).
**First-boot traps.**
- The backend registers its app actor in its dataset with the domain it has on the FIRST boot: set the domains in Coolify before the first deployment; changing them later means deleting the two datasets and redeploying.
- The backend registers its app actor in its dataset with the domain it has on the FIRST boot: set the domains in Coolify before the first deployment.
- The backend runs with WebACL on, as the reference apps do, so its main dataset must be SECURE: the backend creates it so when it does not exist, and refuses to start ("the main dataset is not secure") on an existing one created otherwise. Starting again from nothing (domains changed, or a dataset created before WebACL was on): delete the two datasets in Fuseki AND the secure dataset's files Fuseki's delete leaves on disk (`databases/<main>`, `<main>Acl`, `<main>Mirror`, `configuration/<main>.ttl`), empty the stack's Redis (its cache keeps serving the old app actor, keys and access needs), redeploy — then every user installs the app again. The exact steps are in the compose file's header.
- On a cold first boot, `GET <base>app` answers from an in-memory copy of the actor taken before the access-need group got linked, so it lacks `interop:hasAccessNeedGroup` until the backend restarts (the actor itself, `<base>as/actor/app`, is right). A real sign-in is unaffected — the pod reads the actor — but the rights matrix's programmatic install reads `/app`: restart the backend once after a first deployment.
**Verified 2026-10-19, locally, not yet on Coolify**: both images built from a clean export of the tree, run against the local pod provider behind a same-origin proxy that routes `/api` and `/.well-known` to the backend unstripped with the Host header kept (as Traefik does) — the login flow, the rights matrix (98/98 hard lines) and the revocation probe all pass; the same images at the origin root pass them too.
@@ -87,20 +87,15 @@ The reconstruction is the specification. Its source was read **once, deliberatel
- `types` takes precedence over `shapeTreeUri` when both are set — the **opposite** of what the configuration type documents.
- `custom` is not implemented, so there is **no escape hatch through Refine** for non-CRUD operations.
- Collections are POSTable LDP resources (`/as/collection`) with `semapps:dereferenceItems` — *"all items will be dereferenced (if permissions allow)"*. That is how `event.participations` can come back as full objects for a reader who is allowed, and as URIs otherwise, in one read. Items are added/removed by SparqlPatch on `as:items`.
- `acl:Append` exists among the WAC modes and is really used in app access needs. **Whether it lets a third party insert an item without being able to remove one is NOT VERIFIED** — enforcement lives in `@semapps/webacl`, absent from the checkout. The structure does not depend on the answer: if it works that way the participant appends directly; otherwise the route is `apods:PostOutbox` to the owner's inbox, which is exactly the NextGraph shape, and only latency changes.
- An app declares `accessNeeds` (`shapeTreeUri` + `accessMode`) plus special needs: `apods:CreateCollection`, `apods:CreateWacGroup`, `apods:PostOutbox`, `apods:QuerySparqlEndpoint`, `apods:UpdateWebId`, inbox/outbox reads.
- **Backend-less apps are supported**: `onlineBackend` is false only when the app description document at the `clientId` URI is not fetchable. A static file suffices.
- A custom ontology prefix **must be registered at prefix.cc** (the Pod provider requires every ontology to have a known prefix) — this concerns Festipod's `fp:`. Shape trees must sit at a public URL, one per entity, and **cannot reference other shapes** (no sharing cascade).
- A Festipod **class** with its own shape tree needs `fp:` registered at prefix.cc (the pod registers the ontology of every class it creates a data registration for, and refuses an unknown prefix); shape trees must sit at a public URL, one per entity, and **cannot reference other shapes** (no sharing cascade). A Festipod **predicate** on an existing resource does not: verified 2026-10-29 on a profile with the owner's token (app-token path to confirm live).
**Four contributions this points to** (we may contribute to these providers): `getMany` must signal the unreadable instead of swallowing it; an append path into a collection one does not own; collection support as a resource with `dereferenceItems`; and a teardown for the auth provider's timer.
**Three contributions this points to** (we may contribute to these providers): `getMany` must signal the unreadable instead of swallowing it; collection support as a resource with `dereferenceItems`; and a teardown for the auth provider's timer.
## The deduplication question — answered, and still open
## Participation privacy and deduplication — settled
The attendance model (`brief_2026-07-20_attendance-set-model`, PO 2026-07-27) requires the creator to tell two references from the *same* person apart from two from different people **without knowing who**, or the count is not a count of people and a participant can inflate it.
**Checked on this base: not solved.** A different problem is, and well. `materializeAttendance` (`src/shared/data/registration.ts`) keys its active set by a **per-deposit** `uid` minted from time plus randomness, so a broker re-sync replaying the same deposits converges — that is **replay idempotence**, not person identity. The count counts **deposits**: the same person joining twice counts twice. Further: the **plaintext `userId` travels in the deposit** and the cancellation fallback depends on it (a leave without a known `regUid` cancels the earliest still-active join for that `userId`), so the creator *does* know who and the anonymity the model rests on does not hold. Participations are in the **protected** scope rather than public, and withdrawal is still a deletion. And the gap was **never raised** through the channel meant for it.
So both platforms count deposits rather than people, and the gap sits in **neither Refine provider** — polyfill on one side, server on the other. ActivityPods could arbitrate server-side because the server sees everything.
Settled 2026-10-29: `.project/concepts/functional-domain/decision_2026-10-29_participation-privacy-mechanism.md` — a private Join handled by the backend on emission, attendee lists of per-(person, event) member URNs (distinct-person counts, no identity), recognition by contacts only, a contacts-readable participations collection per person.
## Problems hit, and how they turned out
@@ -148,7 +143,7 @@ What was missing for Festipod's premise — discover other users' public events
- **Attendee lists stay world-readable for now** (2026-10-12) — an explicit deviation from the domain doctrine, where participation is in the protected scope (`functional-domain`, `knowledge_data-scopes-and-discovery`): anonymous readers of a public meeting point's attendee collection see its members' WebIDs, which embed usernames. The matrix tracks it as a KNOWN-DEVIATION line (printed, non-fatal, flags loudly the day it changes). Moving participation to the protected scope is a later task.
- **A revoked app logs the user out** (2026-10-12): picker with a notice, never sent back to consent.
- **Defensive testing only** (2026-10-13): no automated check sends a forged or spoofed activity; such checks are MANUAL-CHECK lines (below).
- **Tests never break the dev environment** (user rule 2026-10-30, concept `bdd-testing`): attacks, forged or spoofed activities included, are allowed between the throwaway accounts a run creates and deletes.
- **Trusted local network** (2026-10-13): no key rotation after the exposure below; it is recorded as a risk. Until `88f23b7` the Festipod backend served, anonymously and on all interfaces (reached from the LAN), an open `/sparql` over its `settings` dataset — the app actor's private key, the pod provider's password hashes and emails (the dataset is shared with the pod provider locally), webhook URLs, catalog snapshots — and the private key again at `/key` through the LDP catch-all. Fuseki still listens on all interfaces with `admin/admin`.
**MANUAL checks** — kept out of the automated matrix because they need a crafted request; each is covered by backend unit tests and owes one manual run by a human: (1) a Delete posted to a user's OWN outbox that embeds a Tombstone of ANOTHER user's event must not de-index it (`catalog.service` ownership, `emitted-activity` re-read); (2) a forged pod notification (webhook) must not publish a private event, grant or revoke a read, or list/de-list anything; (3) a crafted delivery to `POST /as/actor/app/inbox` or `/.webhooks/<id>`, and any write outside them, must change nothing (`public-surface`, `emitted-activity`).
@@ -176,7 +171,7 @@ What was missing for Festipod's premise — discover other users' public events
- Adding `## Needs` to the polyfill declaration is where the per-person discriminator belongs. It is **outward-facing** (the provider reads it on its next pull), so it awaits the user's go.
- `acl:Append` behaviour, as above.
- The router's prefix stripping, with the second backend.
- The four provider contributions.
- The three provider contributions.
- Wiring the seam: DONE 2026-10-09 (T04 tree, tip `f8c2e3b`) — `FestipodDataContext` runs over Refine with the ActivityPods backend as the registered default; the join→collection loop is built but `@suspendu` until the app is installed on a pod (consent screen). Current state: `## Current gate state` above. The app-install blocker is CLEARED end-to-end 2026-10-12 (tips `3aa9131` → this state): the login → Solid-OIDC → consent → registration → app loop completes on the aligned stack, verified by an automated Playwright probe (fresh user: picker → login → consent → `/home` authenticated → event created → meeting point attached). The chain of causes, in the order they surfaced: (1) the pod provider's OIDC adapter resolves an unknown client_id by fetching it and requires a Solid-OIDC Client ID Document — `/app` serves it when the request carries the `JsonLdContext` header, the actor otherwise; (2) the platform stack has to run a coherent version — the app packages are pinned at npm latest (`@activitypods/app` 2.3.2, refine-providers 0.2.0) and the pod provider runs the release tag v2.3.12 (fresh pull + full data reset + the pod frontend needing `VITE_CONFIG_URL` in dev and redis remapped to :6390 per the cookbook); (3) on a cold start the actor's `interop:hasAccessNeedGroup` link races the access-need-group creation and is never attached — an idempotent heal in `backend/services/app.service.js` `started()` patches it when missing; (4) the auth provider's `registerApp` discovers the AppRegistration via a `Link: rel="interop:registeredAgent"` header the pod only returns when the requester's token identity (azp = the configured client_id) equals the registration's `registeredAgent`, which the pod stores under the app ACTOR uri — so the Solid-OIDC client_id IS the actor uri (`${backendUrl}/as/actor/app`), and `/as/actor/app` serves the actor / the Client ID Document by the same header trick through a standalone `client-id-doc` service (routing it to `app.get` deadlocked boot: the app service's own `awaitCreateComplete` polls the actor over HTTP while the service is still starting). The pinned contract carries a dated re-baseline section recording the registerApp/getAppStatus findings (authorized one-time re-read). The verify-compat shim was removed once the aligned stack made it redundant. A revoked app is now detected client-side (`4fc856f`, `d4fcb7e` — see `## T05`). The join→collection scenarios are re-enabled and green (5/5 smoke), and the full authenticated UI flow is banked as a durable gate: `bun run test:e2e-flow` (scripts/e2e-login-flow.ts — picker → login → consent → authenticated → event → meeting point, fresh user, benign-4xx classification, non-zero exit on any fatal ≥400). Platform fragility observed while un-suspending: hand-crafting an `apods:attendees` collection (instead of letting the backend attach it) crashed the festipod backend with an uncaught `Invalid URL` in `@activitypods/app` `pod-collections-handler.getGroupSlugFromCollectionUri(undefined)` — the scenario now reads the attached collection rather than building it; the crash path itself is platform-side.
- Both usage declarations are named `usage_festipod.md`, so the bare stem is ambiguous as a wikilink — refer to either by folder path.
- Driving `getAppStatus()`: DONE (`4fc856f`, `d4fcb7e`). Festipod relies on its `installed` member, which the reconstruction does not pin — recorded in the activitypods-providers usage declaration (constraint 9, need 3), together with its reliance on `create` sending `to` unchanged (constraint 10).
+7
View File
@@ -0,0 +1,7 @@
# Rules (local)
Each rule below shows its one-line summary. **Read the full file** when a rule looks relevant — it carries the *why* and *how to apply* needed to use the rule with judgment.
## activitypods
- **festipod-only-reference-app-patterns** — N'écrire sur le pod ActivityPods que par des patterns des apps de référence du dossier ~/projects/activitypods/, sinon exception justifiée. → `.project/rules/activitypods/festipod-only-reference-app-patterns.md`
@@ -0,0 +1,15 @@
---
name: festipod-only-reference-app-patterns
summary: N'écrire sur le pod ActivityPods que par des patterns des apps de référence du dossier ~/projects/activitypods/, sinon exception justifiée.
group: activitypods
scope: local
created: 2026-10-28
---
# Ne faire sur le pod que ce que font les apps de référence
**Rule:** Dans Festipod, n'utiliser pour interagir avec la plateforme ActivityPods que des patterns déjà présents dans les applications de référence du dossier `~/projects/activitypods/`, sauf si aucune autre solution n'existe — auquel cas l'exception doit être nommée et justifiée explicitement avant de coder.
**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.
+2
View File
@@ -1,3 +1,5 @@
# To discuss
- [ ] clarifier le vocabulaire d'identité employé par Festipod (wallet, utilisateur, profil, username) et réconcilier decision_2026-07-06 (« identifiant = wallet ») et decision_2026-07-20 (« username dans le profil ») avec ce que le contrat publie aujourd'hui : `ensureIdentity()` ne prend aucun identifiant, et `username` ne désigne plus que `UserProfile.username`
- [ ] déclarer (usage_festipod) et faire confirmer par le fournisseur l'usage de `fetchJson` et `resolveContainerUri` (`@activitypods/refine-providers/utils`) pour l'upload d'images : utilisés par `useUploadImage` mais absents du contrat épinglé ; et le moyen de créer/lier un `as:Profile` quand le Pod n'en expose aucun (aujourd'hui : message seulement)
- [ ] afficher « en attente » dans l'écran des contacts les personnes à qui l'utilisateur a envoyé une demande non encore acceptée (elles voient déjà son profil et ses participations : le groupe /contacts les inclut dès l'envoi) — source possible : les `Offer` sans réponse de son outbox, ou une collection des demandes envoyées à demander en amont
+4
View File
@@ -38,3 +38,7 @@ La connaissance détaillée vit dans `.project/concepts/` (système *concept*) :
| `app-security` | Isolation déléguée au SDK (pas de contrôle d'accès dans les écrans), auth wallet, matrice d'autorisations cible |
Pour **documenter** un fait projet : `/concept document <sujet>` (ne pas écrire en libre dans `.project/`).
## Rules
@.project/rules/INDEX.md
+2 -2
View File
@@ -6,9 +6,9 @@ SEMAPPS_FRONT_URL=http://localhost:4000
SEMAPPS_SHAPE_REPOSITORY_URL=https://shapes.activitypods.org/
SEMAPPS_PORT=3003
SEMAPPS_SPARQL_ENDPOINT=http://localhost:3030/
SEMAPPS_MAIN_DATASET=settings
SEMAPPS_MAIN_DATASET=festipod
SEMAPPS_JENA_USER=admin
SEMAPPS_JENA_PASSWORD=admin
SEMAPPS_REDIS_CACHE_URL=redis://localhost:6390/3
SEMAPPS_QUEUE_SERVICE_URL=redis://localhost:6390/4
SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME=settings
SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME=settings-festipod
+6 -2
View File
@@ -1,4 +1,4 @@
const { CacherMiddleware } = require('@semapps/webacl');
const { WebAclMiddleware, CacherMiddleware } = require('@semapps/webacl');
const CONFIG = require('./config/config');
Error.stackTraceLimit = Infinity;
@@ -42,7 +42,11 @@ module.exports = {
middlewares: [
SkipOrphanBlankNodesCleanupMiddleware(),
SystemCallWebIdMiddleware(),
CacherMiddleware(cacherConfig)
// As in the reference apps: the cacher before the WebACL middleware, which checks the read
// rights of every `ldp.resource.get` and gives each resource created in the main dataset the
// rights its container prescribes (the app actor and its public key readable by anyone, say).
CacherMiddleware(cacherConfig),
WebAclMiddleware({ baseUrl: CONFIG.HOME_URL })
],
logger: {
type: 'Console',
+44 -16
View File
@@ -47,6 +47,12 @@ module.exports = {
accessMode: ['acl:Read', 'acl:Write'],
preferredScope: 'interop:AllFromRegistry'
},
{
// The images users upload (event covers, avatars): the Pod's `file` container.
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/File'),
accessMode: ['acl:Read', 'acl:Write'],
preferredScope: 'interop:AllFromRegistry'
},
{
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/vcard/Group'),
accessMode: 'acl:Read',
@@ -56,7 +62,6 @@ module.exports = {
'apods:ReadOutbox',
'apods:PostOutbox',
'apods:QuerySparqlEndpoint',
'apods:CreateWacGroup',
'apods:CreateCollection',
'apods:UpdateWebId'
],
@@ -124,29 +129,52 @@ module.exports = {
// group then exists while the actor never gets `interop:hasAccessNeedGroup`, and both the
// pod frontend (consent screen) and the pod backend (requirements discovery) read the
// needs from that link — an empty consent registers the app with zero grants and the
// register flow loops. Idempotent: only patches when the link is missing.
// register flow loops. The heal itself can ALSO lose the race: it may run before the
// mixin has created the group (observed 2026-10-26 on a fresh dataset — the one-shot
// heal found no group and silently did nothing), so it polls for the group instead of
// checking once. Idempotent: only patches when the link is missing.
const actorUri = urlJoin(CONFIG.HOME_URL, 'as/actor/app');
const actor = await this.broker.call('ldp.resource.get', {
resourceUri: actorUri,
accept: 'application/ld+json',
webId: 'system'
});
const link = actor && actor['interop:hasAccessNeedGroup'];
const hasLink = Array.isArray(link) ? link.length > 0 : Boolean(link);
if (!hasLink) {
const actorHasLink = async () => {
const actor = await this.broker.call('ldp.resource.get', {
resourceUri: actorUri,
accept: 'application/ld+json',
webId: 'system'
});
const link = actor && actor['interop:hasAccessNeedGroup'];
return Array.isArray(link) ? link.length > 0 : Boolean(link);
};
const findGroup = async () => {
const groups = await this.broker.call('access-needs-groups.list', {
filters: {
'http://www.w3.org/ns/solid/interop#accessNecessity': 'http://www.w3.org/ns/solid/interop#AccessRequired'
},
accept: 'application/ld+json'
});
const group = groups && groups['ldp:contains'] && groups['ldp:contains'][0];
if (group) {
await this.broker.call('actors.attachAccessNeedGroup', {
accessNeedGroupUri: group.id || group['@id']
});
this.logger.info(`Healed the actor's interop:hasAccessNeedGroup link to ${group.id || group['@id']}`);
return groups && groups['ldp:contains'] && groups['ldp:contains'][0];
};
// A started() handler must not block the broker's boot, but the link must
// exist before the first consent/register flow; ~1 min is far past the
// observed gap (seconds) while the dataset is empty.
const deadline = Date.now() + 60000;
for (;;) {
try {
if (await actorHasLink()) break;
const group = await findGroup();
if (group) {
await this.broker.call('actors.attachAccessNeedGroup', {
accessNeedGroupUri: group.id || group['@id']
});
this.logger.info(`Healed the actor's interop:hasAccessNeedGroup link to ${group.id || group['@id']}`);
break;
}
} catch (e) {
this.logger.warn(`Access-need-group heal attempt failed: ${e.message}`);
}
if (Date.now() >= deadline) {
this.logger.error('No access-need group appeared within 60s; the actor stays unlinked — consents will register zero grants');
break;
}
await new Promise(r => setTimeout(r, 2000));
}
}
};
+68 -18
View File
@@ -1,5 +1,6 @@
// The last participation act applied for each (meeting point, participant) — what makes attendance
// independent of the order notifications arrive in.
// The last participation act applied for each (meeting point, participant WebID) — what makes attendance
// independent of the order notifications arrive in, and what remembers which member URN that act put in
// (or took out of) the meeting point's attendee list.
//
// WHY. A participant's Join and Leave reach the backend as separate notifications, handled
// concurrently; the per-meeting-point lock (`keyed-serializer.js`) serializes them, but in the order
@@ -16,30 +17,61 @@
// attending, "leave then join" ends attending. All acts of one participant are stamped by her own pod,
// so they share one clock.
//
// THE MEMBER is STICKY per pair. The URN listed for a participant is computed from the secret on their
// profile, which they may rewrite between acts. So the entry remembers the URN once chosen and reuses it
// for every later act of the pair — `member` is persisted (`persistMember`) BEFORE the first attendee-list
// write, WRITE-ONCE (inserted only when absent, never replaced, not rewritten when an act is recorded),
// and never recomputed afterwards: a later Join keeps it, a Leave removes that one. This is what
// keeps the pair listed under exactly ONE URN even when an act fails half-way (the add lands, a later step
// throws, the act is left unrecorded) and the participant then rewrites their secret and acts again —
// without it, the second act would compute a second URN and the list would carry both.
//
// The member and the act are persisted SEPARATELY: `member` the moment a URN is first chosen for the pair,
// the act (its URI, its stamp, its kind) only once BOTH collections match it. So the entry may hold a
// member with no act yet (a first act that failed before recording) — `lastAttendance` returns it so the
// retry reuses the URN, while the absent act makes the retry the "first" act for ordering (`isNewerAct`).
//
// THE KEY is the participant's WebID, not the member URN. This exposes nothing new at rest: the entry
// already holds the act's URI, which lies in the participant's pod and so already identifies them.
//
// WHERE IT LIVES. Like the catalog (`catalog-store.js`): in the backend's main dataset, DEFAULT graph
// (persisted by Fuseki), so the rule holds across a backend restart. One entry per pair, under a minted
// `urn:festipod:attendance:` subject, with three Festipod predicates: the act's URI, its stamp, its kind.
// `urn:festipod:attendance:` subject, with four Festipod predicates: the act's URI, its stamp, its kind,
// the member URN applied.
const { iriRef, stringLiteral } = require('./catalog-store');
const ACT = 'http://festipod.org/attendanceAct';
const PUBLISHED = 'http://festipod.org/attendancePublished';
const CHANGE = 'http://festipod.org/attendanceChange';
const MEMBER = 'http://festipod.org/attendanceMember';
/** The subject of the (meeting point, participant) entry: both URIs percent-encoded into one URN. */
function attendanceSubject(meetingPointUri, memberUri) {
return `urn:festipod:attendance:${encodeURIComponent(meetingPointUri)}:${encodeURIComponent(memberUri)}`;
function attendanceSubject(meetingPointUri, participantUri) {
return `urn:festipod:attendance:${encodeURIComponent(meetingPointUri)}:${encodeURIComponent(participantUri)}`;
}
/** The last act applied for the pair: one row (act, published, change) or none. */
function lastAttendanceQuery(meetingPointUri, memberUri) {
const s = iriRef(attendanceSubject(meetingPointUri, memberUri));
return `SELECT ?act ?published ?change WHERE { ${s} <${ACT}> ?act ; <${PUBLISHED}> ?published ; <${CHANGE}> ?change } LIMIT 1`;
// The pair's entry, anchored on its sticky `member`: a row iff a URN has been chosen for the pair, the
// act fields bound only once an act was recorded (OPTIONAL — a member may be persisted with no act yet).
function lastAttendanceQuery(meetingPointUri, participantUri) {
const s = iriRef(attendanceSubject(meetingPointUri, participantUri));
return `SELECT ?act ?published ?change ?member WHERE { ${s} <${MEMBER}> ?member . OPTIONAL { ${s} <${ACT}> ?act ; <${PUBLISHED}> ?published ; <${CHANGE}> ?change } } LIMIT 1`;
}
/** Replace the pair's entry with `{ activityUri, published, change }`. One update = one transaction. */
function recordAttendanceUpdate(meetingPointUri, memberUri, { activityUri, published, change }) {
const s = iriRef(attendanceSubject(meetingPointUri, memberUri));
// Set the pair's sticky member URN ONLY IF IT HAS NONE: the member is written once and never replaced,
// so a write still in flight from an act that timed out cannot overwrite the URN a later act already
// chose and listed. The caller reads the entry back and applies whatever member it holds.
function persistMemberUpdate(meetingPointUri, participantUri, member) {
const s = iriRef(attendanceSubject(meetingPointUri, participantUri));
return `
INSERT { ${s} <${MEMBER}> ${stringLiteral(member)} }
WHERE { FILTER NOT EXISTS { ${s} <${MEMBER}> ?member } }
`;
}
/** Replace the pair's recorded act `{ activityUri, published, change }`; its member is left as persisted. */
function recordAttendanceUpdate(meetingPointUri, participantUri, { activityUri, published, change }) {
const s = iriRef(attendanceSubject(meetingPointUri, participantUri));
return `
DELETE { ${s} ?p ?o }
INSERT { ${s} <${ACT}> ${iriRef(activityUri)} ; <${PUBLISHED}> ${stringLiteral(published)} ; <${CHANGE}> ${stringLiteral(change)} }
@@ -66,23 +98,41 @@ const termValue = term => (term && typeof term === 'object' ? term.value : term)
// ── Store operations: `ctx` is a moleculer context (anything with `call`) ──
/** The last act applied for the pair, `{ activityUri, published, change }`, or undefined. */
async function lastAttendance(ctx, { dataset, meetingPointUri, memberUri }) {
const rows = await ctx.call('triplestore.query', { query: lastAttendanceQuery(meetingPointUri, memberUri), dataset, webId: 'system' });
/**
* The pair's entry, `{ activityUri, published, change, member }`, or undefined when no URN was ever
* chosen for it. `member` is always set when a row is returned; the act fields are undefined when a
* member was persisted but no act recorded yet (a first act that failed before recording).
*/
async function lastAttendance(ctx, { dataset, meetingPointUri, participantUri }) {
const rows = await ctx.call('triplestore.query', { query: lastAttendanceQuery(meetingPointUri, participantUri), dataset, webId: 'system' });
const row = Array.isArray(rows) ? rows[0] : undefined;
if (!row) return undefined;
return { activityUri: termValue(row.act), published: termValue(row.published), change: termValue(row.change) };
return { activityUri: termValue(row.act), published: termValue(row.published), change: termValue(row.change), member: termValue(row.member) };
}
async function recordAttendance(ctx, { dataset, meetingPointUri, memberUri, act }) {
await ctx.call('triplestore.update', { query: recordAttendanceUpdate(meetingPointUri, memberUri, act), dataset, webId: 'system' });
/**
* Persist `member` as the pair's sticky URN unless it already has one — before any attendee-list write
* (see the header) — then resolve the member the entry holds NOW: `member` itself, or the one another
* act persisted first. The caller applies that one, never its own candidate.
*/
async function persistMember(ctx, { dataset, meetingPointUri, participantUri, member }) {
await ctx.call('triplestore.update', { query: persistMemberUpdate(meetingPointUri, participantUri, member), dataset, webId: 'system' });
const entry = await lastAttendance(ctx, { dataset, meetingPointUri, participantUri });
if (!entry || !entry.member) throw new Error(`the member URN of ${participantUri} on ${meetingPointUri} could not be persisted`);
return entry.member;
}
async function recordAttendance(ctx, { dataset, meetingPointUri, participantUri, act }) {
await ctx.call('triplestore.update', { query: recordAttendanceUpdate(meetingPointUri, participantUri, act), dataset, webId: 'system' });
}
module.exports = {
attendanceSubject,
lastAttendanceQuery,
persistMemberUpdate,
recordAttendanceUpdate,
isNewerAct,
lastAttendance,
persistMember,
recordAttendance
};
+12 -7
View File
@@ -4,14 +4,17 @@ const CONFIG = require('../config/config');
const { neverThrowingMatcher, verifiedEmit, uriOf } = require('./emitted-activity');
const { guardedCreateAndAttach } = require('./collection-attach');
// Attaches an `apods:attendees` collection (and a WAC group of the same members) to every
// `as:Event` created in a user's pod that this backend learns of through the outbox watcher —
// plain events as well as meeting points (observed 2026-10-12). A join adds the actor to it.
// Attaches an `apods:attendees` collection to every `as:Event` created in a user's pod that this
// backend learns of through the outbox watcher — plain events as well as meeting points (observed
// 2026-10-12). A join adds the participant's MEMBER URN to it (never a WebID — see
// registration.service.js, member-urn.js and decision_2026-10-29_participation-privacy-mechanism),
// so the collection's members are opaque URNs only readers of the participant's secret (they, their
// contacts, the app) can recognise, and no WAC group mirrors them.
//
// Who may read the collection: the pod creates it world-readable (`acl:Read` for `foaf:Agent`) — a
// platform default, observed 2026-10-12, not a choice made here. Whether attendee lists of a PUBLIC
// event SHOULD be world-readable is an open product question (the functional-domain doctrine places
// participation in the protected scope); this service does not settle it. What it does settle: the
// platform default, observed 2026-10-12, not a choice made here. The collection is the PUBLIC
// surface of a participation: a count of opaque members, no identity (the model, per
// knowledge_data-scopes-and-discovery — not an open question). What this service settles: the
// collection it creates never stays more readable than its event. Once attached, it records that
// collection's public read as Festipod's (`public-read.recordCreated`) and asks `public-read.reconcile`
// to align the event AND its collection with the event's current public intent — granted while the
@@ -26,7 +29,9 @@ module.exports = {
ordered: false,
summary: 'Meeting point attendees'
},
createWacGroup: true
// Members are member URNs, not agents — a mirrored WAC group would be meaningless
// (decision_2026-10-29_participation-privacy-mechanism).
createWacGroup: false
},
dependencies: ['public-read'],
// The mixin's create handler would act on the object embedded in the (unauthenticated) webhook
+1 -1
View File
@@ -2,7 +2,7 @@
* The catalog index, persisted in the backend's own triplestore so it survives a
* backend restart (it used to be process memory).
*
* WHERE IT LIVES. In the backend's main dataset (`settings`), in the DEFAULT graph.
* WHERE IT LIVES. In the backend's main dataset (`SEMAPPS_MAIN_DATASET`), in the DEFAULT graph.
* Not in a dedicated named graph: that dataset is assembled from TDB2 graphs, and only
* the graphs its assembler declares are TDB2-backed — a triple written to any other
* named graph is held in memory by Fuseki and lost when Fuseki restarts (verified on
+19 -1
View File
@@ -22,6 +22,19 @@ function arrayOf(value) {
*/
const isListable = item => hasListedPublicIntent(item);
/**
* The creator attribution a catalog item carries — `dc:creator` as the event
* resource serves it, publicly: a bare IRI string or an embedded `{ id }`.
* An item without one was indexed from a resource that names no creator; it
* can never match an owner filter.
*/
function creatorOf(item) {
const creator = item?.['dc:creator'];
if (typeof creator === 'string') return creator;
if (creator && typeof creator === 'object' && typeof creator.id === 'string') return creator.id;
return undefined;
}
module.exports = {
name: 'catalog',
mixins: [PodResourcesHandlerMixin],
@@ -109,11 +122,16 @@ module.exports = {
},
actions: {
discover: {
// An `owner` narrows the listing to what that actor declared — the
// catalog snapshot IS the event resource as the pod served it, with its
// public `dc:creator` attribution, so the filter judges the same body the
// listing serves. Without `owner`, every listed item is served, unchanged.
async handler(ctx) {
const { owner } = ctx.params ?? {};
const { items, invalid } = await listEntries(ctx, { dataset: CONFIG.MAIN_DATASET });
if (invalid.length > 0) this.logger.warn(`Catalog skipped unreadable entries: ${invalid.join(', ')}`);
// Filtered on every read, whatever wrote the store: only listed public items are served.
const listed = items.filter(isListable);
const listed = items.filter(item => isListable(item) && (owner === undefined || creatorOf(item) === owner));
return {
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Collection',
+50
View File
@@ -403,6 +403,56 @@ describe('catalog listing — only listed public items are served, shape unchang
});
});
describe('catalog discover — the owner filter narrows the listing to what an actor declared', () => {
const rowOf = item => ({ uri: { value: item.id }, snapshot: { value: JSON.stringify(item) } });
// Declared events carry the creator attribution the pod serves publicly, as
// a bare IRI or embedded — both forms must be matched.
const alices = { ...listedEvent(ALICE), 'dc:creator': ALICE };
const alicesEmbedded = { ...listedEvent(ALICE), id: `${ALICE}/data/e2`, 'dc:creator': { id: ALICE } };
const bobs = { ...listedEvent(BOB), 'dc:creator': BOB };
const noCreator = listedEvent('http://localhost:3000/carol');
// Not listed, even though it is Alice's: the owner filter applies AFTER the
// listing rule, so a non-public event of hers is never served to anyone.
const alicesUnlisted = { ...event(ALICE, { to: BOB, content: 'only for a friend' }), 'dc:creator': ALICE };
const stored = () => [rowOf(alices), rowOf(alicesEmbedded), rowOf(bobs), rowOf(noCreator), rowOf(alicesUnlisted)];
test('with an owner, only their listed items are served — dc:creator as a string or an embedded id', async () => {
const { svc } = service();
const ctx = Object.assign(store({ rows: stored() }), { params: { owner: ALICE } });
const result = await catalog.actions.discover.handler.call(svc, ctx);
expect(result.items).toEqual([alices, alicesEmbedded]);
expect(result.totalItems).toBe(2);
expect(JSON.stringify(result)).not.toContain('only for a friend');
});
test('another owner gets only their own; the unlisted and creator-less items never match', async () => {
const { svc } = service();
const result = await catalog.actions.discover.handler.call(
svc,
Object.assign(store({ rows: stored() }), { params: { owner: BOB } })
);
expect(result.items).toEqual([bobs]);
expect(result.totalItems).toBe(1);
// An owner nobody matches: an honest empty listing.
const nobody = await catalog.actions.discover.handler.call(
svc,
Object.assign(store({ rows: stored() }), { params: { owner: 'http://localhost:3000/dan' } })
);
expect(nobody.items).toEqual([]);
expect(nobody.totalItems).toBe(0);
});
test('without owner (or without params at all) every listed item is served, unchanged', async () => {
const { svc } = service();
const all = [alices, alicesEmbedded, bobs, noCreator];
const bare = await catalog.actions.discover.handler.call(svc, store({ rows: stored() }));
expect(bare.items).toEqual(all);
const emptyParams = await catalog.actions.discover.handler.call(svc, Object.assign(store({ rows: stored() }), { params: {} }));
expect(emptyParams.items).toEqual(all);
});
});
describe('catalog startup — entries it would not serve are removed from its store', () => {
const rowOf = item => ({ uri: { value: item.id }, snapshot: { value: JSON.stringify(item) } });
+2 -2
View File
@@ -1,5 +1,5 @@
// The `createAndAttach` action every Festipod collection handler uses (attendees, contacts), in place
// of the one `PodCollectionsHandlerMixin` (@activitypods/app 2.3.2) gives them.
// The `createAndAttach` action every Festipod collection handler uses (attendees, contacts,
// participations), in place of the one `PodCollectionsHandlerMixin` (@activitypods/app 2.3.2) gives them.
//
// The mixin's version calls `pod-collections.createAndAttach`, then derives a WAC group slug from the
// collection URI it returns. That call returns undefined when it attaches nothing — the resource
+35 -7
View File
@@ -2,6 +2,7 @@ const { test, expect, describe } = require('bun:test');
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
const attendees = require('./attendees.service');
const contacts = require('./contacts.service');
const participations = require('./participations.service');
const ALICE = 'http://localhost:3000/alice';
const MALLORY = 'http://localhost:3000/mallory';
@@ -11,7 +12,7 @@ const PROFILE = `${ALICE}/data/profiles/me`;
const CREATE = `${ALICE}/data/activities/create-1`;
/** A schema bound like moleculer binds it: mixin methods first, the service's own over them. */
function bind(schema, pod, { attached, groupFails = false } = {}) {
function bind(schema, pod, { attached, groupFails = false, recordFails = false } = {}) {
const logs = { info: [], warn: [], error: [] };
const calls = [];
const ctx = {
@@ -23,6 +24,7 @@ function bind(schema, pod, { attached, groupFails = false } = {}) {
}
if (action === 'pod-collections.createAndAttach') return attached;
if (action === 'pod-wac-groups.create' && groupFails) throw new Error('group creation failed');
if (action === 'public-read.recordCreated' && recordFails) throw new Error('record failed');
return undefined;
}
};
@@ -45,25 +47,25 @@ const created = objectUri => ({ id: CREATE, type: 'Create', actor: ALICE, object
const actions = calls => calls.map(c => c.action);
describe('attendees — a verified Create attaches the collection, then aligns its public read with the event', () => {
test('a public event: collection attached, group created, then public-read.reconcile on the event', async () => {
test('a public event: collection attached, recorded, then reconciled — NO WAC group (members are member URNs, not agents)', async () => {
const { calls, emit } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', to: 'as:Public' } }, { attached: C });
await emit('create', created(E), ALICE);
expect(actions(calls).filter(a => a !== 'pod-resources.get')).toEqual([
'pod-collections.createAndAttach',
'public-read.recordCreated',
'pod-wac-groups.create',
'public-read.reconcile'
]);
expect(actions(calls)).not.toContain('pod-wac-groups.create');
expect(calls.find(c => c.action === 'public-read.recordCreated').params).toEqual({ resourceUri: C, actorUri: ALICE });
expect(calls.find(c => c.action === 'public-read.reconcile').params).toEqual({ resourceUri: E, actorUri: ALICE });
});
test('the collection is recorded as Festipod\'s even when the WAC group creation after the attach fails', async () => {
const { calls, emit, logs } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', name: 'private' } }, { attached: C, groupFails: true });
test('the reconcile still runs when the recording right after the attach fails', async () => {
const { calls, emit, logs } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', name: 'private' } }, { attached: C, recordFails: true });
await emit('create', created(E), ALICE);
const order = actions(calls).filter(a => a !== 'pod-resources.get');
expect(order).toEqual(['pod-collections.createAndAttach', 'public-read.recordCreated', 'pod-wac-groups.create', 'public-read.reconcile']);
expect(logs.error.some(m => m.includes('group creation failed'))).toBe(true);
expect(order).toEqual(['pod-collections.createAndAttach', 'public-read.recordCreated', 'public-read.reconcile']);
expect(logs.error.some(m => m.includes('record failed'))).toBe(true);
});
test('a PRIVATE event: its collection is reconciled too (the pod made it world-readable; it must not stay so)', async () => {
@@ -125,3 +127,29 @@ describe('contacts — the webhook is a hint, and nothing attached creates no gr
expect(logs.error).toEqual([]);
});
});
describe('participations — attached at a sign-up only, through the guarded createAndAttach', () => {
const profile = { id: PROFILE, type: ['as:Profile', 'vcard:Individual'] };
test('a profile Create attaches nothing: the service handles no activity', () => {
expect(Object.keys(participations.activities)).toEqual([]);
});
test('createAndAttach on the profile, with the participations predicate, and no WAC group', async () => {
const { svc, calls } = bind(participations, { [PROFILE]: profile }, { attached: `${ALICE}/data/as/collection/p1` });
const uri = await svc.actions.createAndAttach({ resourceUri: PROFILE, actorUri: ALICE });
expect(uri).toBe(`${ALICE}/data/as/collection/p1`);
expect(calls.find(c => c.action === 'pod-collections.createAndAttach').params).toMatchObject({
resourceUri: PROFILE,
attachPredicate: 'http://festipod.org/participations',
actorUri: ALICE
});
expect(actions(calls)).not.toContain('pod-wac-groups.create');
});
test('nothing attached: undefined, nothing thrown', async () => {
const { svc, logs } = bind(participations, { [PROFILE]: profile }, { attached: undefined });
await expect(svc.actions.createAndAttach({ resourceUri: PROFILE, actorUri: ALICE })).resolves.toBeUndefined();
expect(logs.error).toEqual([]);
});
});
+55
View File
@@ -0,0 +1,55 @@
// The items of a pod collection, read and checked with the pod's answer SEEN.
//
// The platform's `pod-collections` service (@activitypods/app 2.3.2) hides a failure of the pod:
// `getItems` answers `[]` whatever the GET returned (it never looks at the status), and `add` /
// `remove` resolve normally when the pod refuses the PATCH (a status ≥ 400 is only logged). A
// decision taken on such an answer is wrong without anything saying so: a Leave read against a
// failed read finds nobody to remove, and an act whose write was refused is recorded as applied.
//
// So the backend reads a collection through `pod-resources.get`, where the status is visible, and
// verifies every write by reading the collection back. The writes themselves stay the platform's
// calls (`pod-collections.add` / `.remove`, through the collection handlers' `add` / `remove`).
//
// readItems(ctx, { collectionUri, actorUri }) → the item URIs; throws unless the pod answers 200
// with that very collection, its items inline (a paged collection is not read here: its items
// are not in the root, and reading them as "none" is the defect this module exists to avoid).
// expectItem(ctx, { collectionUri, itemUri, actorUri, present }) → resolves when the collection,
// read again, holds `itemUri` (`present`) or does not (`!present`); throws otherwise — the write
// just made did not take effect.
const { arrayOf } = require('@semapps/ldp');
const { uriOf } = require('./emitted-activity');
const AS = 'https://www.w3.org/ns/activitystreams#';
/** The item URIs a collection node lists inline, under any spelling of `items` / `orderedItems`. */
function itemsOf(collection) {
const keys = ['items', 'orderedItems', 'as:items', 'as:orderedItems', `${AS}items`, `${AS}orderedItems`];
const value = keys.map(key => collection[key]).find(v => v !== undefined);
return arrayOf(value)
.map(uriOf)
.filter(uri => typeof uri === 'string');
}
/** Whether a collection root sends its items to pages (`first`) instead of listing them. */
const isPaged = collection =>
['first', 'as:first', `${AS}first`].some(key => collection[key] !== undefined) && itemsOf(collection).length === 0;
async function readItems(ctx, { collectionUri, actorUri }) {
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: collectionUri, actorUri });
if (status !== 200 || !body || typeof body !== 'object') {
throw new Error(`the pod does not return the collection ${collectionUri} (HTTP ${status})`);
}
if (uriOf(body) !== collectionUri) throw new Error(`the pod returned another resource for ${collectionUri}`);
if (isPaged(body)) throw new Error(`the collection ${collectionUri} is paged: its items cannot be read from its root`);
return itemsOf(body);
}
async function expectItem(ctx, { collectionUri, itemUri, actorUri, present }) {
const holds = (await readItems(ctx, { collectionUri, actorUri })).includes(itemUri);
if (holds !== present) {
throw new Error(`${collectionUri} ${present ? 'does not hold' : 'still holds'} ${itemUri} after the ${present ? 'add' : 'remove'}: the pod did not apply it`);
}
}
module.exports = { itemsOf, readItems, expectItem };
+64
View File
@@ -0,0 +1,64 @@
const { test, expect, describe } = require('bun:test');
const { itemsOf, readItems, expectItem } = require('./collection-items');
const C = 'http://localhost:3000/alice/data/as/collection/c1';
const ALICE = 'http://localhost:3000/alice';
const A = 'urn:festipod:userref:aa';
const B = 'urn:festipod:userref:bb';
/** A ctx whose `pod-resources.get` answers `reply` (a `{ status, body }`, or a function of the call). */
const pod = reply => {
const calls = [];
return {
calls,
call: async (action, params) => {
calls.push({ action, params });
if (action !== 'pod-resources.get') throw new Error(`unexpected call ${action}`);
return typeof reply === 'function' ? reply(params) : reply;
}
};
};
const collection = extra => ({ status: 200, body: { id: C, type: 'Collection', ...extra } });
describe('itemsOf — the item URIs a collection lists inline', () => {
test('every spelling of items / orderedItems; strings and nodes; none', () => {
expect(itemsOf({ items: [A, B] })).toEqual([A, B]);
expect(itemsOf({ items: A })).toEqual([A]);
expect(itemsOf({ items: [{ id: A }, { '@id': B }] })).toEqual([A, B]);
expect(itemsOf({ orderedItems: [A] })).toEqual([A]);
expect(itemsOf({ 'as:items': [A] })).toEqual([A]);
expect(itemsOf({ 'https://www.w3.org/ns/activitystreams#items': [{ '@id': A }] })).toEqual([A]);
expect(itemsOf({ totalItems: 0 })).toEqual([]);
});
});
describe('readItems — a failed read throws, it never means "empty"', () => {
test('a 200 with the collection: its items, read as the given actor', async () => {
const ctx = pod(collection({ items: [A, B] }));
expect(await readItems(ctx, { collectionUri: C, actorUri: ALICE })).toEqual([A, B]);
expect(ctx.calls).toEqual([{ action: 'pod-resources.get', params: { resourceUri: C, actorUri: ALICE } }]);
expect(await readItems(pod(collection({ totalItems: 0 })), { collectionUri: C, actorUri: ALICE })).toEqual([]);
});
test('any other status, no body, another resource, a paged root: throws', async () => {
for (const status of [0, 401, 403, 404, 500]) {
await expect(readItems(pod({ status }), { collectionUri: C, actorUri: ALICE })).rejects.toThrow(`(HTTP ${status})`);
}
await expect(readItems(pod({ status: 200 }), { collectionUri: C, actorUri: ALICE })).rejects.toThrow('does not return the collection');
await expect(readItems(pod({ status: 200, body: { id: `${C}-other`, items: [A] } }), { collectionUri: C, actorUri: ALICE })).rejects.toThrow('another resource');
await expect(readItems(pod(collection({ first: `${C}?page=1`, totalItems: 3 })), { collectionUri: C, actorUri: ALICE })).rejects.toThrow('is paged');
});
});
describe('expectItem — a write is checked by reading the collection back', () => {
test('present after an add, absent after a remove: resolves', async () => {
await expect(expectItem(pod(collection({ items: [A] })), { collectionUri: C, itemUri: A, actorUri: ALICE, present: true })).resolves.toBeUndefined();
await expect(expectItem(pod(collection({ items: [B] })), { collectionUri: C, itemUri: A, actorUri: ALICE, present: false })).resolves.toBeUndefined();
});
test('a write that did not take effect, or a read-back that fails: throws', async () => {
await expect(expectItem(pod(collection({ items: [B] })), { collectionUri: C, itemUri: A, actorUri: ALICE, present: true })).rejects.toThrow(`does not hold ${A} after the add`);
await expect(expectItem(pod(collection({ items: [A] })), { collectionUri: C, itemUri: A, actorUri: ALICE, present: false })).rejects.toThrow(`still holds ${A} after the remove`);
await expect(expectItem(pod({ status: 500 }), { collectionUri: C, itemUri: A, actorUri: ALICE, present: false })).rejects.toThrow('(HTTP 500)');
});
});
+9 -9
View File
@@ -25,10 +25,12 @@ module.exports = {
},
api: {
port: CONFIG.PORT,
// In front of EVERY route — these below and the ones CoreService's services add (the LDP
// catch-all among them, which with `webacl: false` would serve the whole `settings` dataset,
// the app's private key included): only the allow-listed public endpoints pass, anything else
// is a 404 for every caller. See public-surface.js.
// In front of EVERY route — these below and the ones CoreService's services add, the LDP
// catch-all among them: only the allow-listed public endpoints pass, anything else is a 404 for
// every caller. WebACL already filters what the catch-all serves (the main dataset is a secure
// one); the gate is defence in depth, so that a resource whose rights are too wide is still not
// served over HTTP — the backend's own services reach its data through internal calls. See
// public-surface.js.
use: [createPublicSurfaceGate(BASE_PATH)],
routes: [
{
@@ -81,12 +83,10 @@ module.exports = {
}
]
},
webacl: false,
void: false,
// No public SPARQL endpoint. CoreService mounts one at `/sparql` unless this is `false`, and
// with `webacl: false` it answers anonymous queries over the whole `settings` dataset — the
// app actor's key, the webhook listener URLs, the catalog store and, locally, the pod
// provider's accounts (observed 2026-10-12). Nothing in Festipod queries it.
// No public SPARQL endpoint. CoreService mounts one at `/sparql` unless this is `false`; WebACL
// would filter its answers by the caller's rights, but nothing in Festipod queries it, so it is
// not mounted at all (defence in depth, like the gate above).
sparqlEndpoint: false
}
};
+9 -4
View File
@@ -71,7 +71,11 @@ function asTypesOf(node) {
});
}
const hasAsType = (node, name) => asTypesOf(node).includes(AS + name);
// `name` is an AS name (`Event`), an `as:` name, or a full IRI: only bare names get the AS namespace.
const hasAsType = (node, name) => {
const iri = name.startsWith('as:') ? AS + name.slice('as:'.length) : name.includes(':') ? name : AS + name;
return asTypesOf(node).includes(iri);
};
const ACTIVITY_TYPES = new Set(['Create', 'Update', 'Delete', 'Join', 'Leave']);
@@ -86,8 +90,8 @@ const ANY_POD = Symbol('any pod');
* `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
* ActivityStreams type the stored object must declare (`Event`, `Profile`; not checked for a Delete).
* 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.
*/
@@ -195,7 +199,8 @@ function verifiedHandler(box, type, handle, verify) {
* An `onEmit` (outbox handler) that runs `handle(ctx, verdict, actorUri)` only for an activity
* `verifyActivity` accepts as emitted by `actorUri`, the outbox owner. `objectType` is the type the
* stored object must declare; `objectIn` is `'emitter'` (default: the object is in the emitter's pod)
* or `'any'` (the handler checks where the object lies, e.g. the event a Join names).
* or `'any'` (the handler checks where the object lies, e.g. the meeting point a Join names, in its
* creator's pod).
*/
function verifiedEmit(type, handle, { objectType, objectIn = 'emitter' } = {}) {
if (objectIn !== 'emitter' && objectIn !== 'any') throw new Error(`Unknown objectIn ${objectIn}`);
+34
View File
@@ -264,3 +264,37 @@ describe('verifiedReceive / verifiedEmit with objectIn "any"', () => {
expect(svc.logger.w).toContain(`not in the pod of ${BOB}`);
});
});
describe('verifiedEmit — a Join from a participant\'s outbox (the registration flow)', () => {
const MP = `${ALICE}/data/events/mp1`;
const JOIN = `${BOB}/outbox/join-1`;
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP };
test('the activity AND the meeting point are read through the outbox owner\'s access, the stored object handed over', async () => {
const reads = [];
const stored = { [JOIN]: join, [MP]: { id: MP, type: 'Event', context: `${ALICE}/data/events/e1` } };
const ctx = {
call: async (action, params) => {
reads.push([action, params.resourceUri, params.actorUri]);
const body = stored[params.resourceUri];
return body ? { status: 200, body } : { status: 404 };
}
};
const ran = [];
const service = { name: 'test', logger: { info() {}, warn() {}, error() {} } };
// The webhook body claims another object: only the stored one counts.
await verifiedEmit('Join', async (_ctx, verdict) => ran.push(verdict.object), { objectType: 'Event', objectIn: 'any' })
.call(service, ctx, { ...join, object: { id: `${ALICE}/data/events/other`, type: 'Event' } }, BOB);
expect(reads).toEqual([['pod-resources.get', JOIN, BOB], ['pod-resources.get', MP, BOB]]);
expect(ran).toEqual([stored[MP]]);
});
test('a Join whose stored actor is not the outbox owner is refused', async () => {
const ctx = { call: async (_action, { resourceUri }) => (resourceUri === JOIN ? { status: 200, body: { ...join, actor: ALICE } } : { status: 200, body: { id: MP, type: 'Event' } }) };
const ran = [];
const service = { name: 'test', logger: { info() {}, warn(m) { this.w = m; }, error() {} } };
await verifiedEmit('Join', async () => ran.push(true), { objectType: 'Event', objectIn: 'any' }).call(service, ctx, join, BOB);
expect(ran).toEqual([]);
expect(service.logger.w).toContain('not attributed to');
});
});
+4 -2
View File
@@ -35,8 +35,10 @@
// 30 s: a serialized decision makes a handful of pod round-trips — read the object, read up to two
// ACLs, write up to two (public-read); read, then one store write (catalog); a wait of at most 1.5 s
// for a fresh collection, a read and one write (attendance) — each answering in tens of milliseconds
// on the local stack, a few seconds at worst on a loaded one. 30 s is over ten times the slowest
// for a fresh collection, the participant's participations collection made ready (a profile and an
// ACL read; at its creation, the create-and-attach, a profile re-read, one ACL write and one group
// grant), a read, two writes and their two read-backs (attendance) —
// each answering in tens of milliseconds on the local stack, a few seconds at worst on a loaded one. 30 s is over ten times the slowest
// legitimate run, and short enough that a stalled pod holds one object's decisions for half a minute
// rather than until the backend restarts.
const DEFAULT_TIMEOUT_MS = 30000;
+53
View File
@@ -0,0 +1,53 @@
// The member URN an attendee list records for a participant, and the secret it is derived from
// (decision_2026-10-29_participation-privacy-mechanism).
//
// THE URN. `urn:festipod:userref:` + the lowercase-hex sha256 of the string
// "<participant WebID> <event URI> <secret>" (single spaces), where the event URI is the meeting
// point's anchor (`as:context`). It is the same for one person on every meeting point of one event —
// so the per-event count of distinct URNs is publicly computable — and unrelated from one event to
// another. Only whoever can read the secret can compute it: the person, their contacts, the app. The
// backend computes it from the secret; a client never supplies one, so it cannot inflate a count.
// ("festipod" here is the URN namespace, unrelated to the http://festipod.org/ vocabulary.)
//
// THE SECRET lives in the participant's `as:Profile` under `http://festipod.org/participationSecret`:
// 32 random bytes as 64 lowercase hex characters, written by the participant's browser before the
// first sign-up. Anything else — absent, several values, another length, uppercase — is no secret.
const crypto = require('crypto');
const MEMBER_URN_PREFIX = 'urn:festipod:userref:';
const PARTICIPATION_SECRET = 'http://festipod.org/participationSecret';
const SECRET_PATTERN = /^[0-9a-f]{64}$/;
const listOf = value => (value === undefined || value === null ? [] : Array.isArray(value) ? value : [value]);
/** The string a JSON-LD value holds: a string, or a value node's `@value`; undefined otherwise. */
const stringOf = value => {
if (typeof value === 'string') return value;
if (value && typeof value === 'object' && typeof value['@value'] === 'string') return value['@value'];
return undefined;
};
/**
* participationSecretOf(profile) → the participant's secret (64 lowercase hex characters), or
* undefined when the profile carries none that is usable. Reads the predicate under its full IRI or
* the `fp:` CURIE; exactly one value is accepted.
*/
function participationSecretOf(profile) {
if (!profile || typeof profile !== 'object') return undefined;
const values = listOf(profile[PARTICIPATION_SECRET] ?? profile['fp:participationSecret']);
if (values.length !== 1) return undefined;
const secret = stringOf(values[0]);
return secret !== undefined && SECRET_PATTERN.test(secret) ? secret : undefined;
}
/** memberUrn({ participantUri, eventUri, secret }) → `urn:festipod:userref:<sha256 hex>`. */
function memberUrn({ participantUri, eventUri, secret }) {
for (const [name, value] of Object.entries({ participantUri, eventUri, secret })) {
if (typeof value !== 'string' || value.length === 0) throw new Error(`memberUrn needs a ${name}`);
}
const digest = crypto.createHash('sha256').update(`${participantUri} ${eventUri} ${secret}`, 'utf8').digest('hex');
return MEMBER_URN_PREFIX + digest;
}
module.exports = { MEMBER_URN_PREFIX, PARTICIPATION_SECRET, participationSecretOf, memberUrn };
+50
View File
@@ -0,0 +1,50 @@
const { test, expect, describe } = require('bun:test');
const crypto = require('crypto');
const { MEMBER_URN_PREFIX, PARTICIPATION_SECRET, participationSecretOf, memberUrn } = require('./member-urn');
const BOB = 'http://localhost:3000/bob';
const CAROL = 'http://localhost:3000/carol';
const EVENT = 'http://localhost:3000/alice/data/events/e1';
const OTHER_EVENT = 'http://localhost:3000/alice/data/events/e2';
const SECRET = '0123456789abcdef'.repeat(4);
describe('memberUrn — urn:festipod:userref:<sha256("<WebID> <event URI> <secret>")>', () => {
test('the contract: the lowercase-hex sha256 of the three values joined by single spaces', () => {
const expected = crypto.createHash('sha256').update(`${BOB} ${EVENT} ${SECRET}`).digest('hex');
expect(memberUrn({ participantUri: BOB, eventUri: EVENT, secret: SECRET })).toBe(`urn:festipod:userref:${expected}`);
expect(MEMBER_URN_PREFIX).toBe('urn:festipod:userref:');
expect(memberUrn({ participantUri: BOB, eventUri: EVENT, secret: SECRET })).toMatch(/^urn:festipod:userref:[0-9a-f]{64}$/);
});
test('deterministic for one person and one event; different for another event, person or secret', () => {
const base = { participantUri: BOB, eventUri: EVENT, secret: SECRET };
const urn = memberUrn(base);
expect(memberUrn({ ...base })).toBe(urn);
expect(memberUrn({ ...base, eventUri: OTHER_EVENT })).not.toBe(urn);
expect(memberUrn({ ...base, participantUri: CAROL })).not.toBe(urn);
expect(memberUrn({ ...base, secret: 'f'.repeat(64) })).not.toBe(urn);
});
test('a missing value is a programming error, never a URN', () => {
expect(() => memberUrn({ participantUri: BOB, eventUri: undefined, secret: SECRET })).toThrow('eventUri');
expect(() => memberUrn({ participantUri: BOB, eventUri: EVENT, secret: '' })).toThrow('secret');
});
});
describe('participationSecretOf — 64 lowercase hex characters under fp:participationSecret, or nothing', () => {
test('read under the full IRI, the CURIE, or as a value node', () => {
expect(PARTICIPATION_SECRET).toBe('http://festipod.org/participationSecret');
expect(participationSecretOf({ [PARTICIPATION_SECRET]: SECRET })).toBe(SECRET);
expect(participationSecretOf({ 'fp:participationSecret': SECRET })).toBe(SECRET);
expect(participationSecretOf({ [PARTICIPATION_SECRET]: { '@value': SECRET } })).toBe(SECRET);
expect(participationSecretOf({ [PARTICIPATION_SECRET]: [SECRET] })).toBe(SECRET);
});
test('absent, malformed or ambiguous: no secret', () => {
for (const value of [undefined, '', SECRET.toUpperCase(), SECRET.slice(1), `${SECRET}0`, 'g'.repeat(64), 42, { id: SECRET }, [SECRET, 'f'.repeat(64)]]) {
expect(participationSecretOf({ [PARTICIPATION_SECRET]: value })).toBeUndefined();
}
expect(participationSecretOf(undefined)).toBeUndefined();
expect(participationSecretOf({ name: 'Bob' })).toBeUndefined();
});
});
+294
View File
@@ -0,0 +1,294 @@
// TEST SUPPORT ONLY (required by registration.service.test.js and participations.service.test.js):
// a fake ActivityPods stack for the participation flow — the pods' resources, collections and ACLs,
// the backend's attendance ledger and the notifications sent — with the registration and
// participations services bound to it the way moleculer binds them, so that a call from one service
// to the other runs the other's real code. Every call is recorded.
//
// The platform calls answer as @activitypods/app 2.3.2 does, failures included: `pod-resources.get`
// carries the pod's status (no body on a failure); `pod-collections.getItems` reads through it and
// answers `[]` whatever the status; `pod-collections.add` / `.remove` (and the attendees handler's
// `add` / `remove`, which run them) resolve normally when the pod refuses the PATCH; and
// `pod-collections.createAndAttach` returns the collection it created even when the pod refuses the
// PATCH that links it to the resource.
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
const { arrayOf } = require('@semapps/ldp');
const registration = require('./registration.service');
const participations = require('./participations.service');
const HOST = 'http://localhost:3000';
const APP = 'http://localhost:4000/as/actor/app';
const ALICE = `${HOST}/alice`; // creates the meeting points
const BOB = `${HOST}/bob`; // participates
const CAROL = `${HOST}/carol`; // participates too
const MALLORY = `${HOST}/mallory`;
const EVENT1 = `${HOST}/dave/data/events/festival`;
const EVENT2 = `${HOST}/dave/data/events/concert`;
const FP_SECRET = 'http://festipod.org/participationSecret';
const FP_PARTICIPATIONS = 'http://festipod.org/participations';
const SECRETS = { [BOB]: 'b0'.repeat(32), [CAROL]: 'c0'.repeat(32), [ALICE]: 'a0'.repeat(32) };
const profileOf = actorUri => `${actorUri}/data/profiles/me`;
const contactsOf = actorUri => `${actorUri}/contacts`;
const groupOf = actorUri => `${HOST}/_groups${new URL(actorUri).pathname}/contacts`;
const attendeesOf = meetingPointUri => `${meetingPointUri}-attendees`;
const actor = (uri, extra = {}) => ({ id: uri, type: 'Person', url: profileOf(uri), 'apods:contacts': contactsOf(uri), ...extra });
const profile = (uri, extra = {}) => ({ id: profileOf(uri), type: 'Profile', name: uri.split('/').pop(), [FP_SECRET]: SECRETS[uri], ...extra });
const meetingPoint = (uri, extra = {}) => ({
id: uri,
type: 'Event',
name: `Rendez-vous ${uri.split('/').pop()}`,
context: EVENT1,
'dc:creator': ALICE,
'apods:attendees': attendeesOf(uri),
to: 'as:Public',
...extra
});
// The pod stamps `published` on every activity posted to an outbox.
const activity = (type, actorUri, objectUri, published, n = 1) => ({
id: `${actorUri}/data/activities/${type.toLowerCase()}-${objectUri.split('/').pop()}-${n}`,
type,
actor: actorUri,
object: objectUri,
published
});
/**
* The ACL the pod gives a collection the app creates: an OWN public read (observed 2026-10-12) and
* an OWN `#Control` for the app (observed 2026-10-29 on a participations collection).
*/
const createdCollectionAcl = uri => [
{ '@id': `${HOST}/_acl${new URL(uri).pathname}#Read`, 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' },
{ '@id': `${HOST}/_acl${new URL(uri).pathname}#Control`, 'acl:mode': 'acl:Control', 'acl:agent': APP }
];
const ACT = 'http://festipod.org/attendanceAct';
const MEMBER = 'http://festipod.org/attendanceMember';
/**
* world(options) → { ctx, calls, state, registration, participations, logs, emit }
*
* options: `stored` (uri → body; the default actors and profiles are added), `items` (collection →
* items; every collection a stored resource names under `apods:attendees` exists, empty when unset),
* `acls` (collection → authorizations), `ledger` (attendance subject → act with its member URN),
* `delays` (`read:<uri>`, `create`, `attendees.add`, `attendees.remove`, `pod-collections.add`,
* `pod-collections.remove` → ms), `fail`, `timeoutMs` (the registration lock's time limit).
*
* `fail` (read at each call, so a test may change it between acts): `read` (uri → the status a GET of
* it answers, with no body), `attendeesWrite` / `participationsWrite` (the pod refuses every PATCH of
* an attendee list / a participations collection: the call resolves, nothing changes, the attempt is
* kept in `state.refusedWrites`), `attach` (the pod creates the collection but refuses the PATCH that
* links it), `putStatus` (the status of an ACL PUT), `grant`, `aclRead`, `notify`.
*
* 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 } = {}) {
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) };
const state = {
stored: {
[ALICE]: actor(ALICE),
[BOB]: actor(BOB),
[CAROL]: actor(CAROL),
[profileOf(ALICE)]: profile(ALICE),
[profileOf(BOB)]: profile(BOB),
[profileOf(CAROL)]: profile(CAROL),
...stored
},
items: JSON.parse(JSON.stringify(items)),
acls: JSON.parse(JSON.stringify(acls)),
ledger: { ...ledger },
notifications: [],
created: [],
refusedWrites: []
};
// The attendees handler attached each meeting point's list when the meeting point was created.
for (const body of Object.values(state.stored)) {
const list = body && body['apods:attendees'];
if (typeof list === 'string' && !(list in state.items)) state.items[list] = [];
}
const wait = ms => (ms ? new Promise(resolve => setTimeout(resolve, ms)) : undefined);
const resourceOfAcl = url => url.replace(`${HOST}/_acl`, HOST);
/** A GET as the pod answers it (`pod-resources.get`): the status, and the body on a 200 only. */
const read = uri => {
const failed = fail.read && fail.read[uri];
if (failed) return { ok: false, status: failed };
if (Object.prototype.hasOwnProperty.call(state.items, uri)) {
return { ok: true, status: 200, body: { id: uri, type: 'Collection', items: [...state.items[uri]] } };
}
const body = state.stored[uri];
return body ? { ok: true, status: 200, body } : { ok: false, status: 404 };
};
let registrationSvc;
let participationsSvc;
const ctx = {
call: async (action, params) => {
calls.push({ action, params });
if (action.startsWith('participations.')) {
return participationsSvc.actions[action.slice('participations.'.length)](params);
}
switch (action) {
case 'app.get':
return { id: APP };
case 'pod-resources.get':
await wait(delays[`read:${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.
const { body } = read(params.collectionUri);
return arrayOf(body?.items || body?.orderedItems);
}
case 'attendees.add':
case 'pod-collections.add':
case 'attendees.remove':
case 'pod-collections.remove': {
// The attendees handler's add/remove run `pod-collections.add`/`.remove` as the creator; the
// participations handler's, as the participant. A refused PATCH resolves, as on the platform.
await wait(delays[action]);
if (fail[action.startsWith('attendees.') ? 'attendeesWrite' : 'participationsWrite']) {
state.refusedWrites.push({ action, ...params });
return undefined;
}
const list = (state.items[params.collectionUri] = state.items[params.collectionUri] || []);
if (action.endsWith('.add')) {
if (!list.includes(params.itemUri)) list.push(params.itemUri);
} else {
state.items[params.collectionUri] = list.filter(item => item !== params.itemUri);
}
return undefined;
}
case 'pod-collections.createAndAttach': {
// As the platform does: read the resource, then create, then attach — with a gap between
// the read and the attach, where a concurrent creation can slip in. The attach PATCH is not
// checked: the created collection's URI is returned even when the pod refused it.
const before = state.stored[params.resourceUri];
await wait(delays.create === undefined ? 10 : delays.create);
if (!before || before[params.attachPredicate] !== undefined) return undefined;
const uri = `${params.actorUri}/data/as/collection/participations-${state.created.length + 1}`;
state.created.push(uri);
state.items[uri] = [];
state.acls[uri] = createdCollectionAcl(uri);
if (fail.attach) return uri;
const current = state.stored[params.resourceUri];
const link = { id: uri };
const existing = current[params.attachPredicate];
state.stored[params.resourceUri] = { ...current, [params.attachPredicate]: existing === undefined ? link : [].concat(existing, link) };
return uri;
}
case 'pod-wac-groups.getUriFromCollectionUri': {
const { origin, pathname } = new URL(params.collectionUri);
return `${origin}/_groups${pathname}`;
}
case 'pod-permissions.fetch': {
if (fail.aclRead) return { status: 500 };
const authorizations = state.acls[resourceOfAcl(params.url)];
return authorizations ? { status: 200, body: { '@graph': authorizations } } : { status: 404 };
}
case 'pod-permissions.add': {
if (fail.grant) return false;
const acl = (state.acls[params.uri] = state.acls[params.uri] || []);
const id = `${HOST}/_acl${new URL(params.uri).pathname}#Read`;
let rule = acl.find(a => a['@id'] === id);
if (!rule) acl.push((rule = { '@id': id, 'acl:mode': 'acl:Read' }));
rule[params.agentPredicate] = [...new Set([].concat(rule[params.agentPredicate] ?? [], params.agentUri))];
return true;
}
case 'signature.proxy.query': {
if (params.method !== 'PUT') throw new Error(`unexpected proxy ${params.method} ${params.url}`);
if (fail.putStatus) return { status: fail.putStatus };
const uri = resourceOfAcl(params.url);
const inherited = (state.acls[uri] || []).filter(a => String(a['@id']).includes('#Default'));
state.acls[uri] = [...inherited, ...JSON.parse(params.body)['@graph']];
return { status: 204 };
}
case 'triplestore.query': {
// As the real SELECT, anchored on `<member> ?member`: no row for an entry without a member;
// the act fields unbound (absent) until an act is recorded.
const subject = /<(urn:festipod:attendance:[^>]+)>/.exec(params.query)[1];
const last = state.ledger[subject];
if (!last || last.member === undefined) return [];
const row = { member: { value: last.member } };
if (last.activityUri !== undefined) Object.assign(row, { act: { value: last.activityUri }, published: { value: last.published }, change: { value: last.change } });
return [row];
}
case 'triplestore.update': {
const subject = /<(urn:festipod:attendance:[^>]+)>/.exec(params.query)[1];
const entry = (state.ledger[subject] = state.ledger[subject] || {});
// `recordAttendance`: replaces the act fields, leaves the member as persisted.
const act = new RegExp(`<${ACT}> <([^>]+)> ; <[^>]+> "([^"]*)" ; <[^>]+> "([^"]*)"`).exec(params.query);
if (act) {
Object.assign(entry, { activityUri: act[1], published: act[2], change: act[3] });
return undefined;
}
// `persistMember`: INSERT … WHERE { FILTER NOT EXISTS { member } } — set only when absent.
const member = new RegExp(`<${MEMBER}> "([^"]*)"`).exec(params.query);
if (member && entry.member === undefined) entry.member = member[1];
return undefined;
}
case 'pod-notifications.send':
if (fail.notify) throw new Error('the app outbox is down');
state.notifications.push(params);
return undefined;
default:
throw new Error(`unexpected call ${action}`);
}
}
};
// The participations service, bound like collection-handlers.test.js binds a collection handler.
participationsSvc = {
name: participations.name,
settings: { ...PodCollectionsHandlerMixin.settings, ...participations.settings },
logger,
actions: {}
};
participations.created.call(participationsSvc);
for (const [name, fn] of Object.entries({ ...PodCollectionsHandlerMixin.methods, ...participations.methods })) {
participationsSvc[name] = fn.bind(participationsSvc);
}
for (const [name, def] of Object.entries({ ...PodCollectionsHandlerMixin.actions, ...participations.actions })) {
participationsSvc.actions[name] = (params, opts) => def.handler.call(participationsSvc, { ...opts, params, call: ctx.call });
}
registrationSvc = { name: registration.name, logger };
registration.created.call(registrationSvc);
if (timeoutMs) {
const { createKeyedSerializer } = require('./keyed-serializer');
registrationSvc.serialize = createKeyedSerializer({ timeoutMs });
}
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);
return { ctx, calls, state, logs, registration: registrationSvc, participations: participationsSvc, emit };
}
module.exports = {
world,
activity,
meetingPoint,
actor,
profile,
profileOf,
contactsOf,
groupOf,
attendeesOf,
createdCollectionAcl,
HOST,
APP,
ALICE,
BOB,
CAROL,
MALLORY,
EVENT1,
EVENT2,
SECRETS,
FP_SECRET,
FP_PARTICIPATIONS
};
+191
View File
@@ -0,0 +1,191 @@
const urlJoin = require('url-join');
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
const { arrayOf } = require('@semapps/ldp');
const CONFIG = require('../config/config');
const { isInActorPod } = require('./actor-pod');
const { createKeyedSerializer } = require('./keyed-serializer');
const { uriOf } = require('./emitted-activity');
const { guardedCreateAndAttach } = require('./collection-attach');
const { hasInheritedPublicRead, hasOwnPublicRead, revocationGraph, ownReaders, aclUriOf, readAcl, putOwnRights } = require('./public-read-acl');
const FP_PARTICIPATIONS = 'http://festipod.org/participations';
/** The collection URIs a profile links under `fp:participations` (full IRI or CURIE). */
const participationsLinksOf = profile =>
arrayOf(profile[FP_PARTICIPATIONS] ?? profile['fp:participations'])
.map(uriOf)
.filter(uri => typeof uri === 'string');
// A person's participations collection (decision_2026-10-29_participation-privacy-mechanism): the
// person-side record of the meeting points they joined, for themself and for their contacts. An
// unordered `as:Collection` in the participant's pod, attached to their `as:Profile` under
// `http://festipod.org/participations`, whose items are meeting point URIs — added on a Join and
// removed on a Leave by registration.service.js, acting as the participant.
//
// Who may read it: its owner, the app, and the participant's `/contacts` WAC group — through ONE
// `acl:agentGroup` grant on the collection's own `#Read` rule (the welcometomyplace pattern), the
// group URI derived from the `apods:contacts` collection (`pod-wac-groups.getUriFromCollectionUri`).
// The GROUP, not the `apods:contacts` LIST: a received `Accept` of a contact `Offer` is added to the
// list with no prior request checked (platform defect reported upstream), so the list is forgeable —
// a stranger could grant themself read; the group gains a member only through the person's OWN
// emitted activities (a contact request they send, an acceptance they emit), which a forged received
// activity cannot do. (The group does admit the target of a request the person SENT before it is
// accepted, and keeps a removed contact — platform behaviours, both deliberate here: see the e2e.)
// And never anyone else: the pod creates a collection world-readable (`acl:Read` for `foaf:Agent`,
// observed 2026-10-12 on the attendee lists), and a participations collection readable by strangers
// would list a person's events to anyone.
//
// `syncReaders` makes that true from the collection's ACL as the pod serves it NOW, read as the
// participant: the pod's own public read goes (the `revocationGraph` PUT of `public-read-acl.js` —
// `pod-permissions.remove` cannot do it, see there), then the `/contacts` group gets its grant if it
// has none (`pod-permissions.add`, the welcometomyplace grant call). It runs on every Join (`ensure`,
// before the meeting point is added). The grant names a STABLE group URI, so it never needs revisiting
// when the person's contacts change — the pod keeps the group's membership, nothing here to re-sync.
// Idempotent: on a collection already in that state, one ACL read and nothing written. A failure
// throws — the act that needed it is not recorded, the next one retries.
//
// Created at a person's first sign-up, never on a profile's Create (no activity handler here): a
// profile with no collection has no participation to list. One run at a time per person — two Joins of
// one person on two meeting points run concurrently and would otherwise both find no collection and
// attach one each.
module.exports = {
name: 'participations',
mixins: [PodCollectionsHandlerMixin],
settings: {
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Profile'),
attachPredicate: FP_PARTICIPATIONS,
collectionOptions: {
ordered: false,
summary: 'Festipod participations'
},
// Items are meeting points, not agents: no mirrored WAC group.
createWacGroup: false
},
dependencies: ['pod-permissions'],
// Replaces the mixin's whole map (moleculer does not merge a custom schema key): no handler, so
// no collection is attached to a profile when it is created.
activities: {},
created() {
this.serialize = createKeyedSerializer();
},
actions: {
// Nothing attached → no WAC group (see collection-attach.js).
createAndAttach: guardedCreateAndAttach,
/** The collection the participant's profile links now, or undefined (see `collectionOf`). */
collectionOf: {
params: {
actorUri: { type: 'string' },
profileUri: { type: 'string' }
},
async handler(ctx) {
return this.collectionOf(ctx, ctx.params);
}
},
/** The participant's collection, created if none and readable by their `/contacts` group only (see `ensure`). */
ensure: {
params: {
actorUri: { type: 'string' },
profileUri: { type: 'string' },
contactsUri: { type: 'string', optional: true }
},
async handler(ctx) {
return this.ensure(ctx, ctx.params);
}
}
},
methods: {
/**
* The collection `profileUri` (the profile of `actorUri`) links NOW — read from the pod as the
* participant — or undefined when it links none. Throws when the profile cannot be read, links
* several, or links one outside the participant's pod (ignored: nothing is written to it).
*/
async collectionOf(ctx, { actorUri, profileUri }) {
if (!isInActorPod(profileUri, actorUri)) throw new Error(`the profile ${profileUri} is not in the pod of ${actorUri}`);
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: profileUri, actorUri });
if (status !== 200 || !body || uriOf(body) !== profileUri) {
throw new Error(`the pod does not return the profile ${profileUri} (HTTP ${status})`);
}
const links = participationsLinksOf(body);
if (links.length > 1) throw new Error(`the profile ${profileUri} links several participations collections`);
if (links.length === 1 && !isInActorPod(links[0], actorUri)) {
throw new Error(`the profile ${profileUri} links a participations collection outside the pod of ${actorUri}: ignored`);
}
return links[0];
},
/**
* The participant's collection, ready to receive a meeting point: the one the profile links, or
* one created and attached now when it links none; then readable by the participant's `/contacts`
* WAC group and by no one else (`syncReaders`). One run at a time per participant. Resolves the
* collection URI; throws when any of this cannot be made true — nothing may then be added to it.
*
* After an attach, the collection used is the one the profile links when READ AGAIN, never the
* URI the attach returned: the platform's `createAndAttach` returns the collection it created even
* when the pod refused the profile PATCH that links it (it does not check that write), and returns
* nothing when the profile gained a link meanwhile (another app). A created collection the profile
* does not link is left unused, empty — its contacts could never find it.
*/
async ensure(ctx, { actorUri, profileUri, contactsUri }) {
return this.serialize(actorUri, async signal => {
let collectionUri = await this.collectionOf(ctx, { actorUri, profileUri });
if (!collectionUri) {
const attached = await this.actions.createAndAttach({ resourceUri: profileUri, actorUri }, { parentCtx: ctx });
collectionUri = await this.collectionOf(ctx, { actorUri, profileUri });
if (!collectionUri) {
throw new Error(
attached
? `the pod created ${attached} but did not link it to ${profileUri}: left unused`
: `no participations collection could be attached to ${profileUri}`
);
}
if (attached === collectionUri) this.logger.info(`Attached the participations collection ${collectionUri} to ${profileUri}`);
else if (attached) this.logger.warn(`${profileUri} links ${collectionUri}, not the collection ${attached} just created: that one is left unused`);
}
await this.syncReaders(ctx, { collectionUri, actorUri, contactsUri, signal });
return collectionUri;
});
},
/**
* Makes `collectionUri` readable by the participant's `/contacts` WAC group (one `acl:agentGroup`
* grant on its own `#Read` rule, the group URI derived from their `apods:contacts` collection) and
* by no one else: the pod's own public read is removed first (the `revocationGraph` PUT, one call,
* the app keeping its `#Control`), then the group is granted read if it has none. The grant names a
* stable group URI whose membership the pod keeps, so it is set once and never re-synced. Gives up
* before any write once its time is out (`signal`). Idempotent: on a collection already in that
* state, one ACL read and nothing written. Throws when the ACL cannot be read, the pod refuses a
* change, or the collection stays world-readable through a rule inherited from its container (not
* removable here) — the Join that needed it is then not recorded, and retries.
*/
async syncReaders(ctx, { collectionUri, actorUri, contactsUri, signal }) {
if (!contactsUri || !isInActorPod(contactsUri, actorUri)) {
throw new Error(`${actorUri} names no contacts collection in their pod: who may read ${collectionUri} is unknown`);
}
const groupUri = await ctx.call('pod-wac-groups.getUriFromCollectionUri', { collectionUri: contactsUri });
const acl = await readAcl(ctx, collectionUri, actorUri);
if (!acl.authorizations) throw new Error(`the ACL of ${collectionUri} could not be read (HTTP ${acl.status})`);
if (hasInheritedPublicRead(acl.authorizations)) {
throw new Error(`${collectionUri} is world-readable through a rule inherited from its container: not removable here`);
}
if (hasOwnPublicRead(acl.authorizations)) {
if (signal && signal.aborted) throw new Error(`out of time before removing the public read of ${collectionUri}`);
const document = revocationGraph(acl.authorizations, collectionUri, actorUri, aclUriOf(collectionUri));
const status = await putOwnRights(ctx, collectionUri, document);
if (status !== 204 && status !== 200) {
throw new Error(`the pod refused to remove the public read of ${collectionUri} (HTTP ${status})`);
}
this.logger.info(`Removed the public read of ${collectionUri}`);
}
if (!ownReaders(acl.authorizations).agentGroup.includes(groupUri)) {
if (signal && signal.aborted) throw new Error(`out of time before granting the contacts group read on ${collectionUri}`);
const added = await ctx.call('pod-permissions.add', {
uri: collectionUri,
agentPredicate: 'acl:agentGroup',
agentUri: groupUri,
mode: 'acl:Read',
actorUri
});
if (!added) throw new Error(`the pod refused the read grant of the contacts group ${groupUri} on ${collectionUri}`);
this.logger.info(`Granted the contacts group ${groupUri} read on ${collectionUri}`);
}
}
}
};
@@ -0,0 +1,155 @@
const { test, expect, describe } = require('bun:test');
const participations = require('./participations.service');
const { hasOwnPublicRead, ownReaders } = require('./public-read-acl');
const { world, profile, profileOf, contactsOf, groupOf, createdCollectionAcl, APP, ALICE, BOB, MALLORY, FP_PARTICIPATIONS } = require('./participation-world');
const ensure = (w, extra = {}) => w.participations.actions.ensure({ actorUri: BOB, profileUri: profileOf(BOB), contactsUri: contactsOf(BOB), ...extra });
const collectionOf = w => w.participations.actions.collectionOf({ actorUri: BOB, profileUri: profileOf(BOB) });
const EXISTING = `${BOB}/data/as/collection/participations-existing`;
const linked = uri => ({ [profileOf(BOB)]: profile(BOB, { [FP_PARTICIPATIONS]: { id: uri } }) });
const aclId = (uri, rule) => `http://localhost:3000/_acl${new URL(uri).pathname}#${rule}`;
const appControl = uri => ({ '@id': aclId(uri, 'Control'), 'acl:mode': 'acl:Control', 'acl:agent': APP });
/** The /contacts WAC group that may read Bob's participations collection (what the grant names). */
const GROUP = groupOf(BOB);
/** A collection readable by Bob's /contacts group, the app keeping its Control. */
const groupRead = uri => ({ '@id': aclId(uri, 'Read'), 'acl:mode': 'acl:Read', 'acl:agentGroup': GROUP });
const protectedAcl = uri => [groupRead(uri), appControl(uri)];
/** The collection created at the first sign-up (the world names the first one `participations-1`). */
const CREATED = `${BOB}/data/as/collection/participations-1`;
/** Who the collection's own #Read rule lets read, plus every inherited rule's readers. */
const readersOf = (w, uri) => ownReaders(w.state.acls[uri]);
const writes = w => w.calls.filter(c => ['pod-collections.createAndAttach', 'pod-permissions.add', 'signature.proxy.query'].includes(c.action));
describe('participations — the collection is attached at the first sign-up, never on a profile Create', () => {
test('no activity handler, the full-IRI attach predicate, unordered, no mirrored group', () => {
expect(participations.activities).toEqual({});
expect(participations.settings.attachPredicate).toBe('http://festipod.org/participations');
expect(participations.settings.collectionOptions.ordered).toBe(false);
expect(participations.settings.createWacGroup).toBe(false);
});
});
describe('participations.collectionOf — the collection the profile links now', () => {
test('none, one, one outside the pod, several, an unreadable profile', async () => {
expect(await collectionOf(world())).toBeUndefined();
expect(await collectionOf(world({ stored: linked(EXISTING) }))).toBe(EXISTING);
expect(await collectionOf(world({ stored: { [profileOf(BOB)]: profile(BOB, { [FP_PARTICIPATIONS]: EXISTING }) } }))).toBe(EXISTING); // a plain IRI value
await expect(collectionOf(world({ stored: linked(`${MALLORY}/data/as/collection/x`) }))).rejects.toThrow('outside the pod');
await expect(collectionOf(world({ stored: { [profileOf(BOB)]: profile(BOB, { [FP_PARTICIPATIONS]: [{ id: EXISTING }, { id: `${EXISTING}-2` }] }) } }))).rejects.toThrow('several');
await expect(collectionOf(world({ stored: { [profileOf(BOB)]: undefined } }))).rejects.toThrow('does not return the profile');
});
test('a profile outside the participant\'s pod is never read', async () => {
const w = world();
await expect(w.participations.actions.collectionOf({ actorUri: BOB, profileUri: profileOf(MALLORY) })).rejects.toThrow('not in the pod');
expect(w.calls.some(c => c.action === 'pod-resources.get')).toBe(false);
});
});
describe('participations.ensure — a collection readable by the participant\'s /contacts group', () => {
test('no collection: one is created and attached, the /contacts group granted read, the pod\'s public read removed', async () => {
const w = world();
const uri = await ensure(w);
expect(uri).toBe(CREATED);
expect(w.state.created).toEqual([uri]);
expect(w.state.stored[profileOf(BOB)][FP_PARTICIPATIONS]).toEqual({ id: uri });
const attach = w.calls.find(c => c.action === 'pod-collections.createAndAttach');
expect(attach.params).toMatchObject({ resourceUri: profileOf(BOB), attachPredicate: FP_PARTICIPATIONS, actorUri: BOB });
const grants = w.calls.filter(c => c.action === 'pod-permissions.add').map(c => c.params);
expect(grants).toEqual([{ uri, agentPredicate: 'acl:agentGroup', agentUri: GROUP, mode: 'acl:Read', actorUri: BOB }]);
expect(hasOwnPublicRead(w.state.acls[uri])).toBe(false);
expect(readersOf(w, uri)).toEqual({ agent: [], agentClass: [], agentGroup: [GROUP] });
// The group URI is DERIVED from her apods:contacts collection; the list itself is never read.
expect(w.calls.find(c => c.action === 'pod-wac-groups.getUriFromCollectionUri').params).toEqual({ collectionUri: contactsOf(BOB) });
expect(w.calls.some(c => c.action === 'pod-collections.getItems')).toBe(false);
});
test('the public read goes FIRST (one PUT, the app keeping its Control), then the group grant', async () => {
const w = world();
const uri = await ensure(w);
const order = writes(w).map(c => c.action);
expect(order).toEqual(['pod-collections.createAndAttach', 'signature.proxy.query', 'pod-permissions.add']);
const put = w.calls.find(c => c.action === 'signature.proxy.query');
expect(put.params.url).toBe(`http://localhost:3000/_acl${new URL(uri).pathname}`);
expect(put.params.actorUri).toBe(APP);
const graph = JSON.parse(put.params.body)['@graph'];
expect(JSON.stringify(graph)).not.toContain('foaf:Agent');
expect(graph.some(n => n['@id'].endsWith('#Control') && n['acl:agent'] === APP)).toBe(true);
});
test('no contact yet: the group is granted all the same — its membership is the pod\'s business', async () => {
const w = world();
const uri = await ensure(w);
expect(readersOf(w, uri)).toEqual({ agent: [], agentClass: [], agentGroup: [GROUP] });
// No per-contact acl:agent grant is ever made; the group carries everyone.
expect(w.calls.every(c => c.action !== 'pod-permissions.add' || c.params.agentPredicate === 'acl:agentGroup')).toBe(true);
});
test('idempotent: on a collection already readable by the group, the ACL reads, nothing written', async () => {
const w = world({ stored: linked(EXISTING), acls: { [EXISTING]: protectedAcl(EXISTING) } });
expect(await ensure(w)).toBe(EXISTING);
expect(await ensure(w)).toBe(EXISTING);
expect(writes(w)).toEqual([]);
expect(w.calls.filter(c => c.action === 'pod-permissions.fetch')).toHaveLength(2);
});
test('an existing collection still world-readable: reused, the public read removed, the group granted', async () => {
const w = world({ stored: linked(EXISTING), acls: { [EXISTING]: createdCollectionAcl(EXISTING) } });
expect(await ensure(w)).toBe(EXISTING);
expect(w.state.created).toEqual([]);
expect(hasOwnPublicRead(w.state.acls[EXISTING])).toBe(false);
expect(readersOf(w, EXISTING)).toEqual({ agent: [], agentClass: [], agentGroup: [GROUP] });
});
test('an existing collection granting the group but still world-readable: the public read goes, the grant is not repeated', async () => {
const bothOnRead = { '@id': aclId(EXISTING, 'Read'), 'acl:mode': 'acl:Read', 'acl:agentGroup': GROUP, 'acl:agentClass': 'foaf:Agent' };
const w = world({ stored: linked(EXISTING), acls: { [EXISTING]: [bothOnRead, appControl(EXISTING)] } });
await ensure(w);
expect(hasOwnPublicRead(w.state.acls[EXISTING])).toBe(false);
expect(readersOf(w, EXISTING)).toEqual({ agent: [], agentClass: [], agentGroup: [GROUP] });
expect(writes(w).map(c => c.action)).toEqual(['signature.proxy.query']); // the PUT only, no repeated grant
});
test('one creation per participant: two concurrent calls attach ONE collection', async () => {
const w = world({ delays: { create: 30 } });
const [a, b] = await Promise.all([ensure(w), ensure(w)]);
expect(a).toBe(b);
expect(w.state.created).toHaveLength(1);
});
test('nothing attached (the profile gained a link meanwhile): the linked collection is used', async () => {
const w = world({ stored: linked(EXISTING), acls: { [EXISTING]: protectedAcl(EXISTING) } });
// collectionOf saw no link at first: simulate by an attach that finds one.
const original = w.participations.collectionOf;
let first = true;
w.participations.collectionOf = async (...args) => (first ? ((first = false), undefined) : original(...args));
expect(await ensure(w)).toBe(EXISTING);
expect(w.state.created).toEqual([]);
});
test('a collection the pod creates but does not link (the profile PATCH refused): ensure throws, the collection is never used', async () => {
const w = world({ fail: { attach: true } });
await expect(ensure(w)).rejects.toThrow(`the pod created ${CREATED} but did not link it`);
expect(w.state.created).toEqual([CREATED]);
expect(w.state.stored[profileOf(BOB)][FP_PARTICIPATIONS]).toBeUndefined();
// Not protected, not granted: nothing is done with a collection no contact could find.
expect(writes(w).map(c => c.action)).toEqual(['pod-collections.createAndAttach']);
// The profile is read again after the attach: the link counts, not what the attach returned.
expect(w.calls.filter(c => c.action === 'pod-resources.get' && c.params.resourceUri === profileOf(BOB))).toHaveLength(2);
});
test('every failure throws — nothing may then be added to the collection', async () => {
await expect(ensure(world({ fail: { putStatus: 403 } }))).rejects.toThrow('refused to remove the public read');
await expect(ensure(world({ stored: linked(EXISTING), acls: { [EXISTING]: [appControl(EXISTING)] }, fail: { grant: true } }))).rejects.toThrow('refused the read grant of the contacts group');
await expect(ensure(world({ fail: { aclRead: true } }))).rejects.toThrow('could not be read');
await expect(ensure(world(), { contactsUri: undefined })).rejects.toThrow('names no contacts collection');
await expect(ensure(world(), { contactsUri: `${MALLORY}/contacts` })).rejects.toThrow('names no contacts collection');
const inherited = world({
stored: linked(EXISTING),
acls: { [EXISTING]: [...protectedAcl(EXISTING), { '@id': 'http://localhost:3000/_acl/bob/data/as/collection#DefaultRead', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' }] }
});
await expect(ensure(inherited)).rejects.toThrow('inherited');
expect(writes(inherited)).toEqual([]);
});
});
+82 -7
View File
@@ -14,7 +14,7 @@
// removing the foaf:Agent triple alone would leave the own set empty and the PUT would be refused.
// `revocationGraph` therefore keeps the owner as `acl:agent` of `#Read` when nothing else remains —
// the owner already reads via the default, so no right is gained or lost — and preserves any other
// own agent triples (e.g. a future per-contact grant) unchanged.
// own agent triples (e.g. the `/contacts` group a participations collection grants read to) unchanged.
//
// NARROW: only the public READ goes. The pod keys own rules by mode (`#Read`, `#Write`, `#Append`,
// `#Control`); the `foaf:Agent` triple is removed from the `#Read` rule alone, so a `foaf:Agent` on
@@ -28,6 +28,15 @@
// the compacted `foaf:Agent` the pod returns (removes nothing), and it drops any authorization
// whose removed predicate becomes empty even when other agent predicates remain (would empty the
// set → 400). This module PUTs a correct graph instead.
//
// `ownReaders` says who a resource's own `#Read` rule lets read (its agents, groups and classes):
// participations.service.js uses it to tell whether the `/contacts` WAC group it grants read to a
// participations collection is already granted.
//
// Two pod calls go with the graphs: `readAcl` (the ACL as the pod serves it to the app, a failure
// told apart from "no public read") and `putOwnRights` (the PUT, as the app actor). Every Festipod
// caller that reads or replaces an ACL goes through them: public-read (events and their attendee
// lists) and participations (a person's participations collection).
const { arrayOf } = require('@semapps/ldp');
@@ -98,12 +107,13 @@ const aclContext = aclUri => ({
});
/**
* revocationGraph(authorizations, resourceUri, ownerUri) → a JSON-LD document `{ '@context', '@graph' }`
* to PUT on the resource's ACL so it keeps every one of its OWN agent triples EXCEPT
* `acl:agentClass foaf:Agent` on its `#Read` authorization (a `foaf:Agent` on any other own rule stays). Inherited `#Default*` rules are not emitted
* (a PUT does not touch them). When removing the public-read triple would leave the own set empty,
* the owner is emitted as `acl:agent` of `#Read` so the PUT is accepted without changing any right.
* `aclUri` is where the document will be PUT (used only for the context `@base`).
* revocationGraph(authorizations, resourceUri, ownerUri, aclUri) → a JSON-LD document `{ '@context',
* '@graph' }` to PUT on the resource's ACL so it keeps every one of its OWN agent triples EXCEPT
* `acl:agentClass foaf:Agent` on its `#Read` authorization (a `foaf:Agent` on any other own rule
* stays). The other own rules (`#Write`, `#Append`, `#Control`) stay exactly as they are. Inherited
* `#Default*` rules are not emitted (a PUT does not touch them). When removing the public-read triple
* would leave the own set empty, the owner is emitted as `acl:agent` of `#Read` so the PUT is accepted
* without changing any right. `aclUri` is where the document will be PUT (used only for the context `@base`).
*/
const revocationGraph = (authorizations, resourceUri, ownerUri, aclUri) => {
const graph = [];
@@ -134,13 +144,78 @@ const revocationGraph = (authorizations, resourceUri, ownerUri, aclUri) => {
return { '@context': aclContext(aclUri), '@graph': graph };
};
/**
* ownReaders(authorizations) → `{ agent, agentClass, agentGroup }`: the values (full IRIs, without
* duplicates) the resource's OWN `#Read` authorization grants read to — not the inherited rules.
*/
const ownReaders = authorizations => {
const readers = { agent: [], agentClass: [], agentGroup: [] };
for (const authorization of arrayOf(authorizations)) {
if (suffixOf(authorization) !== 'Read' || !expandedValuesOf(authorization, 'mode').includes(ACL + 'Read')) continue;
for (const name of Object.keys(readers)) {
for (const iri of expandedValuesOf(authorization, name)) {
if (typeof iri === 'string' && !readers[name].includes(iri)) readers[name].push(iri);
}
}
}
return readers;
};
/** The ACL document URI of a pod resource: `{origin}/_acl{path}` (the pod's `getAclUriFromResourceUri`). */
const aclUriOf = resourceUri => {
const { origin, pathname } = new URL(resourceUri);
return `${origin}/_acl${pathname}`;
};
/**
* readAcl(ctx, resourceUri, actorUri) → `{ status, authorizations }`, the authorizations only when
* the pod answered 200. Unlike `pod-permissions.get`, which answers `false` for ANY failure —
* indistinguishable from "no public read" — a failure here is reported, so a caller can tell "not
* public" from "unknown". Read where `putOwnRights` writes (`aclUriOf`), through the pod owner's
* access (`actorUri`).
*/
async function readAcl(ctx, resourceUri, actorUri) {
const { status, body } =
(await ctx.call('pod-permissions.fetch', {
url: aclUriOf(resourceUri),
headers: { Accept: 'application/ld+json' },
actorUri
})) || {};
if (status !== 200 || !body || typeof body !== 'object') return { status: typeof status === 'number' ? status : 0 };
const graph = body['@graph'] !== undefined ? body['@graph'] : body['@id'] || body.id ? [body] : [];
return { status, authorizations: Array.isArray(graph) ? graph : [graph] };
}
/**
* putOwnRights(ctx, resourceUri, document) → the HTTP status of the PUT of `document` (a
* `revocationGraph`) on the resource's ACL, sent as the APP ACTOR through the platform's signature
* proxy — the actor that holds `acl:Control` on the resources Festipod grants or revokes on.
* Resolves the status (0 when none); the caller decides what a refusal means.
*/
async function putOwnRights(ctx, resourceUri, document) {
const app = await ctx.call('app.get');
const { status } =
(await ctx.call('signature.proxy.query', {
url: aclUriOf(resourceUri),
method: 'PUT',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify(document),
actorUri: app.id || app['@id']
})) || {};
return typeof status === 'number' ? status : 0;
}
module.exports = {
grantsPublicRead,
hasOwnPublicRead,
hasInheritedPublicRead,
revocationGraph,
ownReaders,
isPublicRead,
expand,
aclUriOf,
readAcl,
putOwnRights,
FOAF_AGENT,
ACL
};
+14 -32
View File
@@ -5,15 +5,18 @@ const { hasPublicIntent } = require('./public-intent');
const { isInActorPod } = require('./actor-pod');
const { createKeyedSerializer } = require('./keyed-serializer');
const { neverThrowingMatcher, verifiedEmit, uriOf, singleUriOf, hasAsType } = require('./emitted-activity');
const { grantsPublicRead, hasOwnPublicRead, hasInheritedPublicRead, revocationGraph, FOAF_AGENT } = require('./public-read-acl');
const {
grantsPublicRead,
hasOwnPublicRead,
hasInheritedPublicRead,
revocationGraph,
aclUriOf,
readAcl,
putOwnRights,
FOAF_AGENT
} = require('./public-read-acl');
const { recordGrant, forgetGrant, isGrantedByFestipod } = require('./public-read-ledger');
/** The ACL document URI of a pod resource: `{origin}/_acl{path}` (the pod's `getAclUriFromResourceUri`). */
const aclUriOf = resourceUri => {
const { origin, pathname } = new URL(resourceUri);
return `${origin}/_acl${pathname}`;
};
const APODS_ATTENDEES = 'http://activitypods.org/ns/core#attendees';
/** The attendees collection an event names (compacted or full-IRI key), when it names exactly one. */
@@ -165,22 +168,9 @@ module.exports = {
this.logger.error(`Could not re-decide public read on ${resourceUri} after a late write: ${e.message}`);
}
},
/**
* The resource's ACL as the pod serves it to the app: `{ status, authorizations }`, the
* authorizations only when the pod answered 200. Unlike `pod-permissions.get`, which answers
* `false` for ANY failure — indistinguishable from "no public read" — a failure here is reported,
* so a caller can tell "not public" from "unknown". Read where `revoke` writes (`aclUriOf`).
*/
/** The resource's ACL as the pod serves it to the app (`public-read-acl.js` `readAcl`). */
async readAcl(ctx, resourceUri, actorUri) {
const { status, body } =
(await ctx.call('pod-permissions.fetch', {
url: aclUriOf(resourceUri),
headers: { Accept: 'application/ld+json' },
actorUri
})) || {};
if (status !== 200 || !body || typeof body !== 'object') return { status: typeof status === 'number' ? status : 0 };
const graph = body['@graph'] !== undefined ? body['@graph'] : body['@id'] || body.id ? [body] : [];
return { status, authorizations: Array.isArray(graph) ? graph : [graph] };
return readAcl(ctx, resourceUri, actorUri);
},
/**
* Makes `resourceUri` world-readable and records it as FESTIPOD's grant — only when its ACL was
@@ -277,17 +267,9 @@ module.exports = {
return false;
}
const aclUri = aclUriOf(resourceUri);
const document = revocationGraph(acl.authorizations, resourceUri, actorUri, aclUri);
const app = await ctx.call('app.get');
const document = revocationGraph(acl.authorizations, resourceUri, actorUri, aclUriOf(resourceUri));
if (aborted()) return false;
const { status } = await ctx.call('signature.proxy.query', {
url: aclUri,
method: 'PUT',
headers: { 'Content-Type': 'application/ld+json' },
body: JSON.stringify(document),
actorUri: app.id || app['@id']
});
const status = await putOwnRights(ctx, resourceUri, document);
if (status !== 204 && status !== 200) {
throw new Error(`The pod refused to revoke the public-read grant on ${resourceUri} (HTTP ${status})`);
}
+10 -10
View File
@@ -1,15 +1,15 @@
// The backend's whole HTTP surface, as an allow-list checked in front of every route.
//
// WHY. The backend runs SemApps' CoreService with `webacl: false`, so nothing checks rights on the
// resources it stores, and CoreService mounts routes Festipod never asked for — above all the LDP
// catch-all (`@semapps/ldp` `getCatchAllRoute`: `GET|POST|PUT|PATCH|DELETE /<any path>`,
// `authorization: false`), which served the backend's `settings` dataset to anyone: the app actor's
// PRIVATE key (`GET /key`, observed 2026-10-13), the user registrations and grants the app holds
// (`/interop/…`), the webhook listeners. A signed request is no better: any pod user can sign one.
// Rather than chase each route (deny-list), the gateway serves ONLY what a caller legitimately needs
// and refuses everything else with a 404 — for every caller, anonymous or signed, since none needs
// more over HTTP: the backend's own services reach its data through internal calls, never through
// these routes.
// WHY. CoreService mounts routes Festipod never asked for — above all the LDP catch-all
// (`@semapps/ldp` `getCatchAllRoute`: `GET|POST|PUT|PATCH|DELETE /<any path>`), over the dataset that
// holds the app actor's PRIVATE key (`/key`), the user registrations and grants the app holds
// (`/interop/…`), the catalog store and the ledgers. WebACL filters what those routes serve (the main
// dataset is a secure one, see core.service.js), so each resource is only as private as the rights
// its container gave it; this gate does not depend on those rights being right. A signed request is
// no better than an anonymous one here: any pod user can sign one. Rather than chase each route
// (deny-list), the gateway serves ONLY what a caller legitimately needs and refuses everything else
// with a 404 — for every caller, anonymous or signed, since none needs more over HTTP: the backend's
// own services reach its data through internal calls, never through these routes.
//
// WHAT IS PUBLIC, and who calls it (backend request log, 2026-10-09 → 2026-10-13, all e2e runs):
// GET /app the app description — the Festipod front end and the pod (install)
+292 -81
View File
@@ -1,53 +1,122 @@
const { ACTIVITY_TYPES, OBJECT_TYPES, matchActivity } = require('@semapps/activitypub');
const { ACTIVITY_TYPES, matchActivity } = require('@semapps/activitypub');
const { PodActivitiesHandlerMixin } = require('@activitypods/app');
const { arrayOf } = require('@semapps/ldp');
const CONFIG = require('../config/config');
const { isInActorPod } = require('./actor-pod');
const { createKeyedSerializer } = require('./keyed-serializer');
const { isNewerAct, lastAttendance, recordAttendance } = require('./attendance-ledger');
const { neverThrowingMatcher, verifiedEmit, verifiedReceive, singleUriOf, uriOf } = require('./emitted-activity');
const { isNewerAct, lastAttendance, persistMember, recordAttendance } = require('./attendance-ledger');
const { neverThrowingMatcher, verifiedEmit, singleUriOf, uriOf } = require('./emitted-activity');
const { participationSecretOf, memberUrn } = require('./member-urn');
const { readItems, expectItem } = require('./collection-items');
const AS = 'https://www.w3.org/ns/activitystreams#';
const APODS_ATTENDEES = 'http://activitypods.org/ns/core#attendees';
const APODS_CONTACTS = 'http://activitypods.org/ns/core#contacts';
const DC_CREATOR = 'http://purl.org/dc/terms/creator';
const JOIN_PATTERN = { type: ACTIVITY_TYPES.JOIN, object: { type: OBJECT_TYPES.EVENT } };
const LEAVE_PATTERN = { type: ACTIVITY_TYPES.LEAVE, object: { type: OBJECT_TYPES.EVENT } };
const AS_PUBLISHED = 'https://www.w3.org/ns/activitystreams#published';
// Routing matches the activity TYPE alone: routing is a hint, the verified handler re-reads the
// activity and its object from the pod and decides (see emitted-activity.js).
const JOIN_PATTERN = { type: ACTIVITY_TYPES.JOIN };
const LEAVE_PATTERN = { type: ACTIVITY_TYPES.LEAVE };
/** The time the pod stamped on a stored activity (`published`, under any of its spellings). */
const publishedOf = activity => {
if (!activity || typeof activity !== 'object') return undefined;
const value = activity.published ?? activity['as:published'] ?? activity[AS_PUBLISHED];
const value = activity.published ?? activity['as:published'] ?? activity[`${AS}published`];
return value && typeof value === 'object' ? value['@value'] : value;
};
/** The single URI an event names under a compacted or full-IRI key. */
const linkOf = (node, compacted, full) => singleUriOf(node[compacted] ?? node[full]);
/** The single URI a node names under the first of `keys` it carries (compacted, CURIE or full IRI). */
const linkOf = (node, ...keys) => singleUriOf(keys.map(key => node[key]).find(value => value !== undefined));
// Participation: a user joins (or leaves) a meeting point with a Join (Leave) posted to her outbox,
// addressed to its organizer. Two effects, each from one side's box:
// - EMITTED (the joiner's outbox): the organizer joins the joiner's contacts group, so the
// organizer may read her profile;
// - RECEIVED (the organizer's inbox): the joiner is added to (removed from) the meeting point's
// attendees collection.
// The webhook that reports either is a hint (emitted-activity.js): each handler acts only on the
/** The first string a property holds (a string, a value node, or a list of either). */
const firstStringOf = value =>
arrayOf(value)
.map(v => (typeof v === 'string' ? v : v && typeof v === 'object' ? v['@value'] : undefined))
.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.
*/
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 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)) {
return { ok: false, reason: `the meeting point ${meetingPointUri} is not in the pod of its creator ${creatorUri}` };
}
return { ok: true, eventUri, creatorUri };
}
/**
* 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.
*
* 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 —
* is forgeable (a stranger's received `Accept` of a contact `Offer` is added to it with no prior
* 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' }]
};
}
// 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:
// - 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;
// - in the participant's participations collection (their pod, acting as the participant): the
// meeting point URI — the collection created at the first Join and kept readable by the
// participant's `/contacts` WAC group only (`participations.service.js`).
// Then the creator is notified ANONYMOUSLY — always: naming would need a trustworthy "is the creator a
// 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 joiner is the actor whose pod holds the activity, never a value of the webhook body;
// - the organizer is the meeting point's `dc:creator`, accepted only when 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;
// - the collection and the group written to are the ones the STORED meeting point and the
// joiner's STORED actor document name, and must lie in the pod of whoever owns them.
// - 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 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
// order they reach that lock does not decide: under the lock, an act is applied only when it is newer —
// by the time the participant's pod stamped on it — than the last act applied for that participant and
// meeting point (`attendance-ledger.js`, persisted). Whatever order the notifications run in, the
// participant ends in the state of her latest act. The collection is read when acting.
// meeting point (`attendance-ledger.js`, persisted, which also holds the member URN that act applied). Whatever order the notifications run in, the
// participant ends in the state of their latest act. The member URN is STICKY per (meeting point,
// participant): chosen once and persisted BEFORE any attendee-list write, so every later act reuses it —
// the pair is never listed under two URNs, even after a half-way failure and a secret rewrite. An act is
// recorded only once BOTH collections, READ BACK, show it (collection-items.js — the platform's own
// collection calls answer "empty" on a failed read and resolve on a refused write); one that fails
// half-way is not, nor notified, and the participant's next act on that meeting point re-applies it
// (the two writes are not atomic).
module.exports = {
name: 'registration',
mixins: [PodActivitiesHandlerMixin],
dependencies: ['participations'],
created() {
this.serialize = createKeyedSerializer();
},
@@ -55,6 +124,65 @@ module.exports = {
this.logger.info('Registration watcher subscribed');
},
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
* 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) {
const refuse = reason => this.logger.warn(`${this.name} refused the ${change} ${uriOf(activity)} of ${participantUri}: ${reason}`);
const target = meetingPointTarget(meetingPoint, meetingPointUri);
if (!target.ok) return refuse(target.reason);
const participant = await this.participantOf(ctx, participantUri);
if (!participant.ok) return refuse(participant.reason);
const outcome = await this.updateAttendance(ctx, {
change,
eventUri: target.eventUri,
secret: participant.secret,
meetingPointUri,
meetingPoint,
activity,
creatorUri: target.creatorUri,
participantUri,
profileUri: participant.profileUri,
contactsUri: participant.contactsUri
});
if (outcome && outcome.changed) {
await this.notifyCreator(ctx, {
change,
meetingPointUri,
meetingPoint,
eventUri: target.eventUri,
creatorUri: target.creatorUri,
participantUri
});
}
},
/**
* 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
* profile, which must carry a usable participation secret. Resolves `{ ok, profileUri,
* contactsUri, secret }` or a refusal. `contactsUri` is the participant's `apods:contacts`
* collection — whose `/contacts` WAC group is what may read their participations collection.
*/
async participantOf(ctx, participantUri) {
const read = async uri => {
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: uri, actorUri: participantUri });
return status === 200 && body && uriOf(body) === uri ? body : undefined;
};
const actor = await read(participantUri);
if (!actor) return { ok: false, reason: `the pod does not return the actor ${participantUri}` };
const profileUri = linkOf(actor, 'url', 'as:url', `${AS}url`);
if (!profileUri || !isInActorPod(profileUri, participantUri)) {
return { ok: false, reason: `${participantUri} names no single profile in their pod (${profileUri})` };
}
const profile = await read(profileUri);
if (!profile) return { ok: false, reason: `the pod does not return the profile ${profileUri}` };
const secret = participationSecretOf(profile);
if (!secret) return { ok: false, reason: `the profile ${profileUri} carries no usable participation secret` };
const contactsUri = linkOf(actor, 'apods:contacts', APODS_CONTACTS);
return { ok: true, profileUri, contactsUri, secret };
},
/**
* The attendees collection of a meeting point of `actorUri`'s pod, from the STORED meeting point.
* It is attached asynchronously when the meeting point is created (attendees service), so a
@@ -79,11 +207,37 @@ module.exports = {
return collectionUri;
},
/**
* Applies one participant act (`join` / `leave`, the stored activity `activity`) to the meeting
* point's collection — under the meeting point's lock, and only when the act is newer than the last
* one applied for that participant there (see the header). A stale act changes nothing; an act
* without a pod stamp cannot be placed and changes nothing. The decision is recorded once the
* collection matches it. Gives up before any write once its time is out (`signal`).
* The participant's participations collection, under the meeting point's lock: for a Join, the one
* `participations.ensure` makes ready (created if none, contacts-only); for a Leave, the one the
* profile links now, if any. Resolves `{ ok, collectionUri }` — `collectionUri` undefined for a
* Leave without a collection — or `{ ok: false }` once the failure is logged: the act must then
* write nothing and stay unrecorded, so that the next act retries.
*/
async participationsCollectionOf(ctx, { change, participantUri, profileUri, contactsUri }) {
try {
const collectionUri =
change === 'join'
? await ctx.call('participations.ensure', { actorUri: participantUri, profileUri, contactsUri })
: await ctx.call('participations.collectionOf', { actorUri: participantUri, profileUri });
return { ok: true, collectionUri };
} catch (e) {
this.logger.error(
`The participations collection of ${participantUri} is not ready (${e.message}): the ${change} is NOT recorded, the next act retries`
);
return { ok: false };
}
},
/**
* Applies one participant act (`join` / `leave`, the stored activity `activity`) — under the
* meeting point's lock, and only when the act is newer than the last one applied for that member
* there (see the header). A stale act changes nothing; an act without a pod stamp cannot be placed
* and changes nothing. The act is applied to the meeting point's attendees (as the creator), then
* to the participant's participations collection (as the participant), each write read back
* (`applyToAttendees`, `applyToParticipations`), and recorded once both show it; a failed read, a
* refused write or a write that does not show leaves the act unrecorded (logged). Gives up before
* any write once its time is out (`signal`). Resolves `{ changed }` when the act was applied —
* `changed` when it added or removed the member, or flipped the state the ledger held — and
* undefined when it was not.
*
* KNOWN LIMIT — a write in flight at the timeout: an add or remove ALREADY SENT when the time ran
* out still lands, and is not recorded. If a newer act of the same participant was applied in the
@@ -93,37 +247,116 @@ module.exports = {
* is the ledger that is behind — re-applying the ledger would undo it, and the two cases cannot be
* told apart here. Needs a pod slower than the 30 s limit.
*/
async updateAttendance(ctx, { change, memberUri, meetingPointUri, meetingPoint, activity, actorUri }) {
async updateAttendance(ctx, { change, eventUri, secret, meetingPointUri, meetingPoint, activity, creatorUri, participantUri, profileUri, contactsUri }) {
const act = { activityUri: uriOf(activity), published: publishedOf(activity), change };
if (Number.isNaN(Date.parse(act.published))) {
this.logger.warn(`${change} ${act.activityUri} of ${memberUri} carries no pod stamp (published): it cannot be ordered, unchanged`);
return;
this.logger.warn(`${change} ${act.activityUri} of ${participantUri} carries no pod stamp (published): it cannot be ordered, unchanged`);
return undefined;
}
await this.serialize(meetingPointUri, async signal => {
return this.serialize(meetingPointUri, async signal => {
const dataset = CONFIG.MAIN_DATASET;
const last = await lastAttendance(ctx, { dataset, meetingPointUri, memberUri });
const last = await lastAttendance(ctx, { dataset, meetingPointUri, participantUri });
if (!isNewerAct(act, last)) {
this.logger.info(`${change} ${act.activityUri} of ${memberUri} on ${meetingPointUri} is not newer than the last act applied (${last.activityUri}, possibly the same act notified again): unchanged`);
return;
this.logger.info(`${change} ${act.activityUri} of ${participantUri} on ${meetingPointUri} is not newer than the last act applied (${last.activityUri}, possibly the same act notified again): unchanged`);
return undefined;
}
const collectionUri = await this.attendeesCollectionOf(ctx, meetingPoint, meetingPointUri, actorUri);
const collectionUri = await this.attendeesCollectionOf(ctx, meetingPoint, meetingPointUri, creatorUri);
if (!collectionUri) {
this.logger.warn(`No attendees collection on ${meetingPointUri}, skipping ${change}`);
return undefined;
}
if (signal.aborted) return undefined;
const participations = await this.participationsCollectionOf(ctx, { change, participantUri, profileUri, contactsUri });
if (!participations.ok || signal.aborted) return undefined;
// The member URN the act applies is STICKY per (meeting point, participant): the one the ledger
// already holds for the pair, else one computed from the secret on the profile now and persisted
// write-once BEFORE any attendee-list write — the act then applies whatever member the ledger holds
// after that write (another act's, if one landed first) — so that every later act (Join or Leave,
// after any half-way failure, timeout or secret rewrite) uses that same URN and the pair is never
// listed under two.
if (signal.aborted) return undefined;
let wrote;
try {
const memberUri =
last && last.member
? last.member
: await persistMember(ctx, { dataset, meetingPointUri, participantUri, member: memberUrn({ participantUri, eventUri, secret }) });
if (signal.aborted) return undefined;
wrote = await this.applyToAttendees(ctx, { change, collectionUri, memberUri, creatorUri, meetingPointUri, signal });
await this.applyToParticipations(ctx, { change, collectionUri: participations.collectionUri, meetingPointUri, participantUri, signal });
} catch (e) {
this.logger.error(`The ${change} ${act.activityUri} of ${participantUri} on ${meetingPointUri} is NOT recorded (${e.message}): the next act retries`);
return undefined;
}
if (signal.aborted) return undefined;
await recordAttendance(ctx, { dataset, meetingPointUri, participantUri, act });
const wasAttending = Boolean(last && last.change === 'join');
return { changed: wrote || wasAttending !== (change === 'join') };
});
},
/**
* Applies `change` for `memberUri` to the meeting point's attendee list `collectionUri`, in the
* creator's pod and as the creator: reads the list — a failed read throws, it never means "not
* listed" — then adds the member on a Join when absent, removes it on a Leave when present (the
* attendees handler's `add` / `remove`, the reference-app calls) and reads the list back: throws
* unless the write shows. Resolves whether it wrote (false when the list already matched the act).
* Throws instead of writing once its time is out (`signal`).
*/
async applyToAttendees(ctx, { change, collectionUri, memberUri, creatorUri, meetingPointUri, signal }) {
const joining = change === 'join';
const attending = (await readItems(ctx, { collectionUri, actorUri: creatorUri })).includes(memberUri);
if (attending === joining) {
this.logger.info(`${memberUri} already ${joining ? 'attends' : 'does not attend'} ${meetingPointUri}`);
return false;
}
if (signal.aborted) throw new Error(`out of time before the ${change} write to ${collectionUri}`);
await ctx.call(joining ? 'attendees.add' : 'attendees.remove', { collectionUri, itemUri: memberUri, actorUri: creatorUri });
await expectItem(ctx, { collectionUri, itemUri: memberUri, actorUri: creatorUri, present: joining });
return true;
},
/**
* Applies `change` to the participant's participations collection `collectionUri`, in their pod and
* as them: adds the meeting point on a Join, removes it on a Leave (the participations handler's
* `add` / `remove`, the reference-app calls — adding an item already there or removing one that is
* not changes nothing, so no read first), then reads the collection back: throws unless it shows
* the act. Nothing to do on a Leave when the participant has no collection (`collectionUri`
* undefined). Throws instead of writing once its time is out (`signal`).
*/
async applyToParticipations(ctx, { change, collectionUri, meetingPointUri, participantUri, signal }) {
if (!collectionUri) {
this.logger.info(`${participantUri} has no participations collection: nothing to remove ${meetingPointUri} from`);
return;
}
const joining = change === 'join';
if (signal.aborted) throw new Error(`out of time before the ${change} write to ${collectionUri}`);
await ctx.call(joining ? 'participations.add' : 'participations.remove', { collectionUri, itemUri: meetingPointUri, actorUri: participantUri });
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.
* 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.
*/
async notifyCreator(ctx, { change, meetingPointUri, meetingPoint, eventUri, creatorUri, participantUri }) {
try {
if (creatorUri === participantUri) {
this.logger.info(`${participantUri} acted on their own meeting point ${meetingPointUri}: no notification`);
return;
}
if (signal.aborted) return;
const attending = arrayOf(await ctx.call('pod-collections.getItems', { collectionUri, actorUri })).includes(memberUri);
if (signal.aborted) return;
if (change === 'join' && !attending) {
await ctx.call('attendees.add', { collectionUri, itemUri: memberUri, actorUri });
} else if (change === 'leave' && attending) {
await ctx.call('attendees.remove', { collectionUri, itemUri: memberUri, actorUri });
} else {
this.logger.info(`${memberUri} already ${change === 'join' ? 'attends' : 'does not attend'} ${meetingPointUri}`);
}
if (signal.aborted) return;
await recordAttendance(ctx, { dataset, meetingPointUri, memberUri, act });
});
this.logger.info(`Notifying ${creatorUri} of a ${change} on ${meetingPointUri}, anonymously`);
await ctx.call('pod-notifications.send', {
template: notificationTemplate(change),
recipientUri: creatorUri,
context: meetingPointUri,
meetingPointName: firstStringOf(meetingPoint.name ?? meetingPoint['as:name'] ?? meetingPoint[`${AS}name`]) ?? '',
eventUri
});
} catch (e) {
this.logger.error(`Could not notify ${creatorUri} of a ${change} on ${meetingPointUri}: ${e.message}`);
}
}
},
activities: {
@@ -131,48 +364,26 @@ module.exports = {
match: neverThrowingMatcher(function (activity, fetcher) {
return matchActivity(JOIN_PATTERN, activity, fetcher);
}),
// The joiner's outbox: `actorUri` is the joiner. The meeting point lies in the organizer's pod.
// The participant's outbox: `actorUri` is the participant; the meeting point may lie in any pod
// (its creator's — checked by `meetingPointTarget`).
onEmit: verifiedEmit(
'Join',
async function (ctx, { object: meetingPoint, objectUri: meetingPointUri }, actorUri) {
const organizerUri = linkOf(meetingPoint, 'dc:creator', DC_CREATOR);
if (!organizerUri || !isInActorPod(meetingPointUri, organizerUri)) {
this.logger.warn(`${meetingPointUri} is not in the pod of the creator it names: no contact added`);
return;
}
const { body: actor } = await ctx.call('pod-resources.get', { resourceUri: actorUri, actorUri });
const contactsUri = actor && linkOf(actor, 'apods:contacts', APODS_CONTACTS);
if (!contactsUri) return;
if (!isInActorPod(contactsUri, actorUri)) {
this.logger.warn(`${actorUri} names a contacts collection outside its pod: no contact added`);
return;
}
const contactsGroupUri = await ctx.call('pod-wac-groups.getUriFromCollectionUri', { collectionUri: contactsUri });
await ctx.call('pod-wac-groups.addMember', { groupUri: contactsGroupUri, memberUri: organizerUri, actorUri });
async function (ctx, verdict, actorUri) {
await this.onParticipation(ctx, 'join', verdict, actorUri);
},
{ objectType: 'Event', objectIn: 'any' }
),
// The organizer's inbox: `actorUri` is the organizer; the meeting point must lie in her pod.
onReceive: verifiedReceive(
'Join',
async function (ctx, { activity, emitterUri, object, objectUri }, actorUri) {
await this.updateAttendance(ctx, { change: 'join', memberUri: emitterUri, meetingPointUri: objectUri, meetingPoint: object, activity, actorUri });
},
{ objectType: 'Event' }
)
},
leave: {
match: neverThrowingMatcher(function (activity, fetcher) {
return matchActivity(LEAVE_PATTERN, activity, fetcher);
}),
onReceive: verifiedReceive(
onEmit: verifiedEmit(
'Leave',
async function (ctx, { activity, emitterUri, object, objectUri }, actorUri) {
await this.updateAttendance(ctx, { change: 'leave', memberUri: emitterUri, meetingPointUri: objectUri, meetingPoint: object, activity, actorUri });
async function (ctx, verdict, actorUri) {
await this.onParticipation(ctx, 'leave', verdict, actorUri);
},
{ objectType: 'Event' }
{ objectType: 'Event', objectIn: 'any' }
)
}
}
File diff suppressed because it is too large Load Diff
+11 -2
View File
@@ -22,8 +22,17 @@
# backend request would be refused (404).
#
# The backend registers its app actor in its main dataset on the first boot, with the
# domain it had at that time: set the domains in Coolify BEFORE the first deployment. If
# they change later, delete the two datasets in Fuseki and redeploy.
# domain it had at that time: set the domains in Coolify BEFORE the first deployment. The
# main dataset must be a SECURE one (WebACL is on): the backend creates it so when it does
# not exist, and refuses to start on an existing one that is not secure. To start again from
# nothing (the domains changed, or the main dataset was created before WebACL was on):
# 1. delete the two datasets in Fuseki. Fuseki's delete leaves a secure dataset's files on
# disk — remove `databases/<main>`, `databases/<main>Acl`, `databases/<main>Mirror` and
# `configuration/<main>.ttl` under the Fuseki base too, or a dataset created again under
# the same name reopens the old data;
# 2. empty this stack's Redis (the `redis-data` volume): its cache outlives the datasets and
# would keep serving the old app actor, keys and access needs;
# 3. redeploy.
#
# Fuseki is the one shared with the other apps (shared-infra, in the "infra" project): the
# backend joins the "coolify" network to reach it, with its own pair of datasets. Redis
+1 -1
View File
@@ -241,7 +241,7 @@ Alors une erreur de validation est affichée pour "Date"
Alors je peux voir la liste des participants
Alors l'écran affiche les informations de l'événement
Alors je peux m'inscrire à l'événement
Alors je peux voir le QR code
Alors je peux voir le lien de partage
```
## Test Outcomes
File diff suppressed because one or more lines are too long
+950 -1046
View File
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
/**
* DIAGNOSTIC (not a gate): reproduce the invite-link contact flow end to end
* with two FRESH accounts and inspect what lands in each pod's apods:contacts —
* then run the app's own fetchCollectionMemberIds against the real collections.
*
* Usage: bun scripts/diag-invite.ts
*/
import {
loginWithRetry,
http,
poll,
parseJson,
itemUris,
need,
sleep,
} from './e2e-rights-matrix-support';
import type { ApodsSession } from '../src/shared/support/activitypodsStack';
// The app's real reader — tested here against a REAL collection document.
import { fetchCollectionMemberIds } from '../src/shared/data/contacts';
const TS = Date.now();
const PASS = 'Test1test';
const POD = 'http://localhost:3000';
const A_NAME = `diaginv${TS}a`;
const B_NAME = `diaginv${TS}b`;
const config = { podProviderUrl: POD, backendUrl: process.env.BACKEND_URL || 'http://localhost:3003' };
const CRED_CTX = [
'https://www.w3.org/ns/credentials/v2',
{ as: 'https://www.w3.org/ns/activitystreams#', apods: 'http://activitypods.org/ns/core#', acl: 'http://www.w3.org/ns/auth/acl#' },
];
async function actorDoc(session: ApodsSession): Promise<Record<string, any>> {
const r = await http('GET', session.webId, { token: session.token });
return need(parseJson(r.text), `actor doc for ${session.webId}`);
}
const linkOf = (v: any): string | undefined =>
typeof v === 'string' ? v : v?.id ?? v?.['@id'];
async function main() {
console.log('1) Signup of two fresh accounts (browser-driven, slow)...');
const a = await loginWithRetry(config, A_NAME, PASS);
const b = await loginWithRetry(config, B_NAME, PASS);
console.log(' A (inviter):', a.webId);
console.log(' B (invitee):', b.webId);
const aDoc = await actorDoc(a);
const bDoc = await actorDoc(b);
const aProfile = need(linkOf(aDoc.url), 'A profile URI');
const bProfile = need(linkOf(bDoc.url), 'B profile URI');
const bOutbox = need(linkOf(bDoc.outbox), 'B outbox');
console.log('2) A creates the invite capability...');
const issue = async () =>
http('POST', `${a.webId}/vc/v0.3/credentials/issue`, {
token: a.token,
contentType: 'application/json',
accept: 'application/ld+json',
timeoutMs: 30000,
body: JSON.stringify({
credential: {
'@context': CRED_CTX,
type: 'VerifiableCredential',
name: 'Invite Link',
credentialSubject: {
'apods:hasActivityGrant': {
type: 'as:Offer',
'as:to': { '@id': a.webId },
'as:target': { '@id': a.webId },
'as:object': { type: 'as:Add', 'as:object': { type: 'as:Profile' } },
},
'apods:hasAuthorization': {
type: 'acl:Authorization',
'acl:mode': 'acl:Read',
'acl:accessTo': { '@id': aProfile },
},
},
},
}),
});
let capRes = await issue();
for (let attempt = 0; capRes.status === 0 && attempt < 10; attempt++) {
console.log(' VC issue unreachable (', capRes.error ?? 'timeout', ') — retrying in 3 s...');
await sleep(3000);
capRes = await issue();
}
const capJson = parseJson(capRes.text);
const capUri = typeof capJson?.id === 'string' ? capJson.id : undefined;
console.log(' VC issue:', capRes.status, capUri ?? `${capRes.error ?? ''} ${capRes.text.slice(0, 160)}`);
if (!capUri) throw new Error('capability creation failed');
console.log('3) B reads the capability (proof the grant works)...');
const capDoc = await http('GET', capUri, { token: b.token, accept: 'application/ld+json' });
console.log(' capability fetch:', capDoc.status, capDoc.text.slice(0, 120).replace(/\s+/g, ' '));
console.log('4) B builds the presentation (challenge from A, sign at B)...');
const challengeRes = await http('POST', `${a.webId}/vc/v0.3/challenges`, { token: b.token, accept: 'application/ld+json' });
const challenge = need(parseJson(challengeRes.text)?.challenge, 'challenge');
const presRes = await http('POST', `${b.webId}/vc/v0.3/presentations/`, {
token: b.token,
contentType: 'application/json',
accept: 'application/ld+json',
body: JSON.stringify({
presentation: {
'@context': CRED_CTX,
type: 'VerifiablePresentation',
verifiableCredential: [need(parseJson(capDoc.text), 'VC doc')],
},
options: { challenge, persist: false },
}),
});
const presentation = parseJson(presRes.text);
console.log(' presentation:', presRes.status, presentation ? 'ok' : presRes.text.slice(0, 160));
console.log('5) B posts Offer{Add{profile}} + capability to B\'s outbox...');
const offer = await http('POST', bOutbox, {
token: b.token,
contentType: 'application/ld+json',
accept: 'application/ld+json',
body: JSON.stringify({
'@context': 'https://www.w3.org/ns/activitystreams',
type: 'Offer',
actor: b.webId,
object: { type: 'Add', object: { id: bProfile, type: 'Profile' } },
target: a.webId,
to: a.webId,
capability: presentation,
}),
});
console.log(' outbox post:', offer.status, offer.text.slice(0, 120).replace(/\s+/g, ' '));
console.log('6) Polling the apods:contacts collections (up to 60 s)...');
const contactsUriOf = (doc: Record<string, any>) => linkOf(doc['apods:contacts']);
const aContacts = need(contactsUriOf(aDoc), 'A contacts URI');
const bContacts = need(contactsUriOf(bDoc), 'B contacts URI');
const readItems = async (uri: string, token: string) =>
itemUris(parseJson((await http('GET', uri, { token })).text));
const aPoll = await poll(() => readItems(aContacts, a.token), v => v.length > 0, 60000, 2000);
await sleep(3000);
const bPoll = await readItems(bContacts, b.token);
console.log(' A contacts items:', JSON.stringify(aPoll));
console.log(' B contacts items:', JSON.stringify(bPoll));
console.log('7) The APP reader against the REAL collection documents...');
const aDocRaw = (await http('GET', aContacts, { token: a.token })).text;
const bDocRaw = (await http('GET', bContacts, { token: b.token })).text;
console.log(' A collection doc (first 400):', aDocRaw.replace(/\s+/g, ' ').slice(0, 400));
console.log(' B collection doc (first 400):', bDocRaw.replace(/\s+/g, ' ').slice(0, 400));
const appReadsA = await fetchCollectionMemberIds(aContacts, a.token, a.webId);
const appReadsB = await fetchCollectionMemberIds(bContacts, b.token, b.webId);
console.log(' app fetchCollectionMemberIds(A):', JSON.stringify(appReadsA));
console.log(' app fetchCollectionMemberIds(B):', JSON.stringify(appReadsB));
const ok = appReadsA.includes(b.webId) && appReadsB.includes(a.webId);
console.log(ok ? 'VERDICT: platform flow + app reader BOTH OK' : 'VERDICT: MISMATCH — see above');
process.exit(ok ? 0 : 1);
}
main().catch((e) => { console.error('DIAG FAILED:', e); process.exit(2); });
+10
View File
@@ -99,6 +99,9 @@ async function createMeetingPoint(page: Page): Promise<boolean> {
await propose.first().click().catch(() => {});
await page.waitForTimeout(1500);
await page.locator('input[placeholder="Ex : Gouvernance coopérative — retours SCIC"]').fill('Rdv flow').catch(() => {});
// The place is the form's mandatory field (title optional) — without it the
// submit bails on the client and nothing is created.
await page.locator('input[placeholder="Café en face du tiers-lieu"]').fill('Devant la gare').catch(() => {});
await page.getByRole('button', { name: /Créer le point de rencontre/ }).click().catch(() => {});
await page.waitForTimeout(5000);
const txt = await bodyText(page);
@@ -136,11 +139,18 @@ function classify(e: HttpError): string | null {
await browser.close();
await new Promise(r => setTimeout(r, 1200));
// User decision 2026-10-26: the suite must not accumulate pod datasets. The
// platform tombstones the account and drops its dataset on a delay.
const accountDeleted = await fetch(`${config.podProviderUrl}/.account/${USER}`, {
method: 'DELETE', headers: { Authorization: `Bearer ${session.token}` }, signal: AbortSignal.timeout(60000),
}).then(r => r.status).catch(() => 0);
const fatal = errors4xx.filter(e => classify(e) === null);
const benign = errors4xx.map(e => ({ e, label: classify(e) })).filter(x => x.label).map(x => ({ status: x.e.status, url: x.e.url, label: x.label }));
const report = {
user: USER,
authenticated,
accountDeleted,
login: login.detail,
installed: install.actual,
eventCreated,
+7
View File
@@ -325,6 +325,12 @@ function benign(e: HttpError): boolean {
await browser.close();
await sleep(1000);
// User decision 2026-10-26: the suite must not accumulate pod datasets. The
// platform tombstones the account and drops its dataset on a delay.
const accountDeleted = await fetch(`${config.podProviderUrl}/.account/${USER}`, {
method: 'DELETE', headers: { Authorization: `Bearer ${user.token}` }, signal: AbortSignal.timeout(60000),
}).then(r => r.status).catch(() => 0);
// The deferred probe lands at the end of the minute: never before (throttle),
// and not much after (it was deferred, not dropped). Measured between the
// app's own log lines, so a few hundred ms of request latency either way.
@@ -374,6 +380,7 @@ function benign(e: HttpError): boolean {
const report = {
user: USER,
appUri,
accountDeleted,
positive,
negative,
recovered,
+6 -4
View File
@@ -104,9 +104,8 @@ export class Matrix {
}
/**
* A MANUAL-CHECK line: an assertion this script deliberately does NOT exercise
* live (it would send a forged or spoofed activity — user decision 2026-10-13,
* defensive only). Printed with where it is covered; never affects the exit code.
* A MANUAL-CHECK line: an assertion this script does not exercise live yet.
* Printed with where it is covered; never affects the exit code.
*/
manual(id: string, group: string, name: string, coverage: string): void {
this.push({ id, group, name, kind: 'manual', expected: '(not exercised live)', actual: coverage, status: 'MANUAL-CHECK' });
@@ -262,7 +261,7 @@ export async function installApp(config: ApodsStackConfig, session: ApodsSession
if (reg.status < 200 || reg.status >= 300) return { ok: false, actual: `register → ${brief(reg)}` };
const container = await poll(
() => http('GET', `${session.storage}/as/event`, { token: session.token }),
r => r.status === 200, 30000, 500,
r => r.status === 200, 90000, 500,
);
return { ok: container.status === 200, actual: `register → HTTP ${reg.status}; ${session.storage}/as/event → ${brief(container).slice(0, 40)}` };
}
@@ -516,6 +515,9 @@ export async function createMeetingPointUi(page: Page, title: string): Promise<b
await propose.first().click().catch(() => {});
await page.waitForTimeout(1500);
await page.locator('input[placeholder="Ex : Gouvernance coopérative — retours SCIC"]').fill(title, { timeout: 10000 }).catch(() => {});
// The form REQUIRES a place since the title became optional (the pod has no
// slot for a free-text "when"; the place is the one mandatory fact).
await page.locator('input[placeholder="Café en face du tiers-lieu"]').fill('Devant la mairie', { timeout: 10000 }).catch(() => {});
await page.getByRole('button', { name: /Créer le point de rencontre/ }).click({ timeout: 5000 }).catch(() => {});
const txt = await poll(() => bodyText(page), t => t.includes(title), 15000, 500);
return txt.includes(title) && !/pas pu/i.test(txt);
+1024 -160
View File
File diff suppressed because it is too large Load Diff
+44 -4
View File
@@ -1,10 +1,10 @@
import { useEffect } from 'react';
import React, { useEffect } from 'react';
import { RouterProvider, useNavigate, useRouter } from './router';
import { ThemeProvider } from '../shared/context/ThemeContext';
import { NextGraphProvider } from '../shared/context/NextGraphContext';
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
import { useFestipodData, FestipodDataProvider } from '../shared/context/FestipodDataContext';
import { AuthGate } from './AuthGate';
import { ToastContainer } from '../shared/components/sketchy';
import { ToastContainer, Spinner, Text } from '../shared/components/sketchy';
import { initialBackendName } from '../shared/refine/providers';
// Auth
@@ -31,6 +31,44 @@ import { FriendsListScreen } from '../modules/user/screens/FriendsListScreen';
import { ShareProfileScreen } from '../modules/user/screens/ShareProfileScreen';
import { ConnectScreen } from '../modules/user/screens/ConnectScreen';
/**
* A signed-in person whose Pod has no readable profile, or one without a name,
* cannot be recognised by anyone and cannot edit anything: the app tells them
* so and asks for a name instead of leaving them in screens that silently fail.
* Presence of a name is the only completion flag — nothing is stored locally.
*/
function ProfileCompletionGate({ children }: { children: React.ReactNode }) {
const { currentUser, profileStatus } = useFestipodData();
if (profileStatus === 'loading') {
// A blank screen reads as a crash; the profile read is the app's first
// blocking step, so say that plainly while it settles.
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12, padding: 24 }}>
<Spinner size={24} />
<Text style={{ color: '#888', fontSize: 14 }}>Chargement de votre profil…</Text>
</div>
);
}
if (profileStatus === 'missing') {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<p style={{ fontWeight: 700 }}>Votre profil est introuvable</p>
<p style={{ color: '#888', fontSize: 14, lineHeight: 1.5 }}>
Votre Pod ne fournit pas de profil lisible : Festipod ne peut pas l'afficher ni le modifier.
Complétez votre profil depuis votre fournisseur de Pod, puis réessayez. Cet écran apparaît aussi quand la lecture a échoué momentanément.
</p>
<button onClick={() => window.location.reload()} style={{ padding: '10px 18px', borderRadius: 10, border: '1.5px solid #ccc', background: '#fff', cursor: 'pointer' }}>
Réessayer
</button>
</div>
);
}
if (profileStatus === 'ready' && (currentUser?.name ?? '').trim() === '') {
return <UpdateProfileScreen onboarding />;
}
return <>{children}</>;
}
function AppContent() {
const { route } = useRouter();
@@ -81,7 +119,9 @@ export function App() {
<AuthGate>
<FestipodDataProvider>
<div className="app-container">
<AppContent />
<ProfileCompletionGate>
<AppContent />
</ProfileCompletionGate>
<ToastContainer />
</div>
</FestipodDataProvider>
+1 -25
View File
@@ -139,19 +139,6 @@ body {
color: var(--app-black);
}
/* Placeholder box */
.app-placeholder {
background: var(--app-light-gray);
border: 2px dashed #ddd;
border-radius: var(--app-radius-sm);
display: flex;
align-items: center;
justify-content: center;
color: var(--app-gray);
font-family: var(--font-app);
font-size: 14px;
}
/* Divider */
.app-divider {
height: 1px;
@@ -263,7 +250,7 @@ body {
position: relative;
}
/* Badge / Tag */
/* Badge */
.app-badge {
display: inline-block;
padding: 3px 10px;
@@ -274,17 +261,6 @@ body {
color: #666;
}
.app-tag {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 11.5px;
font-weight: 500;
color: #5a3e00;
background: #fff3d6;
letter-spacing: 0.1px;
}
/* Section label */
.app-section-label {
font-size: 12px;
+4 -1
View File
@@ -122,7 +122,10 @@ const server = serve({
return new Response("No shared wallet file configured.", { status: 404 });
},
// Serve index.html for all unmatched routes (must be last)
// Serve index.html for all unmatched routes (must be last). The imported
// HTMLBundle is opaque at runtime (`index.index` is its source path, not
// HTML) — only Bun.serve knows how to build & serve it, so it must stay
// the direct route value; wrapping it in Response() would serve garbage.
"/*": index,
},
@@ -59,10 +59,6 @@ export function WelcomeScreen() {
Déjà membre ? <span onClick={() => navigate('/home')} style={{ color: '#E8590C', cursor: 'pointer', fontWeight: 600 }}>Connexion</span>
</Text>
</div>
<Text style={{ textAlign: 'center', fontSize: 12, color: '#bbb' }}>
Version beta - 127 membres actifs
</Text>
</div>
);
}
@@ -32,8 +32,11 @@ 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 EST délivrée au backend, qui tient la collection
# `apods:attendees` du point de rencontre. Le point de rencontre est créé dans le
# 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
# 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
# conteneur enregistré (`as/event`) ; sa collection `apods:attendees` est attachée
# par le backend lui-même, à la création — le scénario la lit, il ne la crée pas.
Scénario: Rejoindre puis quitter un point de rencontre met à jour la collection apods:attendees
@@ -47,7 +50,8 @@ Fonctionnalité: Smoke e2e de la pile ActivityPods locale
# Dépend du scénario précédent pour la mise en place, mais lit la collection
# elle-même : le compteur est le total de la collection (jamais les profils
# résolus) et une référence non résolue reste comptée et rendue anonyme.
# résolus) et une URN de membre, que rien ne résout, reste comptée et rendue
# anonyme.
Scénario: Le compteur et la liste des participants tiennent une référence non résolue
Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test"
Et l'application Festipod est installée sur le Pod de la session
@@ -1,26 +1,9 @@
import React, { useState, useMemo } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Text, Input, Button, Placeholder, showToast } from '../../../shared/components/sketchy';
import { Header, Text, Input, Button, ImagePicker, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { eventPath, useNavigate } from '../../../app/router';
const importableEvents = [
{
name: 'Festival des Utopies Concrètes',
source: 'Mobilizon',
date: '2026-03-15',
location: 'Paris, Parc de la Villette',
description: 'Festival annuel présentant des alternatives concrètes pour un monde durable.',
},
{
name: "Rencontres de l'Écologie",
source: 'Transiscope',
date: '2026-04-20',
location: 'Lyon, Halle Tony Garnier',
description: 'Deux jours de conférences et ateliers sur la transition écologique.',
},
];
type Step = 1 | 2 | 3;
export function CreateEventScreen() {
@@ -35,7 +18,6 @@ export function CreateEventScreen() {
const [endTime, setEndTime] = useState('');
const [location, setLocation] = useState('');
const [description, setDescription] = useState('');
const [importedFrom, setImportedFrom] = useState<string | null>(null);
const similarExisting = useMemo(() => {
if (name.trim().length < 3) return [];
@@ -43,22 +25,6 @@ export function CreateEventScreen() {
return events.filter(e => e.title.toLowerCase().includes(lower.slice(0, 3)));
}, [name, events]);
const importCandidate = useMemo(() => {
if (importedFrom) return null;
if (name.trim().length < 3) return null;
const lower = name.toLowerCase();
return importableEvents.find(e => e.name.toLowerCase().includes(lower.slice(0, 3))) ?? null;
}, [name, importedFrom]);
const applyImport = (candidate: typeof importableEvents[number]) => {
setName(candidate.name);
setStartDate(candidate.date);
setLocation(candidate.location);
setDescription(candidate.description);
setImportedFrom(candidate.source);
showToast(`Données importées depuis ${candidate.source}`, 'info');
};
const goNext = () => {
if (step === 1) {
if (similarExisting.length > 0) setStep(2);
@@ -74,6 +40,8 @@ export function CreateEventScreen() {
setStep((step - 1) as Step);
};
const [coverImage, setCoverImage] = useState('');
const submit = async () => {
const dateLabel = startDate
? (endDate ? `${startDate} - ${endDate}` : startDate)
@@ -94,6 +62,7 @@ export function CreateEventScreen() {
endTime,
location: location || 'Lieu à définir',
description,
coverImage: coverImage || undefined,
// Option B: the creator does NOT auto-participate (no host notion). The count
// starts at 0 and is DERIVED by the owner-materializer from real inbox
// deposits (|active registrations|) — never a local literal. Passing 1 here was
@@ -139,33 +108,6 @@ export function CreateEventScreen() {
</div>
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
{importCandidate && step === 1 && (
<div
style={{
background: '#EFF6FF',
border: '1.5px solid #93C5FD',
borderRadius: 12,
padding: 12,
marginBottom: 16,
}}
>
<Text style={{ margin: 0, fontWeight: 'bold', fontSize: 14, marginBottom: 4 }}>
Données trouvées sur {importCandidate.source}
</Text>
<Text style={{ margin: 0, fontSize: 13, lineHeight: 1.5 }}>
« {importCandidate.name} » — {importCandidate.location}
</Text>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="primary" style={{ padding: '6px 12px', fontSize: 13 }} onClick={() => applyImport(importCandidate)}>
Importer
</Button>
<Button style={{ padding: '6px 12px', fontSize: 13 }} onClick={() => setImportedFrom('dismissed')}>
Ignorer
</Button>
</div>
</div>
)}
{step === 1 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Text style={{ fontSize: 13, color: '#888', margin: 0 }}>
@@ -179,7 +121,6 @@ export function CreateEventScreen() {
value={name}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
if (importedFrom === 'dismissed') setImportedFrom(null);
}}
/>
</div>
@@ -254,11 +195,7 @@ export function CreateEventScreen() {
{step === 3 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<Placeholder
height={140}
label="+ Ajouter une photo"
style={{ cursor: 'pointer' }}
/>
<ImagePicker value={coverImage} label="+ Ajouter une photo" onChange={setCoverImage} />
<div style={{ display: 'flex', gap: 12 }}>
<div style={{ flex: 1 }}>
@@ -10,7 +10,6 @@ export function EventDetailScreen() {
const { eventId } = useParams();
const {
getEvent,
currentUser,
isParticipating,
joinMeetingPoint,
leaveMeetingPoint,
@@ -36,9 +35,8 @@ export function EventDetailScreen() {
const meetingPoints: MeetingPointData[] = meetingPointsRaw.map(mp => ({
id: mp.id,
title: mp.location,
when: mp.time,
duration: '~60 min',
title: mp.title,
when: mp.time || undefined,
lieu: mp.location,
// Derived by the data layer (absent where it cannot be counted per point).
participantCount: mp.participantCount,
@@ -81,11 +79,9 @@ export function EventDetailScreen() {
// the answer is still coming.
const ownership = eventId ? getEventOwnership(eventId) : 'unknown';
const canEdit = ownership === 'mine';
// Preview list shows the OTHER participants (deliberate — the total is in the
// header count; the full list at "Voir tous les participants" shows everyone).
// On this backend `currentUser.id` and `p.id` are BOTH actor URIs (the id the
// attendees collection keys memberships by), so comparing them drops self.
const knownParticipants = participants.filter(p => p.id !== currentUser?.id);
// Preview and full list apply the same rule — everyone, self included — so the
// rows agree with the header count.
const knownParticipants = participants;
// The rest of the total could not be resolved to a profile — a non-contact
// sees only anonymous attendees. The total still counts them, so the
// difference is rendered as anonymous rows (mirrors ParticipantsListScreen),
@@ -140,7 +136,7 @@ export function EventDetailScreen() {
</div>
<div style={{ padding: '0 16px 12px' }}>
<EventCover eventId={event.id} height={100} />
<EventCover url={event.coverImage} height={100} />
</div>
<div style={{ margin: '0 16px 12px', padding: 14, background: '#fafafa', borderRadius: 12, display: 'flex', flexDirection: 'column', gap: 8 }}>
@@ -217,7 +213,7 @@ export function EventDetailScreen() {
onClick={() => navigate(userPath(p.id))}
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', borderBottom: '1px solid #f5f5f5', cursor: 'pointer' }}
>
<Avatar initials={displayInitials} size={38} color="#2B6CB0" />
<Avatar initials={displayInitials} photo={p.photo} size={38} color="#2B6CB0" />
<div style={{ flex: 1 }}>
<div className="user-content" style={{ fontSize: 14, fontWeight: 600 }}>{displayName}</div>
{displayUsername && (
@@ -238,7 +234,7 @@ export function EventDetailScreen() {
</div>
</div>
))}
{unknownCount > 0 && (
{participants.length > 0 && (
<div
style={{ marginTop: 12, padding: 12, background: '#f9f9f9', borderRadius: 12, textAlign: 'center', cursor: 'pointer' }}
onClick={() => navigate(eventPath(event.id, 'participants'))}
+13 -30
View File
@@ -1,17 +1,11 @@
import { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Input, Card, Badge, BottomNav, AvatarStack, getEventPhotoUrl, Text } from '../../../shared/components/sketchy';
import { Header, Input, Card, BottomNav, EventCover, Text } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { eventPath, useNavigate } from '../../../app/router';
import { matchesQuery } from '../../../shared/utils/search';
import { useDiscoverEvents } from '../useDiscoverEvents';
const PEOPLE = [
{ name: 'Marie Leroy', color: '#E8590C' },
{ name: 'Jean Morel', color: '#2B6CB0' },
{ name: 'Alice Duval', color: '#9C4DC7' },
{ name: 'Thomas Bazin', color: '#38A169' },
{ name: 'Camille Noir', color: '#D69E2E' },
];
const EVENT_COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E'];
function EventCard({
@@ -19,23 +13,15 @@ function EventCard({
participantCount,
onClick,
}: {
event: { id: string; title: string; date: string; location: string; distance?: number };
event: { id: string; title: string; date: string; location: string; distance?: number; coverImage?: string };
/** `undefined` when nobody counted it (a catalog entry) — then no count is shown, never a made-up 0. */
participantCount: number | undefined;
onClick: () => void;
}) {
const color = EVENT_COLORS[Number(event.id.replace(/\D/g, '') || 0) % EVENT_COLORS.length] ?? '#E8590C';
const people = PEOPLE.slice(0, Math.min(5, Math.max(1, participantCount ?? 0)));
return (
<Card onClick={onClick} style={{ marginBottom: 12, padding: 0, overflow: 'hidden' }} accentColor={color}>
<div
style={{
height: 100,
backgroundImage: `url(${getEventPhotoUrl(event.id)})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}
/>
<EventCover url={event.coverImage} height={100} borderRadius={0} />
<div style={{ padding: 14 }}>
<div className="user-content" style={{ fontSize: 15.5, fontWeight: 700, lineHeight: 1.3, marginBottom: 6 }}>{event.title}</div>
<div style={{ fontSize: 12.5, color: '#888', marginBottom: 10 }}>
@@ -44,7 +30,6 @@ function EventCard({
{participantCount !== undefined && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<AvatarStack people={people} size={26} />
<span style={{ fontSize: 12, color: '#666' }}>{participantCount} inscrits</span>
</div>
</div>
@@ -57,7 +42,9 @@ function EventCard({
export function EventsScreen() {
const navigate = useNavigate();
const { events: ownEvents, getEvent } = useFestipodData();
const events = useDiscoverEvents(ownEvents);
const { events: allEvents, loading } = useDiscoverEvents(ownEvents);
const [query, setQuery] = useState('');
const events = allEvents.filter(e => matchesQuery(query, e.title, e.location));
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
@@ -67,22 +54,18 @@ export function EventsScreen() {
/>
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
<Input placeholder="Rechercher un événement..." />
</div>
<div style={{ display: 'flex', gap: 8, padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
<Badge style={{ background: '#1a1a1a', color: '#fff' }}>Tous</Badge>
<Badge>Cette semaine</Badge>
<Badge>Proches</Badge>
<Badge>Amis</Badge>
<Input placeholder="Rechercher un événement..." value={query} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)} />
</div>
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
{events.length === 0 && (
<Text style={{ textAlign: 'center', color: '#888', marginTop: 32 }}>
Aucun événement à afficher
{loading ? 'Chargement des événements…' : query.trim() ? 'Aucun événement ne correspond à votre recherche.' : 'Aucun événement à afficher pour le moment.'}
</Text>
)}
{events.length > 0 && loading && (
<Text style={{ textAlign: 'center', color: '#888', fontSize: 12, marginBottom: 8 }}>Chargement des événements…</Text>
)}
{events.map(event => {
// An event the data layer READ (own, or another user's opened by URI)
// carries a derived count; a catalog entry nobody opened carries none —
+33 -7
View File
@@ -3,17 +3,20 @@ import { ArrowLeft } from 'lucide-react';
import { Header, Input, Text, Avatar, Checkbox, Button, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { matchesQuery } from '../../../shared/utils/search';
import { eventPath, useNavigate, useParams } from '../../../app/router';
export function InviteScreen() {
const navigate = useNavigate();
const { eventId } = useParams();
const { getFriends } = useFestipodData();
const { getFriends, inviteToEvent } = useFestipodData();
const friends = getFriends();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [step, setStep] = useState<'select' | 'message'>('select');
const [message, setMessage] = useState('');
const [query, setQuery] = useState('');
const shownFriends = friends.filter(f => matchesQuery(query, safeUserName(f), safeUserUsername(f)));
const toggleFriend = (id: string) => {
const next = new Set(selected);
@@ -23,9 +26,22 @@ export function InviteScreen() {
const selectedFriends = friends.filter(f => selected.has(f.id));
const [sending, setSending] = useState(false);
const send = () => {
showToast(`${selected.size} invitation${selected.size > 1 ? 's' : ''} envoyée${selected.size > 1 ? 's' : ''}`, 'success');
navigate(eventPath(eventId ?? ''));
if (!eventId || sending) return;
setSending(true);
const count = selected.size;
// The confirmation follows the post: nothing is announced as sent until the Pod accepted it.
void Promise.resolve(inviteToEvent(eventId, [...selected], message))
.then(() => {
showToast(`${count} invitation${count > 1 ? 's' : ''} envoyée${count > 1 ? 's' : ''}`, 'success');
navigate(eventPath(eventId));
})
.catch((err: unknown) => {
console.error('[Invite] invitation failed:', err);
showToast("Les invitations n'ont pas pu être envoyées", 'error');
})
.finally(() => setSending(false));
};
if (step === 'message') {
@@ -126,9 +142,11 @@ export function InviteScreen() {
left={<ArrowLeft size={20} onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer' }} />}
/>
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
<Input placeholder="Rechercher un ami..." />
</div>
{friends.length > 0 && (
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
<Input placeholder="Rechercher un ami..." value={query} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)} />
</div>
)}
{selected.size > 0 && (
<div style={{
@@ -143,7 +161,15 @@ export function InviteScreen() {
)}
<div style={{ flex: 1, overflow: 'auto' }}>
{friends.map((friend) => (
{friends.length === 0 && (
<Text style={{ textAlign: 'center', color: '#888', margin: '32px 24px', lineHeight: 1.5 }}>
Vous n'avez pas encore de contact à inviter. Ajoutez des personnes à votre réseau depuis leur profil.
</Text>
)}
{friends.length > 0 && shownFriends.length === 0 && (
<Text style={{ textAlign: 'center', color: '#888', marginTop: 32 }}>Aucun contact ne correspond à votre recherche.</Text>
)}
{shownFriends.map((friend) => (
<div
key={friend.id}
onClick={() => toggleFriend(friend.id)}
@@ -1,35 +1,39 @@
import React, { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Button, Avatar, Input, showToast } from '../../../shared/components/sketchy';
import { Button, Input, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials } from '../../../shared/context/festipodDataBase';
import { eventPath, useNavigate, useParams } from '../../../app/router';
export function MeetingPointsScreen() {
const navigate = useNavigate();
const { eventId } = useParams();
const { addMeetingPoint, currentUser, getFriends } = useFestipodData();
const friends = getFriends();
const { addMeetingPoint } = useFestipodData();
const [title, setTitle] = useState('');
const [when, setWhen] = useState('');
const [duration, setDuration] = useState('~30 min');
const [lieu, setLieu] = useState('');
const [invited, setInvited] = useState<string[]>(friends.slice(0, 3).map(f => f.id));
const removeInvited = (id: string) => {
setInvited(invited.filter(i => i !== id));
};
const submit = () => {
if (!eventId) return;
addMeetingPoint({
if (!lieu.trim()) {
showToast('Indiquez le lieu du point de rencontre', 'error');
return;
}
// Only what the backend can store is asked for: the title and the place.
// A free-text "when" / duration has no slot in the pod's vocabulary (no
// ActivityStreams term holds either), so the form no longer offers them.
void Promise.resolve(addMeetingPoint({
eventId,
location: title || lieu || 'Point de rencontre',
time: when || duration,
});
showToast(title ? `Point de rencontre créé : ${title}` : 'Point de rencontre créé', 'success');
navigate(eventPath(eventId ?? ''));
title: title.trim() || undefined,
location: lieu.trim(),
}))
.then(() => {
showToast(title.trim() ? `Point de rencontre créé : ${title.trim()}` : 'Point de rencontre créé', 'success');
navigate(eventPath(eventId));
})
.catch((err: unknown) => {
console.error('[MeetingPoints] creation failed:', err);
showToast("Le point de rencontre n'a pas pu être créé", 'error');
});
};
return (
@@ -52,49 +56,14 @@ export function MeetingPointsScreen() {
style={{ marginBottom: 16 }}
/>
<div style={{ display: 'flex', gap: 10, marginBottom: 16 }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: '#999', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 6 }}>Quand</label>
<Input
value={when}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setWhen(e.target.value)}
placeholder="Ven. 22 · 9h00"
/>
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: 12, fontWeight: 600, color: '#999', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 6 }}>Durée</label>
<Input
value={duration}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setDuration(e.target.value)}
placeholder="~30 min"
/>
</div>
</div>
<label style={{ fontSize: 12, fontWeight: 600, color: '#999', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 6 }}>Lieu</label>
<label style={{ fontSize: 12, fontWeight: 600, color: '#999', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 6 }}>Lieu *</label>
<Input
value={lieu}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setLieu(e.target.value)}
placeholder="Café en face du tiers-lieu"
style={{ marginBottom: 16 }}
style={{ marginBottom: 24 }}
/>
<label style={{ fontSize: 12, fontWeight: 600, color: '#999', textTransform: 'uppercase', letterSpacing: 0.8, display: 'block', marginBottom: 8 }}>Inviter</label>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 20 }}>
{invited.map(id => {
const friend = friends.find(f => f.id === id);
if (!friend) return null;
return (
<div key={id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px 6px 6px', borderRadius: 20, background: '#f5f5f5' }}>
<Avatar initials={safeUserInitials(friend)} color="#2B6CB0" size={22} />
<span style={{ fontSize: 12, fontWeight: 500 }}>{safeUserName(friend).split(' ')[0]}</span>
<span onClick={() => removeInvited(id)} style={{ fontSize: 14, color: '#bbb', cursor: 'pointer' }}>×</span>
</div>
);
})}
<button style={{ padding: '6px 14px', borderRadius: 20, border: '1.5px dashed #ccc', background: 'none', fontSize: 12, cursor: 'pointer', color: '#999', fontFamily: 'var(--font-app)' }}>+ ajouter</button>
</div>
<Button
variant="primary"
style={{ width: '100%', padding: 14, fontSize: 15 }}
@@ -1,7 +1,9 @@
import { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Avatar, Text, Input } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { matchesQuery } from '../../../shared/utils/search';
import { eventPath, useNavigate, useParams, userPath } from '../../../app/router';
const COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E'];
@@ -9,9 +11,10 @@ const COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E'
export function ParticipantsListScreen() {
const navigate = useNavigate();
const { eventId } = useParams();
const { getEvent, getEventParticipants } = useFestipodData();
const { getEvent, getEventParticipants, getEventReadState } = useFestipodData();
const event = eventId ? getEvent(eventId) : undefined;
const participants = eventId ? getEventParticipants(eventId) : [];
const readState = eventId ? getEventReadState(eventId) : 'unavailable';
// The displayed NUMBER of participants comes ONLY from `event.participantCount`
// (the owner-derived, single source of truth — Option B), never from a local
@@ -21,12 +24,15 @@ export function ParticipantsListScreen() {
const totalCount = event ? event.participantCount : 0;
const unknownCount = Math.max(0, totalCount - participants.length);
const rows = [
const [query, setQuery] = useState('');
const allRows = [
...participants.map((p, i) => ({
key: p.id,
initials: safeUserInitials(p),
name: safeUserName(p),
username: safeUserUsername(p),
photo: p.photo,
color: COLORS[i % COLORS.length] ?? '#888',
known: true,
})),
@@ -35,11 +41,29 @@ export function ParticipantsListScreen() {
initials: '?',
name: '',
username: '',
photo: undefined as string | undefined,
color: '#ccc',
known: false,
})),
];
// An active search narrows to the named people: an anonymous row has nothing to match.
const rows = query.trim() ? allRows.filter(r => r.known && matchesQuery(query, r.name, r.username)) : allRows;
if (!event) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Participants"
left={<ArrowLeft size={20} onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer' }} />}
/>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Text>{readState === 'loading' ? 'Chargement des participants…' : 'Événement non trouvé'}</Text>
</div>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
@@ -48,10 +72,15 @@ export function ParticipantsListScreen() {
/>
<div style={{ padding: 16, borderBottom: '1px solid #f0f0f0' }}>
<Input placeholder="Rechercher un participant..." />
<Input placeholder="Rechercher un participant..." value={query} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)} />
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{rows.length === 0 && (
<Text style={{ textAlign: 'center', color: '#888', marginTop: 32 }}>
{query.trim() ? 'Aucun participant ne correspond à votre recherche.' : 'Aucun participant pour le moment.'}
</Text>
)}
{rows.map((p) => (
<div
key={p.key}
@@ -65,7 +94,7 @@ export function ParticipantsListScreen() {
borderBottom: '1px solid #f5f5f5',
}}
>
<Avatar initials={p.initials} color={p.color} size="sm" />
<Avatar initials={p.initials} photo={p.photo} color={p.color} size="sm" />
<div style={{ flex: 1 }}>
{p.known ? (
<>
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Header, Text, Input, Button, Placeholder, showToast } from '../../../shared/components/sketchy';
import { Header, Text, Input, Button, ImagePicker, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { eventPath, useNavigate, useParams } from '../../../app/router';
@@ -23,6 +23,7 @@ export function UpdateEventScreen() {
const [startTime, setStartTime] = useState(event?.startTime ?? '');
const [endTime, setEndTime] = useState(event?.endTime ?? '');
const [location, setLocation] = useState(event?.location ?? '');
const [coverImage, setCoverImage] = useState(event?.coverImage ?? '');
const [description, setDescription] = useState(event?.description ?? '');
const save = () => {
@@ -43,6 +44,7 @@ export function UpdateEventScreen() {
endTime,
location,
description,
coverImage: coverImage || undefined,
}))
.then(() => {
showToast('Événement mis à jour', 'success');
@@ -99,11 +101,9 @@ export function UpdateEventScreen() {
/>
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
<Placeholder
height={140}
label="Photo de couverture"
style={{ marginBottom: 20, cursor: 'pointer' }}
/>
<div style={{ marginBottom: 20 }}>
<ImagePicker value={coverImage} label="Photo de couverture" onChange={setCoverImage} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div>
@@ -7,7 +7,11 @@ import {
attendeesCollectionUri,
contextLink,
createResource,
ensureParticipationSecret,
linkId,
MEMBER_URN_PREFIX,
memberUrnOf,
participationSecretOf,
podLogin,
postOutboxActivity,
readAttendees,
@@ -267,26 +271,41 @@ Given(
When('la session rejoint le point de rencontre', async function (this: FestipodWorld) {
const pod = session(this);
const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');
// The app's own path (src/shared/data/outbox.ts): as:Join to the user's own
// outbox, `object` = the meeting point URI and `to` = the HOST (the record's
// dc:creator). Without the addressee the backend silently drops the activity;
// and an activity carrying no routing target never reaches the host.
const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId;
await postOutboxActivity(pod, 'Join', mpUri, host);
// The app's own sequence (refineData.tsx, src/shared/data/outbox.ts —
// 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.
await ensureParticipationSecret(pod);
await postOutboxActivity(pod, 'Join', mpUri);
});
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');
const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId;
await postOutboxActivity(pod, 'Leave', mpUri, host);
await postOutboxActivity(pod, 'Leave', mpUri);
});
/**
* The session's own member URN at the scenario's event: what the attendee list
* records for it — computed from the secret its OWN profile carries (the person
* reads their secret; nobody hands it over), and the anchor event the meeting
* point names.
*/
async function ownMemberUrn(world: FestipodWorld): Promise<string> {
const pod = session(world);
const profileUri = requires(world, linkId((await readResource(pod, pod.webId))['url']), "the session's profile (actor url)");
const secret = requires(world, participationSecretOf(await readResource(pod, profileUri)), "the session's participation secret");
return memberUrnOf(pod.webId, requires(world, world.apods.anchorUri, 'anchor URI'), secret);
}
Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) {
const pod = session(this);
const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');
const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));
expect(members.map(linkId)).to.include(pod.webId);
const urn = await ownMemberUrn(this);
const members = (await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(urn))).map(linkId);
expect(members, 'the member URN derived from my secret is listed').to.include(urn);
expect(members, 'the list never holds a WebID').to.not.include(pod.webId);
});
Then(
@@ -294,8 +313,10 @@ Then(
async function (this: FestipodWorld) {
const pod = session(this);
const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');
const members = await waitForMembers(pod, collectionUri, found => !found.map(linkId).includes(pod.webId));
expect(members.map(linkId)).to.not.include(pod.webId);
const urn = await ownMemberUrn(this);
const members = (await waitForMembers(pod, collectionUri, found => !found.map(linkId).includes(urn))).map(linkId);
expect(members).to.not.include(urn);
expect(members).to.not.include(pod.webId);
},
);
@@ -304,25 +325,28 @@ Then(
Then('le compteur du point de rencontre compte cette inscription', async function (this: FestipodWorld) {
const pod = session(this);
const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');
const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));
const urn = await ownMemberUrn(this);
const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(urn));
const collection = await readResource(pod, collectionUri);
// The count is the collection total — kept as-is, never the number of
// RESOLVED profiles, so it stays total for a reader who resolves nobody.
const raw = collection.totalItems ?? collection['as:totalItems'];
const total = raw === undefined ? members.length : Number(raw);
expect(total, 'the count reflects the joined session').to.be.greaterThan(0);
expect(members.map(linkId), 'the counted session is the one listed').to.include(pod.webId);
expect(members.map(linkId), 'the counted session is the one listed (its member URN)').to.include(urn);
});
Then('la liste des participants rend ce participant comme anonyme', async function (this: FestipodWorld) {
const pod = session(this);
const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');
const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));
const joined = members.find(member => linkId(member) === pod.webId);
const urn = await ownMemberUrn(this);
const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(urn));
const joined = members.find(member => linkId(member) === urn);
expect(joined, 'the joined session is listed').to.not.be.undefined;
// "Anonymous" is the read-side fact: the member travels as a REFERENCE with no
// profile the reader can resolve — the list has nothing but the URI to render,
// which is exactly what the app shows for a participant it cannot dereference.
// "Anonymous" is the read-side fact: the member is a member URN — a value in
// no pod, no WebID, nothing to dereference — so a reader who cannot compute
// it from a contact's secret has nothing but the count to render.
expect(members.map(linkId).every(id => typeof id === 'string' && id.startsWith(MEMBER_URN_PREFIX)), 'every member is a member URN').to.be.true;
const profileFields = ['name', 'preferredUsername', 'as:name', 'nickname'];
const hasProfile =
typeof joined === 'object' && joined !== null && profileFields.some(field => (joined as Record<string, unknown>)[field] !== undefined);
+17 -4
View File
@@ -9,23 +9,36 @@ import { getBackendUrl } from '../../shared/utils/runtimeConfig';
* Connected mode only — without an ActivityPods session (demo) or a configured
* backend the catalog is never called and the list is `own` unchanged.
*/
export function useDiscoverEvents(own: FpEventData[]): FpEventData[] {
export function useDiscoverEvents(own: FpEventData[]): { events: FpEventData[]; loading: boolean } {
const session = useCurrentSession();
const backendUrl = getBackendUrl();
const connected = !!session && !!backendUrl;
const [catalog, setCatalog] = useState<FpEventData[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!connected || !backendUrl) {
setCatalog([]);
setLoading(false);
return;
}
const controller = new AbortController();
void fetchCatalogEvents(backendUrl, controller.signal).then(events => {
if (!controller.signal.aborted) setCatalog(events);
setLoading(true);
void fetchCatalogEvents(backendUrl, { signal: controller.signal }).then(events => {
if (controller.signal.aborted) return;
setCatalog(events);
setLoading(false);
}).catch((err: unknown) => {
// An unreachable/failing catalog must not hold the list in "loading"
// forever: the screen falls back to the user's own events, empty catalog.
if (controller.signal.aborted) return;
console.error('[useDiscoverEvents] catalog fetch failed:', err);
setCatalog([]);
setLoading(false);
});
return () => controller.abort();
}, [connected, backendUrl]);
return useMemo(() => unionEvents(own, catalog), [own, catalog]);
const events = useMemo(() => unionEvents(own, catalog), [own, catalog]);
return { events, loading };
}
+22 -57
View File
@@ -1,16 +1,7 @@
import { useState } from 'react';
import { Title, Card, AvatarStack, BottomNav, EventCover, EventMeetingPoints, Spinner, type MeetingPointData } from '../../../shared/components/sketchy';
import { Title, Text, Card, BottomNav, EventCover, EventMeetingPoints, Spinner, type MeetingPointData } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { usePendingQueries } from '../../../shared/data/pendingQueries';
import { useNavigate } from '../../../app/router';
const PEOPLE = [
{ name: 'Marie Leroy', color: '#E8590C' },
{ name: 'Jean Morel', color: '#2B6CB0' },
{ name: 'Alice Duval', color: '#9C4DC7' },
{ name: 'Thomas Bazin', color: '#38A169' },
{ name: 'Camille Noir', color: '#D69E2E' },
];
import { useNavigate, eventPath } from '../../../app/router';
const EVENT_COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E'];
@@ -19,24 +10,21 @@ function EventCardBody({
joinedIds,
onToggle,
onClick,
isOngoing = false,
}: {
event: { id: string; title: string; date: string; location: string; meetingPoints?: MeetingPointData[] };
event: { id: string; title: string; date: string; location: string; coverImage?: string; meetingPoints?: MeetingPointData[] };
joinedIds: Set<string>;
onToggle: (id: string) => void;
onClick: () => void;
isOngoing?: boolean;
}) {
const color = EVENT_COLORS[Number(event.id.replace(/\D/g, '') || 0) % EVENT_COLORS.length] ?? '#E8590C';
const borderStyle = isOngoing ? '2px solid #c6f6d5' : undefined;
const meetingPoints = event.meetingPoints ?? [];
return (
<Card
onClick={onClick}
style={{ marginBottom: 14, padding: 0, overflow: 'hidden', border: borderStyle }}
style={{ marginBottom: 14, padding: 0, overflow: 'hidden' }}
accentColor={color}
>
<EventCover eventId={event.id} height={110} borderRadius={0} />
<EventCover url={event.coverImage} height={110} borderRadius={0} />
<div style={{ padding: 14 }}>
<div className="user-content" style={{ fontSize: 16, fontWeight: 700, marginBottom: 4 }}>
{event.title}
@@ -44,12 +32,6 @@ function EventCardBody({
<div style={{ fontSize: 12.5, color: '#888', marginBottom: 10 }}>
{event.date} · {event.location}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
<AvatarStack people={PEOPLE} size={26} />
<span style={{ fontSize: 12, color: '#666' }}>
{PEOPLE.length} {isOngoing ? 'connexions présentes' : 'connexions'}
</span>
</div>
{meetingPoints.length > 0 && (
<EventMeetingPoints
@@ -65,29 +47,27 @@ function EventCardBody({
export function HomeScreen() {
const navigate = useNavigate();
const { getUserEvents, currentUserId, getEventMeetingPoints } = useFestipodData();
const { getUserEvents, currentUserId, getEventMeetingPoints, isParticipating, joinMeetingPoint, leaveMeetingPoint } = useFestipodData();
const pendingQueries = usePendingQueries();
const [joinedIds, setJoinedIds] = useState<Set<string>>(new Set());
const myEvents = getUserEvents(currentUserId);
const ongoing = myEvents.slice(0, 1);
const upcoming = myEvents.slice(1, 4);
const upcoming = myEvents.slice(0, 4);
const joinedIds = new Set(
myEvents.flatMap(e => getEventMeetingPoints(e.id)).filter(mp => isParticipating(mp.id)).map(mp => String(mp.id)),
);
const toggle = (id: string) => {
setJoinedIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const failed = (err: unknown) => console.error('[Home] meeting point participation failed:', err);
const action = joinedIds.has(id) ? leaveMeetingPoint : joinMeetingPoint;
void Promise.resolve(action(id)).catch(failed);
};
const withMeetingPoints = (event: { id: string; title: string; date: string; location: string }) => ({
const withMeetingPoints = (event: { id: string; title: string; date: string; location: string; coverImage?: string }) => ({
...event,
meetingPoints: getEventMeetingPoints(event.id).map(mp => ({
id: mp.id,
title: mp.location,
when: mp.time,
duration: '~60 min',
title: mp.title,
when: mp.time || undefined,
lieu: mp.location,
})),
});
@@ -123,26 +103,11 @@ export function HomeScreen() {
</div>
<div style={{ padding: '0 16px' }}>
{ongoing.length > 0 && (
<>
<div style={{ fontSize: 13, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, color: '#22543D', marginBottom: 10, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#38A169', display: 'inline-block' }} />
En cours
</div>
{ongoing.map(event => (
<EventCardBody
key={event.id}
event={withMeetingPoints(event)}
joinedIds={joinedIds}
onToggle={toggle}
onClick={() => navigate(`/events/${event.id}`)}
isOngoing
/>
))}
</>
{upcoming.length === 0 && (
<Text style={{ textAlign: 'center', color: '#888', marginTop: 32, lineHeight: 1.5 }}>
Vous n'avez pas encore d'événement. Relayez-en un, ou découvrez ceux des autres dans l'onglet Découvrir.
</Text>
)}
{upcoming.length > 0 && (
<>
<div style={{ fontSize: 13, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, color: '#E8590C', margin: '20px 0 10px' }}>
@@ -155,7 +120,7 @@ export function HomeScreen() {
event={withMeetingPoints(event)}
joinedIds={joinedIds}
onToggle={toggle}
onClick={() => navigate(`/events/${event.id}`)}
onClick={() => navigate(eventPath(event.id))}
/>
))}
</>
@@ -21,5 +21,5 @@ Fonctionnalité: US-16 Indiquer un ou plusieurs points de rencontre
Scénario: Renseigner les détails du point de rencontre
Étant donné que je suis sur la page "points de rencontre"
Alors l'écran contient un champ "Quand"
Et l'écran contient un champ "Durée"
Alors l'écran contient un champ "Titre"
Et l'écran contient un champ "Lieu"
@@ -3,7 +3,7 @@
Fonctionnalité: US-10 Visualiser la fiche/le profil d'un participant
En tant qu'utilisateur
Je peux sélectionner un individu dans la liste des inscrits à un événement/atelier
Afin de voir les événements auxquels la personne a participé et voir un formulaire de contact
Afin de voir les événements auxquels la personne a participé et de pouvoir l'ajouter à mon réseau
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
@@ -21,9 +21,9 @@ Fonctionnalité: US-10 Visualiser la fiche/le profil d'un participant
Étant donné que je suis sur la page "profil utilisateur"
Alors les événements affichent leur localisation et distance
Scénario: Voir le formulaire de contact
Scénario: Ajouter l'utilisateur à mon réseau
Étant donné que je suis sur la page "profil utilisateur"
Alors je peux contacter l'utilisateur
Alors je peux ajouter l'utilisateur à mon réseau
Scénario: Vérifier les informations du profil
* Scénario non implémenté
@@ -3,8 +3,8 @@
Fonctionnalité: US-22 Parrainer un nouvel utilisateur
En tant qu'utilisateur
Je peux parrainer un nouvel utilisateur
En lui partageant mon QR code ou lien de contact
Afin de savoir combien de personnes ont rejoint le réseau grâce à moi
En lui partageant mon lien de contact
Afin d'inviter des personnes à rejoindre le réseau
Contexte:
Étant donné que je suis connecté en tant qu'utilisateur
@@ -18,17 +18,11 @@ Fonctionnalité: US-22 Parrainer un nouvel utilisateur
Quand je navigue vers "partage de profil"
Alors je vois l'écran "share-profile"
Scénario: Voir le QR code de parrainage
Scénario: Voir l'invitation de parrainage
Étant donné que je suis sur la page "partage profil"
Alors l'écran contient un texte "Scannez pour me retrouver sur Festipod"
Alors l'écran contient un texte "Retrouvez-moi sur Festipod"
Scénario: Voir le lien de parrainage
Étant donné que je suis sur la page "partage profil"
Alors l'écran contient une section "Mon lien de profil"
Et l'écran contient un bouton "Copier"
Scénario: Voir les statistiques de parrainage
Étant donné que je suis sur la page "partage profil"
Alors l'écran contient une section "Statistiques de parrainage"
Et l'écran contient un texte "Personnes parrainées"
Et l'écran contient un texte "Scans du QR code"
Alors l'écran contient une section "Mon lien d'invitation"
Et l'écran contient un bouton "Créer un lien d'invitation"
@@ -3,7 +3,7 @@
Fonctionnalité: US-23 Me connecter avec d'autres utilisateurs
En tant qu'utilisateur
Je peux me connecter avec d'autres utilisateurs
En partageant mon QR code ou mon lien de contact
En partageant mon lien de contact
Afin d'étendre mon réseau
Contexte:
@@ -13,13 +13,13 @@ Fonctionnalité: US-23 Me connecter avec d'autres utilisateurs
Étant donné que je suis sur la page "mon profil"
Alors l'écran contient une section "Partager"
Scénario: Voir le QR code
Scénario: Voir l'invitation à me retrouver
Étant donné que je suis sur la page "partage profil"
Alors l'écran contient un texte "Scannez pour me retrouver sur Festipod"
Alors l'écran contient un texte "Retrouvez-moi sur Festipod"
Scénario: Voir le lien de partage
Étant donné que je suis sur la page "partage profil"
Alors l'écran contient une section "Mon lien de profil"
Alors l'écran contient une section "Mon lien d'invitation"
Scénario: Accéder à l'écran de partage dédié
Étant donné que je suis sur la page "mon profil"
@@ -29,4 +29,5 @@ Fonctionnalité: US-9 Visualiser la photo d'un individu
Et l'écran contient un texte "@mariedupont"
Scénario: Ajouter une photo personnelle sur une fiche existante
* Scénario non implémenté
Étant donné que je suis sur la page "modifier le profil"
Alors l'écran contient un bouton "Changer la photo"
+45 -160
View File
@@ -1,181 +1,66 @@
import React, { useEffect, useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Avatar, Text, Button, showToast } from '../../../shared/components/sketchy';
import { Header, Avatar, Text, Button, Card, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials } from '../../../shared/context/festipodDataBase';
import { useNavigate, useGoBack } from '../../../app/router';
type Mode = 'choice' | 'show' | 'scan';
import { copyText } from '../../../shared/utils/inviteLink';
import { useInviteLink } from '../../../shared/hooks/useInviteLink';
import { useGoBack } from '../../../app/router';
export function ConnectScreen() {
const navigate = useNavigate();
const goBack = useGoBack();
const { currentUser } = useFestipodData();
const [mode, setMode] = useState<Mode>('choice');
const [scanProgress, setScanProgress] = useState(0);
const invite = useInviteLink();
useEffect(() => {
if (mode !== 'scan') return;
setScanProgress(0);
const start = Date.now();
const id = setInterval(() => {
const p = Math.min(1, (Date.now() - start) / 2800);
setScanProgress(p);
if (p >= 1) {
clearInterval(id);
showToast('Connexion établie avec Léa Bernard', 'success');
navigate('/profile/friends');
}
}, 80);
return () => clearInterval(id);
}, [mode, navigate]);
const copy = async () => {
if (!invite.link) return;
showToast((await copyText(invite.link)) ? 'Lien copié' : "Le lien n'a pas pu être copié", 'info');
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Se connecter"
left={
<ArrowLeft
size={20}
onClick={() => (mode === 'choice' ? goBack() : setMode('choice'))}
style={{ cursor: 'pointer' }}
/>
}
title="Établir une connexion"
left={<ArrowLeft size={20} onClick={goBack} style={{ cursor: 'pointer' }} />}
/>
<div style={{ flex: 1, overflow: 'auto', padding: 16 }}>
{mode === 'choice' && (
<Card style={{ textAlign: 'center', padding: 24, marginBottom: 16 }}>
<Avatar initials={safeUserInitials(currentUser)} photo={currentUser?.photo} color="#E8590C" size="lg" />
<Text className="user-content" style={{ fontWeight: 'bold', margin: '12px 0 0 0' }}>{safeUserName(currentUser, '')}</Text>
</Card>
<Text style={{ fontSize: 13, color: '#888', marginBottom: 16, lineHeight: 1.5 }}>
Envoyez ce lien à une personne : elle ouvre votre profil et se connecte en un clic — la connexion est
acceptée automatiquement.
</Text>
{invite.link ? (
<Card style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text style={{ margin: 0, flex: 1, fontSize: 14, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#888' }}>
{invite.link}
</Text>
<Button variant="primary" style={{ flexShrink: 0, padding: '8px 10px' }} onClick={copy}>Copier</Button>
</Card>
) : invite.creating || invite.discovering ? (
<Text style={{ fontSize: 13, color: '#888' }}>Création du lien…</Text>
) : invite.unavailable === 'session' ? (
<Text style={{ fontSize: 13, color: '#888' }}>
Lien d'invitation indisponible : il nécessite une session connectée à votre Pod.
</Text>
) : invite.error ? (
<>
<Text style={{ fontSize: 13, color: '#888', marginBottom: 16, lineHeight: 1.5 }}>
Pour vous connecter, scannez le QR code de l'autre personne ou affichez le vôtre pour qu'elle le scanne.
</Text>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Button variant="primary" style={{ padding: 16, fontSize: 15 }} onClick={() => setMode('show')}>
Afficher mon QR code
</Button>
<Button style={{ padding: 16, fontSize: 15 }} onClick={() => setMode('scan')}>
Scanner un QR code
</Button>
</div>
<Text style={{ fontSize: 13, color: '#888', marginBottom: 8 }}>{invite.error}</Text>
<Button style={{ padding: '8px 10px' }} onClick={invite.create}>Réessayer</Button>
</>
)}
{mode === 'show' && (
<div style={{ textAlign: 'center', paddingTop: 8 }}>
<div
style={{
width: 220,
height: 220,
margin: '0 auto 16px',
border: '2px solid #e0e0e0',
borderRadius: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fff',
position: 'relative',
}}
>
<div
style={{
width: 190,
height: 190,
background: `
linear-gradient(90deg, #1a1a1a 10%, transparent 10%, transparent 20%, #1a1a1a 20%, #1a1a1a 30%, transparent 30%, transparent 40%, #1a1a1a 40%, #1a1a1a 50%, transparent 50%, transparent 60%, #1a1a1a 60%, #1a1a1a 70%, transparent 70%, transparent 80%, #1a1a1a 80%, #1a1a1a 90%, transparent 90%),
linear-gradient(#1a1a1a 10%, transparent 10%, transparent 20%, #1a1a1a 20%, #1a1a1a 30%, transparent 30%, transparent 40%, #1a1a1a 40%, #1a1a1a 50%, transparent 50%, transparent 60%, #1a1a1a 60%, #1a1a1a 70%, transparent 70%, transparent 80%, #1a1a1a 80%, #1a1a1a 90%, transparent 90%)
`,
backgroundSize: '17px 17px',
opacity: 0.85,
borderRadius: 8,
}}
/>
<div
style={{
position: 'absolute',
background: '#fff',
padding: 4,
borderRadius: '50%',
}}
>
<Avatar initials={safeUserInitials(currentUser)} color="#E8590C" size="sm" />
</div>
</div>
<Text className="user-content" style={{ fontWeight: 'bold', margin: '0 0 4px 0' }}>{safeUserName(currentUser, '')}</Text>
<Text style={{ color: '#888', margin: 0, fontSize: 13 }}>
Montrez ce QR code pour permettre à l'autre personne de vous connecter.
) : invite.unavailable === 'frontend' ? (
<>
<Text style={{ fontSize: 13, color: '#888', marginBottom: 8 }}>
Lien d'invitation indisponible : le frontend de votre Pod est introuvable.
</Text>
</div>
)}
{mode === 'scan' && (
<div style={{ textAlign: 'center', paddingTop: 8 }}>
<div
style={{
width: 240,
height: 240,
margin: '0 auto 16px',
background: '#111',
borderRadius: 16,
position: 'relative',
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
inset: 24,
border: '2px solid rgba(255,255,255,0.8)',
borderRadius: 10,
}}
/>
{(['tl', 'tr', 'bl', 'br'] as const).map(corner => {
const base: React.CSSProperties = {
position: 'absolute',
width: 22,
height: 22,
border: '3px solid #E8590C',
};
const pos: React.CSSProperties =
corner === 'tl' ? { top: 18, left: 18, borderRight: 'none', borderBottom: 'none' }
: corner === 'tr' ? { top: 18, right: 18, borderLeft: 'none', borderBottom: 'none' }
: corner === 'bl' ? { bottom: 18, left: 18, borderRight: 'none', borderTop: 'none' }
: { bottom: 18, right: 18, borderLeft: 'none', borderTop: 'none' };
return <div key={corner} style={{ ...base, ...pos }} />;
})}
<div
style={{
position: 'absolute',
left: 24,
right: 24,
top: `calc(24px + (100% - 48px) * ${(Math.sin(scanProgress * Math.PI * 2) + 1) / 2})`,
height: 2,
background: 'linear-gradient(90deg, transparent, #E8590C, transparent)',
boxShadow: '0 0 12px #E8590C',
}}
/>
</div>
<Text style={{ fontSize: 13, color: '#888', margin: 0 }}>
Alignez le QR code dans le cadre…
</Text>
<div
style={{
marginTop: 16,
height: 4,
background: '#f0f0f0',
borderRadius: 2,
overflow: 'hidden',
}}
>
<div
style={{
width: `${scanProgress * 100}%`,
height: '100%',
background: '#E8590C',
transition: 'width 0.08s linear',
}}
/>
</div>
</div>
<Button style={{ padding: '8px 10px' }} onClick={() => window.location.reload()}>Réessayer</Button>
</>
) : (
<Button variant="primary" onClick={invite.create}>Créer un lien d'invitation</Button>
)}
</div>
</div>
+24 -3
View File
@@ -1,7 +1,9 @@
import { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Text, Avatar, Input, Button, BottomNav } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { matchesQuery } from '../../../shared/utils/search';
import { useNavigate, userPath } from '../../../app/router';
const COLORS = ['#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E', '#E8590C'];
@@ -10,6 +12,8 @@ export function FriendsListScreen() {
const navigate = useNavigate();
const { getFriends } = useFestipodData();
const friends = getFriends();
const [query, setQuery] = useState('');
const shown = friends.filter(f => matchesQuery(query, safeUserName(f), safeUserUsername(f)));
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
@@ -19,11 +23,28 @@ export function FriendsListScreen() {
/>
<div style={{ padding: 16, borderBottom: '1px solid #f0f0f0' }}>
<Input placeholder="Rechercher..." />
<Input placeholder="Rechercher..." value={query} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)} />
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
{friends.map((person, i) => (
{friends.length === 0 && (
<div style={{ textAlign: 'center', padding: '32px 24px' }}>
<Text style={{ color: '#888', lineHeight: 1.5 }}>
Votre réseau est vide. Partagez votre lien de profil, ou ouvrez le profil d'un participant pour lui envoyer une demande.
</Text>
<Button
variant="primary"
style={{ marginTop: 16 }}
onClick={() => navigate('/profile/connect')}
>
Établir une connexion
</Button>
</div>
)}
{friends.length > 0 && shown.length === 0 && (
<Text style={{ textAlign: 'center', color: '#888', marginTop: 32 }}>Aucun contact ne correspond à votre recherche.</Text>
)}
{shown.map((person, i) => (
<div
key={person.id}
onClick={() => navigate(userPath(person.id))}
@@ -36,7 +57,7 @@ export function FriendsListScreen() {
borderBottom: '1px solid #f5f5f5',
}}
>
<Avatar initials={safeUserInitials(person)} color={COLORS[i % COLORS.length]} size="sm" />
<Avatar initials={safeUserInitials(person)} photo={person.photo} color={COLORS[i % COLORS.length]} size="sm" />
<div style={{ flex: 1 }}>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{safeUserName(person)}</Text>
<Text style={{ margin: 0, fontSize: 13, color: '#888' }}>
+11 -29
View File
@@ -1,4 +1,4 @@
import { Header, Avatar, Title, Text, Button, Card, Divider, BottomNav, Tag } from '../../../shared/components/sketchy';
import { Header, Avatar, Title, Text, Button, Card, Divider, BottomNav } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { eventPath, useNavigate } from '../../../app/router';
@@ -19,9 +19,11 @@ export function ProfileScreen() {
<div style={{ flex: 1, overflow: 'auto' }}>
<div style={{ padding: 24, textAlign: 'center' }}>
<Avatar initials={safeUserInitials(user)} color="#E8590C" size="lg" />
<Avatar initials={safeUserInitials(user)} photo={user?.photo} color="#E8590C" size="lg" />
<Title className="user-content" style={{ marginTop: 16, marginBottom: 4 }}>{safeUserName(user, '')}</Title>
<Text className="user-content" style={{ margin: 0, color: '#888' }}>{safeUserUsername(user)}</Text>
{user?.city && <Text className="user-content" style={{ margin: '4px 0 0', fontSize: 13, color: '#888' }}>📍 {user.city}</Text>}
{user?.bio && <Text className="user-content" style={{ margin: '12px 0 0', fontSize: 14 }}>{user.bio}</Text>}
<div style={{ display: 'flex', justifyContent: 'center', gap: 32, marginTop: 20 }}>
<div style={{ textAlign: 'center' }}>
@@ -32,10 +34,12 @@ export function ProfileScreen() {
<Text style={{ fontWeight: 'bold', margin: 0 }}>{user?.friendsCount ?? friends.length}</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Amis</Text>
</div>
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{user?.participationsCount ?? 0}</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Participations</Text>
</div>
{user?.participationsCount !== undefined && (
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{user.participationsCount}</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Participations</Text>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 20, justifyContent: 'center' }}>
@@ -46,28 +50,6 @@ export function ProfileScreen() {
<Divider />
<div style={{
margin: '12px 16px',
padding: '14px 16px',
background: 'linear-gradient(135deg, #FFF7ED, #FFFBF5)',
borderRadius: 16,
border: '1px solid #FDDCB5',
}}>
<div style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: 1, color: '#C05621', marginBottom: 8 }}>
Mes intentions
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, alignItems: 'center' }}>
<Tag label="gouvernance coopérative" />
<Tag label="communs numériques" />
<Tag label="habitat participatif" color="#4a3000" bg="#e8f5e9" />
<span style={{ fontSize: 20, cursor: 'pointer', color: '#C05621', lineHeight: 1 }}>
+
</span>
</div>
</div>
<Divider />
<div style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Mes événements à venir</Text>
{myEvents.slice(0, 3).map((event) => (
@@ -77,7 +59,7 @@ export function ProfileScreen() {
</Card>
))}
<Button style={{ width: '100%' }} onClick={() => navigate('/events')}>
Voir tous les événements
Découvrir des événements
</Button>
</div>
+66 -69
View File
@@ -1,15 +1,33 @@
import { ArrowLeft } from 'lucide-react';
import { Header, Text, Button, Card, Divider, Avatar } from '../../../shared/components/sketchy';
import { Header, Text, Button, Card, Divider, Avatar, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { safeUserName, safeUserInitials } from '../../../shared/context/festipodDataBase';
import { copyText } from '../../../shared/utils/inviteLink';
import { useInviteLink } from '../../../shared/hooks/useInviteLink';
import { useNavigate } from '../../../app/router';
export function ShareProfileScreen() {
const navigate = useNavigate();
const { currentUser } = useFestipodData();
const user = currentUser;
const shareHandle = safeUserUsername(user).replace('@', '');
const profileLink = shareHandle ? `festipod.app/u/${shareHandle}` : '';
const invite = useInviteLink();
const copy = async () => {
if (!invite.link) return;
showToast((await copyText(invite.link)) ? 'Lien copié' : "Le lien n'a pas pu être copié", 'info');
};
const share = async () => {
if (!invite.link) return;
if (typeof navigator.share === 'function') {
try {
await navigator.share({ title: 'Rejoignez mon réseau sur Festipod', url: invite.link });
} catch {
// Dismissed by the person — nothing to report.
}
return;
}
await copy();
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
@@ -20,79 +38,58 @@ export function ShareProfileScreen() {
<div style={{ flex: 1, overflow: 'auto', padding: 16 }}>
<Card style={{ textAlign: 'center', padding: 24 }}>
<div style={{
width: 180,
height: 180,
margin: '0 auto 16px',
border: '2px solid #e0e0e0',
borderRadius: 16,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fff',
position: 'relative',
}}>
<div style={{
width: 150,
height: 150,
background: `
linear-gradient(90deg, #1a1a1a 10%, transparent 10%, transparent 20%, #1a1a1a 20%, #1a1a1a 30%, transparent 30%, transparent 40%, #1a1a1a 40%, #1a1a1a 50%, transparent 50%, transparent 60%, #1a1a1a 60%, #1a1a1a 70%, transparent 70%, transparent 80%, #1a1a1a 80%, #1a1a1a 90%, transparent 90%),
linear-gradient(#1a1a1a 10%, transparent 10%, transparent 20%, #1a1a1a 20%, #1a1a1a 30%, transparent 30%, transparent 40%, #1a1a1a 40%, #1a1a1a 50%, transparent 50%, transparent 60%, #1a1a1a 60%, #1a1a1a 70%, transparent 70%, transparent 80%, #1a1a1a 80%, #1a1a1a 90%, transparent 90%)
`,
backgroundSize: '15px 15px',
opacity: 0.8,
borderRadius: 8,
}} />
<div style={{
position: 'absolute',
background: '#fff',
padding: 4,
borderRadius: '50%',
}}>
<Avatar initials={safeUserInitials(user)} color="#E8590C" size="sm" />
</div>
</div>
<Avatar initials={safeUserInitials(user)} photo={user?.photo} color="#E8590C" size="lg" />
<Text className="user-content" style={{ fontWeight: 'bold', margin: '0 0 4px 0' }}>{safeUserName(user, '')}</Text>
<Text className="user-content" style={{ fontWeight: 'bold', margin: '12px 0 4px 0' }}>{safeUserName(user, '')}</Text>
<Text style={{ color: '#888', margin: 0, fontSize: 14 }}>
Scannez pour me retrouver sur Festipod
Retrouvez-moi sur Festipod
</Text>
</Card>
<Divider />
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Mon lien de profil</Text>
<Card style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text style={{
margin: 0,
flex: 1,
fontSize: 14,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: '#888',
}}>
{profileLink}
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Mon lien d'invitation</Text>
<Text style={{ fontSize: 13, color: '#888', marginBottom: 12, lineHeight: 1.5 }}>
La personne ouvre le lien, voit votre profil et se connecte en un clic — la connexion est acceptée
automatiquement.
</Text>
{invite.link ? (
<Card style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Text style={{
margin: 0,
flex: 1,
fontSize: 14,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: '#888',
}}>
{invite.link}
</Text>
<Button style={{ flexShrink: 0, padding: '8px 10px' }} title="Copier le lien" onClick={copy}>📋</Button>
<Button variant="primary" style={{ flexShrink: 0, padding: '8px 10px' }} title="Partager" onClick={share}>↗</Button>
</Card>
) : invite.creating || invite.discovering ? (
<Text style={{ fontSize: 13, color: '#888' }}>Création du lien…</Text>
) : invite.unavailable === 'session' ? (
<Text style={{ fontSize: 13, color: '#888' }}>
Lien d'invitation indisponible : il nécessite une session connectée à votre Pod.
</Text>
<Button style={{ flexShrink: 0, padding: '8px 10px' }} title="Copier le lien">📋</Button>
<Button variant="primary" style={{ flexShrink: 0, padding: '8px 10px' }} title="Partager">↗</Button>
</Card>
<Divider />
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Statistiques de parrainage</Text>
<Card>
<div style={{ display: 'flex', justifyContent: 'space-around', textAlign: 'center' }}>
<div>
<Text style={{ fontWeight: 'bold', fontSize: 24, margin: 0, color: '#E8590C' }}>12</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Personnes parrainées</Text>
</div>
<div>
<Text style={{ fontWeight: 'bold', fontSize: 24, margin: 0, color: '#E8590C' }}>47</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Scans du QR code</Text>
</div>
</div>
</Card>
) : invite.error ? (
<>
<Text style={{ fontSize: 13, color: '#888', marginBottom: 8 }}>{invite.error}</Text>
<Button style={{ padding: '8px 10px' }} onClick={invite.create}>Réessayer</Button>
</>
) : invite.unavailable === 'frontend' ? (
<>
<Text style={{ fontSize: 13, color: '#888', marginBottom: 8 }}>
Lien d'invitation indisponible : le frontend de votre Pod est introuvable.
</Text>
<Button style={{ padding: '8px 10px' }} onClick={() => window.location.reload()}>Réessayer</Button>
</>
) : (
<Button variant="primary" onClick={invite.create}>Créer un lien d'invitation</Button>
)}
</div>
</div>
);
@@ -3,8 +3,9 @@ import { Header, Text, Input, Button, Avatar, showToast } from '../../../shared/
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserInitials } from '../../../shared/context/festipodDataBase';
import { useNavigate } from '../../../app/router';
import { useUploadImage } from '../../../shared/hooks/useUploadImage';
export function UpdateProfileScreen() {
export function UpdateProfileScreen({ onboarding = false }: { onboarding?: boolean } = {}) {
const navigate = useNavigate();
const { currentUser, updateProfile } = useFestipodData();
const user = currentUser;
@@ -13,19 +14,36 @@ export function UpdateProfileScreen() {
const [firstName, setFirstName] = useState(nameParts[0] ?? '');
const [lastName, setLastName] = useState(nameParts.slice(1).join(' '));
const [username, setUsername] = useState(user?.username ?? '');
const [city, setCity] = useState(user?.city ?? 'Lyon, France');
const [city, setCity] = useState(user?.city ?? '');
const [bio, setBio] = useState(user?.bio ?? '');
const [photo, setPhoto] = useState(user?.photo ?? '');
const { upload, uploading } = useUploadImage();
const fileInput = React.useRef<HTMLInputElement>(null);
const onPhoto = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
upload(file).then(setPhoto).catch((err: unknown) => {
console.error('[UpdateProfile] photo upload failed:', err);
showToast("La photo n'a pas pu être envoyée", 'error');
});
};
const handleSave = () => {
const fullName = `${firstName} ${lastName}`.trim();
if (!fullName) {
showToast('Indiquez au moins votre prénom ou votre nom', 'error');
return;
}
const initials = `${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase();
// The edit REJECTS when no profile of mine is resolved — it refuses to write
// into someone else's document. So confirm and leave the screen only once the
// write has settled, never before.
void Promise.resolve(updateProfile({ name: fullName, initials, username, city, bio }))
void Promise.resolve(updateProfile({ name: fullName, initials, username, city, bio, photo }))
.then(() => {
showToast('Profil mis à jour', 'success');
navigate('/profile');
if (!onboarding) navigate('/profile');
})
.catch((err: unknown) => {
console.error('[UpdateProfile] profile write failed:', err);
@@ -36,16 +54,23 @@ export function UpdateProfileScreen() {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Modifier le profil"
left={<span onClick={() => navigate('/profile')} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
title={onboarding ? 'Complétez votre profil' : 'Modifier le profil'}
left={onboarding ? undefined : <span onClick={() => navigate('/profile')} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
/>
{onboarding && (
<div style={{ padding: '12px 16px', background: '#FFF7ED', color: '#C05621', fontSize: 13, lineHeight: 1.5 }}>
Votre Pod ne fournit pas encore de nom pour votre profil. Renseignez-le pour que les autres vous reconnaissent.
</div>
)}
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Avatar initials={safeUserInitials(user)} color="#E8590C" size="lg" />
<Button style={{ marginTop: 12 }}>
Changer la photo
<Avatar initials={safeUserInitials(user)} photo={photo || undefined} color="#E8590C" size="lg" />
<Button style={{ marginTop: 12 }} onClick={() => fileInput.current?.click()} disabled={uploading}>
{uploading ? 'Envoi…' : 'Changer la photo'}
</Button>
<input ref={fileInput} type="file" accept="image/*" onChange={onPhoto} style={{ display: 'none' }} />
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
+86 -70
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Avatar, Title, Text, Button, Card, Badge, Divider } from '../../../shared/components/sketchy';
import { Header, Avatar, Title, Text, Button, Card, Divider, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { eventPath, useNavigate, useGoBack, useParams } from '../../../app/router';
@@ -8,19 +9,41 @@ export function UserProfileScreen() {
const navigate = useNavigate();
const goBack = useGoBack();
const { userId } = useParams();
const { users, currentUserId, getUser, getUserEvents, addFriend, getFriends } = useFestipodData();
const { currentUserId, getUser, getUserEvents, openUserEvents, addFriend, getFriends } = useFestipodData();
// Someone else's events are read only when asked for: this screen asks.
useEffect(() => {
if (userId) openUserEvents(userId);
}, [userId, openUserEvents]);
const viewedUser = userId ? getUser(userId) : users.find(u => u.id !== currentUserId);
const viewedUser = userId ? getUser(userId) : undefined;
const friends = getFriends();
const isFriend = viewedUser ? friends.some(f => f.id === viewedUser.id) : false;
const [requested, setRequested] = useState(false);
const [sending, setSending] = useState(false);
const isMe = viewedUser?.id === currentUserId;
// A profile renders only for me and for my contacts; for both the full
// events list is answerable — the catalog declares a contact's public events,
// and their participations collection (read once asked, above) lists where
// they participate. For anyone else the data layer answers [], and this
// screen never renders them anyway.
const userEvents = viewedUser ? getUserEvents(viewedUser.id) : [];
const pastEvents = [
{ title: 'Forum Ouvert Transition', date: '22 jan.', location: "Tiers-lieu L'Hermitage", distance: 89, common: true },
{ title: 'Rencontre des Colibris', date: '12 jan.', location: "La Maison de l'Environnement", distance: 7, common: true },
{ title: 'Formation CNV', date: '8 jan.', location: 'MJC Montplaisir, Lyon', distance: 5, common: false },
{ title: 'Café des possibles', date: '15 déc.', location: 'Café de la Mairie, Lyon 3', distance: 2, common: false },
];
if (!viewedUser) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Profil"
left={<ArrowLeft size={20} onClick={goBack} style={{ cursor: 'pointer' }} />}
/>
<div style={{ flex: 1, padding: 24, textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold' }}>Profil inconnu ou non partagé</Text>
<Text style={{ color: '#888', fontSize: 14, lineHeight: 1.5 }}>
Cette personne n'a pas partagé son profil avec vous : il n'est lisible que par ses contacts.
</Text>
</div>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
@@ -31,40 +54,73 @@ export function UserProfileScreen() {
<div style={{ flex: 1, overflow: 'auto' }}>
<div style={{ padding: 24, textAlign: 'center' }}>
<Avatar initials={safeUserInitials(viewedUser)} color="#2B6CB0" size="lg" />
<Avatar initials={safeUserInitials(viewedUser)} photo={viewedUser?.photo} color="#2B6CB0" size="lg" />
<Title className="user-content" style={{ marginTop: 16, marginBottom: 4 }}>{safeUserName(viewedUser, viewedUser?.id === currentUserId ? '' : undefined)}</Title>
<Text className="user-content" style={{ margin: 0, color: '#888' }}>{safeUserUsername(viewedUser)}</Text>
{viewedUser?.city && <Text className="user-content" style={{ margin: '4px 0 0', fontSize: 13, color: '#888' }}>📍 {viewedUser.city}</Text>}
{viewedUser?.bio && <Text className="user-content" style={{ margin: '12px 0 0', fontSize: 14 }}>{viewedUser.bio}</Text>}
<div style={{ display: 'flex', justifyContent: 'center', gap: 32, marginTop: 20 }}>
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{viewedUser?.eventsCount ?? userEvents.length}</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Événements</Text>
</div>
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{viewedUser?.friendsCount ?? 23}</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Contacts</Text>
</div>
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{viewedUser?.participationsCount ?? 42}</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Participations</Text>
</div>
{/* The events count shows for me always; for a contact only when it
is a real answer — a bare 0 claims "they do nothing", which a
participations collection not read (yet, or at all) cannot prove. */}
{(isMe || userEvents.length > 0) && (
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{userEvents.length}</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Événements</Text>
</div>
)}
{/* Someone else's contact count is not readable (their apods:contacts
is theirs only) — a real count for me, nothing claimed for others. */}
{isMe && (
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{friends.length}</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Contacts</Text>
</div>
)}
{isMe && viewedUser?.participationsCount !== undefined && (
<div style={{ textAlign: 'center' }}>
<Text style={{ fontWeight: 'bold', margin: 0 }}>{viewedUser.participationsCount}</Text>
<Text style={{ fontSize: 12, color: '#888', margin: 0 }}>Participations</Text>
</div>
)}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 20, justifyContent: 'center' }}>
<Button
variant={isFriend ? 'default' : 'primary'}
onClick={() => viewedUser && addFriend(viewedUser.id)}
>
{isFriend ? '✓ Dans mon réseau' : 'Ajouter au réseau'}
</Button>
<Button>Contacter</Button>
{!isMe && (
<Button
variant={isFriend || requested ? 'default' : 'primary'}
disabled={isFriend || requested || sending}
onClick={() => {
if (!viewedUser || sending) return;
setSending(true);
void Promise.resolve(addFriend(viewedUser.id))
.then(() => {
setRequested(true);
showToast('Demande envoyée', 'success');
})
.catch((err: unknown) => {
console.error('[UserProfile] contact request failed:', err);
showToast("La demande n'a pas pu être envoyée", 'error');
})
.finally(() => setSending(false));
}}
>
{isFriend ? '✓ Dans mon réseau' : requested ? 'Demande envoyée' : 'Ajouter au réseau'}
</Button>
)}
</div>
</div>
<Divider />
<div style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Événements à venir</Text>
{userEvents.length > 0 && (
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Événements</Text>
)}
{userEvents.length === 0 && (
<Text style={{ fontSize: 13, color: '#888', margin: 0 }}>Aucun événement visible.</Text>
)}
{userEvents.map((event) => (
<Card key={event.id} onClick={() => navigate(eventPath(event.id))} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
@@ -76,50 +132,10 @@ export function UserProfileScreen() {
{event.distance != null && ` · ${event.distance} km`}
</Text>
</div>
<Badge style={{ background: '#FFF7ED', color: '#E8590C' }}>moi aussi</Badge>
</div>
</Card>
))}
</div>
<Divider />
<div style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Événements passés</Text>
{pastEvents.map((event, i) => (
<Card key={i} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<Text style={{ margin: 0, fontWeight: 'bold' }}>{event.title}</Text>
<Text style={{ margin: '4px 0 0 0', fontSize: 13, color: '#888' }}>{event.date}</Text>
<Text style={{ margin: '2px 0 0 0', fontSize: 13, color: '#888' }}>
📍 {event.location} · {event.distance} km
</Text>
</div>
{event.common && <Badge style={{ background: '#FFF7ED', color: '#E8590C' }}>moi aussi</Badge>}
</div>
</Card>
))}
</div>
<Divider />
<div style={{ padding: 16 }}>
<Text style={{ fontWeight: 'bold', marginBottom: 12 }}>Envoyer un message</Text>
<div style={{
border: '1.5px solid #e0e0e0',
borderRadius: 12,
padding: 12,
minHeight: 80,
fontSize: 14,
color: '#bbb',
}}>
Écrivez votre message ici...
</div>
<Button variant="primary" style={{ width: '100%', marginTop: 12 }}>
Envoyer
</Button>
</div>
</div>
</div>
);
+12 -26
View File
@@ -41,13 +41,13 @@ Then('l\'écran affiche les informations du profil', async function (this: Festi
expect(avatar, 'Profile screen should render an avatar').to.not.be.null;
});
Then('je peux contacter l\'utilisateur', async function (this: FestipodWorld) {
Then('je peux ajouter l\'utilisateur à mon réseau', async function (this: FestipodWorld) {
expect(this.currentScreenId).to.equal('user-profile');
const doc = this.renderedDoc;
expect(doc, 'Screen should be rendered').to.not.be.null;
const buttons = Array.from(doc!.querySelectorAll('button')).map(b => b.textContent ?? '');
expect(buttons.some(t => t.includes('Contacter')),
'User profile should expose a "Contacter" button').to.be.true;
expect(buttons.some(t => /Ajouter au réseau|Dans mon réseau|Demande envoyée/.test(t)),
'User profile should expose the "Ajouter au réseau" action (or its resulting state)').to.be.true;
});
Then('je peux voir les événements auxquels l\'utilisateur a participé', async function (this: FestipodWorld) {
@@ -55,10 +55,8 @@ Then('je peux voir les événements auxquels l\'utilisateur a participé', async
const doc = this.renderedDoc;
expect(doc, 'Screen should be rendered').to.not.be.null;
const text = doc!.body.textContent ?? '';
expect(text.includes('Événements à venir'),
'User profile should have "Événements à venir" section').to.be.true;
expect(text.includes('Événements passés'),
'User profile should have "Événements passés" section').to.be.true;
expect(text.includes('Événements'),
'User profile should have the "Événements" section').to.be.true;
});
Then('les événements affichent leur localisation et distance', async function (this: FestipodWorld) {
@@ -70,30 +68,18 @@ Then('les événements affichent leur localisation et distance', async function
expect(/\d+\s*km/.test(text), 'Events should show a distance in km').to.be.true;
});
Then('je peux voir le QR code', async function (this: FestipodWorld) {
const doc = this.renderedDoc;
expect(doc, 'Screen should be rendered').to.not.be.null;
const text = doc!.body.textContent ?? '';
if (this.currentScreenId === 'share-profile') {
expect(text.includes('Scannez pour me retrouver'),
'Share profile should invite to scan for follow-up').to.be.true;
} else if (this.currentScreenId === 'meeting-points') {
expect(text.includes('QR') || text.includes('Scannez'),
'Meeting points should reference a QR code').to.be.true;
} else {
expect.fail(`QR code should be on share-profile or meeting-points, not "${this.currentScreenId}"`);
}
});
Then('je peux voir le lien de partage', async function (this: FestipodWorld) {
expect(this.currentScreenId, 'Share link should be on share-profile screen').to.equal('share-profile');
const doc = this.renderedDoc;
expect(doc, 'Screen should be rendered').to.not.be.null;
const text = doc!.body.textContent ?? '';
expect(text.includes('Mon lien de profil'),
'Share profile should label the profile link section').to.be.true;
expect(/festipod\.app\/u\//.test(text),
'Share profile should display a festipod.app/u/… link').to.be.true;
expect(text.includes("Mon lien d'invitation"),
'Share profile should label the invite link section').to.be.true;
// No connected pod session in the local harness: the invite capability VC
// cannot be issued, so the screen must say it honestly instead of
// displaying a fake address.
expect(text.includes("Lien d'invitation indisponible"),
'Share profile should admit the link is unavailable without a connected pod').to.be.true;
});
function expectProfileDom(
+18 -37
View File
@@ -1,6 +1,10 @@
import { useState } from 'react';
interface AvatarProps {
initials?: string;
name?: string;
/** Picture URL; initials are shown when absent. */
photo?: string;
color?: string;
size?: 'sm' | 'md' | 'lg' | number;
className?: string;
@@ -14,10 +18,15 @@ const sizeMap = {
lg: 56,
};
export function Avatar({ initials, name, color, size = 'md', className = '', online, border }: AvatarProps) {
export function Avatar({ initials, name, photo, color, size = 'md', className = '', online, border }: AvatarProps) {
// A photo URL can be stale or unreadable (best-effort public grant, relative
// URL, pod down). Once a given URL has failed, fall back to initials — keyed
// by the URL itself so a changed photo gets a fresh attempt.
const [failedPhoto, setFailedPhoto] = useState<string | null>(null);
const pixelSize = typeof size === 'number' ? size : sizeMap[size];
const displayInitials = initials || (name ? name.split(' ').map(n => n[0]).join('').slice(0, 2) : '?');
const bg = color || '#999';
const showPhoto = !!photo && /^https?:\/\//i.test(photo) && failedPhoto !== photo;
return (
<div
@@ -30,43 +39,15 @@ export function Avatar({ initials, name, color, size = 'md', className = '', onl
border: border || 'none',
}}
>
{displayInitials}
{showPhoto ? (
<img
src={photo}
alt={name ?? displayInitials}
onError={() => setFailedPhoto(photo)}
style={{ width: '100%', height: '100%', borderRadius: '50%', objectFit: 'cover' }}
/>
) : displayInitials}
{online && <div className="online-dot" />}
</div>
);
}
interface AvatarStackProps {
people: Array<{ name: string; color: string }>;
size?: number;
}
export function AvatarStack({ people, size = 28 }: AvatarStackProps) {
return (
<div style={{ display: 'flex' }}>
{people.slice(0, 4).map((p, i) => (
<div key={i} style={{ marginLeft: i > 0 ? -8 : 0, zIndex: people.length - i }}>
<Avatar name={p.name} color={p.color} size={size} border="2px solid #fff" />
</div>
))}
{people.length > 4 && (
<div style={{
marginLeft: -8,
width: size,
height: size,
borderRadius: '50%',
background: '#f0f0f0',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 11,
fontWeight: 600,
color: '#666',
border: '2px solid #fff',
}}>
+{people.length - 4}
</div>
)}
</div>
);
}
-21
View File
@@ -14,27 +14,6 @@ export function Badge({ children, className = '', style }: BadgeProps) {
);
}
interface TagProps {
label: string;
color?: string;
bg?: string;
className?: string;
}
export function Tag({ label, color, bg, className = '' }: TagProps) {
return (
<span
className={`app-tag ${className}`}
style={{
...(color ? { color } : {}),
...(bg ? { background: bg } : {}),
}}
>
{label}
</span>
);
}
interface RelevanceIconProps {
level?: number;
}
+5 -18
View File
@@ -1,31 +1,22 @@
import React from 'react';
const EVENT_PHOTOS: Record<string, string> = {
'1': 'https://images.unsplash.com/photo-1529119513315-c7c361862fc7?auto=format&fit=crop&w=800&q=70',
'2': 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=800&q=70',
'3': 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?auto=format&fit=crop&w=800&q=70',
'4': 'https://images.unsplash.com/photo-1523050854058-8df90110c9f1?auto=format&fit=crop&w=800&q=70',
default: 'https://images.unsplash.com/photo-1540575467063-178a50c2df87?auto=format&fit=crop&w=800&q=70',
};
import { cssUrl } from '../../utils/cssUrl';
interface EventCoverProps {
eventId?: string | number;
/** The event's cover image URL; a neutral placeholder is drawn when absent. */
url?: string;
height?: number;
borderRadius?: number;
style?: React.CSSProperties;
children?: React.ReactNode;
}
export function EventCover({ eventId = 'default', height = 140, borderRadius = 12, style, children }: EventCoverProps) {
const url = EVENT_PHOTOS[String(eventId)] ?? EVENT_PHOTOS.default;
export function EventCover({ url, height = 140, borderRadius = 12, style, children }: EventCoverProps) {
return (
<div
style={{
height,
borderRadius,
backgroundImage: `url(${url})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
background: cssUrl(url) ? `center / cover no-repeat ${cssUrl(url)}` : 'linear-gradient(135deg, #f3ece6, #e6edf3)',
position: 'relative',
overflow: 'hidden',
...style,
@@ -35,7 +26,3 @@ export function EventCover({ eventId = 'default', height = 140, borderRadius = 1
</div>
);
}
export function getEventPhotoUrl(eventId: string | number) {
return EVENT_PHOTOS[String(eventId)] ?? EVENT_PHOTOS.default;
}
@@ -3,9 +3,10 @@ import { showToast } from './Toast';
export interface MeetingPointData {
id: string | number;
title: string;
when: string;
duration: string;
/** Optional: a meeting point may be informal and carry no title. */
title?: string;
/** Free-text moment, shown only when the record carries one. */
when?: string;
lieu: string;
/** How many signed up to this point — shown when the caller knows it, omitted otherwise. */
participantCount?: number;
@@ -30,9 +31,9 @@ export function EventMeetingPoints({ points, joinedIds, onToggle, expanded = fal
const handleToggle = (p: MeetingPointData) => {
const willJoin = !joinedIds.has(String(p.id));
onToggle(String(p.id), p.title, willJoin);
onToggle(String(p.id), p.title || p.lieu, willJoin);
showToast(
willJoin ? `Inscription : ${p.title}` : `Désinscription : ${p.title}`,
willJoin ? `Inscription : ${p.title || p.lieu}` : `Désinscription : ${p.title || p.lieu}`,
willJoin ? 'success' : 'info',
);
};
@@ -51,12 +52,18 @@ export function EventMeetingPoints({ points, joinedIds, onToggle, expanded = fal
borderColor: isJoined ? '#c6f6d5' : '#eee',
}}
>
<div style={{ fontSize: 14, fontWeight: 700, color: '#1a1a1a', marginBottom: 4 }}>
{p.title}
</div>
{p.title && (
<div style={{ fontSize: 14, fontWeight: 700, color: '#1a1a1a', marginBottom: 4 }}>
{p.title}
</div>
)}
<div style={{ fontSize: 12, color: '#666', marginBottom: 10 }}>
🕒 {p.when} · {p.duration}
<br />
{p.when && (
<>
🕒 {p.when}
<br />
</>
)}
📍 {p.lieu}
{p.participantCount !== undefined && (
<>
@@ -0,0 +1,51 @@
import React, { useRef } from 'react';
import { showToast } from './Toast';
import { cssUrl } from '../../utils/cssUrl';
import { useUploadImage } from '../../hooks/useUploadImage';
interface ImagePickerProps {
/** Current image URL (preview). */
value?: string;
label: string;
height?: number;
onChange: (url: string) => void;
}
/** A tappable zone that uploads a chosen image to the Pod and reports its URL. */
export function ImagePicker({ value, label, height = 140, onChange }: ImagePickerProps) {
const { upload, uploading } = useUploadImage();
const input = useRef<HTMLInputElement>(null);
const onFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
upload(file)
.then(onChange)
.catch((err: unknown) => {
console.error('[ImagePicker] upload failed:', err);
showToast("L'image n'a pas pu être envoyée", 'error');
});
};
return (
<div
onClick={() => !uploading && input.current?.click()}
style={{
height,
borderRadius: 12,
border: cssUrl(value) ? 'none' : '1.5px dashed #ccc',
background: cssUrl(value) ? `center / cover no-repeat ${cssUrl(value)}` : '#fafafa',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#999',
fontSize: 14,
cursor: 'pointer',
}}
>
{uploading ? 'Envoi…' : cssUrl(value) ? null : label}
<input ref={input} type="file" accept="image/*" onChange={onFile} style={{ display: 'none' }} />
</div>
);
}
@@ -1,26 +0,0 @@
import React from 'react';
interface PlaceholderProps {
width?: string | number;
height?: string | number;
label?: string;
className?: string;
style?: React.CSSProperties;
}
export function Placeholder({
width = '100%',
height = 100,
label = 'Image',
className = '',
style
}: PlaceholderProps) {
return (
<div
className={`app-placeholder ${className}`}
style={{ width, height, ...style }}
>
{label}
</div>
);
}
+4 -4
View File
@@ -2,9 +2,8 @@ export { Button } from './Button';
export { Input } from './Input';
export { Card } from './Card';
export { Title, Subtitle, Text } from './Text';
export { Placeholder } from './Placeholder';
export { Avatar, AvatarStack } from './Avatar';
export { Badge, Tag, RelevanceIcon } from './Badge';
export { Avatar } from './Avatar';
export { Badge, RelevanceIcon } from './Badge';
export { Toggle } from './Toggle';
export { Checkbox } from './Checkbox';
export { ListItem } from './ListItem';
@@ -12,7 +11,8 @@ export { Header } from './Header';
export { NavBar } from './NavBar';
export { BottomNav } from './BottomNav';
export { ToastContainer, showToast } from './Toast';
export { EventCover, getEventPhotoUrl } from './EventCover';
export { EventCover } from './EventCover';
export { ImagePicker } from './ImagePicker';
export { EventMeetingPoints } from './EventMeetingPoints';
export type { MeetingPointData } from './EventMeetingPoints';
export { Divider } from './Divider';
+9 -1
View File
@@ -110,9 +110,17 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
}, [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
// screen announce a creation that did not happen.
return Promise.reject(new Error('addMeetingPoint: nothing is stored in the disconnected demo'));
}, [logPrefix]);
const addFriend = useCallback((_friendId: string) => {
console.log(`${logPrefix} addFriend (local, no-op)`);
return Promise.reject(new Error('addFriend: contacts live on a Pod — nothing is sent in the disconnected demo'));
}, [logPrefix]);
const inviteToEvent = useCallback((_eventId: string, _recipientIds: string[], _message?: string) => {
console.log(`${logPrefix} inviteToEvent (local, no-op)`);
return Promise.reject(new Error('inviteToEvent: invitations are posted to a Pod — nothing is sent in the disconnected demo'));
}, [logPrefix]);
const updateProfile = useCallback((_updates: Partial<FpUserData>) => {
console.log(`${logPrefix} updateProfile (local, no-op)`);
@@ -138,7 +146,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
...queries,
getEventOwnership,
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint,
addMeetingPoint, addFriend, updateProfile, loadTestData,
addMeetingPoint, addFriend, inviteToEvent, updateProfile, loadTestData,
};
}
+37 -2
View File
@@ -53,6 +53,13 @@ export type EventReadState = 'ready' | 'loading' | 'unavailable';
export interface FestipodDataContextValue {
currentUserId: string;
currentUser: FpUserData | undefined;
/**
* Whether MY profile could be resolved — only the Pod-backed provider knows:
* `'loading'` (still reading), `'ready'` (`currentUser` is defined), `'missing'`
* (the Pod links no readable profile, so nothing of mine can be edited).
* Absent on providers where the question does not arise.
*/
profileStatus?: 'loading' | 'ready' | 'missing';
/**
* WHO THE SESSION SIGNED IN AS — the identity signing in returned, known
* before any document is read. For display (and log attribution) only: it is
@@ -94,6 +101,15 @@ export interface FestipodDataContextValue {
* change: call it from an effect, never during render.
*/
openEvent(eventId: string): void;
/**
* Ask for a person's events — a screen about to show `getUserEvents(userId)`
* for someone else (a profile). A backend that reads a contact's
* participations only on demand reads them from here; `getUserEvents`
* answers with what has landed. Idempotent; a no-op for oneself, for anyone
* who is not a contact, and on backends whose read set is already everything
* they can read. A state change: call it from an effect, never during render.
*/
openUserEvents(userId: string): void;
/** Where `getEvent(eventId)` stands — see {@link EventReadState}. */
getEventReadState(eventId: string): EventReadState;
@@ -115,8 +131,23 @@ 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;
addMeetingPoint(mp: Omit<FpMeetingPointData, 'id'>): void;
addFriend(friendId: string): void;
/**
* Create a meeting point on an event. Same contract as `addFriend`: resolves
* once the meeting point was created and persisted — rejects when nothing
* was created (no backend to write to, or the write was refused).
*/
addMeetingPoint(mp: Omit<FpMeetingPointData, 'id'>): void | Promise<void>;
/**
* Ask a person to become a contact (an ActivityPods contact request). Resolves
* once the request was sent — the other side accepts on their own Pod; rejects
* when nothing was sent (no backend to send it, or the Pod refused).
*/
addFriend(friendId: string): void | Promise<void>;
/**
* Invite contacts to an event. Same contract: resolves only once the invitation
* was posted, rejects otherwise.
*/
inviteToEvent(eventId: string, recipientIds: string[], message?: string): Promise<void>;
updateProfile(updates: Partial<FpUserData>): void | Promise<void>;
loadTestData(): Promise<BootstrapResult>;
}
@@ -294,6 +325,7 @@ export function buildQueries(
return {
getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getFriends, getEventMeetingPoints,
openEvent: openEventInCompleteReadSet,
openUserEvents: openUserEventsInCompleteReadSet,
getEventReadState,
};
}
@@ -305,6 +337,9 @@ export function buildQueries(
*/
function openEventInCompleteReadSet(_eventId: string): void {}
/** `openUserEvents` for the same providers — every participation is already read. */
function openUserEventsInCompleteReadSet(_userId: string): void {}
// ============================================================================
// Hook
// ============================================================================
+11 -10
View File
@@ -993,6 +993,7 @@ function useNgData(): FestipodDataContextValue {
if (updates.endTime !== undefined) persists.push(updateEntityField(graph, id, 'endTime', str(updates.endTime)));
if (updates.location !== undefined) persists.push(updateEntityField(graph, id, 'location', str(updates.location)));
if (updates.distance !== undefined) persists.push(updateEntityField(graph, id, 'distance', flt(updates.distance)));
if (updates.coverImage !== undefined) persists.push(updateEntityField(graph, id, 'coverImage', str(updates.coverImage)));
await Promise.all(persists).catch(err => console.error(`${logPrefix} persist event update failed:`, err));
}, []);
@@ -1190,15 +1191,12 @@ function useNgData(): FestipodDataContextValue {
setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]);
}, []);
const addFriend = useCallback((friendId: string) => {
setFriendships(prev => {
if (prev.some(f =>
(f.userId === currentUserId && f.friendId === friendId) ||
(f.userId === friendId && f.friendId === currentUserId)
)) return prev;
return [...prev, { id: `ng-fr-${Date.now()}`, userId: currentUserId, friendId }];
});
}, [currentUserId]);
// The suspended NextGraph path has no contact graph: refuse instead of
// pushing simulated state the screens would take for a real contact.
const addFriend = useCallback((_friendId: string) =>
Promise.reject(new Error('addFriend: not supported on the suspended NextGraph backend')), []);
const inviteToEvent = useCallback((_eventId: string, _recipientIds: string[], _message?: string) =>
Promise.reject(new Error('inviteToEvent: not supported on the suspended NextGraph backend')), []);
const updateProfile = useCallback(async (updates: Partial<FpUserData>) => {
console.log(`${logPrefix} updateProfile (NG):`, updates);
@@ -1222,6 +1220,9 @@ function useNgData(): FestipodDataContextValue {
if (updates.username !== undefined) persists.push(updateEntityField(graph, graph, 'username', str(updates.username)));
if (updates.role !== undefined) persists.push(updateEntityField(graph, graph, 'role', str(updates.role)));
if (updates.isPublic !== undefined) persists.push(updateEntityField(graph, graph, 'isPublic', bool(updates.isPublic)));
if (updates.bio !== undefined) persists.push(updateEntityField(graph, graph, 'bio', str(updates.bio)));
if (updates.city !== undefined) persists.push(updateEntityField(graph, graph, 'city', str(updates.city)));
if (updates.photo !== undefined) persists.push(updateEntityField(graph, graph, 'photo', str(updates.photo)));
await Promise.all(persists).catch(err => console.error(`${logPrefix} persist profile update failed:`, err));
}, [currentUser, users]);
@@ -1267,7 +1268,7 @@ function useNgData(): FestipodDataContextValue {
...queries,
getEventOwnership,
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint,
addMeetingPoint, addFriend, updateProfile, loadTestData,
addMeetingPoint, addFriend, inviteToEvent, updateProfile, loadTestData,
};
}
+530 -155
View File
@@ -1,33 +1,39 @@
/**
* Read-your-writes of a join/leave, driven through the REAL data provider
* (`RefineDataProvider` over `useFestipodData`) against a scripted server.
* The participation mechanism of the ActivityPods data path
* (decision_2026-10-29_participation-privacy-mechanism), driven through the
* REAL data provider (`RefineDataProvider` over `useFestipodData`) against a
* scripted server.
*
* THE RACE (rights matrix, line `leave.ui-counts`, 1 run in 5). A join or leave
* is posted to the user's outbox and the backend updates the meeting point's
* attendees collection LATER. When the reads that follow one act all land
* before the backend has processed it, the act is never confirmed; if the user
* then makes the opposite act, the read still on screen (from BEFORE the first
* act) already shows what the second act asserts. Then the next read — the
* first act processed, the second not yet — shows the opposite, and the view
* must not follow it.
* THE TWO CHANNELS OF READ-YOUR-WRITES. A join/leave is confirmed by MY
* PARTICIPATIONS COLLECTION — the backend lists there each meeting point I
* joined, after processing my act; it is linked from my profile once the
* backend processed my first Join. The COUNT is the attendee list's business
* (public, maintained asynchronously by the backend): a stale list read must
* move the number, never the membership.
*
* THE SCRIPT IS ORDERED BY READS, NOT BY CLOCK. The server answers the
* collection from `server.members`, and the test changes it only after counting
* the reads served since an act — so the ordering "act → stale read → backend
* THE SCRIPT IS ORDERED BY READS, NOT BY CLOCK. The server answers from its
* state, and the test changes it (`backendProcesses`) only after counting the
* reads served since an act — so the ordering "act → stale read → backend
* processes → read" is the same on every run. Only the data provider's own
* re-read timers run on the real clock.
*
* What is stubbed: the Refine data provider (Alice's own list: empty, or her
* own meeting point) and `fetch` (catalog, records, collection, actor
* document, outbox). Everything between them — the foreign read set, the
* collection reads, the overlay, the counts — is the production code.
* What is stubbed: the Refine data provider (Alice's own list, and her profile
* — read and merged-on-update like the pod does) and `fetch` (catalog,
* records, collections, actor documents, contacts' profiles, outbox — the
* outbox records what Alice posts). Everything between them — the foreign
* read set, the participations read, the secret step, the recognition by
* hashing, the count overlay, the counts — is the production code. The member
* URNs the lists hold are computed here with node's own sha256, independently
* of the app's Web Crypto path.
*
* THE OVERLAY MUST ALSO GO. A view that only ever shows the overlay would pass
* every count check above; what proves the act was RELEASED (confirmed by a
* read) is that the re-reads stop: they run only while an act is pending.
* every count check; what proves the act was RELEASED (confirmed by a read) is
* that the re-reads stop: the schedule runs only until the act is confirmed
* and the count has moved from its baseline.
*/
import { afterAll, afterEach, beforeAll, expect, test } from 'bun:test';
import { createHash } from 'node:crypto';
import { Window } from 'happy-dom';
import { useLayoutEffect, type ReactNode } from 'react';
import type { DataProvider } from '@refinedev/core';
@@ -36,33 +42,98 @@ import { setCurrentSession } from '../utils/currentSession';
import { setRuntimeConfig } from '../utils/runtimeConfig';
import { useFestipodData, type FestipodDataContextValue } from './festipodDataBase';
const FP_SECRET = 'http://festipod.org/participationSecret';
const FP_PARTICIPATIONS = 'http://festipod.org/participations';
const ALICE = 'http://alice.pod.test/alice';
const ALICE_OUTBOX = 'http://alice.pod.test/alice/outbox';
const ALICE_CONTACTS = 'http://alice.pod.test/alice/contacts';
const ALICE_PROFILE = 'http://alice.pod.test/alice/data/as/profile/alice';
const ALICE_PARTICIPATIONS = 'http://alice.pod.test/alice/data/as/collection/participations';
const BOB = 'http://bob.pod.test/bob';
const BOB_PROFILE = 'http://bob.pod.test/bob/data/as/profile/bob';
const BOB_PARTICIPATIONS = 'http://bob.pod.test/bob/data/as/collection/participations';
const DAVE = 'http://dave.pod.test/dave';
const DAVE_PROFILE = 'http://dave.pod.test/dave/data/as/profile/dave';
/** Carol is nobody's contact here: her profile is never read. */
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';
/** 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';
const BACKEND = 'http://backend.test';
const ALICE_SECRET = 'a1'.repeat(32);
const BOB_SECRET = 'b2'.repeat(32);
const CAROL_SECRET = 'c3'.repeat(32);
const DAVE_SECRET = 'd4'.repeat(32);
/** The member URN the backend records: the prefixed sha256 of "<WebID> <event URI> <secret>". */
function urnOf(webId: string, eventUri: string, secret: string): string {
return `urn:festipod:userref:${createHash('sha256').update(`${webId} ${eventUri} ${secret}`).digest('hex')}`;
}
const ALICE_URN_E = urnOf(ALICE, E, ALICE_SECRET);
const BOB_URN_E = urnOf(BOB, E, BOB_SECRET);
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 MP_RECORD = { id: M, type: 'Event', location: 'Au bar', 'as:context': E, 'dc:creator': BOB, 'apods:attendees': C, to: 'as:Public' };
/** M as Bob's pod serves it before the backend attached its collection. */
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. */
const E2 = 'http://bob.pod.test/bob/data/events/festival';
const E2_RECORD = { id: E2, type: 'Event', name: 'Festival', startTime: '2026-12-05T18:00:00', 'dc:creator': BOB, to: 'as:Public' };
/** M as Bob's pod serves it before the backend attached its attendee list. */
const MP_RECORD_UNLINKED = { id: M, type: 'Event', location: 'Au bar', 'as:context': E, 'dc:creator': BOB, to: 'as:Public' };
/** M as ALICE's own meeting point on Bob's event, as her pod lists it before the backend attached its collection. */
/** M as ALICE's own meeting point on Bob's event, as her pod lists it before the backend attached its list. */
const OWN_MP_UNLINKED = { id: M, type: 'Event', location: 'Au bar', 'as:context': E, 'dc:creator': ALICE, to: 'as:Public' };
/**
* The scripted server: the collection's members, how many reads of it were
* served, and Alice's own list (`own`, with M's collection link once `ownLinked`).
*/
type JsonRecord = Record<string, unknown>;
/** A profile record as the pod serves it — the Festipod predicates under their full IRI. */
function profileRecord(id: string, name: string, extra: { secret?: string; participations?: string } = {}): JsonRecord {
return {
id, type: 'Profile', name,
...(extra.secret !== undefined ? { [FP_SECRET]: extra.secret } : {}),
...(extra.participations !== undefined ? { [FP_PARTICIPATIONS]: extra.participations } : {}),
};
}
/** The scripted server's state — reset by `render`. */
const server = {
/** Attendee lists of M and M2, and how many reads of either were served. */
members: [] as string[],
members2: [] as string[],
reads: 0,
posted: [] as string[],
own: [] as Record<string, unknown>[],
/** Alice's own list (`own`, with M's list link once `ownLinked`). */
own: [] as JsonRecord[],
ownLinked: false,
/** Whether Bob's pod serves M with its collection link yet. */
/** Whether Bob's pod serves M with its list link yet; `secondMp` puts M2 in the catalog. */
mpLinked: true,
secondMp: false,
/** Alice's profile record, as her pod stores it. */
profile: profileRecord(ALICE_PROFILE, 'Alice'),
profileReads: 0,
profileWrites: [] as JsonRecord[],
profileWriteFails: false,
/** What Alice's participations collection lists, and how many reads of it were served. */
myParticipations: [] as string[],
myParticipationsReads: 0,
/** What Alice's outbox received, in order, and every write in order ('profile-write', 'Join', 'Leave'). */
posted: [] as JsonRecord[],
log: [] as string[],
/** Alice's apods:contacts members. */
contacts: [] as string[],
/** The contacts' profile records, served to anyone who asks (the scripted pod checks no grant). */
contactProfiles: {} as Record<string, JsonRecord>,
/** What Bob's participations collection lists, and how many reads of it were served. */
bobParticipations: [] as string[],
bobParticipationsReads: 0,
/** What the catalog declares for `?owner=BOB`. */
bobDeclared: [] as JsonRecord[],
catalogAsks: 0,
};
function json(body: unknown): Response {
@@ -72,49 +143,112 @@ function json(body: unknown): Response {
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');
if ((init?.method ?? 'GET') === 'POST') {
if (url !== ALICE_OUTBOX) return new Response(null, { status: 405 });
server.posted.push(String((JSON.parse(String(init?.body)) as { type?: unknown }).type));
return new Response(null, { status: 201 });
const method = init?.method ?? 'GET';
if (method === 'POST') {
if (url === ALICE_OUTBOX) {
const body = JSON.parse(String(init?.body)) as JsonRecord;
server.posted.push(body);
server.log.push(String(body['type']));
return new Response(null, { status: 201 });
}
return new Response(null, { status: 405 });
}
if (url.startsWith(`${BACKEND}/catalog`)) {
server.catalogAsks++;
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]);
return json({ type: 'OrderedCollection', items });
}
switch (url) {
case ALICE: return json({ id: ALICE, type: 'Person', outbox: ALICE_OUTBOX });
case `${BACKEND}/catalog`: return json({ type: 'OrderedCollection', items: [EVENT_RECORD, MP_RECORD] });
case ALICE: return json({ id: ALICE, type: 'Person', outbox: ALICE_OUTBOX, url: ALICE_PROFILE, 'apods:contacts': ALICE_CONTACTS });
case ALICE_CONTACTS: return json({ id: ALICE_CONTACTS, type: 'Collection', items: [...server.contacts] });
case ALICE_PARTICIPATIONS:
server.myParticipationsReads++;
return json({ id: ALICE_PARTICIPATIONS, type: 'Collection', items: [...server.myParticipations] });
case BOB: return json({ id: BOB, type: 'Person', url: BOB_PROFILE });
case DAVE: return json({ id: DAVE, type: 'Person', url: DAVE_PROFILE });
case BOB_PROFILE:
case DAVE_PROFILE: {
const record = server.contactProfiles[url];
return record ? json(record) : new Response('forbidden', { status: 403 });
}
case BOB_PARTICIPATIONS:
server.bobParticipationsReads++;
return json({ id: BOB_PARTICIPATIONS, type: 'Collection', items: [...server.bobParticipations] });
case E: return json(EVENT_RECORD);
case E2: return json(E2_RECORD);
case M: return json(server.mpLinked ? MP_RECORD : MP_RECORD_UNLINKED);
case M2: return json(MP2_RECORD);
case C:
server.reads++;
return json({ id: C, type: 'Collection', items: [...server.members] });
default: return new Response('not found', { status: 404 });
case C2:
server.reads++;
return json({ id: C2, type: 'Collection', items: [...server.members2] });
default:
return new Response('not found', { status: 404 });
}
}
/** Alice's pod: her own list is `server.own` (the attendees link appears once the backend attached it). */
/** Alice's pod: her own list is `server.own`; her profile is read and merged-on-update. */
const alicePod = {
getList: async () => {
const data = server.own.map(record => (server.ownLinked ? { ...record, 'apods:attendees': C } : record));
return { data, total: data.length };
},
getOne: async () => { throw new Error('no record'); },
getOne: async ({ resource, id }: { resource: string; id: string }) => {
if (resource !== 'profiles' || id !== ALICE_PROFILE) throw new Error('no record');
server.profileReads++;
return { data: { ...server.profile } };
},
getMany: async () => ({ data: [] }),
create: async () => { throw new Error('read-only'); },
update: async () => { throw new Error('read-only'); },
update: async ({ resource, id, variables }: { resource: string; id: string; variables: JsonRecord }) => {
if (resource !== 'profiles' || id !== ALICE_PROFILE) throw new Error('read-only');
if (server.profileWriteFails) throw Object.assign(new Error('Forbidden'), { status: 403 });
server.log.push('profile-write');
server.profileWrites.push(variables);
// The data provider's update MERGES the given fields into the stored record.
server.profile = { ...server.profile, ...variables };
return { data: { ...server.profile } };
},
deleteOne: async () => { throw new Error('read-only'); },
getApiUrl: () => 'http://alice.pod.test',
} as unknown as DataProvider;
/**
* The backend processing one of Alice's acts on a meeting point: her member URN
* in (or out of) its list, the meeting point in (or out of) her participations
* 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';
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];
server.profile = { ...server.profile, [FP_PARTICIPATIONS]: ALICE_PARTICIPATIONS };
} else {
server[list] = server[list].filter(member => member !== ALICE_URN_E);
server.myParticipations = server.myParticipations.filter(id => id !== mp);
}
}
let latest: FestipodDataContextValue | undefined;
/** Whether the Probe opens E — the reload tests mount a fresh session that never opened anything. */
let probeOpensEvent = true;
function Probe() {
const data = useFestipodData();
latest = data;
// What the event detail screen does: ask for the event it shows.
const { openEvent } = data;
useLayoutEffect(() => { openEvent(E); }, [openEvent]);
useLayoutEffect(() => { if (probeOpensEvent) openEvent(E); }, [openEvent]);
return null;
}
// --- environment ---------------------------------------------------------------
const saved = { fetch: globalThis.fetch, build: activitypods.build, log: console.log, warn: console.warn };
const saved = { fetch: globalThis.fetch, build: activitypods.build, log: console.log, warn: console.warn, error: console.error };
const installed: string[] = [];
let createRoot: (typeof import('react-dom/client'))['createRoot'];
let RefineDataProvider: (props: { children: ReactNode }) => ReactNode;
@@ -132,6 +266,7 @@ beforeAll(async () => {
activitypods.build = () => ({ dataProvider: alicePod });
console.log = () => {};
console.warn = () => {};
console.error = () => {};
createRoot = (await import('react-dom/client')).createRoot;
RefineDataProvider = (await import('./refineData')).RefineDataProvider;
setRuntimeConfig({ backendUrl: BACKEND });
@@ -153,6 +288,7 @@ afterAll(() => {
activitypods.build = saved.build;
console.log = saved.log;
console.warn = saved.warn;
console.error = saved.error;
for (const name of installed) delete (globalThis as Record<string, unknown>)[name];
});
@@ -189,30 +325,69 @@ async function settledView(match: (v: ReturnType<typeof view>) => boolean, timeo
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
function render(members: string[], own: Record<string, unknown>[] = [], mpLinked = true): void {
server.members = members;
interface Scenario {
members?: string[];
members2?: string[];
own?: JsonRecord[];
mpLinked?: boolean;
secondMp?: boolean;
opensEvent?: boolean;
/** Alice's secret on her profile (absent by default — she never joined). */
secret?: string;
/** What Alice's participations collection lists; `undefined` = her profile links none. */
myParticipations?: string[];
contacts?: string[];
contactProfiles?: Record<string, JsonRecord>;
bobParticipations?: string[];
bobDeclared?: JsonRecord[];
}
function render(s: Scenario = {}): void {
server.members = s.members ?? [];
server.members2 = s.members2 ?? [];
server.reads = 0;
server.posted = [];
server.own = own;
server.own = s.own ?? [];
server.ownLinked = false;
server.mpLinked = mpLinked;
server.mpLinked = s.mpLinked ?? true;
server.secondMp = s.secondMp ?? false;
server.profile = profileRecord(ALICE_PROFILE, 'Alice', {
secret: s.secret,
participations: s.myParticipations === undefined ? undefined : ALICE_PARTICIPATIONS,
});
server.profileReads = 0;
server.profileWrites = [];
server.profileWriteFails = false;
server.myParticipations = s.myParticipations ?? [];
server.myParticipationsReads = 0;
server.posted = [];
server.log = [];
server.contacts = s.contacts ?? [];
server.contactProfiles = s.contactProfiles ?? {};
server.bobParticipations = s.bobParticipations ?? [];
server.bobParticipationsReads = 0;
server.bobDeclared = s.bobDeclared ?? [];
server.catalogAsks = 0;
probeOpensEvent = s.opensEvent ?? true;
const host = document.createElement('div');
root = createRoot(host as unknown as Element);
root.render(<RefineDataProvider><Probe /></RefineDataProvider>);
}
async function mount(members: string[]): Promise<void> {
render(members);
await until(() => server.reads >= 1, 'the first read of the attendees collection');
await until(() => view().mp === members.length, `M listed with ${members.length} inscrit`);
/** Mount with E opened and M's list read, my profile read. */
async function mount(s: Scenario = {}): Promise<void> {
render(s);
await until(() => server.reads >= 1, 'the first read of the attendee list');
await until(() => view().mp === (s.members ?? []).length, `M listed with ${(s.members ?? []).length} inscrit`);
await until(() => latest?.currentUser !== undefined, 'my profile read');
}
/**
* How many reads followed the act by `untilMs` after it — a point later than
* the step of the re-read schedule after the confirming read. The schedule runs
* only while an act is pending, so an act released by its confirming read
* leaves exactly that many; one never released (or never re-read) does not.
* Never throws: the count goes into the compared timeline.
* How many LIST reads followed the act by `untilMs` after it — a point later
* than the scheduled steps that should have fired. The schedule stops early
* once the act is confirmed and the count has moved from its baseline, so a
* converged act leaves exactly the reads up to that point; one whose list
* never moves runs the bounded window. Never throws: the count goes into the
* compared timeline.
*/
async function readsBy(servedBefore: number, actAt: number, untilMs: number): Promise<number> {
await sleep(Math.max(0, actAt + untilMs - Date.now()));
@@ -220,137 +395,337 @@ async function readsBy(servedBefore: number, actAt: number, untilMs: number): Pr
}
const NOT_JOINED = { event: 0, mp: 0, joined: false };
const JOINED = { event: 1, mp: 1, joined: true };
const JOINED_M = { event: 1, mp: 1, joined: true };
const isSecret = (value: unknown) => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
// --- the race, both directions -------------------------------------------------------
// --- joining: the secret first, then a Join naming the meeting point --------------
test('a leave following a still-unconfirmed join is never undone by a read that predates the leave', async () => {
await mount([]);
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 () => {
await mount();
// JOIN — the backend has not processed it when the reads that follow it land.
let served = server.reads;
await latest!.joinMeetingPoint(M);
await until(() => server.reads >= served + 2, 'two reads after the join');
await land();
const afterJoin = view();
// The backend processes the join now — no read has seen it.
server.members = [ALICE];
// LEAVE — the backend processes it only after two more reads.
served = server.reads;
await latest!.leaveMeetingPoint(M);
const leftAt = Date.now();
await until(() => server.reads >= served + 1, 'the first read after the leave');
await land();
const afterLeaveFirstRead = view();
await until(() => server.reads >= served + 2, 'the second read after the leave');
await land();
const afterLeaveSecondRead = view();
// The backend processes the leave.
server.members = [];
const afterBackendProcessedLeave = await settledView(v => v.mp === 0 && v.event === 0 && !v.joined, 8000);
// The third read (the 3 s step) confirms the leave; the 6 s step must find nothing pending.
const readsAfterLeave = await readsBy(served, leftAt, 7000);
expect(server.posted).toEqual(['Join', 'Leave']);
expect({ afterJoin, afterLeaveFirstRead, afterLeaveSecondRead, afterBackendProcessedLeave, readsAfterLeave }).toEqual({
afterJoin: JOINED,
afterLeaveFirstRead: NOT_JOINED,
afterLeaveSecondRead: NOT_JOINED,
afterBackendProcessedLeave: NOT_JOINED,
readsAfterLeave: 3,
});
}, 30000);
test('a join following a still-unconfirmed leave is never undone by a read that predates the join', async () => {
await mount([ALICE]);
expect(view()).toEqual(JOINED);
// LEAVE — the backend has not processed it when the reads that follow it land.
let served = server.reads;
await latest!.leaveMeetingPoint(M);
await until(() => server.reads >= served + 2, 'two reads after the leave');
await land();
const afterLeave = view();
// The backend processes the leave now — no read has seen it.
server.members = [];
// JOIN — the backend processes it only after two more reads.
served = server.reads;
const served = server.reads;
await latest!.joinMeetingPoint(M);
const joinedAt = Date.now();
await until(() => server.reads >= served + 1, 'the first read after the join');
await land();
const afterJoinFirstRead = view();
await until(() => server.reads >= served + 2, 'the second read after the join');
await land();
const afterJoinSecondRead = view();
// The backend processes the join.
server.members = [ALICE];
const afterBackendProcessedJoin = await settledView(v => v.mp === 1 && v.event === 1 && v.joined, 8000);
const readsAfterJoin = await readsBy(served, joinedAt, 7000);
// The order on the wire: the profile write, then the Join.
expect(server.log).toEqual(['profile-write', 'Join']);
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 }]);
expect(server.posted).toEqual(['Leave', 'Join']);
expect({ afterLeave, afterJoinFirstRead, afterJoinSecondRead, afterBackendProcessedJoin, readsAfterJoin }).toEqual({
afterLeave: NOT_JOINED,
afterJoinFirstRead: JOINED,
afterJoinSecondRead: JOINED,
afterBackendProcessedJoin: JOINED,
readsAfterJoin: 3,
// The membership shows at once (overlay) while the backend has processed
// nothing: my profile links no collection yet, the list is empty.
await until(() => view().joined, 'my membership reflected at once');
await land();
const beforeProcessed = view();
// The backend processes the Join: my URN in M's list, M in my newly created
// participations collection, linked from my profile.
backendProcesses('join', M);
const afterProcessed = await settledView(v => v.mp === 1 && v.event === 1 && v.joined, 8000);
// Confirmed and the total moved → the re-read schedule stops: bounded list reads.
const readsAfterJoin = await readsBy(served, joinedAt, 4000);
expect({ beforeProcessed, afterProcessed, readsAfterJoin }).toEqual({
beforeProcessed: JOINED_M,
afterProcessed: JOINED_M,
readsAfterJoin: 2,
});
// The confirmation came from the collection, found through a re-read of my profile.
expect(server.myParticipationsReads).toBeGreaterThanOrEqual(1);
expect(latest!.getUserEvents(latest!.currentUserId).map(e => e.id)).toEqual([E]);
}, 30000);
test('an own meeting point whose collection link arrives after the act is still re-read until a read confirms the act', async () => {
// Alice's own list shows M before the backend attached its attendees collection.
render([], [OWN_MP_UNLINKED]);
await until(() => view().mp === 0, 'M listed (own, no collection linked yet)');
// The backend attaches the collection now — no list read has seen it.
test('a secret already on my profile is not rewritten: the Join is posted straight away', async () => {
await mount({ secret: ALICE_SECRET, myParticipations: [] });
await latest!.joinMeetingPoint(M);
expect(server.log).toEqual(['Join']);
expect(server.profileWrites).toEqual([]);
expect(server.profile[FP_SECRET]).toBe(ALICE_SECRET);
}, 30000);
test('a malformed secret on my profile reads as absent: a fresh one replaces it before the Join', async () => {
await mount({ secret: 'NOT-A-SECRET' });
await latest!.joinMeetingPoint(M);
expect(server.log).toEqual(['profile-write', 'Join']);
expect(isSecret(server.profile[FP_SECRET])).toBe(true);
}, 30000);
test('the secret write fails: the join rejects and NO Join is posted', async () => {
await mount();
server.profileWriteFails = true;
await expect(latest!.joinMeetingPoint(M)).rejects.toThrow();
await land();
expect(server.posted).toEqual([]);
expect(view().joined).toBe(false);
}, 30000);
test('two joins at once with no secret yet write ONE secret, and both Joins follow it', async () => {
await mount({ secondMp: true });
await until(() => latest!.getEventMeetingPoints(E).some(mp => mp.id === M2), 'M2 listed');
await Promise.all([latest!.joinMeetingPoint(M), latest!.joinMeetingPoint(M2)]);
expect(server.profileWrites).toHaveLength(1);
expect(server.log[0]).toBe('profile-write');
expect(server.posted.map(body => body['object']).sort()).toEqual([M, M2].sort());
}, 30000);
// --- leaving: no secret step, a Leave naming the meeting point -------------------
test("a leave is never undone by a stale read: the membership is my participations collection's word, the count is the attendee list's", async () => {
// Alice joined M long ago: her collection lists M; M's list holds her URN.
render({ members: [ALICE_URN_E], secret: ALICE_SECRET, myParticipations: [M] });
// Two independent chains land here — my participations read (joined) and
// the catalog → meeting point → list reads (the counts). Assert only once
// BOTH have settled.
await settledView(v => v.joined && v.mp === 1 && v.event === 1, 10000);
expect(view()).toEqual(JOINED_M);
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.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
// stays, the membership must NOT flip back.
await until(() => view().joined === false, 'the leave reflects at once');
await until(() => server.reads >= served + 1, 'the first count re-read after the leave');
await land();
const afterStaleRead = view();
backendProcesses('leave', M);
const afterProcessed = await settledView(v => v.mp === 0 && v.event === 0 && !v.joined, 8000);
const readsAfterLeave = await readsBy(served, leftAt, 6000);
expect({ afterStaleRead, afterProcessed, readsAfterLeave }).toEqual({
afterStaleRead: { event: 1, mp: 1, joined: false },
afterProcessed: NOT_JOINED,
readsAfterLeave: 2,
});
expect(latest!.getUserEvents(latest!.currentUserId)).toEqual([]);
}, 30000);
test('an own meeting point whose list link arrives after the join: the membership holds on the overlay; the count follows the late-linked list', async () => {
// Alice's own list shows M before the backend attached its attendee list.
render({ own: [OWN_MP_UNLINKED], secret: ALICE_SECRET, myParticipations: [] });
await until(() => view().mp === 0, 'M listed (own, no list linked yet)');
await until(() => latest?.currentUser !== undefined, 'my profile read');
// The backend attaches the list now — no list read has seen it.
server.ownLinked = true;
const served = server.reads;
await latest!.joinMeetingPoint(M);
const joinedAt = Date.now();
// The list read the act triggers brings the link; the collection's first read predates the backend's processing.
await until(() => server.reads >= served + 1, 'the first read of the newly linked collection');
await until(() => view().joined, 'my membership reflected at once');
// The own-list read the act triggers brings the link; the list's first read
// predates the backend's processing: the baseline is an empty list.
await until(() => server.reads >= served + 1, 'the first read of the newly linked list');
await land();
const afterFirstRead = view();
server.members = [ALICE]; // the backend processes the join
const beforeProcessed = view();
backendProcesses('join', M);
// The next scheduled step (1.5 s) re-reads the collection it did not know at
// act time, that read confirms the join, and the 3 s step finds nothing pending.
const afterProcessed = await settledView(v => v.mp === 1 && v.event === 1 && v.joined, 8000);
const readsAfterJoin = await readsBy(served, joinedAt, 4000);
expect({ afterFirstRead, afterConfirmed: view(), readsAfterJoin }).toEqual({
afterFirstRead: JOINED,
afterConfirmed: JOINED,
expect({ beforeProcessed, afterProcessed, readsAfterJoin }).toEqual({
beforeProcessed: JOINED_M,
afterProcessed: JOINED_M,
readsAfterJoin: 2,
});
}, 30000);
test("another user's meeting point read before its collection was attached is re-read until a read confirms the act", async () => {
// Bob's pod served M before the backend attached its attendees collection.
render([], [], false);
await until(() => view().mp === 0, 'M listed (no collection linked yet)');
// The backend attaches the collection now — no read of M has seen it.
test("another user's meeting point read before its list was attached: the membership holds on the overlay; the count follows the late-linked list", async () => {
// Bob's pod served M before the backend attached its attendee list.
render({ mpLinked: false, secret: ALICE_SECRET, myParticipations: [] });
await until(() => view().mp === 0, 'M listed (no list linked yet)');
await until(() => latest?.currentUser !== undefined, 'my profile read');
server.mpLinked = true;
const served = server.reads;
await latest!.joinMeetingPoint(M);
const joinedAt = Date.now();
// M is read again by URI, which brings the link; the collection's first read predates the backend's processing.
await until(() => server.reads >= served + 1, 'the first read of the newly linked collection');
await until(() => view().joined, 'my membership reflected at once');
// M is read again by URI, which brings the link; the list's first read predates the backend's processing.
await until(() => server.reads >= served + 1, 'the first read of the newly linked list');
await land();
const afterFirstRead = view();
server.members = [ALICE]; // the backend processes the join
const beforeProcessed = view();
backendProcesses('join', M);
// The 1.5 s step re-reads the collection, that read confirms the join, and the 3 s step finds nothing pending.
const afterProcessed = await settledView(v => v.mp === 1 && v.event === 1 && v.joined, 8000);
const readsAfterJoin = await readsBy(served, joinedAt, 4000);
expect({ afterFirstRead, afterConfirmed: view(), readsAfterJoin }).toEqual({
afterFirstRead: JOINED,
afterConfirmed: JOINED,
expect({ beforeProcessed, afterProcessed, readsAfterJoin }).toEqual({
beforeProcessed: JOINED_M,
afterProcessed: JOINED_M,
readsAfterJoin: 2,
});
}, 30000);
// --- my participations collection after a reload --------------------------------
test('present: a meeting point my participations collection lists is read with its event — the event lands on my home without ever opening it', async () => {
render({ members: [ALICE_URN_E], secret: ALICE_SECRET, myParticipations: [M], opensEvent: false });
await until(() => server.myParticipationsReads >= 1, 'my participations read');
await settledView(v => v.joined && v.mp === 1 && v.event === 1, 10000);
await until(() => latest!.getUserEvents(latest!.currentUserId).some(e => e.id === E), 'the joined event on my home');
expect(latest!.getUserEvents(latest!.currentUserId).map(e => e.id)).toEqual([E]);
expect(view()).toEqual(JOINED_M);
// The event came from my collection alone: no event was opened, no catalog asked.
expect(server.catalogAsks).toBe(0);
}, 30000);
test('absent: a profile linking no participations collection — nothing joined, no collection and no list read', async () => {
render({ opensEvent: false });
await until(() => latest?.currentUser !== undefined, 'my profile read');
await land();
await land();
expect(latest!.getUserEvents(latest!.currentUserId)).toEqual([]);
expect(latest!.isParticipating(M)).toBe(false);
expect(server.myParticipationsReads).toBe(0);
expect(server.reads).toBe(0);
}, 30000);
test('after a confirmed leave: a collection that no longer lists M keeps it — and its event — off my home, its list never read', async () => {
// The host's list WOULD still count me if it were stale: nothing may re-join
// the point on its word.
render({ members: [ALICE_URN_E], secret: ALICE_SECRET, myParticipations: [], opensEvent: false });
await until(() => server.myParticipationsReads >= 1, 'my participations read');
await land();
await land();
expect(latest!.getUserEvents(latest!.currentUserId)).toEqual([]);
expect(latest!.isParticipating(M)).toBe(false);
expect(server.reads).toBe(0);
}, 30000);
// --- one person, one event: the public distinct-person count ------------------
test('Alice in both meeting points of the event: each point shows its list count; the event counts her ONCE (the same URN in both)', async () => {
render({ members: [ALICE_URN_E], members2: [ALICE_URN_E], secondMp: true, secret: ALICE_SECRET, myParticipations: [M, M2] });
const counts = () => {
const data = latest;
if (!data) return undefined;
const mps = data.getEventMeetingPoints(E);
const mp = mps.find(p => p.id === M)?.participantCount;
const mp2 = mps.find(p => p.id === M2)?.participantCount;
const event = data.getEvent(E)?.participantCount;
return mp !== undefined && mp2 !== undefined && event !== undefined ? { mp, mp2, event } : undefined;
};
await until(() => counts()?.mp === 1 && counts()?.mp2 === 1 && counts()?.event === 1, 'each point at its list total and the event at the distinct-person count');
expect(counts()).toEqual({ mp: 1, mp2: 1, event: 1 });
await until(() => latest?.getUserEvents(latest!.currentUserId).some(e => e.id === E) === true, 'the joined event on my home');
expect(latest!.getEventParticipants(E).map(u => u.id)).toEqual([ALICE]);
}, 30000);
// --- recognition: a contact's secret, hashed per event, against the lists -------
const bobProfile = (extra: { secret?: string; participations?: string } = { secret: BOB_SECRET }) =>
({ [BOB_PROFILE]: profileRecord(BOB_PROFILE, 'Bob', extra) });
test('a contact in a list is recognised by hashing their secret; a non-contact member stays anonymous; a contact whose profile carries no secret is not recognised', async () => {
// Bob and Dave are Alice's contacts; Carol is not. Dave's profile was read
// before he generated his secret (none on it), while the list holds the URN
// the backend computed from the secret he has since.
render({
members: [BOB_URN_E, CAROL_URN_E, DAVE_URN_E],
contacts: [BOB, DAVE],
contactProfiles: { ...bobProfile(), [DAVE_PROFILE]: profileRecord(DAVE_PROFILE, 'Dave') },
});
await until(() => latest?.getEventParticipants(E).some(u => u.id === BOB) === true, 'Bob recognised at E');
await until(() => latest?.getUser(DAVE) !== undefined, "Dave's profile read");
await land();
const data = latest!;
expect(data.getEventParticipants(E).map(u => u.id)).toEqual([BOB]);
expect(data.isParticipating(M, BOB)).toBe(true);
expect(data.isParticipating(M, DAVE)).toBe(false);
expect(data.isParticipating(M, CAROL)).toBe(false);
// The count stays the total: three inscriptions, one of them recognised.
expect(view()).toEqual({ event: 3, mp: 3, joined: false });
// Recognition read nothing beyond the lists and the contacts' profiles.
expect(server.bobParticipationsReads).toBe(0);
}, 30000);
test('a contact in both meeting points of one event: listed once as a participant, participating in each point, counted once', async () => {
render({ members: [BOB_URN_E], members2: [BOB_URN_E], secondMp: true, contacts: [BOB], contactProfiles: bobProfile() });
await until(() => latest?.isParticipating(M2, BOB) === true, 'Bob recognised in M2');
const data = latest!;
expect(data.getEventParticipants(E).map(u => u.id)).toEqual([BOB]);
expect(data.isParticipating(M, BOB)).toBe(true);
expect(data.getEvent(E)?.participantCount).toBe(1);
}, 30000);
test('a contact absent from the lists is not a participant', async () => {
render({ members: [CAROL_URN_E], contacts: [BOB], contactProfiles: bobProfile() });
await until(() => latest?.getUser(BOB) !== undefined, "Bob's profile read");
await until(() => view().mp === 1, 'M listed with its one member');
await land();
expect(latest!.getEventParticipants(E)).toEqual([]);
expect(latest!.isParticipating(M, BOB)).toBe(false);
}, 30000);
// --- a contact's events: declared (catalog) ∪ participated (their collection, on demand) ---
test("a contact's participations collection is read only once their events are asked for; then declared and participated events list once each, and a stranger's stay empty", async () => {
// Bob declared E2 (catalog) and participates in M and M2, both anchoring E.
render({
opensEvent: false,
contacts: [BOB],
contactProfiles: bobProfile({ secret: BOB_SECRET, participations: BOB_PARTICIPATIONS }),
bobParticipations: [M, M2],
bobDeclared: [E2_RECORD],
});
await until(() => latest?.getUser(BOB) !== undefined, "Bob's profile read");
await until(() => latest?.getUserEvents(BOB).length === 1, "Bob's declared event listed");
await land();
// Nothing asked for Bob's events yet: his collection is not read.
expect(server.bobParticipationsReads).toBe(0);
expect(latest!.getUserEvents(BOB).map(e => e.id)).toEqual([E2]);
latest!.openUserEvents(BOB);
await until(() => latest?.getUserEvents(BOB).length === 2, "Bob's participated event listed");
expect(latest!.getUserEvents(BOB).map(e => e.id).sort()).toEqual([E, E2].sort());
expect(server.bobParticipationsReads).toBe(1);
// A stranger is nobody's contact: asking reads nothing, the answer stays [].
latest!.openUserEvents(CAROL);
await land();
expect(latest!.getUserEvents(CAROL)).toEqual([]);
expect(server.bobParticipationsReads).toBe(1);
// My own view is untouched: Alice joined nothing.
expect(latest!.getUserEvents(ALICE)).toEqual([]);
}, 30000);
test('a contact whose profile links no participations collection: asking for their events lists only what they declared', async () => {
render({ opensEvent: false, contacts: [BOB], contactProfiles: bobProfile() });
await until(() => latest?.getUser(BOB) !== undefined, "Bob's profile read");
latest!.openUserEvents(BOB);
await until(() => server.catalogAsks >= 1, 'the owner-filtered catalog read');
await land();
await land();
expect(latest!.getUserEvents(BOB)).toEqual([]);
expect(server.bobParticipationsReads).toBe(0);
}, 30000);
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More