Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9671345634 | |||
| ebcf5f4266 | |||
| 9169a5817c | |||
| 5540d361c7 | |||
| af0cba6920 | |||
| 9cbe78948b | |||
| 4abab5a55a | |||
| f45fc564ec | |||
| 9e2df2478b | |||
| ea359488b6 | |||
| 223059c629 | |||
| 5e3e821e2a | |||
| 908f7ea973 | |||
| f90651eb9a | |||
| 5b61ce9905 | |||
| a28a318c24 | |||
| 738d59596a | |||
| 8d0670a637 | |||
| 812844e99d | |||
| 6ee95c9e68 | |||
| 9381c206df | |||
| 88f23b7023 | |||
| d38f8a7384 | |||
| 6bcf40306b | |||
| f14cc37f4c | |||
| fbb1f6f61b | |||
| a0d2cb8f73 | |||
| d4fcb7e311 | |||
| f32e166601 | |||
| 4fc856fa7e | |||
| e21c408dc1 |
+10
-35
@@ -1,7 +1,7 @@
|
||||
# Festipod — variables d'environnement (exemple)
|
||||
#
|
||||
# Copier en `.env` et renseigner les valeurs.
|
||||
# En dev (`bun run dev`) ET en prod (`bun run start`), l'app sert depuis src/ et
|
||||
# En dev (`pnpm dev`) ET en prod (`bun run start`), l'app sert depuis src/ et
|
||||
# lit ces variables au RUNTIME (via l'endpoint /festipod-config.json de src/index.ts).
|
||||
#
|
||||
# REQUIS POUR LES TESTS. La suite Cucumber tourne sous `node` (pas sous Bun), qui
|
||||
@@ -37,48 +37,23 @@ FESTIPOD_SHARED_WALLET_FILE_BASE64=
|
||||
FESTIPOD_AUTO_SEED=
|
||||
|
||||
# ── Serveur ────────────────────────────────────────────────────────────────
|
||||
# Port HTTP du serveur (4000 : l'app est servie sur l'origine :4000 de la pile locale).
|
||||
PORT=4000
|
||||
# Port HTTP du serveur (4005 : l'app Festipod, paire 3005/4005 des apps sœurs).
|
||||
PORT=4005
|
||||
|
||||
# NODE_ENV=production bascule `bun run start` (pas de HMR). En dev, laisser vide.
|
||||
NODE_ENV=
|
||||
|
||||
# ── Backend ActivityPods ───────────────────────────────────────────────────
|
||||
# URL publique du backend Festipod (écoute sur le port 3003).
|
||||
SEMAPPS_HOME_URL=http://localhost:3003
|
||||
# Le backend lit son propre backend/.env (voir backend/.env.example).
|
||||
|
||||
# URL publique du frontend (utilisée par le backend pour les OIDC redirects, etc.)
|
||||
SEMAPPS_FRONT_URL=http://localhost:4000
|
||||
|
||||
# Endpoint SPARQL du Fuseki auquel se connecte le backend.
|
||||
SEMAPPS_SPARQL_ENDPOINT=http://localhost:3030/
|
||||
|
||||
# Dataset Jena/Fuseki principal utilisé par le backend.
|
||||
SEMAPPS_MAIN_DATASET=settings
|
||||
|
||||
# Identifiants Jena/Fuseki.
|
||||
SEMAPPS_JENA_USER=admin
|
||||
SEMAPPS_JENA_PASSWORD=admin
|
||||
|
||||
# Redis : cache (db 3) et file des jobs (db 4).
|
||||
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
|
||||
|
||||
# Dépôt de shapes/shapetrees utilisé pour déclarer les besoins d'accès.
|
||||
SEMAPPS_SHAPE_REPOSITORY_URL=https://shapes.activitypods.org/
|
||||
|
||||
# Nom et description de l'application (backend).
|
||||
SEMAPPS_APP_NAME=Festipod
|
||||
SEMAPPS_APP_DESCRIPTION=Points de rencontre sur les événements publics
|
||||
SEMAPPS_APP_LANG=en,fr
|
||||
SEMAPPS_PORT=3003
|
||||
# ── Infrastructure locale (`make start`) ───────────────────────────────────
|
||||
# docker compose lit aussi ce fichier. Port hôte du Redis de docker-compose-dev.yml,
|
||||
# vide = 6379. Le changer si un autre Redis occupe ce port, et reporter la même
|
||||
# valeur dans SEMAPPS_QUEUE_SERVICE_URL de backend/.env.
|
||||
REDIS_PORT=
|
||||
|
||||
# ── Frontend / Backend coupling ────────────────────────────────────────────
|
||||
# URL du backend telle que vue par le navigateur.
|
||||
VITE_BACKEND_URL=http://localhost:3003
|
||||
VITE_BACKEND_URL=http://localhost:3005
|
||||
|
||||
# Client ID OAuth/Solid du backend (laisser vide pour dériver `${VITE_BACKEND_URL}/as/actor/app`).
|
||||
# Le client_id DOIT être l'URI de l'acteur app : le pod enregistre l'app sous cette URI et
|
||||
|
||||
@@ -45,5 +45,8 @@ dist-staging/
|
||||
*.ngw
|
||||
.tasks/
|
||||
|
||||
# Local data of the dev stack (docker-compose-dev.yml): fuseki, redis, pod provider
|
||||
/data
|
||||
|
||||
# Per-developer contract access map (canonical provider → local checkout) — never committed.
|
||||
.project/contracts.local.yaml
|
||||
|
||||
@@ -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 and addressed to the Festipod app actor alone, so that only its author and the app can read it; the pod delivers it to the app's inbox. What the list below declares is therefore measured where a runtime check exists and declared elsewhere; `against:` names the observation's commit, and the package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped.
|
||||
|
||||
## Consumed surface
|
||||
|
||||
@@ -24,12 +24,18 @@ All three are built together, **per backend instance**, inside the seam's `build
|
||||
|
||||
`login({ issuer, redirect })` and `register(...)` — Festipod supplies its own Pod-provider picker. `handleCallback()` from its own callback route. `logout()`. `check()`. `getSession()` for the `webId` and the token. `registerApp()` and `getAppStatus()`, the second one on Festipod's own schedule since the built-in interval is off.
|
||||
|
||||
Of the `getAppStatus()` result Festipod reads `upgradeNeeded` (pinned by the 2026-10-12 re-baseline) and `installed`, not pinned (the reconstruction records the pod's body, `{ upgradeNeeded }` "plus whatever the pod adds"): `true` while the app is registered, `false` once the user removed it — then Festipod logs the user out to the picker, never calling `registerApp`. See constraint 9, need 3.
|
||||
|
||||
### Resources and operations
|
||||
|
||||
Four resources, one per persisted entity: **events**, **meeting points**, **participations** and **user profiles**. Each is configured with `types` naming Festipod's own class URIs, and `jsonContext` carries the context that expands them.
|
||||
|
||||
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
|
||||
|
||||
A record's `id` — the resource's URI — is the **only** identifier Festipod uses: the seam's `recordKey` and `domainId` both return it. `update` merging the fields it is given means the seam declares this backend as patching, and sends only what changed.
|
||||
@@ -47,26 +53,28 @@ 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. See need 1: this is the constraint the package does not currently let the app satisfy.
|
||||
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.
|
||||
8. **Each entity carries a declared visibility** — public, protected or private, per the product's confidentiality model — and it is the backend that honours it. Refine has no notion of it, so the intent is declared at the seam and enforcement is the backend's.
|
||||
9. **`getAppStatus()` must keep telling a removed registration from a current one** (`installed`). Otherwise a user who revoked the app stays in a connected view whose writes cannot land — or a current user is logged out.
|
||||
10. **`create` must keep sending the caller's variables unchanged, `to` included.** A provider dropping or rewriting `to` would make every new event and meeting point private and undiscoverable, with no error.
|
||||
|
||||
## Frictions
|
||||
|
||||
1. **`authProvider` installs a timer and a listener it never removes.** Construction registers a `setInterval` and a `visibilitychange` listener with no teardown. Festipod builds providers once per backend selection, so every switch to this backend would accumulate another pair — directly against constraint 7. Workaround carried: pass `appStatusCheckInterval: false` and call `getAppStatus()` on the app's own schedule. That works, but it means the package's own staleness handling is unusable here, and the only way to opt out is to opt out entirely. The re-baseline of 2026-10-12 pinned the `getAppStatus` shape (`GET /.well-known/app-status` → `{ upgradeNeeded }`), so the workaround is now implementable; wiring it is tracked in the handoff.
|
||||
1. **`authProvider` installs a timer and a listener it never removes.** Construction registers a `setInterval` and a `visibilitychange` listener with no teardown. Festipod builds providers once per backend selection, so every switch to this backend would accumulate another pair — directly against constraint 7. Workaround carried: pass `appStatusCheckInterval: false` and call `getAppStatus()` on the app's own schedule — on load and on focus or visibility change, at most once a minute, with its own teardown. That works, but it means the package's own staleness handling is unusable here, and the only way to opt out is to opt out entirely.
|
||||
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.
|
||||
|
||||
## Needs
|
||||
|
||||
1. **A read that is not scoped to the logged-in user's own Pod.** Every container resolution — in `getList`, in `create`, and in a list subscription — runs against the session's own WebID. So `getList('events')` returns only the events the current user created, and `getList('participations')` only their own participations.
|
||||
|
||||
This blocks the product, not a detail of it. Festipod exists to let a user **discover other people's public events** and see **how many people have signed up** to a meeting point. Through this provider the app can show neither: no other user's event is reachable, and the participant count of constraint 4 cannot be computed from participations it cannot read.
|
||||
|
||||
The request is modest in shape, because the capability already sits one layer down: the package's own TypeIndex discovery takes a WebID as a parameter, and notes that the public TypeIndex is readable without any special grant. What is missing is a way to reach it **through the data provider** — a way to scope a `getList` (and the matching list subscription) to a given WebID, or to a set of them. Festipod has no interest in reimplementing container resolution beside the provider; that would put the app back in the business the provider exists to hold.
|
||||
1. **A read that is not scoped to the logged-in user's own Pod.** Every container resolution — in `getList`, in `create`, and in a list subscription — runs against the session's own WebID, so `getList('events')` returns only the current user's own events. To let a user **discover other people's public events** and see **how many people signed up**, Festipod now carries a workaround beside the provider: other users' events are found through the Festipod backend's catalog (a cross-Pod index of public records), and their records and attendee collections are read by URI with plain anonymous HTTP. Neither gets Refine's cache or live updates. The capability sits one layer down (the package's TypeIndex discovery takes a WebID; the public TypeIndex needs no grant), so the request stands: a way to scope a `getList` (and its list subscription) to a given WebID, or a set of them, through the data provider.
|
||||
|
||||
2. **Releases that can be pinned, and a statement of what changes between them.** The package carries no git tags, so the only thing this repo can pin is a commit on a moving branch, and the only way to learn what an upgrade changed is to re-read the source. Tagged releases plus a note of what moved in each — in whatever form suits the project — would let a consumer upgrade deliberately instead of re-observing from scratch every time.
|
||||
|
||||
3. **`installed` pinned in the app status.** Festipod depends on it (constraint 9); stating it as part of the status, `false` once the user removed the app, would stop an upgrade from silently taking it away.
|
||||
|
||||
@@ -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).
|
||||
@@ -5,19 +5,34 @@ summary: Bring up a local ActivityPods pod provider for Festipod development —
|
||||
|
||||
# Cookbook — Local ActivityPods platform
|
||||
|
||||
Platform checkout: `/home/sylvain/projects/activitypods/activitypods` (never modify it; verified pristine 2026-10-09). Everything runs from `pod-provider/`.
|
||||
Festipod's local setup follows the sibling ActivityPods apps (welcometomyplace, mutual-aid.app, portejunes): a local pod provider and its infrastructure run from images through `make start`, and the app's own backend and frontend run outside Docker, one command each.
|
||||
|
||||
## Ports
|
||||
|
||||
pod provider 3000 (OpenID + `/auth` signup/login), Festipod backend 3003, Festipod app 4000, fuseki 3030 (admin/admin), mailcatcher 1080, redis 6390, arena 4567, tripleadmin 3033. The pod provider frontend owns :5000 — never run it while testing Festipod.
|
||||
One fixed port pair per app on localhost, the pod provider keeping 3000/5000:
|
||||
|
||||
| Service | Port |
|
||||
|---|---|
|
||||
| pod provider (OpenID + `/auth` signup/login) / its frontend | 3000 / 5000 |
|
||||
| welcometomyplace backend / frontend | 3002 / 4002 |
|
||||
| mutual-aid backend / frontend | 3003 / 4003 |
|
||||
| portejunes backend / frontend | 3004 / 4004 |
|
||||
| **Festipod backend / app** (committed defaults) | **3005 / 4005** |
|
||||
| fuseki (admin/admin) | 3030 |
|
||||
| redis | 6379 (`REDIS_PORT`) |
|
||||
| mailcatcher | 1080 (SMTP 1025) |
|
||||
| arena | 4567 |
|
||||
|
||||
A developer whose ports differ overrides them in the two gitignored env files, never in committed code: `backend/.env` (`SEMAPPS_PORT`, `SEMAPPS_HOME_URL`, `SEMAPPS_FRONT_URL`, the Redis and Fuseki endpoints) and the root `.env` (`PORT` for the app, `VITE_BACKEND_URL`, `VITE_POD_PROVIDER_URL`, and `REDIS_PORT`, which docker compose reads from that file). The e2e suites and `scripts/validate.ts` read the same values from the environment (`PORT` → app URL, `VITE_BACKEND_URL`, `VITE_POD_PROVIDER_URL`), falling back to 3005 / 4005 / 3000. The app origin (`FRONT_URL`) is the only registered OIDC redirect origin, so the app must be served exactly there. The pod provider frontend on :5000 must run whenever Festipod signs a user in through the browser: the pod's login and app-consent screens are its pages (the UI smoke spawns its own app and does not need it; `test:e2e-flow`, `test:e2e-rights` and `test:e2e-revocation` do).
|
||||
|
||||
## Bring up
|
||||
|
||||
1. Infra: `cd pod-provider && docker compose up -d`. If host port 6379 is already taken, add an override file publishing redis on another port (6390 was used) and pass `SEMAPPS_QUEUE_SERVICE_URL=redis://localhost:6390/1` and `SEMAPPS_REDIS_OIDC_PROVIDER_URL=redis://localhost:6390/2` when starting the backend — dotenv-flow does not override the process environment.
|
||||
2. Install once: `cd backend && COREPACK_ENABLE_AUTO_PIN=0 corepack yarn install --frozen-lockfile`, same in `frontend/`. Keep `COREPACK_ENABLE_AUTO_PIN=0`: corepack auto-pin rewrites `packageManager` in package.json. Note: the pod-provider README says `yarn start` for the frontend; only the `dev` script exists — use `BROWSER=none corepack yarn dev`.
|
||||
3. Backend: `cd backend && corepack yarn start`. Wait for "ServiceBroker with ... service(s) started successfully" (~11 s).
|
||||
4. Frontend: `cd frontend && BROWSER=none corepack yarn dev`. Verify: `GET http://localhost:5000/` → 200 and `GET http://localhost:3000/.well-known/config.js` → 200.
|
||||
5. Festipod stack (this repo root): `cp backend/.env.example backend/.env` before `docker compose up`. The compose reads `backend/.env` as an optional `env_file`; on a fresh clone the file is gitignored and absent, so this copy is what feeds the backend its ActivityPods settings. With the coherent layout the app is served on :4000 and the Festipod backend on :3003 — keep the pod provider frontend (which owns :5000) stopped while testing Festipod. To serve the app manually (`bun run start` on :4000) ALSO copy the root env: `cp .env.example .env` — without `VITE_BACKEND_URL` the config route serves an empty backend URL and the app deliberately falls back to the local demo (seeded data, no login).
|
||||
1. Once: `cp backend/.env.example backend/.env` and `cp .env.example .env` (both gitignored; fill the shared-wallet values in the root one), then `pnpm install` at the root and in `backend/`.
|
||||
2. Pod provider and infrastructure: `make start` (`docker-compose-dev.yml`: `activitypods/backend` in host network mode on 3000, `activitypods/frontend` on 5000, `semapps/jena-fuseki-webacl` on 3030 with `./data/fuseki`, `redis` on `REDIS_PORT` (6379 by default) with `./data/redis`, mailcatcher, `activitypods/arena` configured by `docker/arena-dev.json`). Other targets: `make stop`, `make config`, `make upgrade`, `make attach-activitypods`. `./data/` is gitignored.
|
||||
3. Festipod backend: `cd backend && pnpm dev`. Wait for "ServiceBroker with ... service(s) started successfully" (~11 s). `pnpm dev` hot-reloads a changed service file; restart the backend after editing one instead of trusting the reload. After a reload of `services/app.service.js`, pod webhooks still answered 200 but were no longer processed (no attendees collection, no Join handling; observed 2026-11-02, cause not established).
|
||||
4. Festipod app: `pnpm dev` at the root (the frontend lives at the root, not in a `frontend/` directory; the root scripts run Bun, pnpm only launches them).
|
||||
|
||||
`backend/.env` follows the sibling layout: own dataset `festipod` and accounts dataset `settings-festipod`, `SEMAPPS_REDIS_CACHE_URL=` empty (no cache locally), queue on `redis://localhost:6379/15` (the siblings use dbs 12, 13 and 14). dotenv-flow does not override the process environment, so a variable exported in the shell wins over the file. If host port 6379 is already taken, set `REDIS_PORT` in the root `.env` and put the same port in `SEMAPPS_QUEUE_SERVICE_URL` of `backend/.env`. The compose publishes redis on that port and points the pod provider's queue and OIDC URLs at it. The pod provider runs in host network mode, so it reaches redis only through the host port: its image's own default is `localhost:6379`, which the compose replaces with `REDIS_PORT`.
|
||||
|
||||
## Account and resource flow (all VERIFIED 2026-10-09)
|
||||
|
||||
@@ -31,8 +46,28 @@ pod provider 3000 (OpenID + `/auth` signup/login), Festipod backend 3003, Festip
|
||||
|
||||
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 (`./data/fuseki`) 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).
|
||||
- The pod provider deletes files from disk, at its image's `SEMAPPS_FUSEKI_BASE` (`../data/fuseki`, i.e. `/app/data/fuseki` in the container):
|
||||
- at once, the account's backups (`backups/`);
|
||||
- 24 h later, its secure dataset (`databases/<user>`, `<user>Acl`, `<user>Mirror`, `configuration/<user>.ttl`), since Fuseki's own delete leaves those files behind.
|
||||
|
||||
`docker-compose-dev.yml` therefore mounts all of `./data/fuseki` there. Without any mount, every deletion answers 500 (`ENOENT … scandir '../data/fuseki/backups'`, VERIFIED 2026-11-02), the account stays tombstoned with its dataset in place, and the suites still pass. With only `backups/` mounted, the deletion answers 200 but the dataset files stay on disk (about 40 MB per test account).
|
||||
- Never sign up a local pod account named after an existing dataset (`festipod`, `settings-festipod`, another app's): per the pod provider's code, the pod is written into that dataset, and deleting the account deletes the dataset's files and backups. The provider reserves `sparql`, `auth`, `common`, `data` and `settings`: of the dataset names, only `settings` is protected.
|
||||
|
||||
## 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: delete the two datasets and the secure dataset's files on disk (`./data/fuseki`), and empty the backend's Redis queue database. After a reset, pod accounts that had installed the app must install it again: the new backend knows none of their registrations. A deleted dataset re-created under the same name needs a Fuseki restart in between (`docker restart fuseki`), else every query fails with `ClosedException: already closed`. Changing `SEMAPPS_HOME_URL` (a new port, say) changes the app actor's URI, hence the app's identity: a persistent account keeps a participations collection whose `acl:Control` belongs to the previous app actor, so the backend refuses every Join of that account ("the pod refused the read grant of the contacts group…") — remove the `http://festipod.org/participations` link from that account's profile with its own token (`DELETE DATA` of the exact triple; `DELETE WHERE` answers 400) and the next Join creates a new collection.
|
||||
|
||||
`GET /app` serves a snapshot of the app actor that the `@activitypods/app` mixin takes at boot BEFORE it creates or replaces the access-need group (VERIFIED 2026-11-02 in the mixin's code and live). On a fresh dataset (first boot, or after a wipe), that snapshot lacks `interop:hasAccessNeedGroup`, even though the store has it. On a boot that changes the access needs, it names the group just deleted, which answers 404. Either way it stayed wrong until the next restart. On the first `make start` stack, the smoke and the rights matrix failed on their "access-need group" prerequisites. `backend/services/app.service.js` refreshes the snapshot from the store at the end of its own `started()`.
|
||||
|
||||
The same handler first checks the link in the store and attaches the group when it is missing, polling up to 60 s. On a fresh dataset (2026-10-26), consents registered zero grants and `GET /app` linked a group that 404s. A one-shot version of that check found no group. The cause was not established: the mixin creates the actor, then the group, one after the other. The 404 matches the stale snapshot above. Before running consented flows on a fresh dataset, check that `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.
|
||||
Stop the two dev processes (`pkill -f moleculer-runner`, and the app's `bun --hot`), then `make stop`. Data persists in `./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 `apods:attendees` collection of its target — a meeting point, or the event itself for a direct sign-up (concept `functional-domain`, [[decision_2026-11-06_direct-event-registration]]) — plus the target's URI in the person's participations collection (linked from the profile as `http://festipod.org/participations`); the person's `http://festipod.org/participationSecret` on the profile is what lets their contacts recognise the URN. No shape describes any of these — see [[decision_2026-10-29_participation-privacy-mechanism]] and `src/shared/data/participationPrivacy.ts` (verified in code).
|
||||
|
||||
**An event has no host.** `hostName`/`hostInitials` are gone from the type and the shape alike — the event is only the anchor, and the host lives one level down on the meeting point (`FpMeetingPointData.hostId`, SHEX `fp:MeetingPoint.host`). See concept `functional-domain`, [[knowledge_actors-and-concepts]].
|
||||
|
||||
|
||||
@@ -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/**"]
|
||||
---
|
||||
|
||||
@@ -14,16 +14,19 @@ Festipod's **functional domain**: what the product promises and the business voc
|
||||
|
||||
## Pivotal idea
|
||||
|
||||
Festipod lets users create **meeting points** that *graft* themselves onto existing **public events**. The event (festival, conference…) is only a *pretext* and a space-time anchor; the value produced is the meeting point. **You sign up to a meeting point, never to an event.**
|
||||
Festipod lets users create **meeting points** that *graft* themselves onto existing **public events**. The event (festival, conference…) is only a *pretext* and a space-time anchor; the value produced is the meeting point. **You sign up to a meeting point, or directly to an event; being in one of an event's meeting points registers you to that event** ([[decision_2026-11-06_direct-event-registration]]).
|
||||
|
||||
## Scope & confidentiality
|
||||
|
||||
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
|
||||
- [[decision_2026-11-06_direct-event-registration]] — direct sign-up to an event (FP-001): registration derived from it or from a meeting point membership, anonymous notification of the declarer, confidentiality unchanged, confirmed cascade on leaving the event
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
---
|
||||
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-11-06_direct-event-registration]] — a direct sign-up to an event is a participation under this rule, unchanged
|
||||
- [[decision_2026-11-03_participation-delivered-to-the-app]] — how that private Join reaches the backend: addressed to the app actor alone, delivered to the app's inbox
|
||||
- [[decision_2026-10-21_contacts-are-the-pod-social-graph]] — what "contact" means (the Pod's bilateral `apods:contacts`)
|
||||
+12
@@ -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.
|
||||
+20
@@ -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.
|
||||
+59
@@ -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. Widened by [[decision_2026-11-06_direct-event-registration]]: a sign-up may also name an event itself, under this mechanism unchanged (its own list, the same URN, the event URI in the participations collection, its declarer notified anonymously). Enforces [[behavior_participation-listing-is-contacts-only]] on the ActivityPods backend, using only patterns of the reference apps (project rule `festipod-only-reference-app-patterns`). ActivityPods itself is never modified.
|
||||
|
||||
## What it protects: two links
|
||||
|
||||
- **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.
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Join/Leave are addressed to the Festipod app actor alone and handled on reception in the app's inbox, no longer picked up unaddressed by an outbox webhook; any compatible sender may send, authorship rests on the signature and the pod, the meeting point is read in its creator's pod
|
||||
---
|
||||
|
||||
# Decision (2026-11-03): participation activities are delivered to the app
|
||||
|
||||
User-validated 2026-11-03, on the advice of the ActivityPods developer: an activity meant for an actor who acts on it is addressed to that actor and reaches it through its inbox — the ActivityPub protocol rather than a side channel. This replaces steps 1 and 2 (the unaddressed sign-up, "the backend acts on emission") of [[decision_2026-10-29_participation-privacy-mechanism]]; every other step of that mechanism (member URN, secret, participations collection, recognition, anonymous notification) and all its residuals stand.
|
||||
|
||||
## The delivery
|
||||
|
||||
- The participant posts the `as:Join` (withdrawal: `as:Leave`) to their own outbox, naming the meeting point, with `to` = the Festipod app actor (`<backend>/as/actor/app`, which is also the Solid-OIDC client id) and no other addressee. Their pod delivers it to the app's inbox.
|
||||
- The backend handles it with `onReceive` of `@semapps/activitypub`'s `ActivitiesHandlerMixin` (`registration.service.js`), the path `@activitypods/app` itself uses for `Create interop:ApplicationRegistration`. No reference app receives user activities in the app's inbox: this is a named exception to the project rule `festipod-only-reference-app-patterns`, justified by the platform developer's advice and by the framework's own use of that path.
|
||||
- The outbox webhook (`apods:ReadOutbox`) stays: the catalog, the contacts and the attendee lists still follow the user's own resources there, which have no actor to address.
|
||||
|
||||
## Who may send: anyone, nobody for someone else
|
||||
|
||||
- **No "app installed" gate** (user decision): interoperability is the aim of the ActivityPods architecture, and a Join sent by another compatible app is legitimate. The processing filters by what it needs: a sender whose profile secret the backend cannot read is refused, nothing written.
|
||||
- **Authorship.** The inbox accepts a delivery only when it is signed by the activity's `actor` (semapps 1.2.7 `inbox.post` refuses any other actor, `INVALID_ACTOR`; read in code 2026-11-03). The handler further requires the activity to lie in that actor's pod and its stored copy to name them, and acts on the stored copy (`verifiedDelivery` in `emitted-activity.js`). A pod refuses writes under another actor's path, so nobody can join or leave for someone else.
|
||||
- **The meeting point is read in its creator's pod.** Read through the participant's pod, a resource of another pod goes through the proxy the participant's own actor document declares: for a participant on another server, an answer that server chooses — another meeting point's attendee list, a forged name (reproduced by the adversarial review 2026-11-03 against mocked platform calls). The handler reads the meeting point again through its creator's access and uses that copy alone (`meetingPointOf`).
|
||||
|
||||
## Confidentiality: unchanged
|
||||
|
||||
The Join stays readable by its author and its only addressee, the app; the creator's inbox receives nothing. The rights matrix lines on the Join's read rights pass unchanged (148/148 hard lines, run 2026-11-03 with the change as committed, creator re-read included). The backend learns the participant's identity as before.
|
||||
|
||||
## Costs accepted
|
||||
|
||||
- The inbox POST awaits the processing (semapps `processInbox` waits for its job), so each Join/Leave delivery holds the participant's pod's request for the whole handling: 2.6–6.2 s over the 9 deliveries of a full validation run (measured 2026-11-03 on the local stack, creator re-read included, from the queue's job timestamps), against 0.7–1.5 s for the app's other inbox jobs. The app's inbox queue runs 5 jobs at a time, shared with app registrations. Whether the pod provider serializes its outgoing deliveries is not verified.
|
||||
- The app's inbox stores a copy of every Join/Leave in the backend's dataset — the person ↔ meeting point link the attendance ledger already holds. An anonymous GET of the app's inbox answers 404 (observed 2026-11-03).
|
||||
- Known exposure, not introduced here and not settled: the backend issues app-signed GETs to URIs a sender chooses — the meeting point it names, its own documents — as the framework's app-registration handler already does with the registration URI a delivery names. Festipod filters no private or internal address; whether the framework does is not verified.
|
||||
|
||||
## Open
|
||||
|
||||
How to welcome a participant from another app, whose secret the backend cannot read — refused today. Leads: count them under a member URN only the backend can compute (counted, recognised by nobody); agree with the ActivityPods developer on a shared vocabulary for the secret; another policy. A product decision, not taken.
|
||||
@@ -0,0 +1,214 @@
|
||||
---
|
||||
type: decision
|
||||
summary: A user signs up to a meeting point or directly to an event; registration to an event = direct sign-up OR membership of one of its meeting points, derived at read and never stored; a direct sign-up notifies the declarer anonymously and follows the participation confidentiality rules unchanged; leaving an event with meeting points asks for confirmation and withdraws them too
|
||||
---
|
||||
|
||||
# Decision (2026-11-06): direct sign-up to an event (FP-001)
|
||||
|
||||
User-validated 2026-11-06 (ticket FP-001). It **amends the pivotal sentence** of the product model: the old "you sign up to a meeting point, never to an event" becomes **"you sign up to a meeting point, or directly to an event; being in one of an event's meeting points registers you to that event"** — [[knowledge_business-model]], the `functional-domain` overview, [[knowledge_actors-and-concepts]] and `AGENTS.md` carry the new sentence. The meeting point stays the **unit of value**; an event can now be joined on its own — "I'll be there" before, or without, any meeting point.
|
||||
|
||||
Every rule of the participation mechanism stands ([[decision_2026-10-29_participation-privacy-mechanism]], [[decision_2026-11-03_participation-delivered-to-the-app]]); only the **target** of the act widens from "a meeting point" to "a meeting point or an event". The confidentiality rules apply **without change** ([[behavior_participation-listing-is-contacts-only]]): a direct sign-up is a participation like any other.
|
||||
|
||||
## The request
|
||||
|
||||
- sign-up to a meeting point ⇒ registered to its event;
|
||||
- withdrawal from a meeting point ⇒ still registered iff in another meeting point of that event **or** signed up to it directly (user-confirmed);
|
||||
- withdrawal from an event ⇒ if the user is in meeting points of it, ask for confirmation, saying those memberships go too;
|
||||
- a direct sign-up counts in no meeting point (user-confirmed);
|
||||
- a direct sign-up **notifies the event's declarer** (user decision, replacing the "no notification" first proposed).
|
||||
|
||||
## Model: two acts stored, one status derived
|
||||
|
||||
| Fact | Stored? | Where |
|
||||
|---|---|---|
|
||||
| **Direct sign-up** to event E | yes — an act of the user | an `as:Join` naming **E itself**, through the same channel as a meeting point Join |
|
||||
| **Membership** of meeting point M | yes — unchanged | the existing `as:Join` naming M |
|
||||
| **Registered to E** | **no — derived at read** | `direct(E) ∨ ∃ M of E : member(M)` |
|
||||
|
||||
Deriving the registration, rather than writing an event-level Join as a side effect of every meeting point Join, is the core choice:
|
||||
|
||||
- R2 holds by construction — no second write that can fail, lag or drift from the first.
|
||||
- R3 needs no decision logic at withdrawal: after the last meeting point is left, the status falls back to whatever the direct sign-up says.
|
||||
- **Existing data needs no migration**: every registration stored today is a meeting point membership, hence "derived, not direct" — after the change each user is registered to exactly the events they are registered to now.
|
||||
|
||||
Rejected: *writing an event-level Join on every meeting point Join* (two non-atomic writes per act; on withdrawal of the last meeting point the app could no longer tell a direct sign-up from an automatic one without storing a flag — re-creating the derived status by hand). Rejected: *a "direct" flag on the participation* (no participation document exists on ActivityPods; the act is the record).
|
||||
|
||||
### On ActivityPods (the active backend)
|
||||
|
||||
- **The act.** `as:Join` / `as:Leave` posted to the user's outbox, `to` = the app actor alone, `object` = the event URI, delivered to the app's inbox ([[decision_2026-11-03_participation-delivered-to-the-app]]). Same privacy: readable by its author and the app only.
|
||||
- **The backend's target resolution** (`registration.service.js`, `meetingPointTarget` / `meetingPointOf`) accepts a stored `as:Event` **without** `as:context` as an event target whose anchor is itself. Everything else applies as is: re-read in the declarer's pod, `dc:creator` must own that pod, serialized per target, newer-act-wins ledger keyed by (target, participant), both collections read back before the act counts.
|
||||
- **The member URN is the same** as in the event's meeting points: `sha256("<WebID> <event URI> <secret>")` with event URI = the event itself. One person carries one URN across the event's own list and all its meeting points' lists — the count stays "distinct URNs", publicly computable.
|
||||
- **The event's own `apods:attendees` collection** already exists: `attendees.service.js` attaches one to every `as:Event` created, plain events included (observed 2026-10-12). It receives the direct sign-ups' URNs, under the same public-read reconciliation as a meeting point's list (readable anonymously while the event is public, closed when it is switched private).
|
||||
- **The participations collection** receives the event URI on a direct Join (removed on its Leave). Its audience is unchanged (the participant's `/contacts` group).
|
||||
- **The declarer is notified**, exactly as a meeting point's host is: by `notifyCreator` (`pod-notifications.send`), recipient = the event's `dc:creator`, on a direct sign-up **and** on its withdrawal, only when the ledger state flips (a repeated or stale act notifies no one), **always anonymous** — no name, no WebID, no activity, the participant's contacts list never read. The notification names the event (template parameter, not the participant) and its link opens the event. A notification failure does not fail the act. Notifying gives the declarer no read right: they stay "anyone else" towards the participant unless a contact.
|
||||
|
||||
### Reads (`refineDerive.ts`, `refineData.tsx`)
|
||||
|
||||
- `deriveContainer` reads the attendee list of **events** too; a direct sign-up becomes a participation keyed by the event id.
|
||||
- `eventParticipantCount` = distinct member values over the event's own list **∪** its meeting points' lists.
|
||||
- "Mine": my participations collection holds meeting point URIs **and** event URIs; an event URI there is my direct sign-up. `getUserEvents(me)` unions both; a contact's `participatedEventsByContact` likewise.
|
||||
- Recognition of contacts on an event (`getEventParticipants`) includes the event's own list — with the contacts' secrets only, as for meeting points.
|
||||
- Pending acts / read-your-writes (`PendingActs`, `settleMembershipActs`, `countsWithOverlay`) are keyed by **target id** instead of meeting point id.
|
||||
|
||||
### Local demo and the suspended NextGraph path
|
||||
|
||||
- `LocalDataProvider`: the seed's `seedParticipations` are keyed by **event** id — they read as direct sign-ups; mutations stay no-ops.
|
||||
- `NgDataProvider` (suspended): out of scope; the new mutations **reject** with an explicit "not supported on the suspended NextGraph path" error, never a silent success.
|
||||
|
||||
## Screen-facing contract (`festipodDataBase.ts`)
|
||||
|
||||
```ts
|
||||
interface EventRegistration {
|
||||
direct: boolean; // signed up to the event itself
|
||||
meetingPointIds: string[]; // the event's meeting points I am a member of
|
||||
registered: boolean; // direct || meetingPointIds.length > 0 (R2, R3)
|
||||
}
|
||||
getEventRegistration(eventId: string): EventRegistration;
|
||||
joinEvent(eventId: string): Promise<void>; // direct sign-up (R1), idempotent
|
||||
leaveEvent(eventId: string): Promise<void>; // withdraws the direct sign-up AND every membership of the event's meeting points (R4)
|
||||
```
|
||||
|
||||
- The **cascade belongs to the data context**, the **confirmation to the screen**.
|
||||
- `leaveEvent` posts one Leave per held target (the event if `direct`, each joined meeting point), in parallel; it resolves only when all were accepted and **rejects naming the targets that failed** (R8).
|
||||
- `registered` replaces the screens' local `joinedMeetingPointIds.size > 0` (which gates « Inviter »).
|
||||
|
||||
## UI (`EventDetailScreen`, Home)
|
||||
|
||||
- Event detail: an event-level toggle under the info block — « S'inscrire à l'événement » when not registered; « ✓ Inscrit à l'événement » + « Se désinscrire de l'événement » when registered (direct or derived).
|
||||
- Withdrawal with memberships ⇒ a confirmation dialog: title « Se désinscrire de l'événement ? », body « Vous êtes inscrit aux points de rencontre suivants : <titres>. En confirmant, vous en serez aussi désinscrit. », actions « Confirmer la désinscription » / « Annuler ». Without memberships: no dialog, immediate withdrawal.
|
||||
- A shared `ConfirmDialog` in `src/shared/components/sketchy/` (none exists today).
|
||||
- Meeting point toggles are unchanged (no confirmation when leaving a meeting point — R3).
|
||||
- Home's « mes événements » comes from `getUserEvents(me)`: directly joined events appear there with no screen change.
|
||||
- The participants shown on an event remain the viewer's **contacts** only, whatever their path (direct or meeting point); anyone else is counted, never shown.
|
||||
|
||||
## Business rules
|
||||
|
||||
- **R1 — Direct sign-up.** A user can sign up to an event directly, whether or not it has meeting points. Signing up twice changes nothing (idempotent).
|
||||
- **R2 — Meeting point ⇒ event.** A member of at least one meeting point of an event is registered to that event, without any further act.
|
||||
- **R3 — Leaving a meeting point.** Leaving a meeting point never asks for confirmation and never touches the other memberships. Afterwards the user is still registered to the event iff they are a member of another of its meeting points **or** signed up to it directly; otherwise they are no longer registered.
|
||||
- **R4 — Leaving an event.** If the user is a member of at least one of its meeting points, a confirmation names those meeting points and says they will be left too; confirming withdraws the direct sign-up (if any) and every such membership; cancelling changes nothing. Without memberships, the withdrawal is immediate, with no confirmation.
|
||||
- **R5 — One person counts once.** The event's participant count is the number of distinct persons registered to it, directly or through meeting points; a meeting point's count is its own members only — a direct sign-up counts in no meeting point.
|
||||
- **R6 — The declarer is notified, anonymously.** A direct sign-up and its withdrawal notify the event's declarer, as a meeting point's sign-up notifies its host: once per change of state, never naming the participant (no name, no WebID). Leaving an event through the cascade notifies the declarer (if the sign-up was direct) and each left meeting point's host, each once. A failed notification does not fail the act.
|
||||
- **R7 — Same confidentiality, unchanged.** A direct sign-up is a participation and follows exactly the rules of a meeting point participation ([[behavior_participation-listing-is-contacts-only]], [[decision_2026-10-29_participation-privacy-mechanism]]):
|
||||
- **R7a — The act is private**: readable by its author and the app only — not by the declarer, not by anyone else.
|
||||
- **R7b — Event → participants**: the event's own list holds opaque member URNs, never a WebID or a name; anyone reads a count; only a **contact** of the participant recognises them among the event's participants.
|
||||
- **R7c — Person → events**: listing someone's events, direct sign-ups included, is reserved to the person and their contacts; anyone else — **the declarer included** — is refused.
|
||||
- **R7d — No particular right for the declarer**: declaring an event grants no read on who signed up to it, as creating a meeting point grants none to its host; the notification is their only signal, and it is anonymous.
|
||||
- **R7e — No cross-event correlation**: the URN of one person differs from one event to another; the same person on the event and on its meeting points carries one URN (counted once — R5).
|
||||
- **R8 — Nothing succeeds in silence.** A refused or failed act shows an error; the displayed status follows the data (a partially failed withdrawal leaves the user shown as registered through what remains).
|
||||
- **R9 — Withdrawal is authoritative.** After a withdrawal (from a meeting point or an event) and a real reload, the user is not registered back ([[caveat_participation-deletion]]).
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
| # | Given | When | Then | Rules |
|
||||
|---|---|---|---|---|
|
||||
| AC1 | not registered to E (E with or without meeting points) | taps « S'inscrire à l'événement » | shown « ✓ Inscrit à l'événement »; E in Home's events; E's count +1 | R1, R5 |
|
||||
| AC2 | signed up directly to E | signs up again (API) | no second entry; count unchanged; no second notification | R1, R6 |
|
||||
| AC3 | not registered to E | joins meeting point M of E | shown registered to E, with no event-level act sent | R2 |
|
||||
| AC4 | member of M1 and M2 of E, not direct | leaves M1 | still registered to E; no dialog | R3 |
|
||||
| AC5 | member of M1 only, not direct | leaves M1 | no longer registered to E; E leaves Home's events | R3 |
|
||||
| AC6 | member of M1 only, **and** direct | leaves M1 | still registered to E | R3 |
|
||||
| AC7 | member of M1 and M2 of E | taps « Se désinscrire de l'événement » | dialog lists M1 and M2 and warns they will be left | R4 |
|
||||
| AC8 | AC7's dialog | « Annuler » | nothing sent, still member of M1, M2, still registered | R4 |
|
||||
| AC9 | AC7's dialog | « Confirmer la désinscription » | not registered to E, member of neither M1 nor M2 | R4 |
|
||||
| AC10 | signed up directly, no membership | taps « Se désinscrire de l'événement » | no dialog; no longer registered | R4 |
|
||||
| AC11 | direct + member of M1, same person | — | E's count counts them once; M1's count counts them once | R5 |
|
||||
| AC12 | D declared E, A is not D's contact | A signs up directly to E | D is notified of a new participant to E, without A's name or WebID | R6, R7d |
|
||||
| AC13 | AC12 | A withdraws from E | D is notified of a withdrawal, anonymously | R6 |
|
||||
| AC14 | A direct + member of M1 (host H) | A confirms leaving E | D notified once (leave), H notified once (leave), both anonymous | R6 |
|
||||
| AC15 | the notification cannot be sent | A signs up directly | the sign-up stands | R6 |
|
||||
| AC16 | direct sign-up by A | anyone reads A's Join | refused, the declarer included | R7a |
|
||||
| AC17 | direct sign-up by A | B, not A's contact, opens E / reads E's list | B sees a count and opaque URNs, never A | R7b |
|
||||
| AC18 | direct sign-up by A | B, not A's contact (declarer D included), reads A's participations collection | refused | R7c, R7d |
|
||||
| AC19 | A's contact C | opens E and A's profile | C recognises A among E's participants and sees E in A's events | R7b, R7c |
|
||||
| AC20 | A direct to E and to another event E' | anyone reads both lists | the two URNs differ | R7e |
|
||||
| AC21 | the Join/Leave is refused (e.g. no session) | any act | an error toast; the status shown is unchanged | R8 |
|
||||
| AC22 | after AC5, AC9 or AC10 | real reload | still not registered | R9 |
|
||||
| AC23 | data stored before the change (memberships only) | first load after | every user registered to exactly the same events as before; nobody shown as directly registered | migration |
|
||||
|
||||
## Test plan
|
||||
|
||||
The `@data` and `@e2e` suites of the NextGraph broker are **suspended** (`@suspendu`); on the active backend the data layer is exercised by `@e2e-activitypods` and by the rights matrix (`bun run test:e2e-rights`). Mapping of [[rule_test-layer-contracts]]: **`@ui`** = rendering over the seed; **data** = unit tests (`bun test`) of the derivations and the backend handler, plus `@e2e-activitypods` scenarios; **`@e2e`** = one `@e2e-activitypods @browser` journey. All runs obey [[rule_tests-never-break-the-dev-environment]]: throwaway accounts only, cleaned up; nothing resets the stack; a down stack means "not run", never started by the test.
|
||||
|
||||
### Already written (commit 4abab5a) and still valid
|
||||
|
||||
- `@ui` (`us-7-inscription-evenement.feature`): « Voir le bouton d'inscription à l'événement », « Un inscrit direct voit son inscription à l'événement », « Être inscrit à un point de rencontre affiche l'inscription à l'événement », « Se désinscrire d'un événement avec points de rencontre demande confirmation », « La confirmation de désinscription précise les points de rencontre quittés », « Annuler la confirmation ne change rien », « Se désinscrire d'un événement sans point de rencontre ne demande pas de confirmation », « Quitter un point de rencontre ne demande pas de confirmation », « Le bouton Inviter suit l'inscription à l'événement, directe ou par un point de rencontre ».
|
||||
- `@e2e-activitypods` (`e2e-activitypods.feature`): « Rejoindre puis quitter un événement met à jour sa collection apods:attendees », « L'inscription directe et l'inscription à un point de rencontre partagent la même URN de membre », « Quitter le dernier point de rencontre sans inscription directe désinscrit de l'événement », « Quitter le dernier point de rencontre garde l'inscription directe », « Se désinscrire de l'événement retire l'inscription directe et celles des points de rencontre », and the journey « Parcours : s'inscrire à un événement, rejoindre un point de rencontre, se désinscrire de l'événement avec confirmation ».
|
||||
- Units: `refineDerive.test.ts`, `festipodDataBase.test.ts`, `refineData.test.tsx` as written; in `registration.service.test.js` the direct sign-up block **except** its notification test (below).
|
||||
- Rights matrix group `direct sign-up to E`: `direct.join`, `direct.non-contact-refused`, `direct.contact-lists`, `direct.leave`.
|
||||
|
||||
### Delta for the QA — to modify
|
||||
|
||||
| Where | Test | Change | Rules |
|
||||
|---|---|---|---|
|
||||
| `backend/services/registration.service.test.js` | `a direct sign-up notifies nobody: the declarer of an event is not its host` | **replace** by `a direct sign-up notifies the event's declarer, anonymously`: one notification, recipient = the event's `dc:creator`, join wording, no name / WebID / activity / `participantName`, the participant's contacts list never read, the event named by template parameter, the link opening the event | R6, R7d |
|
||||
|
||||
### Delta for the QA — to add
|
||||
|
||||
**Backend units** (`registration.service.test.js`, in the direct sign-up block):
|
||||
|
||||
- `a Leave naming the event notifies the declarer, anonymously` — leave wording, same anonymity checks (R6).
|
||||
- `a repeated direct Join notifies once; a Leave of someone never signed up notifies no one` — ledger-driven, as for meeting points (R1, R6).
|
||||
- `a failed declarer notification does not fail the direct sign-up` (R6).
|
||||
- `leaving the event and its meeting point notifies the declarer and the host, each once` — Leave on E then on M1: two anonymous notifications, one per recipient (R6).
|
||||
- `a direct Join's URN on one event differs from the same person's URN on another event` (R7e).
|
||||
|
||||
**Rights matrix** (`scripts/e2e-rights-matrix.ts`, group `direct sign-up to E`; E's declarer must **not** be Alice's contact for the anonymity lines — Carol's event `CE` fits, or a declarer created for the group):
|
||||
|
||||
- `direct.notify-anonymous` — the declarer is notified of the direct Join in their own inbox, naming neither her name nor her WebID (R6, R7d).
|
||||
- `direct.leave-notify-anonymous` — the same for the Leave (R6).
|
||||
- `direct.activity-declarer-refused` — the declarer GETs Alice's Join naming E (URI handed out of band): refused 403 (R7a).
|
||||
- `direct.list-opaque` — E's own attendee list (anonymous read) holds Alice's member URN — the one computed from her secret with E as anchor — and no WebID (R7b).
|
||||
- `direct.declarer-refused-participations` — the declarer, not her contact, is refused Alice's participations collection although it lists E (R7c, R7d).
|
||||
|
||||
**`@ui`** (`src/modules/event/features/us-7-inscription-evenement.feature`, seed: a contact of Marie directly registered to an event, no meeting point membership):
|
||||
|
||||
- « Un contact inscrit directement apparaît parmi les participants de l'événement » (R7b).
|
||||
- « Un participant inscrit directement qui n'est pas un contact n'est que compté » — counted, not shown (R7b).
|
||||
|
||||
**`@ui`** (`src/modules/user/features/us-10-profil-participant.feature`):
|
||||
|
||||
- « Le profil d'un contact liste les événements auxquels il est inscrit directement » (R7c).
|
||||
|
||||
**`@e2e-activitypods`** (`e2e-activitypods.feature`, `@inscription-evenement`):
|
||||
|
||||
- « Une inscription directe notifie le déclarant de l'événement sans nommer le participant » — the declarer's inbox holds a join notification about E, anonymous; then the Leave's (R6). Only if the declarer is another throwaway account; otherwise the rights matrix lines above suffice and this scenario is dropped.
|
||||
|
||||
### Traceability
|
||||
|
||||
| Rule | Scenarios |
|
||||
|---|---|
|
||||
| R1 | « Voir le bouton d'inscription à l'événement », « Un inscrit direct voit son inscription à l'événement », « Rejoindre puis quitter un événement met à jour sa collection apods:attendees », journey, repeated-Join unit |
|
||||
| R2 | « Être inscrit à un point de rencontre affiche l'inscription à l'événement », « Le bouton Inviter suit l'inscription… », `getEventRegistration` unit, journey |
|
||||
| R3 | « Quitter un point de rencontre ne demande pas de confirmation », « Quitter le dernier point de rencontre sans inscription directe désinscrit de l'événement », « Quitter le dernier point de rencontre garde l'inscription directe », `getEventRegistration` unit |
|
||||
| R4 | « Se désinscrire d'un événement avec points de rencontre demande confirmation », « La confirmation… précise les points de rencontre quittés », « Annuler la confirmation ne change rien », « Se désinscrire d'un événement sans point de rencontre ne demande pas de confirmation », « Se désinscrire de l'événement retire l'inscription directe et celles des points de rencontre », journey |
|
||||
| R5 | « L'inscription directe et l'inscription à un point de rencontre partagent la même URN de membre », `refineDerive` units |
|
||||
| R6 | the 5 notification units, `direct.notify-anonymous`, `direct.leave-notify-anonymous`, « Une inscription directe notifie le déclarant… » |
|
||||
| R7 | `direct.activity-declarer-refused` (a), `direct.list-opaque` + the two `@ui` participants scenarios (b), `direct.non-contact-refused` + `direct.declarer-refused-participations` + `direct.contact-lists` + « Le profil d'un contact liste… » (c, d), URN-per-event unit (e) |
|
||||
| R8 | `refineData` partial-failure unit, journey |
|
||||
| R9 | « Rejoindre puis quitter un événement… », « Se désinscrire de l'événement retire… », journey (reload) |
|
||||
|
||||
## Files to touch
|
||||
|
||||
- **Doctrine** (done with this decision): this file, [[knowledge_business-model]], [[knowledge_actors-and-concepts]], [[knowledge_data-scopes-and-discovery]], the `functional-domain` overview, `AGENTS.md`.
|
||||
- **Code comments carrying the old sentence** (for the dev): the header of `src/shared/context/festipodDataBase.ts` (l. 5), `src/modules/event/screens/EventDetailScreen.tsx` (l. 45–46), `src/modules/event/steps/ui/event.steps.ts` (l. 80–82); `README.md §Modèle fonctionnel` (l. 24) says the same.
|
||||
- **Contract & queries**: `src/shared/context/festipodDataBase.ts`, `src/shared/context/FestipodDataContext.tsx`, `src/shared/context/ngData.tsx`.
|
||||
- **ActivityPods reads/writes**: `src/shared/context/refineDerive.ts`, `src/shared/context/refineData.tsx`, `src/shared/data/outbox.ts`, `src/shared/refine/providers/activitypods.ts`.
|
||||
- **Backend**: `backend/services/registration.service.js` (event target; `notifyCreator` for an event target: recipient the declarer, template naming the event, link to the event).
|
||||
- **UI**: `EventDetailScreen.tsx`, `HomeScreen.tsx`, new `src/shared/components/sketchy/ConfirmDialog.tsx` + export, stories.
|
||||
|
||||
## Risks
|
||||
|
||||
- **Events without an attendee list.** A direct Join is skipped ("No attendees collection") on an event whose list was never attached. Check on the local stack before implementing; if such events exist, attach the list on demand in the handler.
|
||||
- **Partial cascade.** `leaveEvent` is N independent acts; mitigated by R8 — not atomic, as every act already is.
|
||||
- **Read cost.** One more collection read per event (its own list).
|
||||
- **Notification volume for the declarer.** Every direct sign-up of a popular event reaches its declarer, who may be a mere referencer. Accepted by the user; a per-event mute belongs to the notification settings (`us-18`), not designed here.
|
||||
- **Product dilution.** A direct sign-up lets an event be joined with no meeting point at all. Accepted by the request; keep meeting points forward in the UI after a direct sign-up.
|
||||
- **Delivery latency.** 2.6–6.2 s per act ([[decision_2026-11-03_participation-delivered-to-the-app]] §Costs); a cascade multiplies the acts.
|
||||
|
||||
## Settled with the user (2026-11-06)
|
||||
|
||||
- R3 default: stays registered after the last meeting point only if signed up directly.
|
||||
- A direct sign-up counts in no meeting point (R5).
|
||||
- A direct sign-up notifies the declarer (R6) — the first proposal said no notification.
|
||||
- Confidentiality applies unchanged (R7).
|
||||
@@ -13,16 +13,17 @@ Vocabulary reference. Every actor is a specialization of an authenticated **user
|
||||
|---|---|
|
||||
| **User** | Anyone with an account (a NextGraph wallet). The root of all the others. |
|
||||
| **Connection ("friend")** | Another user I am connected to. Used to scope lists ("my friends who are attending…") and trust. Bilateral (accepted on both sides). |
|
||||
| **Declarer of an event** | The user who inserted the event into Festipod. *Not necessarily the real organizer*: just whoever references it. **There is NO notion of "event host"** — and this now holds all the way down: the event carries no host field at all, in the shape or in the app type (`data-layer` → [[knowledge_entities]]). The event is public, merely flagged by its declarer, who **is NOT required to attend**: at creation no participation is written, the counter starts at 0, and the declarer signs up and withdraws through the same path as anyone else. The declarer is nonetheless the event document's **owner**, hence its only writer (`app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]]). The "host" is an actor at the **meeting point** level (next row), never at the event level. |
|
||||
| **Declarer of an event** | The user who inserted the event into Festipod. *Not necessarily the real organizer*: just whoever references it. **There is NO notion of "event host"** — and this now holds all the way down: the event carries no host field at all, in the shape or in the app type (`data-layer` → [[knowledge_entities]]). The event is public, merely flagged by its declarer, who **is NOT required to attend**: at creation no participation is written, the counter starts at 0, and the declarer signs up and withdraws through the same path as anyone else. The declarer is nonetheless the event document's **owner**, hence its only writer (`app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]]). The "host" is an actor at the **meeting point** level (next row), never at the event level. The declarer **is notified, anonymously,** of direct sign-ups to the event and their withdrawals, and gains no read right over who signed up ([[decision_2026-11-06_direct-event-registration]]). |
|
||||
| **Host of a meeting point** | The user who created a meeting point attached to an event. |
|
||||
| **Participant in a meeting point** | A user signed up to a meeting point; in effect they become an attendee of the parent event. |
|
||||
| **Participant in a meeting point** | A user signed up to a meeting point; in effect they become registered to the parent event. |
|
||||
| **Participant in an event** | A user registered to an event: signed up to it directly, or a participant in one of its meeting points (derived, never stored — [[decision_2026-11-06_direct-event-registration]]). A direct sign-up notifies the declarer anonymously and follows the same confidentiality rules as a meeting point participation. |
|
||||
| **Member of an interest community** | A user subscribed to a community in order to discover the events it references. |
|
||||
|
||||
## Business concepts
|
||||
|
||||
| Concept | Definition |
|
||||
|---|---|
|
||||
| **Meeting point** | *The app's unit of value.* A moment of meeting proposed by a host at a given place and time, grafted onto a public event. It is what you sign up to. |
|
||||
| **Meeting point** | *The app's unit of value.* A moment of meeting proposed by a host at a given place and time, grafted onto a public event. It is what you mainly sign up to — an event can also be joined directly. |
|
||||
| **Event** | The anchor. A real public event referenced in Festipod to serve as a support for meeting points. A mere pretext (title, dates, location, themes). |
|
||||
| **Interest community** | A thematic grouping of users. Used mainly to discover events (through subscription) and to delimit referencing scopes. |
|
||||
| **Curated list** | An editorialized list of events (by a user or a community), distinct from "the events I declared". Allows organizing/recommending. |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The meeting point is the unit of value, grafted onto an event that is only a pretext; you sign up to the meeting point, not to the event
|
||||
summary: The meeting point is the unit of value, grafted onto an event that is only a pretext; you sign up to a meeting point or directly to the event, and a meeting point registers you to its event
|
||||
---
|
||||
|
||||
# Business model: the grafted meeting point
|
||||
@@ -13,7 +13,7 @@ The **public event** (festival, conference, trade show, exhibition…) is **only
|
||||
|
||||
Direct consequences for modelling:
|
||||
|
||||
- **You sign up to a meeting point, not to an event.** Without meeting points, a Festipod event is of no interest whatsoever.
|
||||
- **You sign up to a meeting point, or directly to an event.** Being in one of an event's meeting points registers you to that event; a direct sign-up says "I'll be there" before, or without, any meeting point, and counts in no meeting point ([[decision_2026-11-06_direct-event-registration]]). The meeting point stays the value: without meeting points, a Festipod event brings little more than a head count.
|
||||
- The **declarer** of an event is *not* (necessarily) its real organizer — just someone who inserted the reference into Festipod so that others can attach meeting points to it.
|
||||
- The **host** of a meeting point is whoever created it; the act of creating makes you the host. Likewise the act of declaring an event makes you its declarer.
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The product model of confidentiality and discovery — every entity lives in the SCOPE matching who must read it; other users' events are found through a shared index, not by reading the public scope
|
||||
summary: The product model of confidentiality and discovery — every entity lives in the SCOPE matching who must read it; other users' events are found through a shared index (the Festipod backend's catalog on ActivityPods), not by reading the public scope
|
||||
---
|
||||
|
||||
# Data scopes and discovery
|
||||
|
||||
The **product** model of who sees what, and of how events are found. This is **domain**: the technical *how* is the `@ng-eventually/polyfill` data SDK's business — the app only states **the business intent**.
|
||||
The **product** model of who sees what, and of how events are found. This is **domain**: the technical *how* belongs to the data layer behind the active backend (concept `data-layer`) — the app only states **the business intent**.
|
||||
|
||||
## Three scopes per piece of data
|
||||
|
||||
@@ -16,28 +16,31 @@ 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 or directly to an event | **public object, private link** | everyone sees *that* (and how many) participate; *who* participates is contacts-only — [[behavior_participation-listing-is-contacts-only]] |
|
||||
| Connection index | **protected** | the holder + their connections |
|
||||
| Private profile (settings, email, preferences) | **private** | the holder alone |
|
||||
| Connection A↔B (bilateral link, + future messaging) | **dialog** | both users |
|
||||
|
||||
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.
|
||||
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, 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.
|
||||
- **Bilateral connection**: `DemandeDeConnexion` (unilateral, transient) → `Connexion` (bilateral, persistent) — the latter opens access to the other person's *protected* data.
|
||||
|
||||
Festipod **places each entity in its scope**; isolation between scopes is **handled by the data SDK**, not by application code (see concept `app-security`).
|
||||
Festipod **places each entity in its scope**; isolation between scopes is **handled by the data layer** (the SDK on NextGraph; the pod's access control and the Festipod backend's public-read grants on ActivityPods), not by screen code (see concept `app-security`).
|
||||
|
||||
## Event discovery
|
||||
|
||||
Reading the `public` scope only ever returns **this session's own** public documents — there is no call that unions every user's public store (concept `data-layer`, [[contract_polyfill-surface]]). A declared event was therefore reachable by its declarer alone, which made the whole cross-user sign-up flow — the product's premise — unreachable.
|
||||
A user's own reads only ever return **their own** records — on either backend, no call unions every user's public data (concept `data-layer`). Discovering events one did not create — the product's premise — therefore goes through a **shared index**: the **primary** discovery axis. A **secondary**, relational axis stays layered on top: the connections' *protected* participations ("my friends are attending…").
|
||||
|
||||
**A user discovers events they did not create through a shared index**: an ordinary public document, indistinguishable from any other, that the app reaches by a reference it hardcodes. Anyone may deposit a reference to their event into it; only the index's owner curates those deposits into visible entries, ordered by the event's start date. This is the **primary** discovery axis, settled as [[decision_2026-08-17_discovery-through-a-shared-index]] (concept `data-layer`) — **no code consumes the index yet.** A **secondary**, relational axis stays layered on top: the connections' *protected* participations ("my friends are attending…").
|
||||
The intent is the same on both backends — every public event is findable by everyone — but the index is not:
|
||||
|
||||
Four costs come with it, accepted rather than solved: an event becomes findable only once **someone curates** the index, on no fixed schedule — curation is an operator role, not a feature that runs itself; an event declared before its document could carry the field the index reads never becomes findable through it, permanently; a withdrawn or later-corrected event **stays listed** — nothing here removes an entry, so a reader of the index must tolerate a reference that no longer resolves, or resolves to something changed; and an event's position in the list is **frozen at the moment it was curated** — correcting its date afterwards does not move it. Full mechanics and the rejected alternative: [[decision_2026-08-17_discovery-through-a-shared-index]].
|
||||
- **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; signing up directly to an event, and withdrawing, notifies its declarer the same way ([[decision_2026-11-06_direct-event-registration]]). The intent is "identified if the participant is one of the host's connections, unnamed otherwise"; on ActivityPods it is **always unnamed** for now — the host has no read right over who signed up, and the app has no trustworthy contacts signal to decide otherwise ([[decision_2026-10-29_participation-privacy-mechanism]]).
|
||||
|
||||
## Settled: the event write model is OWNER-ONLY
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ type: _overview
|
||||
summary: Stack and tooling — Bun-first (runtime, bundler, native APIs), build pipeline, and the project's commands
|
||||
triggers:
|
||||
keywords: [bun, bunx, build, bundler, vite, webpack, jest, npm, storybook, "bun.serve", hmr, tailwind, package.json]
|
||||
paths: ["build.ts", "package.json", "pnpm-lock.yaml", "Dockerfile", ".env.example", "bunfig.toml", "tsconfig.json", "src/index.ts", "src/index.html", ".storybook/**", "scripts/**"]
|
||||
paths: ["build.ts", "package.json", "pnpm-lock.yaml", "Dockerfile", "backend/Dockerfile", "docker-compose*.yml", "backend/services/base-path.js", ".env.example", "bunfig.toml", "tsconfig.json", "src/index.ts", "src/index.html", ".storybook/**", "scripts/**"]
|
||||
---
|
||||
|
||||
# Tech stack
|
||||
@@ -18,4 +18,4 @@ The project's stack and tooling. Guiding principle: **Bun-first** — Bun replac
|
||||
- [[knowledge_bun-apis]] — native Bun APIs (serve, sqlite, redis, sql, file, shell)
|
||||
- [[knowledge_build-pipeline]] — build.ts, bundler, server, harness built separately, Storybook
|
||||
- [[knowledge_stack-and-commands]] — stack components + the real scripts (plus quirks)
|
||||
- [[knowledge_deployment]] — Dockerfile, prod served from src/, no CI, `portless` in dev
|
||||
- [[knowledge_deployment]] — the Coolify test stack (one base URL, backend under /api), both images, first-boot traps, prod served from src/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: A running `bun run dev` never picks up a refreshed data-layer package — VERIFIED, not even across a real rebuild (new bundle hash, still the stale dependency). Only a restart serves the fresh copy; suspect a stale server before suspecting the code.
|
||||
summary: A running `pnpm dev` never picks up a refreshed data-layer package — VERIFIED, not even across a real rebuild (new bundle hash, still the stale dependency). Only a restart serves the fresh copy; suspect a stale server before suspecting the code.
|
||||
last_checked: 2026-08-16
|
||||
---
|
||||
|
||||
@@ -10,7 +10,7 @@ last_checked: 2026-08-16
|
||||
|
||||
**VERIFIED, controlled sandbox test with this project's own bun.** A dependency resolving to copy A, overlaid with copy B: the running server still serves A at +3 s and +13 s after the overlay. An edit to **application source** then triggers a genuine rebuild — a new bundle hash confirms it — and the rebuilt bundle **still serves A**. Only a restart serves B. So the mechanism is not "the watcher never fires because `node_modules` is excluded" — a rebuild the watcher DOES trigger still carries the stale dependency forward; the server's resolution of that import is pinned at process start, and a rebuild does not re-resolve it.
|
||||
|
||||
**So: restart `bun run dev` after every refresh of the package — a rebuild is not a substitute, even a real one.** There is no signal that you needed to; a stale server looks exactly like a current one.
|
||||
**So: restart `pnpm dev` after every refresh of the package — a rebuild is not a substitute, even a real one.** There is no signal that you needed to; a stale server looks exactly like a current one.
|
||||
|
||||
## Why this is worth a leaf
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ last_checked: 2026-08-16
|
||||
|
||||
| Path | Command | What is served |
|
||||
|---|---|---|
|
||||
| **Dev** | `bun run dev` → `bun --hot src/index.ts` | **`src/`** — HMR, port 3000 |
|
||||
| **Dev** | `pnpm dev` → `bun --hot src/index.ts` | **`src/`** — HMR, port `PORT` (default 4005) |
|
||||
| **Production** | `bun run start` → `NODE_ENV=production bun src/index.ts` | **`src/` as well** — Bun transpiles on the fly |
|
||||
| Bundle | `bun run build` → `build.ts` (Bun bundler + Tailwind plugin) → `dist/` | **nothing** |
|
||||
|
||||
@@ -40,7 +40,7 @@ Practical consequence: to exercise the "shared wallet" flow in dev **end to end*
|
||||
```
|
||||
FESTIPOD_SHARED_WALLET_PASSWORD=festipod-e2e-tests \
|
||||
FESTIPOD_SHARED_WALLET_FILE=./festipod-e2e-tests.ngw \
|
||||
bun run dev
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## The test harness is built separately
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Deployment — multi-stage Bun Alpine Dockerfile; install through pnpm (git+node inside the image) but bun at runtime; runs `bun run start` from src/ (not dist/), EXPOSE 3000; the data-layer git dependency must be pinned to a tag/commit and match `contracts.yaml`'s ref; the shared wallet reaches the container through env vars, not a mount, because it isn't a secret; no CI/CD committed; dev goes through the portless wrapper
|
||||
last_checked: 2026-08-17
|
||||
summary: Deployment — the Coolify test stack (app + backend + Redis, shared Fuseki), one base URL with the backend under /api (Strip Prefixes OFF), both images' build traps, first-boot traps, the data-layer git pin, the shared wallet as env config
|
||||
last_checked: 2026-10-19
|
||||
---
|
||||
|
||||
# Deployment & infra
|
||||
|
||||
Nothing has actually been deployed with this shape yet — this leaf states what a deployment needs to line up, verified against the code and manifests, not a procedure that has been run end to end.
|
||||
## The Coolify test stack
|
||||
|
||||
## Dockerfile
|
||||
`docker-compose.coolify.yml` is what Coolify (build pack *Docker Compose*) builds and runs from the git repository, on the test server only for now: `frontend` (the root `Dockerfile`, below), `backend` (`backend/Dockerfile`, Moleculer + `@activitypods/app`) and a per-stack Redis. Fuseki is the one shared with the other ActivityPods apps of the Coolify instance (`shared-infra`): only the backend joins the external `coolify` network to reach it, with a pair of datasets of its own, named as on the sibling apps: explicit `MAIN_DATASET` / `SETTINGS_DATASET` for the main stack (`festipod-test` / `settings-festipod-test`), computed from `DATASET_PREFIX` and the backend's service name when left empty (a preview: `festipod-backend-pr-<n>`). Coolify stores the compose default of those two variables, itself an expression, as their value — it reads as unresolved text in its UI but resolves at deploy time; setting explicit names avoids the confusion. The variables Coolify must carry are listed at the top of the compose file; `SERVICE_FQDN_*` / `SERVICE_NAME_*` are generated by Coolify at deploy time.
|
||||
|
||||
**One base URL per environment** (user decision 2026-10-19, so a pull-request preview lives on a single host, like the sibling apps): the app at `https://festipod.dev.reconnexion.coop/`, the backend on the same host under `/api` — its Domains field is `https://<host>/api,https://<host>/.well-known`. Coolify's **Strip Prefixes must be OFF** (it is on the sibling apps): the backend mounts its routes under `/api` itself, read from `SEMAPPS_HOME_URL=https://<host>/api/`, and the front end is pointed at `VITE_BACKEND_URL=https://<host>/api`. With the prefix stripped every backend request is refused (404).
|
||||
|
||||
**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:3005`, 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.
|
||||
- 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. Two more traps, both met on the test server 2026-10-30:
|
||||
- **A deleted dataset cannot be re-created under the same name in the same Fuseki process**: Jena keeps the closed storage of that location, and every query on the new dataset fails with `ClosedException: already closed` (500), so the backend crashes at boot. Restart Fuseki before re-creating it — on the test server it is the shared one, so every test app loses its triplestore meanwhile — or give the stack new dataset names (`MAIN_DATASET` / `SETTINGS_DATASET`, e.g. `festipod-test-2`).
|
||||
- A "Model permissions violation: http://semapps.org/securedWebacl" in Fuseki's log at the backend's boot is EXPECTED: semapps probes the WebACL graph anonymously to check the dataset is secure and wants that refusal. The backend must reach Fuseki as the Fuseki user named exactly `admin` (`JENA_USER`), the only one the semapps permissions module grants a secure dataset to (semapps `ShiroEvaluator`, read in source).
|
||||
- On a cold first boot, `GET <base>app` answered from an in-memory copy of the actor taken before the access-need group got linked, so it lacked `interop:hasAccessNeedGroup` until the backend restarted (the actor itself, `<base>as/actor/app`, was right). A real sign-in is unaffected — the pod reads the actor — but the rights matrix's programmatic install reads `/app`. Since 2026-11-02 the backend refreshes that copy from the store at the end of its boot (`backend/services/app.service.js`). That is verified locally without a Redis cache, not yet on Coolify, whose stack has one: after a first deployment, check that `GET <base>app` links a group before running the matrix.
|
||||
|
||||
**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.
|
||||
|
||||
## Backend image
|
||||
|
||||
`node:22-slim`, `pnpm install --frozen-lockfile --prod`, `pnpm start` (tsx over the Moleculer runner). Two things it needs that a local install hides:
|
||||
- **pnpm 10 skips dependency build scripts unless approved**: `backend/package.json` approves `bcrypt`, `sharp` and `sodium-native` (`pnpm.onlyBuiltDependencies`). Without it the image builds and the container exits at start (`sharp-linux-x64.node` missing; bcrypt would fail next). A local `node_modules` may carry the binaries from an earlier manual build, which is how this stayed invisible.
|
||||
- **sharp must not depend on GitHub at build time**: its installer downloads prebuilt libvips and binaries from GitHub, and that download timed out on every in-build attempt (2026-10-19) and failed the build. The image installs `libvips-dev` (so sharp detects a system libvips and compiles itself) and sets `npm_config_nodedir=/usr/local` (node-gyp uses the image's own headers). bcrypt still tries GitHub first and falls back to compiling, slower but working offline. Cost: a larger image (a multi-stage build keeping only `libvips42` at runtime would drop several hundred MB — not done).
|
||||
|
||||
## App image (root Dockerfile)
|
||||
|
||||
A `Dockerfile` exists (multi-stage Bun Alpine). **Installation goes through pnpm, but runtime/build/test stay on bun** (see [[knowledge_stack-and-commands]]):
|
||||
- `FROM oven/bun:1-alpine`, `install` stage: `apk add --no-cache git nodejs npm` then `npm install -g pnpm@10.26.0` (the bun image has neither Node nor pnpm; Alpine's `apk nodejs` does not ship corepack), `COPY package.json pnpm-lock.yaml`, then `pnpm install --frozen-lockfile`. `git` is required because `@ng-eventually/polyfill` is a public **git+https** dependency (Gitea, no auth). `release` stage: copies `node_modules` plus the source.
|
||||
@@ -31,15 +52,13 @@ A `Dockerfile` exists (multi-stage Bun Alpine). **Installation goes through pnpm
|
||||
|
||||
**`pnpm install --frozen-lockfile` (the Dockerfile's install step) never regenerates — it only verifies.** `pnpm-lock.yaml` must already reproduce `package.json` exactly, so any change to the git specifier (ref, path, or package name) needs `pnpm install` run and the regenerated lockfile committed *before* the image can build; skipping that step fails the build outright, not silently. This has bitten once: the lockfile still named the old package and path after the dependency was renamed, so `--frozen-lockfile` refused and the image could not build until it was regenerated.
|
||||
|
||||
## CI/CD
|
||||
|
||||
**No** pipeline is committed (`.github/workflows/` absent, no Coolify config in the repo). A knowingly accepted blind spot. To host the Bun app, the `coolify-hosting` skill applies.
|
||||
## The NextGraph-era deployment
|
||||
|
||||
**A deployed origin IS embeddable in the hosted broker's iframe — VERIFIED 2026-08-17 in production**, on the first deployment carrying the injected wallet and the external data layer: a user signed in and saw their own data, which is only reachable through that iframe. The question had been open because nothing in this repo exercises it; it is settled for this origin, and it is settled by the deployment rather than by a test — **no scenario covers it**, so a change of origin, of proxy, or of the broker's embedding policy would be found by a person, not by the suite. [[caveat_firefox-lna-blocks-broker-iframe]] remains the one recorded failure mode, and it is a local-dev-origin one (`127.0.0.1` blocked by Firefox LNA).
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `PORT` (default 3000), `NODE_ENV` (enables/disables HMR and the dev auto-seed — see concept `data-layer`).
|
||||
- `PORT` (default 4005), `NODE_ENV` (enables/disables HMR and the dev auto-seed — see concept `data-layer`).
|
||||
- No `.env*` is committed (`.env` is gitignored).
|
||||
|
||||
### The shared wallet: config, not a secret, not a mount
|
||||
@@ -56,6 +75,6 @@ A `Dockerfile` exists (multi-stage Bun Alpine). **Installation goes through pnpm
|
||||
|
||||
## Dev
|
||||
|
||||
`bun run dev` = **`portless festipod bun --hot src/index.ts`** — it goes through the **`portless`** wrapper (an external port-management tool), not a bare `bun --hot`. HMR is active outside production.
|
||||
`pnpm dev` = **`bun --hot src/index.ts`**, served on the fixed port `PORT` (root `.env`, default 4005) like the sibling ActivityPods apps — no port-management wrapper. HMR is active outside production. The local stack (pod provider, backend, ports) is in [[cookbook_local-activitypods-platform]].
|
||||
|
||||
**Reactive local overlay for the SDK**: in production the `@ng-eventually/polyfill` dependency comes from Gitea (git+https, pinned by `pnpm-lock.yaml`), and `@ng-helpers/indexing` likewise. When a provider's package has to be exercised from a local checkout, `pnpm run overlay:polyfill` or `pnpm run overlay:indexing` (script `scripts/overlay-local-checkout.ts`, one provider per run) replaces `node_modules/<package>` with a **real copy** of that checkout (location overridable with `NG_EVENTUALLY_LOCAL` / `NG_HELPERS_LOCAL`) — **without** its own `node_modules/*` — and resyncs on every edit. Copying rather than symlinking is what keeps a **single instance** of every package the provider shares with Festipod installed (`@ng-org/*`, and for `indexing`, `@ng-eventually/polyfill` itself): a symlink would drag in a second one and the SDK would stop working. To go back to the committed state: `pnpm install`.
|
||||
|
||||
@@ -21,8 +21,8 @@ summary: Stack components (Bun runtime/build/test, install through pnpm, React,
|
||||
|
||||
| Script | Command / role |
|
||||
|---|---|
|
||||
| `dev` | `portless festipod bun --hot src/index.ts` — dev with HMR through the `portless` wrapper (see [[knowledge_deployment]]) |
|
||||
| `start` | `NODE_ENV=production bun src/index.ts` — production, served from `src/` (not `dist/`) |
|
||||
| `dev` | `bun --hot src/index.ts` — dev with HMR on the fixed port `PORT` (root `.env`, default 4005); launched with `pnpm dev`, run by Bun |
|
||||
| `start` | `NODE_ENV=production bun src/index.ts` — production, served from `src/` (not `dist/`), same `PORT` default (4005) |
|
||||
| `build` | `bun run build.ts` — Bun bundler + Tailwind → `dist/`, **which nothing serves**: production runs `start`, from `src/` ([[knowledge_build-pipeline]]) |
|
||||
| `test:cucumber` | chains `cucumber:run` → `cucumber:report` → `features:parse` → `steps:extract` |
|
||||
| `cucumber:run` | `node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js` — **through Node+tsx, not Bun** (Playwright/happy-dom plugin compatibility), and through the package's **actual JS entry**, not the `.bin/` shim (see Pitfalls) |
|
||||
|
||||
@@ -8,7 +8,7 @@ status: open
|
||||
|
||||
## Where the work lives
|
||||
|
||||
Branch **`refine-backends`**, worktree `/home/sylvain/projects/festipod/festipod/.claude/worktrees/refine-backends`, pushed to origin; the tip records the T04 tree close plus the adversarial-review round — `## Current gate state` below names the states by date and tip.
|
||||
Branch **`refine-backends`**, worktree `/home/sylvain/projects/festipod/festipod/.claude/worktrees/refine-backends`. On top of the T04 tree close and its adversarial-review round sits the T05 tree (public data, discovery, the end-to-end rights matrix), commits `e21c408` → `88f23b7` plus the commit recording its close — `## T05` and `## Current gate state` below name the states by date and tip.
|
||||
|
||||
It is based on **`ng-eventually`** at `c7cda38` — the most advanced branch, and the reason this branch exists. An earlier attempt, **`festipod-refine`** (tip `352d1f1`, worktree `.claude/worktrees/festipod-64`), was built on `main`, which turned out to be far behind; the Refine work was transferred here and that branch is now only an archive. It holds the **only** worked implementation of a `@ng-org/refine-providers` backend (`src/shared/refine/providers/nextgraph.ts`, in its history up to the commit that withdrew it) — the reference for the shape of the next backend.
|
||||
|
||||
@@ -32,12 +32,13 @@ Recorded in `data-layer/brief_2026-10-09_activitypods-first-backend`; the essent
|
||||
|
||||
In `src/shared/refine/`:
|
||||
|
||||
- `variant.ts` + `variant.test.ts` — pure `resolveVariant(pathname, known)`: which provider set the URL selects. 8 tests covering the case space, including the one true today (empty registry → every URL is the default).
|
||||
- `variant.ts` + `variant.test.ts` — pure `resolveVariant(pathname, known)`: which provider set the URL selects, tested over the case space.
|
||||
- `providers/types.ts` — the contract a backend must satisfy (`RefineBackend`, `RecordBinding`), each field derived from a measured encounter rather than anticipated.
|
||||
- `providers/index.ts` — the registry. **EMPTY on purpose**: no backend is implemented yet.
|
||||
- `providers/activitypods.ts` (+ test) — the ActivityPods backend: the three providers of `@activitypods/refine-providers` and the bindings of the four entities (events, meeting points, participations, user profiles); events and meeting points are created addressed `to: as:Public`.
|
||||
- `providers/index.ts` — the registry: ActivityPods is the one registered backend, hence the default; the NextGraph+polyfill backend would be the second when it resumes.
|
||||
- `RefineProvider.tsx` — mounts `<Refine>` over the backend the URL named.
|
||||
|
||||
**None of it is wired.** Nothing imports `RefineProvider`; `FestipodDataContext` on this base is untouched and still talks to the polyfill directly. Wiring it means rewriting that context against this base's much-evolved version — that is the step everything else waits on.
|
||||
**Wired since 2026-10-09 (T04).** `FestipodDataContext` runs over Refine with the ActivityPods backend (`src/shared/context/refineData.tsx`). Beside Refine, by design: joins and leaves posted to the user's outbox (`src/shared/data/outbox.ts`); discovery through the Festipod backend catalog and anonymous reads of other users' records and attendee collections (`src/shared/data/catalog.ts`, `publicRead.ts`, `attendees.ts`); sign-in, consent and the app-status probe (`src/app/auth/`). The Festipod backend (`backend/`, Moleculer + `@activitypods/app`) acts on users' pods as the app actor: attendee collections, public-read grants, the catalog, and the allow-listed public surface.
|
||||
|
||||
In `.project/`:
|
||||
|
||||
@@ -86,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
|
||||
|
||||
@@ -109,27 +105,80 @@ So both platforms count deposits rather than people, and the gap sits in **neith
|
||||
- **The migration was delegated to a subagent** and verified independently afterwards: the five transferred files are byte-identical, the manifest merge preserved both pre-existing entries, and the gates were re-run by hand. Its one worry — that a missing local `node_modules` had made the baseline incomparable — was **unfounded** (pnpm had installed the full tree locally; 22 entries against the main checkout's 21). Its debt block was better than the specification it was given: it caught the `__FESTIPOD_BACKEND__` consequence and two branch-relative phrasings.
|
||||
- **Moving base falsified three claims in the transferred code**, all corrected: the inbox the count comment points at is local here; the worked provider implementation is not in this branch's ancestry; and the `MeetingPoint` SHEX shape **does** exist here, so its binding stopped being optional (a backend that cannot carry one of the four entities cannot serve the app).
|
||||
|
||||
## Current gate state (2026-10-10, adversarial-review round closed, tip = the commit recording this state)
|
||||
## Current gate state (2026-10-13, T05 closed, tip = the commit recording this state)
|
||||
|
||||
Verified by the orchestrator's own runs after the round's fixes: `bunx tsc --noEmit` exit 0 ; `bun test` 96 pass / 0 fail ; the UI cucumber layer 125 scenarios / 0 failed (54 fully implemented + 71 `Scénario non implémenté` placeholders that carry a skipped step by design) ; smoke `@e2e-activitypods` 3/3 against the live local stack (pod provider :3000, festipod backend :3003, app served on an ephemeral port) ; `bun run build.ts` ok ; `contracts.py check --integrity-only` clean.
|
||||
The gate, every command run from the worktree root, the live ones against the local stack (pod provider :3000, pod frontend :5000, Festipod backend :3003, the app served on :4000 — the only registered OIDC redirect origin; bring-up: `data-layer`, `cookbook_local-activitypods-platform`):
|
||||
|
||||
- `bunx tsc --noEmit` — exit 0, no output
|
||||
- `bun test` — 395 pass / 0 fail (33 files)
|
||||
- the UI layer, `node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json` — 125 scenarios (71 skipped, 54 passed), 0 failed (the 71 are `Scénario non implémenté` placeholders, skipped by design); restore `reports/` afterwards with `git checkout -- reports/`
|
||||
- the smoke, same command with `--config cucumber.smoke.json` — 5 scenarios (5 passed), 22 steps
|
||||
- `bun run test:e2e-flow` — the banked login flow, fresh user (picker → pod login → consent → install verified → event → meeting point) — `"ok": true` (install asserted: the user's `as/event` container answers 200)
|
||||
- `bun run test:e2e-rights` — the rights matrix: three fresh users plus anonymous, positive AND negative lines with live controls (backend public surface, public reads, refused writes, profile privacy, catalog and public intent, discovery, join/leave and counters, isolation, deletion, visibility toggles); exit 0 only when every hard line holds — 98 hard lines, 98 PASS / 0 FAIL, plus KNOWN-DEVIATION 1, KNOWN-DEFECT 1, MANUAL-CHECK 3, INFO 7 — identical on three consecutive runs with fresh users (2026-10-13)
|
||||
- `bun run test:e2e-revocation` — revoking the app on the pod logs the user out to the picker; signing in again goes through consent — exit 0 (positive, negative and recovered phases `ok: true`)
|
||||
- `bun run validate` — runs the UI layer, the smoke, and the two e2e probes above as stack-gated layers: the stack is probed first; down → both SKIPPED, loudly, never counted as passed; up → a non-zero exit fails the layer. The smoke layer never skips (an unreachable stack fails it), so a fully-down stack still fails `validate` — all four layers passed (2026-10-13); with `APP_URL` pointed at a dead port, the UI layer and the smoke pass and the two probes are reported SKIPPED, the unreachable URL named
|
||||
|
||||
The three e2e scripts share one sign-in drive, `driveLogin` in `scripts/e2e-rights-matrix-support.ts`: a state machine over observed hops (the browser must leave the app for the pod, Accept is clicked once stable and must land, the shell counts only with the bottom navigation shown and no picker); the login-flow probe and the matrix also assert the install after it.
|
||||
|
||||
Earlier state (2026-10-10, the T04 adversarial-review round), verified by the orchestrator's own runs after the round's fixes: `bunx tsc --noEmit` exit 0 ; `bun test` 96 pass / 0 fail ; the UI cucumber layer 125 scenarios / 0 failed (54 fully implemented + 71 `Scénario non implémenté` placeholders that carry a skipped step by design) ; smoke `@e2e-activitypods` 3/3 against the live local stack (pod provider :3000, festipod backend :3003, app served on an ephemeral port) ; `bun run build.ts` ok ; `contracts.py check --integrity-only` clean.
|
||||
|
||||
The round: three adversarial passes (auth & security, data seam & backend, gates & ops); every confirmed finding was fixed in the commit recording this state. Fix themes — auth: `redirect` param validated (same-origin leading-slash only), callback re-entry guarded against StrictMode double-exchange, stale `?code=` URLs degrade to the picker instead of the fatal screen, `register_app` without a session falls back to the picker (was a permanent blank screen), ActivityPods logout wired through the provider, deep links survive login, clientId trailing slash normalized. Data fidelity: the meeting point's host is read from the Pod-stamped `dc:creator` (the `organizer` term is absent from the platform context and never round-tripped, so the Join lost its addressee), `location`/`city` are written as inline named nodes (`as:location` is `@type: @id`; a free-text string resolved into a garbage IRI), record ids are URL-encoded (absolute IRIs broke the router's `/events/:id` parse), participant resolution is keyed by actor URI (profiles were stored under their document URI and never matched), one delayed re-invalidation reconciles the count after join/leave, a Join/Leave without addressee throws instead of posting to no one, anonymous placeholder rows render on the event detail screen. Backend & ops: the attendees collection gets a public `acl:Read` grant in `after.createAndAttach` (the count is readable by everyone; anonymity lives in the profiles' own WAC), Join/Leave handlers retry while the collection is still attaching, `tsx` promoted to dependencies so the `--prod` image starts, compose `env_file` made optional with fuseki/redis services added and the frontend published on :4000 (the SEMAPPS_FRONT_URL default — fixes the shipped redirect_uri mismatch; :5000 belongs to the pod provider frontend and is deliberately avoided), `test:data` removed (zero matching scenarios since the suspension — a vacuous green), `strict: true` on both cucumber configs.
|
||||
|
||||
`contracts.py check` also reports, pre-existing: `polyfill-surface: provider moved: a33fb8a214 → a8d53010c2` — the pulled copy is behind the pinned ref.
|
||||
|
||||
## T05 — public data, discovery, rights matrix (closed 2026-10-13)
|
||||
|
||||
What was missing for Festipod's premise — discover other users' public events and sign up to their meeting points — and what closed it, by commit:
|
||||
|
||||
- **Public read (events, meeting points, attendee lists)** — `a0d2cb8`: a record is world-readable only when Festipod marks it public, i.e. creates it addressed `to: as:Public` (events and meeting points both); `bto`/`bcc`/`audience` never count. The grant on the attendee collection follows. An event POSTed with `apods:attendees` pre-attached no longer crashes the backend.
|
||||
- **Discovery in the app (catalog read path)** — `e21c408`: Découvrir lists the user's own events plus the backend catalog's, de-duplicated, own first; meeting points filtered out.
|
||||
- **Opening another user's event** — `fbb1f6f`, then `6bcf403`: detail, meeting points, per-point counts (event = sum), join; foreign reads are anonymous direct reads by URI (the session token only goes to the session's own origin); a join or leave stays on screen until a read issued after it confirms it.
|
||||
- **Catalog persistence** — `f32e166`: the index survives a backend restart (backend `settings` dataset, default graph); an owner's deletion de-indexes.
|
||||
- **Catalog hardening** — `f14cc37`: only LISTED-public records are listed (`to: as:Public`; `cc`-only = unlisted: readable, never listed); only the owner's own outbox can change an entry.
|
||||
- **Revocation detection** — `4fc856f`, `d4fcb7e`: an app-status probe on load, focus and visibility, at most once a minute; `installed: false` → logout + picker notice (never `registerApp`); `upgradeNeeded: true` → consent.
|
||||
- **Backend trust surface** — `88f23b7`: an allow-list in front of every backend route (9 public endpoints, everything else 404 for every caller — `/sparql` and `/key` included); every decision fed by a pod notification is taken on the activity and object re-read from the pod, serialized per object; public read and catalog listing follow the owner's visibility changes both ways, attendee collections included.
|
||||
- **Rights matrix** — `d38f8a7`, then extended with the public-surface and visibility groups at the close: `bun run test:e2e-rights` (see `## Current gate state`).
|
||||
|
||||
**User decisions recorded during T05:**
|
||||
|
||||
- **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.
|
||||
- **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`).
|
||||
|
||||
**Platform defects to report upstream** (pre-existing, platform-side, never patched here):
|
||||
|
||||
- Anonymous `PATCH` on an attendee collection is accepted (204) although its only public rule is `acl:Read` — an `as:items` insert LANDS (attendee forgery; arbitrary predicates are dropped). Tracked by the matrix's KNOWN-DEFECT line.
|
||||
- `pod-permissions.remove` removes nothing for a public read: it compares the full `foaf:Agent` IRI to the compacted form the pod stores, and would PUT an empty rule set the pod refuses (Festipod revokes through its own ACL rewrite instead).
|
||||
- The collections mixin crashes the backend on a record POSTed with `apods:attendees` already set (`getGroupSlugFromCollectionUri(undefined)` → `new URL(undefined)`).
|
||||
- The webhook listener does not await its handlers: any handler throw, or a webhook arriving during backend startup, kills the backend process.
|
||||
- The delete matcher compares `formerType` `as:Event` to the compacted `Event`, so `onDelete` never fires for events (it would call `onUpdate`).
|
||||
- The refine-providers live provider's `subscribe` throws an uncaught `Forbidden` when the pod refuses its push channel (`POST /.notifications/WebSocketChannel2023` → 403), one per refusal.
|
||||
|
||||
**Known limitations** (T05.i, accepted): (a) an attendance add or remove already sent when the 30 s serialization limit hits still lands, unrecorded — if a newer act of the same participant was applied meanwhile, the collection shows the older act's effect until her next act; (b) a public-read or catalog write that lands late leaves a wrong state until its single re-decision runs, and that re-decision is not itself followed up; (c) a share the owner adds between Festipod's ACL read and its PATCH is recorded as Festipod's (the pod has no conditional write); (d) a timeout between the ledger record and the PATCH leaves a record without a grant; (e) grants made before the ledger existed are never revoked.
|
||||
|
||||
**Open follow-ups:**
|
||||
|
||||
- A person who joined two meeting points of one event is counted twice in the event's total (sum semantics).
|
||||
- Webfinger and the app actor's ActivityPub collections now answer 404 (not on the allow-list): add them only if a pod or remote server turns out to need them.
|
||||
- No backfill: pre-ledger grants (development data) are never revoked, events made private before `88f23b7` keep a public attendee collection until their next update, and a record created while the backend was not receiving notifications is never listed.
|
||||
- Also noted by the children: an owner's card ignores other users' meeting points until opened; other users' joins show only after a reload; does leaving revoke the organizer's read on the participant's profile (the join grants it); provider calls have no timeout; a hanging `provider.logout` leaves the connected view until reload.
|
||||
|
||||
## Open points
|
||||
|
||||
- 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.
|
||||
- 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. Known remainder: a revoked app is not detected client-side (getAppStatus wiring — now implementable, `{upgradeNeeded}` shape pinned). 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.
|
||||
- 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 was seen without its `interop:hasAccessNeedGroup` link (cause not established, possibly the stale `GET /app` snapshot fixed 2026-11-02) — 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()` is parked: the pinned contract documents the method but not the shape of the `AppStatus` result, so "stale" is not decidable without guessing. Belongs in the declaration's Frictions/Needs when the outward channel opens.
|
||||
- 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).
|
||||
- The `activitypods-providers` reconstruction is stamped at the 0.1.5-era pin while the installed package is 0.2.0 — re-constructing the contract at the installed version is a pending decision (the source is read once at adoption time; a re-read is its own decision).
|
||||
- The attendees collection is publicly readable by decision (count for everyone; member URIs are not secret; anonymity lives in the profiles' WAC). Revisit if a product rule ever hides membership.
|
||||
- The attendees collection of a public meeting point is world-readable — an explicit deviation from the protected participation scope, decided 2026-10-12 (see `## T05`); moving participation to the protected scope is a later task.
|
||||
- The dedup defi (same event + same place + same time) is confirmed absent across the seam; the 2026-10-03 brief marks it an open challenge — a product call, not a regression.
|
||||
- The catalog service's discovery index is process memory: a backend restart empties it while the Pods still hold the events. Latent until the cross-pod catalog provider lands.
|
||||
- Discovery through the backend catalog: DONE — read path `e21c408`, persistence `f32e166`, hardening `f14cc37` (see `## T05`).
|
||||
- `src/modules/home/**` screens still build raw `/events/${id}` paths; `parsePath` tolerates them, but they should adopt `eventPath`/`userPath` next time they are touched.
|
||||
- The dockerized path is validated by `docker compose config` only — the backend image has never been built or started by the gates; one manual operator run is owed.
|
||||
|
||||
|
||||
@@ -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 une activité sur `onEmit` de l'outbox de son émetteur (catalogue, contacts, listes de participants) ; exception nommée (2026-11-03, sur avis du développeur d'ActivityPods, `decision_2026-11-03_participation-delivered-to-the-app`) : un Join/Leave de participation est adressé à l'acteur de l'app et traité à sa réception dans l'inbox de l'app (`onReceive` de l'`ActivitiesHandlerMixin` de semapps, le chemin du framework pour l'installation d'une app, `registration.service.js`) ; collection lisible par un groupe = `pod-permissions.add({agentPredicate: 'acl:agentGroup', mode: 'acl:Read'})` sur une collection `pod-collections` (`attendees.service.js`), le groupe `/contacts` d'un utilisateur trouvé par `pod-wac-groups.getUriFromCollectionUri({collectionUri: apods:contacts})` — jamais un droit par élément de la liste `apods:contacts`, falsifiable par un `Accept` forgé ; notifier = `pod-notifications.send`, qui exige un backend en WebACL activé sur un dataset sécurisé, comme les trois apps de référence. Ne JAMAIS reprendre leur `addMember` de l'organisateur dans le groupe `/contacts` du participant : c'est un choix produit de welcometomyplace (événements privés), une fuite d'identité sous la règle de Festipod (`behavior_participation-listing-is-contacts-only`). Couvre l'usage de la plateforme, pas les choix produit propres à Festipod.
|
||||
@@ -1,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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Festipod
|
||||
|
||||
Web app mobile-first où les utilisateurs créent des **points de rencontre** qui se *greffent* sur des **événements publics** existants, pour favoriser les rencontres. L'événement n'est qu'un prétexte/ancrage ; la valeur, c'est le point de rencontre — **on s'inscrit à un point de rencontre, pas à un événement**. Stack : Bun + React + NextGraph (P2P, local-first, chiffré).
|
||||
Web app mobile-first où les utilisateurs créent des **points de rencontre** qui se *greffent* sur des **événements publics** existants, pour favoriser les rencontres. L'événement n'est qu'un prétexte/ancrage ; la valeur, c'est le point de rencontre — **on s'inscrit à un point de rencontre ou directement à un événement ; être inscrit à un de ses points de rencontre inscrit à l'événement**. Stack : Bun + React + NextGraph (P2P, local-first, chiffré).
|
||||
|
||||
## Invariants à toujours garder
|
||||
|
||||
@@ -12,7 +12,7 @@ Web app mobile-first où les utilisateurs créent des **points de rencontre** qu
|
||||
src/screens/index.ts # Registre d'écrans (utilisé par Storybook)
|
||||
```
|
||||
- **Un module n'importe QUE depuis `shared/` — jamais d'un autre module.** C'est l'invariant qui rend l'archi réelle.
|
||||
- **Bun-first** : `bun` / `bun install` / `bun test` / `bun build`, jamais node/npm/vite/jest. `bun run dev` (portless, port dynamique ; défaut servi par `bun run start` : 4000).
|
||||
- **Bun-first** : `bun` / `bun install` / `bun test` / `bun build`, jamais node/npm/vite/jest. `pnpm dev` (port fixe, `PORT` du `.env` racine, défaut 4005 ; même défaut pour `pnpm start`).
|
||||
|
||||
## Frontière SDK
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
.PHONY: start stop config upgrade attach-activitypods
|
||||
|
||||
# The local pod provider and its infrastructure (fuseki, redis, mailcatcher, arena).
|
||||
# Festipod's own backend and app run outside Docker: `cd backend && pnpm dev`, `pnpm dev`.
|
||||
DOCKER_COMPOSE_DEV=docker compose -f docker-compose-dev.yml
|
||||
|
||||
start:
|
||||
$(DOCKER_COMPOSE_DEV) up -d
|
||||
|
||||
stop:
|
||||
$(DOCKER_COMPOSE_DEV) kill
|
||||
$(DOCKER_COMPOSE_DEV) rm -fv
|
||||
|
||||
config:
|
||||
$(DOCKER_COMPOSE_DEV) config
|
||||
|
||||
upgrade:
|
||||
$(DOCKER_COMPOSE_DEV) pull
|
||||
$(DOCKER_COMPOSE_DEV) up -d
|
||||
|
||||
attach-activitypods:
|
||||
$(DOCKER_COMPOSE_DEV) exec activitypods-backend pm2 attach 0
|
||||
@@ -21,7 +21,7 @@ Tous les utilisateurs sont authentifiés — il n'y a pas d'accès anonyme à l'
|
||||
|
||||
### Concepts métier
|
||||
|
||||
- **Point de rencontre** — *l'unité de valeur de l'app*. Un moment de rencontre proposé par un hôte à un endroit et à un horaire donnés, greffé sur un événement public. C'est ce à quoi on s'inscrit (on ne s'inscrit pas à un événement). Sans points de rencontre, un événement Festipod n'a pas d'intérêt.
|
||||
- **Point de rencontre** — *l'unité de valeur de l'app*. Un moment de rencontre proposé par un hôte à un endroit et à un horaire donnés, greffé sur un événement public. On s'inscrit à un point de rencontre ou directement à un événement ; être inscrit à un de ses points de rencontre inscrit à l'événement. Sans points de rencontre, un événement Festipod n'a pas d'intérêt.
|
||||
- **Événement** — l'ancrage. Un événement public réel (festival, conférence, salon, exposition…) référencé dans Festipod pour servir de support à des points de rencontre. C'est simplement un *prétexte* (titre, dates, lieu, thèmes) ; le déclarant n'est pas l'organisateur officiel de l'événement, juste celui qui l'a inscrit dans Festipod.
|
||||
- **Communauté d'intérêt** — un groupement thématique d'utilisateurs. Sert principalement à découvrir des événements (via abonnement) et à délimiter les périmètres de référencement.
|
||||
- **Liste curated** — une liste d'événements éditorialisée (par un utilisateur ou une communauté), distincte de « les événements que j'ai déclarés » ou « les événements de la communauté ». Permet d'organiser/recommander.
|
||||
@@ -60,11 +60,18 @@ Identifiées comme nécessaires (notamment pour la scalabilité et la découvert
|
||||
|
||||
## Quick Start
|
||||
|
||||
Comme les autres apps ActivityPods : le Pod provider local et l'infrastructure tournent dans Docker, le backend et l'app hors Docker. Ports par défaut : Pod provider 3000 (frontend 5000), backend Festipod 3005, app 4005 — à surcharger dans `backend/.env` et `.env`.
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run dev # Dev server avec HMR (port 3000)
|
||||
cp .env.example .env && cp backend/.env.example backend/.env
|
||||
make start # Pod provider local + Fuseki + Redis + mailcatcher + Arena
|
||||
pnpm install && (cd backend && pnpm install)
|
||||
cd backend && pnpm dev # backend (port SEMAPPS_PORT, 3005 par défaut)
|
||||
pnpm dev # app avec HMR, dans un autre terminal (port PORT, 4005 par défaut)
|
||||
```
|
||||
|
||||
Détails, pièges et remise à zéro : concept `data-layer`, `cookbook_local-activitypods-platform`.
|
||||
|
||||
## Commandes utiles
|
||||
|
||||
```bash
|
||||
|
||||
+14
-7
@@ -1,14 +1,21 @@
|
||||
SEMAPPS_APP_NAME=Festipod
|
||||
SEMAPPS_APP_DESCRIPTION=Points de rencontre sur les événements publics
|
||||
SEMAPPS_APP_LANG=en,fr
|
||||
SEMAPPS_HOME_URL=http://localhost:3003
|
||||
SEMAPPS_FRONT_URL=http://localhost:4000
|
||||
|
||||
SEMAPPS_HOME_URL=http://localhost:3005/
|
||||
SEMAPPS_FRONT_URL=http://localhost:4005/
|
||||
SEMAPPS_PORT=3005
|
||||
|
||||
SEMAPPS_SHAPE_REPOSITORY_URL=https://shapes.activitypods.org/
|
||||
SEMAPPS_PORT=3003
|
||||
|
||||
# Triple store
|
||||
SEMAPPS_SPARQL_ENDPOINT=http://localhost:3030/
|
||||
SEMAPPS_MAIN_DATASET=settings
|
||||
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_MAIN_DATASET=festipod
|
||||
SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME=settings-festipod
|
||||
|
||||
# Redis (format: redis://localhost:6379/0) — no cache locally; queue db 15 (the sibling apps use 12, 13, 14)
|
||||
# The port is the one `make start` publishes: REDIS_PORT of the root .env when set, else 6379.
|
||||
SEMAPPS_REDIS_CACHE_URL=
|
||||
SEMAPPS_QUEUE_SERVICE_URL=redis://localhost:6379/15
|
||||
|
||||
+10
-1
@@ -1,17 +1,26 @@
|
||||
FROM node:22-slim
|
||||
|
||||
# Install pnpm and build tools required for native modules (sharp, bcrypt).
|
||||
# libvips-dev: with a system libvips present, sharp compiles itself against it instead of
|
||||
# downloading its prebuilt binaries from GitHub, a download that timed out on every in-build
|
||||
# attempt (2026-10-19) and failed the build.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
pkg-config \
|
||||
libvips-dev \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& corepack enable && corepack prepare pnpm@10.26.0 --activate
|
||||
|
||||
# node-gyp compiles against the headers this image already ships rather than downloading them.
|
||||
ENV npm_config_nodedir=/usr/local
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first to leverage Docker layer caching.
|
||||
# Install dependencies first to leverage Docker layer caching. The native modules' build
|
||||
# scripts only run because package.json approves them (pnpm.onlyBuiltDependencies).
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile --prod
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
require('dotenv-flow').config();
|
||||
// The backend's own env files, wherever the process starts: the root `bun test` runs the
|
||||
// backend's tests too, from the repository root.
|
||||
require('dotenv-flow').config({ path: require('path').join(__dirname, '..') });
|
||||
|
||||
module.exports = {
|
||||
APP_NAME: process.env.SEMAPPS_APP_NAME,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -34,5 +34,12 @@
|
||||
"engines": {
|
||||
"node": ">=22.12"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"bcrypt",
|
||||
"sharp",
|
||||
"sodium-native"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Whether a resource lies in an actor's OWN pod — the ownership test in front of any mutation the
|
||||
// backend applies because of an activity an actor emitted (the catalog's index/update/de-index).
|
||||
//
|
||||
// THE TRUST ANCHOR is the pod provider's namespace. An ActivityPods actor's URI is its pod root
|
||||
// (`http://localhost:3000/alice`) and every resource of that pod lives under it
|
||||
// (`http://localhost:3000/alice/data/…`); the pod lets no one create or modify a resource under
|
||||
// another actor's path (observed 2026-10-12: an outbox `Create` or `Update` carrying another pod's
|
||||
// resource is refused, 403 "Remote resource(s) cannot be created/modified"). The platform's own
|
||||
// "is this resource on the actor's pod" test is the same path-prefix rule (`isLocal` in its
|
||||
// fetch-pod-or-proxy mixin).
|
||||
//
|
||||
// NOT the actor document's `pim:storage`: it does declare the storage (`…/alice/data`), but that
|
||||
// document is writable by its owner — a user can add another user's storage to her own
|
||||
// `pim:storage` (verified 2026-10-12: PATCH → 204, the profile then lists both) — so it proves
|
||||
// nothing about who owns a resource.
|
||||
//
|
||||
// `actorUri` must come from the server side — the actor whose outbox a webhook fired on, or the
|
||||
// signer the app's inbox verified for a delivered activity — never from a field nothing vouches for,
|
||||
// such as a webhook body's `actor`, which the emitting client writes. One use takes an unvouched
|
||||
// value as a HINT only: registration's `meetingPointOf` reads a meeting point in the pod its first
|
||||
// copy names, and believes that pod only once the copy it serves names the same creator.
|
||||
|
||||
const WEB_PROTOCOLS = new Set(['http:', 'https:']);
|
||||
|
||||
/**
|
||||
* The URL when `value` is an http(s) URL written in canonical form, else undefined. Requiring the
|
||||
* canonical form means no `..` / `%2e%2e` segment, host-case, default-port or other variant can
|
||||
* read as one path to this test and as another to whoever later resolves it.
|
||||
*/
|
||||
function canonicalUrl(value) {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return WEB_PROTOCOLS.has(url.protocol) && url.href === value ? url : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* isInActorPod(uri, actorUri) → boolean. True only when `uri` is strictly under the actor's pod
|
||||
* root: same origin, and a path that continues the actor's path with a `/` and at least one more
|
||||
* character (so `/alice2/…` is not under `/alice`). False for anything else, including a missing or
|
||||
* non-canonical argument, the actor URI itself (with or without a trailing slash), and an actor at
|
||||
* the origin root (it would own the whole host).
|
||||
*/
|
||||
function isInActorPod(uri, actorUri) {
|
||||
const resource = canonicalUrl(uri);
|
||||
const actor = canonicalUrl(actorUri);
|
||||
if (!resource || !actor) return false;
|
||||
const root = actor.pathname.replace(/\/+$/, '');
|
||||
if (root.length === 0) return false;
|
||||
const prefix = `${root}/`;
|
||||
return resource.origin === actor.origin && resource.pathname.startsWith(prefix) && resource.pathname.length > prefix.length;
|
||||
}
|
||||
|
||||
module.exports = { isInActorPod };
|
||||
@@ -0,0 +1,56 @@
|
||||
const { test, expect } = require('bun:test');
|
||||
const { isInActorPod } = require('./actor-pod');
|
||||
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const BOB = 'http://localhost:3000/bob';
|
||||
|
||||
test("a resource of the actor's own pod is in it", () => {
|
||||
expect(isInActorPod(`${ALICE}/data/0b5e7c1e-event`, ALICE)).toBe(true);
|
||||
expect(isInActorPod(`${ALICE}/data/as/event/x`, ALICE)).toBe(true);
|
||||
expect(isInActorPod(`${ALICE}/data/x#fragment`, ALICE)).toBe(true);
|
||||
// An actor URI written with a trailing slash owns the same namespace.
|
||||
expect(isInActorPod(`${ALICE}/data/x`, `${ALICE}/`)).toBe(true);
|
||||
});
|
||||
|
||||
test("another actor's resource is not — including a name that merely starts the same", () => {
|
||||
expect(isInActorPod(`${BOB}/data/x`, ALICE)).toBe(false);
|
||||
expect(isInActorPod('http://localhost:3000/alice2/data/x', ALICE)).toBe(false);
|
||||
expect(isInActorPod('http://localhost:3000/alicedata/x', ALICE)).toBe(false);
|
||||
});
|
||||
|
||||
test('another origin is not, even with the same path', () => {
|
||||
expect(isInActorPod('http://localhost:3001/alice/data/x', ALICE)).toBe(false);
|
||||
expect(isInActorPod('https://localhost:3000/alice/data/x', ALICE)).toBe(false);
|
||||
expect(isInActorPod('http://evil.example/alice/data/x', ALICE)).toBe(false);
|
||||
});
|
||||
|
||||
test('a path that only LOOKS under the actor is refused (non-canonical forms)', () => {
|
||||
for (const uri of [
|
||||
`${ALICE}/../bob/data/x`,
|
||||
`${ALICE}/./data/x`,
|
||||
`${ALICE}/%2e%2e/bob/data/x`,
|
||||
`${ALICE}/data/../../bob/data/x`,
|
||||
'HTTP://localhost:3000/alice/data/x',
|
||||
'http://LOCALHOST:3000/alice/data/x',
|
||||
'http://localhost:3000//alice/data/x/../../../bob',
|
||||
` ${ALICE}/data/x`,
|
||||
`${ALICE}/data/x `
|
||||
]) {
|
||||
expect(isInActorPod(uri, ALICE)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('the actor URI itself, its parent, and missing or malformed input are refused', () => {
|
||||
expect(isInActorPod(ALICE, ALICE)).toBe(false);
|
||||
expect(isInActorPod(`${ALICE}/`, ALICE)).toBe(false);
|
||||
expect(isInActorPod('http://localhost:3000/', ALICE)).toBe(false);
|
||||
expect(isInActorPod(undefined, ALICE)).toBe(false);
|
||||
expect(isInActorPod(`${ALICE}/data/x`, undefined)).toBe(false);
|
||||
expect(isInActorPod({ id: `${ALICE}/data/x` }, ALICE)).toBe(false);
|
||||
expect(isInActorPod('not a url', ALICE)).toBe(false);
|
||||
expect(isInActorPod('urn:x:alice/data', 'urn:x:alice')).toBe(false);
|
||||
});
|
||||
|
||||
test('an actor at the origin root owns nothing (it would own the whole host)', () => {
|
||||
expect(isInActorPod('http://localhost:3000/bob/data/x', 'http://localhost:3000/')).toBe(false);
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { AppService } = require('@activitypods/app');
|
||||
const CONFIG = require('../config/config');
|
||||
const { requestUri } = require('./base-path');
|
||||
|
||||
// `GET /app` has two personalities, discriminated by the request's `JsonLdContext` header.
|
||||
//
|
||||
@@ -46,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',
|
||||
@@ -55,7 +62,6 @@ module.exports = {
|
||||
'apods:ReadOutbox',
|
||||
'apods:PostOutbox',
|
||||
'apods:QuerySparqlEndpoint',
|
||||
'apods:CreateWacGroup',
|
||||
'apods:CreateCollection',
|
||||
'apods:UpdateWebId'
|
||||
],
|
||||
@@ -65,8 +71,8 @@ module.exports = {
|
||||
},
|
||||
actions: {
|
||||
// Overrides the mixin's `get` (which returns `this.appActor` for every request) to
|
||||
// answer the Solid-OIDC Client ID Document fetch as well. `this.appActor` is set by the
|
||||
// mixin's `started()`, i.e. long before any request is served.
|
||||
// answer the Solid-OIDC Client ID Document fetch as well. `this.appActor` is the mixin's
|
||||
// snapshot of the actor, refreshed by this service's own started() (see there).
|
||||
get: {
|
||||
async handler(ctx) {
|
||||
// moleculer-web exposes the raw request only when the route alias sets
|
||||
@@ -78,11 +84,9 @@ module.exports = {
|
||||
const isOidcClientIdDocument =
|
||||
jsonLdContextKey !== undefined && String(headers[jsonLdContextKey]).includes('solid/oidc-context.jsonld');
|
||||
|
||||
// `this.appActor` is set at the very END of the mixin's started(). During boot,
|
||||
// this action is already routable (the api gateway is up before the services
|
||||
// finish starting) and `@activitypods/app`'s own `awaitCreateComplete` polls the
|
||||
// actor over HTTP through this very route — waiting on an action that waits on the
|
||||
// boot it blocks. Read the actor straight from the triplestore in that window.
|
||||
// Moleculer registers this action only once every started() handler of the service has
|
||||
// finished, by which time the mixin has set `this.appActor`: reading the actor from the
|
||||
// triplestore when it is unset is a guard, not a boot path.
|
||||
let actor = this.appActor;
|
||||
if (!actor) {
|
||||
try {
|
||||
@@ -100,12 +104,12 @@ module.exports = {
|
||||
// The client_id must equal the fetched URI exactly. Both `/app` and `/as/actor/app`
|
||||
// serve this document (the configured client_id is the actor uri; `/app` remains
|
||||
// a valid alias), so echo the request path rather than hardcoding one.
|
||||
// `req.url` is rewritten to the alias remainder by moleculer-web; the raw path
|
||||
// survives in `originalUrl`.
|
||||
const requestPath = String((req && (req.originalUrl || req.url)) || '/as/actor/app').split('?')[0];
|
||||
// `req.url` is rewritten to the alias remainder by moleculer-web; the raw path, base
|
||||
// path included, survives in `originalUrl`.
|
||||
const requestPath = String((req && (req.originalUrl || req.url)) || '').split('?')[0];
|
||||
return {
|
||||
'@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'],
|
||||
client_id: urlJoin(CONFIG.HOME_URL, requestPath),
|
||||
client_id: requestPath ? requestUri(CONFIG.HOME_URL, requestPath) : urlJoin(CONFIG.HOME_URL, 'as/actor/app'),
|
||||
redirect_uris: [urlJoin(CONFIG.FRONT_URL, 'login')],
|
||||
client_name: CONFIG.APP_NAME,
|
||||
client_uri: CONFIG.FRONT_URL
|
||||
@@ -118,34 +122,67 @@ module.exports = {
|
||||
},
|
||||
async started() {
|
||||
// Heal the actor ↔ access-need-group link. `@activitypods/app` attaches the group to the
|
||||
// actor in an after.post hook of the group's creation, but the hook races the actor's own
|
||||
// creation on a cold start (both run inside the broker's parallel started() phase): the
|
||||
// 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.
|
||||
// actor in an after.post hook of the group's creation. On a fresh dataset (2026-10-26)
|
||||
// consents registered zero grants and a one-shot version of this heal found no group; the
|
||||
// cause was not established — the mixin's started() creates the actor, then the group,
|
||||
// before this handler runs. 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 — so the link
|
||||
// is checked in the store and attached when missing, polling for the group rather than
|
||||
// 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 readActor = () =>
|
||||
this.broker.call('ldp.resource.get', {
|
||||
resourceUri: actorUri,
|
||||
accept: 'application/ld+json',
|
||||
webId: 'system'
|
||||
});
|
||||
const linksGroup = actor => {
|
||||
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 (linksGroup(await readActor())) 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));
|
||||
}
|
||||
// `get` serves `this.appActor`, the snapshot the mixin's started() takes BEFORE it creates
|
||||
// or replaces the group: on a dataset's first boot that snapshot lacks the link, and on a
|
||||
// boot that changes the access needs it names the group just deleted. `GET /app` kept
|
||||
// that until the next restart (observed 2026-11-02), so refresh it from the store.
|
||||
try {
|
||||
const actor = await readActor();
|
||||
if (linksGroup(actor)) this.appActor = actor;
|
||||
} catch (e) {
|
||||
this.logger.warn(`Could not refresh the app actor snapshot: ${e.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
const { test, expect, describe, afterEach, setSystemTime } = require('bun:test');
|
||||
const app = require('./app.service');
|
||||
|
||||
const ACTOR = 'http://localhost:3005/as/actor/app';
|
||||
const GROUP = 'http://localhost:3005/interop/access-need-group/g1';
|
||||
|
||||
const actor = (extra = {}) => ({ id: ACTOR, type: ['Application', 'interop:Application'], ...extra });
|
||||
const linked = () => actor({ 'interop:hasAccessNeedGroup': GROUP });
|
||||
|
||||
/**
|
||||
* The app service as moleculer runs its own started(), after the mixin's: `snapshot` is the
|
||||
* `this.appActor` the mixin left, `store` the actor the triplestore holds, `groups` what the
|
||||
* group listing answers. Attaching a group writes the link into the store, as the mixin does.
|
||||
* `failReadsFrom` makes the n-th actor read (1-based) and every later one throw.
|
||||
*/
|
||||
function service({ snapshot, store, groups = [], failReadsFrom = Infinity }) {
|
||||
const calls = [];
|
||||
const logs = { info: [], warn: [], error: [] };
|
||||
let reads = 0;
|
||||
const svc = {
|
||||
appActor: snapshot,
|
||||
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
|
||||
broker: {
|
||||
call: async (action, params) => {
|
||||
calls.push(action);
|
||||
if (action === 'ldp.resource.get') {
|
||||
reads += 1;
|
||||
if (reads >= failReadsFrom) throw new Error('triplestore down');
|
||||
return store;
|
||||
}
|
||||
if (action === 'access-needs-groups.list') return { 'ldp:contains': groups };
|
||||
if (action === 'actors.attachAccessNeedGroup') {
|
||||
store = { ...store, 'interop:hasAccessNeedGroup': params.accessNeedGroupUri };
|
||||
return undefined;
|
||||
}
|
||||
throw new Error(`unexpected call ${action}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
return { svc, calls, logs };
|
||||
}
|
||||
|
||||
describe('app actor snapshot — GET /app serves the access-need group once the store links it', () => {
|
||||
// The deadline test moves the clock; reset it even when a test times out mid-way.
|
||||
afterEach(() => setSystemTime());
|
||||
|
||||
test("a dataset's first boot: the snapshot predates the link the store holds, so it is refreshed", async () => {
|
||||
const { svc, calls } = service({ snapshot: actor(), store: linked() });
|
||||
await app.started.call(svc);
|
||||
expect(svc.appActor['interop:hasAccessNeedGroup']).toBe(GROUP);
|
||||
expect(calls).not.toContain('actors.attachAccessNeedGroup');
|
||||
});
|
||||
|
||||
test('the cold-start race: the store lacks the link too, the heal attaches the group, then the snapshot follows', async () => {
|
||||
const { svc, calls, logs } = service({ snapshot: actor(), store: actor(), groups: [{ id: GROUP }] });
|
||||
await app.started.call(svc);
|
||||
expect(calls).toContain('actors.attachAccessNeedGroup');
|
||||
expect(logs.info.some(m => m.includes(GROUP))).toBe(true);
|
||||
expect(svc.appActor['interop:hasAccessNeedGroup']).toBe(GROUP);
|
||||
});
|
||||
|
||||
test('a later boot: the snapshot already carries the link and keeps it', async () => {
|
||||
const { svc } = service({ snapshot: linked(), store: linked() });
|
||||
await app.started.call(svc);
|
||||
expect(svc.appActor['interop:hasAccessNeedGroup']).toBe(GROUP);
|
||||
});
|
||||
|
||||
test('a boot that changes the access needs: the snapshot names the deleted group, the store the new one', async () => {
|
||||
const { svc } = service({ snapshot: actor({ 'interop:hasAccessNeedGroup': `${GROUP}-old` }), store: linked() });
|
||||
await app.started.call(svc);
|
||||
expect(svc.appActor['interop:hasAccessNeedGroup']).toBe(GROUP);
|
||||
});
|
||||
|
||||
test('no group ever appears: the heal gives up and the snapshot is not replaced by an unlinked actor', async () => {
|
||||
const snapshot = actor();
|
||||
const { svc, logs } = service({ snapshot, store: actor({ name: 'from the store' }) });
|
||||
// The group listing answers empty; jump the clock past the heal's 60 s deadline at that
|
||||
// point so the loop gives up without sleeping.
|
||||
const call = svc.broker.call;
|
||||
svc.broker.call = async (action, params) => {
|
||||
if (action === 'access-needs-groups.list') setSystemTime(new Date(Date.now() + 61000));
|
||||
return call(action, params);
|
||||
};
|
||||
await app.started.call(svc);
|
||||
expect(logs.error.some(m => m.includes('No access-need group appeared'))).toBe(true);
|
||||
expect(svc.appActor).toBe(snapshot);
|
||||
});
|
||||
|
||||
test('a failed refresh read leaves the snapshot as it was and says so', async () => {
|
||||
const snapshot = actor();
|
||||
const { svc, logs } = service({ snapshot, store: linked(), failReadsFrom: 2 });
|
||||
await app.started.call(svc);
|
||||
expect(svc.appActor).toBe(snapshot);
|
||||
expect(logs.warn.some(m => m.includes('refresh the app actor snapshot'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
// 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
|
||||
// their handlers reach it — after verification reads of unequal duration — not in the order the
|
||||
// participant acted. "Join then Leave" could be applied as Leave (nothing to remove) then Join, and leave
|
||||
// her listed. A Join or a Leave says what to do, not what the state should be, so the decision must
|
||||
// compare it with what was already applied.
|
||||
//
|
||||
// THE RULE (`isNewerAct`). Each act is placed by the time the participant's pod stamped on it — its
|
||||
// `published` (the pod overwrites any client value when the activity is posted to the outbox) — then by
|
||||
// its URI, to break a tie. Under the lock, an act is applied only when it is newer than the last act
|
||||
// applied for that pair; an older one (a late notification, a replay) changes nothing. Whatever the
|
||||
// arrival order, the pair ends in the state of the participant's latest act: "join then leave" ends not
|
||||
// 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 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, participantUri) {
|
||||
return `urn:festipod:attendance:${encodeURIComponent(meetingPointUri)}:${encodeURIComponent(participantUri)}`;
|
||||
}
|
||||
|
||||
// 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`;
|
||||
}
|
||||
|
||||
// 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)} }
|
||||
WHERE { OPTIONAL { ${s} ?p ?o . FILTER(?p IN (<${ACT}>, <${PUBLISHED}>, <${CHANGE}>)) } }
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* isNewerAct(candidate, last) → boolean. `candidate` and `last` are `{ activityUri, published }`
|
||||
* (`last` undefined when nothing was applied yet). Newer = a later `published` instant, or the same
|
||||
* instant and a greater URI. A candidate without a readable stamp is never newer (it cannot be placed).
|
||||
*/
|
||||
function isNewerAct(candidate, last) {
|
||||
const at = Date.parse(candidate && candidate.published);
|
||||
if (Number.isNaN(at)) return false;
|
||||
if (!last) return true;
|
||||
const lastAt = Date.parse(last.published);
|
||||
if (Number.isNaN(lastAt) || at > lastAt) return true;
|
||||
if (at < lastAt) return false;
|
||||
return String(candidate.activityUri) > String(last.activityUri);
|
||||
}
|
||||
|
||||
const termValue = term => (term && typeof term === 'object' ? term.value : term);
|
||||
|
||||
// ── Store operations: `ctx` is a moleculer context (anything with `call`) ──
|
||||
|
||||
/**
|
||||
* 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), member: termValue(row.member) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
@@ -1,15 +1,24 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
|
||||
const { PodCollectionsHandlerMixin, PodResourcesHandlerMixin } = require('@activitypods/app');
|
||||
const CONFIG = require('../config/config');
|
||||
const { neverThrowingMatcher, verifiedEmit, uriOf } = require('./emitted-activity');
|
||||
const { guardedCreateAndAttach } = require('./collection-attach');
|
||||
|
||||
// Visibility decision: the attendees collection is readable by ANY visitor, including
|
||||
// unauthenticated ones (acl:agentClass foaf:Agent). A meeting point's participant count and
|
||||
// its list of participants must be visible to non-contacts — the app shows outsiders the
|
||||
// anonymous participants plus the total counter, and that is a product feature, not a leak.
|
||||
// Member URIs are not secret; the anonymity guarantee lives in each profile's own WAC (a
|
||||
// participant whose profile is not shared simply renders as anonymous), not here. The
|
||||
// reference welcometomyplace grants this to its announces group, but Festipod has no
|
||||
// announces concept, so the grant is public.
|
||||
// Attaches an `apods:attendees` collection 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. 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
|
||||
// event is public, revoked while it is not, in both directions on every later update.
|
||||
module.exports = {
|
||||
name: 'attendees',
|
||||
mixins: [PodCollectionsHandlerMixin],
|
||||
@@ -20,30 +29,59 @@ 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
|
||||
},
|
||||
actions: {
|
||||
async givePublicReadPermission(ctx) {
|
||||
const { collectionUri, actorUri } = ctx.params;
|
||||
|
||||
await ctx.call('pod-permissions.add', {
|
||||
uri: collectionUri,
|
||||
agentPredicate: 'acl:agentClass',
|
||||
agentUri: 'http://xmlns.com/foaf/0.1/Agent',
|
||||
mode: 'acl:Read',
|
||||
actorUri
|
||||
});
|
||||
dependencies: ['public-read'],
|
||||
// The mixin's create handler would act on the object embedded in the (unauthenticated) webhook
|
||||
// body, attributed to the activity's self-declared `actor`. Override it: the matcher stays the
|
||||
// mixin's (made never-throwing), but the handler runs only for a Create `verifiedEmit` accepts —
|
||||
// re-read from the pod as the app, attributed to the owner of the outbox the webhook fired on,
|
||||
// the STORED object, an `as:Event` of that owner's pod — and attaches the collection there.
|
||||
activities: {
|
||||
create: {
|
||||
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.create.match),
|
||||
onEmit: verifiedEmit(
|
||||
'Create',
|
||||
async function (ctx, { object }, actorUri) {
|
||||
await this.onCreate(ctx, object, actorUri);
|
||||
},
|
||||
{ objectType: 'Event' }
|
||||
)
|
||||
}
|
||||
},
|
||||
hooks: {
|
||||
after: {
|
||||
async createAndAttach(ctx, res) {
|
||||
const { actorUri } = ctx.params;
|
||||
const collectionUri = res;
|
||||
|
||||
await this.actions.givePublicReadPermission({ collectionUri, actorUri }, { parentCtx: ctx });
|
||||
|
||||
return res;
|
||||
actions: {
|
||||
// Nothing attached → no WAC group (see collection-attach.js).
|
||||
createAndAttach: guardedCreateAndAttach
|
||||
},
|
||||
methods: {
|
||||
// Called by `createAndAttach` right after it attached a collection it CREATED, before anything
|
||||
// that may fail: Festipod created it, so the public read the pod gave it is Festipod's to take back
|
||||
// (public-read-ledger.js). A collection it did not create (nothing attached) is never recorded,
|
||||
// so its public read is never revoked. A failure here fails the attach step (logged by onCreate).
|
||||
async onCollectionAttached(ctx, { collectionUri, actorUri }) {
|
||||
await ctx.call('public-read.recordCreated', { resourceUri: collectionUri, actorUri });
|
||||
},
|
||||
// Replaces the mixin's `onCreate`. The watcher runs the handlers of one `Create` in sequence
|
||||
// and does not catch, in an order that is not fixed (attendees ran before public-read and
|
||||
// catalog in the runs observed 2026-10-12): a throw here would skip whichever handlers come
|
||||
// after it, and — the webhook call being un-awaited — end the process. So a failure is
|
||||
// logged, never thrown.
|
||||
async onCreate(ctx, resource, actorUri) {
|
||||
const resourceUri = uriOf(resource);
|
||||
try {
|
||||
await this.actions.createAndAttach({ resourceUri, actorUri }, { parentCtx: ctx });
|
||||
} catch (e) {
|
||||
this.logger.error(`Could not set up the attendees collection of ${resourceUri}: ${e.message}`);
|
||||
}
|
||||
// Whatever happened above, the collection the event now names follows the event's CURRENT
|
||||
// visibility. `reconcile` reads the event again under its lock, so this cannot race the
|
||||
// public-read handlers of the same event's later updates.
|
||||
try {
|
||||
await ctx.call('public-read.reconcile', { resourceUri, actorUri });
|
||||
} catch (e) {
|
||||
this.logger.error(`Could not align the public read of ${resourceUri} and its attendees collection: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// The path the backend is served under, read from its base URL (SEMAPPS_HOME_URL): `/` when the
|
||||
// backend owns its origin (http://localhost:3005 locally), `/api/` when it shares the front end's
|
||||
// origin (https://<host>/api/, the Coolify layout, where one base URL per environment keeps pull
|
||||
// request previews on a single host). The proxy in front passes the path through unchanged; SemApps
|
||||
// mounts every route it adds under the base path, except the `/.well-known` ones, which stay at the
|
||||
// origin root. Festipod's own routes and its public-surface allow-list follow the same split.
|
||||
|
||||
const urlJoin = require('url-join');
|
||||
|
||||
/** basePathOf(baseUrl) → the base URL's path, always ending with `/` (`/` when it has none or is unset). */
|
||||
function basePathOf(baseUrl) {
|
||||
if (!baseUrl) return '/';
|
||||
const { pathname } = new URL(baseUrl);
|
||||
return pathname.endsWith('/') ? pathname : `${pathname}/`;
|
||||
}
|
||||
|
||||
/** underBasePath(basePath, path) → the absolute `path` (e.g. `/catalog`) as mounted under `basePath`. */
|
||||
function underBasePath(basePath, path) {
|
||||
return basePath + path.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* requestUri(baseUrl, requestPath) → the absolute URI a request was made to. `requestPath` is the raw
|
||||
* path the gateway received (moleculer-web's `originalUrl`), base path included: the base path is
|
||||
* taken off before joining it to the base URL, which would otherwise repeat it. Joining (rather than
|
||||
* resolving with `new URL`) keeps the base URL exactly as written — host case, explicit port — so the
|
||||
* result matches the URIs the backend mints from the same base URL, its actor's first.
|
||||
*/
|
||||
function requestUri(baseUrl, requestPath) {
|
||||
const basePath = basePathOf(baseUrl);
|
||||
const relative = requestPath.startsWith(basePath) ? requestPath.slice(basePath.length - 1) : requestPath;
|
||||
return urlJoin(baseUrl, relative);
|
||||
}
|
||||
|
||||
module.exports = { basePathOf, underBasePath, requestUri };
|
||||
@@ -0,0 +1,55 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const { basePathOf, underBasePath, requestUri } = require('./base-path');
|
||||
|
||||
describe('basePathOf — the path the backend is served under', () => {
|
||||
test('a backend owning its origin is served at the root, with or without a trailing slash', () => {
|
||||
expect(basePathOf('http://localhost:3003')).toBe('/');
|
||||
expect(basePathOf('http://localhost:3003/')).toBe('/');
|
||||
});
|
||||
|
||||
test('a backend sharing the front end origin is served under its path, always ending with a slash', () => {
|
||||
expect(basePathOf('https://festipod.dev.reconnexion.coop/api/')).toBe('/api/');
|
||||
expect(basePathOf('https://festipod.dev.reconnexion.coop/api')).toBe('/api/');
|
||||
});
|
||||
|
||||
test('an unset base URL reads as the root', () => {
|
||||
expect(basePathOf(undefined)).toBe('/');
|
||||
expect(basePathOf('')).toBe('/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('underBasePath — where a route is mounted', () => {
|
||||
test('at the root, a route keeps its path', () => {
|
||||
expect(underBasePath('/', '/catalog')).toBe('/catalog');
|
||||
expect(underBasePath('/', '/as/actor/app')).toBe('/as/actor/app');
|
||||
});
|
||||
|
||||
test('under a base path, a route is prefixed by it, without a doubled slash', () => {
|
||||
expect(underBasePath('/api/', '/catalog')).toBe('/api/catalog');
|
||||
expect(underBasePath('/api/', 'as/actor/app')).toBe('/api/as/actor/app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestUri — the URI a request was made to', () => {
|
||||
test('at the root, the request path is appended to the origin', () => {
|
||||
expect(requestUri('http://localhost:3003', '/as/actor/app')).toBe('http://localhost:3003/as/actor/app');
|
||||
expect(requestUri('http://localhost:3003/', '/app')).toBe('http://localhost:3003/app');
|
||||
});
|
||||
|
||||
test('under a base path, the raw request path already carries it and is not prefixed twice', () => {
|
||||
expect(requestUri('https://festipod.dev.reconnexion.coop/api/', '/api/as/actor/app')).toBe(
|
||||
'https://festipod.dev.reconnexion.coop/api/as/actor/app'
|
||||
);
|
||||
expect(requestUri('https://festipod.dev.reconnexion.coop/api', '/api/app')).toBe(
|
||||
'https://festipod.dev.reconnexion.coop/api/app'
|
||||
);
|
||||
});
|
||||
|
||||
test('the base URL is kept exactly as written — the same URI the backend mints its actor under', () => {
|
||||
const urlJoin = require('url-join');
|
||||
for (const baseUrl of ['http://localhost:80', 'https://Festipod.Example/api/', 'https://host:443/api']) {
|
||||
const path = `${basePathOf(baseUrl)}as/actor/app`;
|
||||
expect(requestUri(baseUrl, path)).toBe(urlJoin(baseUrl, 'as/actor/app'));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* 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 (`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
|
||||
* the local stack, 2026-10-12). The default graph is the one that persists.
|
||||
*
|
||||
* ONE ENTRY PER EVENT. The entry's subject IS the event URI, with exactly two
|
||||
* Festipod predicates: the snapshot (the resource as the catalog serves it, as JSON
|
||||
* text, so the `GET /catalog` items are returned byte-for-byte as they were indexed)
|
||||
* and the time it was first indexed (the listing order). Re-indexing replaces the
|
||||
* snapshot in ONE SPARQL update, which Fuseki runs as a single write transaction, so
|
||||
* concurrent re-adds of the same event cannot leave two snapshots; the listing also
|
||||
* groups by subject, so even a stray duplicate could never list an event twice.
|
||||
*/
|
||||
|
||||
const SNAPSHOT = 'http://festipod.org/catalogSnapshot';
|
||||
const INDEXED_AT = 'http://festipod.org/catalogIndexedAt';
|
||||
|
||||
/** An IRI as a SPARQL IRIREF, refusing the characters the grammar forbids in one. */
|
||||
function iriRef(uri) {
|
||||
if (typeof uri !== 'string' || uri.length === 0 || /[\u0000- <>"{}|^`\\]/.test(uri)) {
|
||||
throw new Error(`Not an IRI the catalog can store: ${JSON.stringify(uri)}`);
|
||||
}
|
||||
return `<${uri}>`;
|
||||
}
|
||||
|
||||
const STRING_ESCAPES = { '\\': '\\\\', '"': '\\"', '\n': '\\n', '\r': '\\r', '\t': '\\t', '\b': '\\b', '\f': '\\f' };
|
||||
|
||||
/** A string as a SPARQL double-quoted literal. */
|
||||
function stringLiteral(value) {
|
||||
return `"${String(value).replace(/[\\"\n\r\t\b\f]/g, c => STRING_ESCAPES[c])}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index (or re-index) one event: replace its snapshot, keep the time it was first
|
||||
* indexed (or stamp it now). One update request = one write transaction.
|
||||
*/
|
||||
function upsertEntryUpdate(uri, resource) {
|
||||
const s = iriRef(uri);
|
||||
return `
|
||||
DELETE { ${s} <${SNAPSHOT}> ?old }
|
||||
INSERT { ${s} <${SNAPSHOT}> ${stringLiteral(JSON.stringify(resource))} . ${s} <${INDEXED_AT}> ?at }
|
||||
WHERE {
|
||||
OPTIONAL { ${s} <${SNAPSHOT}> ?old }
|
||||
OPTIONAL { ${s} <${INDEXED_AT}> ?first }
|
||||
BIND(COALESCE(?first, NOW()) AS ?at)
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/** Remove one event's entry — only the catalog's own predicates, nothing else about that URI. */
|
||||
function removeEntryUpdate(uri) {
|
||||
const s = iriRef(uri);
|
||||
return `
|
||||
DELETE { ${s} ?p ?o }
|
||||
WHERE { ${s} ?p ?o . FILTER(?p IN (<${SNAPSHOT}>, <${INDEXED_AT}>)) }
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one event's entry ONLY while its snapshot is still exactly `snapshot` — the text a caller
|
||||
* read and judged. If the event was re-indexed in between, its new snapshot does not match and
|
||||
* nothing is removed, so a sweep never deletes an entry it did not see.
|
||||
*/
|
||||
function removeEntryIfSnapshotUpdate(uri, snapshot) {
|
||||
const s = iriRef(uri);
|
||||
return `
|
||||
DELETE { ${s} ?p ?o }
|
||||
WHERE {
|
||||
${s} <${SNAPSHOT}> ${stringLiteral(snapshot)} .
|
||||
${s} ?p ?o . FILTER(?p IN (<${SNAPSHOT}>, <${INDEXED_AT}>))
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/** Every entry, one row per event, in the order events were first indexed. */
|
||||
const LIST_ENTRIES_QUERY = `
|
||||
SELECT ?uri (SAMPLE(?snap) AS ?snapshot) (MIN(?at) AS ?indexedAt)
|
||||
WHERE {
|
||||
?uri <${SNAPSHOT}> ?snap .
|
||||
OPTIONAL { ?uri <${INDEXED_AT}> ?at }
|
||||
}
|
||||
GROUP BY ?uri
|
||||
ORDER BY ASC(?indexedAt) ASC(STR(?uri))
|
||||
`;
|
||||
|
||||
/** A SELECT binding's value: the triplestore service returns RDF/JS terms. */
|
||||
function termValue(term) {
|
||||
return term && typeof term === 'object' ? term.value : term;
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries from the listing rows: each event URI, its snapshot text as stored, and the item
|
||||
* parsed from it. A snapshot that is not valid JSON is skipped and reported (`invalid`).
|
||||
*/
|
||||
function entriesFromRows(rows) {
|
||||
const entries = [];
|
||||
const invalid = [];
|
||||
for (const row of rows || []) {
|
||||
const uri = termValue(row.uri);
|
||||
const snapshot = termValue(row.snapshot);
|
||||
try {
|
||||
entries.push({ uri, snapshot, item: JSON.parse(snapshot) });
|
||||
} catch {
|
||||
invalid.push(uri);
|
||||
}
|
||||
}
|
||||
return { entries, invalid };
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog items from the listing rows. A snapshot that is not valid JSON is
|
||||
* skipped and reported, rather than failing the whole public listing.
|
||||
*/
|
||||
function itemsFromRows(rows) {
|
||||
const { entries, invalid } = entriesFromRows(rows);
|
||||
return { items: entries.map(entry => entry.item), invalid };
|
||||
}
|
||||
|
||||
// ── Store operations: `ctx` is a moleculer context (anything with `call`) ──
|
||||
|
||||
async function upsertEntry(ctx, { dataset, uri, resource }) {
|
||||
await ctx.call('triplestore.update', { query: upsertEntryUpdate(uri, resource), dataset, webId: 'system' });
|
||||
}
|
||||
|
||||
async function removeEntry(ctx, { dataset, uri }) {
|
||||
await ctx.call('triplestore.update', { query: removeEntryUpdate(uri), dataset, webId: 'system' });
|
||||
}
|
||||
|
||||
async function listEntries(ctx, { dataset }) {
|
||||
// The default `accept` (JSON) makes the service parse SELECT results into RDF/JS terms.
|
||||
const rows = await ctx.call('triplestore.query', { query: LIST_ENTRIES_QUERY, dataset, webId: 'system' });
|
||||
return itemsFromRows(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every entry whose item fails `keep(item)`, each one only if its snapshot is still the one
|
||||
* judged (see `removeEntryIfSnapshotUpdate`). An entry whose snapshot cannot be parsed is not
|
||||
* judged, so it is left in place (the listing never serves it). Resolves the removed URIs.
|
||||
*/
|
||||
async function sweepEntries(ctx, { dataset, keep }) {
|
||||
const rows = await ctx.call('triplestore.query', { query: LIST_ENTRIES_QUERY, dataset, webId: 'system' });
|
||||
const { entries } = entriesFromRows(rows);
|
||||
const removed = [];
|
||||
for (const { uri, snapshot, item } of entries) {
|
||||
if (keep(item)) continue;
|
||||
await ctx.call('triplestore.update', { query: removeEntryIfSnapshotUpdate(uri, snapshot), dataset, webId: 'system' });
|
||||
removed.push(uri);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SNAPSHOT,
|
||||
INDEXED_AT,
|
||||
LIST_ENTRIES_QUERY,
|
||||
iriRef,
|
||||
stringLiteral,
|
||||
upsertEntryUpdate,
|
||||
removeEntryUpdate,
|
||||
removeEntryIfSnapshotUpdate,
|
||||
entriesFromRows,
|
||||
itemsFromRows,
|
||||
upsertEntry,
|
||||
removeEntry,
|
||||
listEntries,
|
||||
sweepEntries
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const {
|
||||
SNAPSHOT,
|
||||
INDEXED_AT,
|
||||
LIST_ENTRIES_QUERY,
|
||||
iriRef,
|
||||
stringLiteral,
|
||||
upsertEntryUpdate,
|
||||
removeEntryUpdate,
|
||||
removeEntryIfSnapshotUpdate,
|
||||
entriesFromRows,
|
||||
itemsFromRows,
|
||||
upsertEntry,
|
||||
removeEntry,
|
||||
listEntries,
|
||||
sweepEntries
|
||||
} = require('./catalog-store');
|
||||
|
||||
const EVENT = 'http://localhost:3000/alice/data/0b5e7c1e-event';
|
||||
|
||||
/** Decode the first SPARQL double-quoted literal of a query (the grammar's ECHAR escapes). */
|
||||
function firstLiteral(query) {
|
||||
const match = /"((?:[^"\\]|\\.)*)"/.exec(query);
|
||||
if (!match) throw new Error('no literal in query');
|
||||
const map = { t: '\t', b: '\b', n: '\n', r: '\r', f: '\f', '"': '"', "'": "'", '\\': '\\' };
|
||||
return match[1].replace(/\\(.)/g, (_, c) => map[c]);
|
||||
}
|
||||
|
||||
function fakeCtx(result) {
|
||||
const calls = [];
|
||||
return { calls, call: async (action, params) => (calls.push({ action, params }), result) };
|
||||
}
|
||||
|
||||
describe('catalog store — SPARQL it sends', () => {
|
||||
test('an IRI is wrapped as an IRIREF; anything that could break out of one is refused', () => {
|
||||
expect(iriRef(EVENT)).toBe(`<${EVENT}>`);
|
||||
for (const bad of ['', 'http://x/a b', 'http://x/a>', 'http://x/<a', 'http://x/"a', 'http://x/a\\b', 'http://x/{a}', undefined, 42]) {
|
||||
expect(() => iriRef(bad)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test('a string literal escapes quotes, backslashes and line breaks', () => {
|
||||
expect(stringLiteral('a"b\\c\nd\re\tf')).toBe('"a\\"b\\\\c\\nd\\re\\tf"');
|
||||
});
|
||||
|
||||
test('the indexed snapshot decodes back to exactly the resource, whatever its text holds', () => {
|
||||
const resource = {
|
||||
'@context': ['https://www.w3.org/ns/activitystreams', 'http://localhost:3003/.well-known/context.jsonld'],
|
||||
id: EVENT,
|
||||
type: 'Event',
|
||||
name: 'Concert "plein air" } ; DROP ALL ; # \\ fin',
|
||||
content: 'ligne 1\nligne 2\ttab',
|
||||
'apods:attendees': 'http://localhost:3000/alice/data/attendees'
|
||||
};
|
||||
const query = upsertEntryUpdate(EVENT, resource);
|
||||
expect(JSON.parse(firstLiteral(query))).toEqual(resource);
|
||||
expect(firstLiteral(query)).toBe(JSON.stringify(resource));
|
||||
});
|
||||
|
||||
test('re-indexing replaces the snapshot and keeps the first-indexed time, in one update', () => {
|
||||
const query = upsertEntryUpdate(EVENT, { id: EVENT });
|
||||
expect(query).toContain(`DELETE { <${EVENT}> <${SNAPSHOT}> ?old }`);
|
||||
expect(query).toContain(`<${EVENT}> <${INDEXED_AT}> ?at`);
|
||||
expect(query).toContain('COALESCE(?first, NOW())');
|
||||
// One operation: no `;` separator outside the literal.
|
||||
expect(query.replace(/"(?:[^"\\]|\\.)*"/g, '""')).not.toContain(';');
|
||||
});
|
||||
|
||||
test('removal touches only the catalog predicates of that event', () => {
|
||||
const query = removeEntryUpdate(EVENT);
|
||||
expect(query).toContain(`DELETE { <${EVENT}> ?p ?o }`);
|
||||
expect(query).toContain(`FILTER(?p IN (<${SNAPSHOT}>, <${INDEXED_AT}>))`);
|
||||
});
|
||||
|
||||
test('the listing yields one row per event, oldest first', () => {
|
||||
expect(LIST_ENTRIES_QUERY).toContain('GROUP BY ?uri');
|
||||
expect(LIST_ENTRIES_QUERY).toContain('ORDER BY ASC(?indexedAt)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog store — rows to items', () => {
|
||||
test('snapshots are parsed back to the items, in row order', () => {
|
||||
const a = { id: 'http://p/a', type: 'Event', name: 'A' };
|
||||
const b = { id: 'http://p/b', type: 'Event', 'as:context': 'http://p/a' };
|
||||
const rows = [
|
||||
{ uri: { value: a.id }, snapshot: { value: JSON.stringify(a) } },
|
||||
{ uri: { value: b.id }, snapshot: { value: JSON.stringify(b) } }
|
||||
];
|
||||
expect(itemsFromRows(rows)).toEqual({ items: [a, b], invalid: [] });
|
||||
});
|
||||
|
||||
test('an unreadable snapshot is skipped and reported, not fatal', () => {
|
||||
const rows = [
|
||||
{ uri: { value: 'http://p/bad' }, snapshot: { value: '{not json' } },
|
||||
{ uri: { value: 'http://p/ok' }, snapshot: { value: '{"id":"http://p/ok"}' } }
|
||||
];
|
||||
expect(itemsFromRows(rows)).toEqual({ items: [{ id: 'http://p/ok' }], invalid: ['http://p/bad'] });
|
||||
});
|
||||
|
||||
test('no rows, no items', () => {
|
||||
expect(itemsFromRows([])).toEqual({ items: [], invalid: [] });
|
||||
expect(itemsFromRows(undefined)).toEqual({ items: [], invalid: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog store — triplestore calls', () => {
|
||||
test('index, remove and list go to the given dataset as the system', async () => {
|
||||
const ctx = fakeCtx([]);
|
||||
await upsertEntry(ctx, { dataset: 'settings', uri: EVENT, resource: { id: EVENT } });
|
||||
await removeEntry(ctx, { dataset: 'settings', uri: EVENT });
|
||||
await listEntries(ctx, { dataset: 'settings' });
|
||||
expect(ctx.calls.map(c => c.action)).toEqual(['triplestore.update', 'triplestore.update', 'triplestore.query']);
|
||||
for (const { params } of ctx.calls) {
|
||||
expect(params.dataset).toBe('settings');
|
||||
expect(params.webId).toBe('system');
|
||||
}
|
||||
expect(ctx.calls[0].params.query).toBe(upsertEntryUpdate(EVENT, { id: EVENT }));
|
||||
expect(ctx.calls[1].params.query).toBe(removeEntryUpdate(EVENT));
|
||||
expect(ctx.calls[2].params.query).toBe(LIST_ENTRIES_QUERY);
|
||||
});
|
||||
|
||||
test('a URI that is not a storable IRI is refused before anything is sent', async () => {
|
||||
const ctx = fakeCtx([]);
|
||||
await expect(upsertEntry(ctx, { dataset: 'settings', uri: 'http://x/a> } ; DROP ALL', resource: {} })).rejects.toThrow();
|
||||
expect(ctx.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog store — sweeping entries that fail a predicate', () => {
|
||||
const listed = { id: 'http://p/listed', type: 'Event', to: 'as:Public' };
|
||||
const unlisted = { id: 'http://p/private', type: 'Event', name: 'secret "address" \\ 12 rue X\nfloor 2' };
|
||||
const rowOf = item => ({ uri: { value: item.id }, snapshot: { value: JSON.stringify(item) } });
|
||||
const keepListed = item => item.to === 'as:Public';
|
||||
|
||||
test('the conditional removal names the exact snapshot text it judged, and only the catalog predicates', () => {
|
||||
const snapshot = JSON.stringify(unlisted);
|
||||
const query = removeEntryIfSnapshotUpdate(unlisted.id, snapshot);
|
||||
expect(firstLiteral(query)).toBe(snapshot);
|
||||
expect(query).toContain(`<${unlisted.id}> <${SNAPSHOT}> "`);
|
||||
expect(query).toContain(`DELETE { <${unlisted.id}> ?p ?o }`);
|
||||
expect(query).toContain(`FILTER(?p IN (<${SNAPSHOT}>, <${INDEXED_AT}>))`);
|
||||
expect(query.replace(/"(?:[^"\\]|\\.)*"/g, '""')).not.toContain(';');
|
||||
expect(() => removeEntryIfSnapshotUpdate('http://x/a> } ; DROP ALL', snapshot)).toThrow();
|
||||
});
|
||||
|
||||
test('rows become entries carrying the URI, the stored text and the parsed item', () => {
|
||||
const rows = [rowOf(listed), { uri: { value: 'http://p/bad' }, snapshot: { value: '{oops' } }];
|
||||
expect(entriesFromRows(rows)).toEqual({
|
||||
entries: [{ uri: listed.id, snapshot: JSON.stringify(listed), item: listed }],
|
||||
invalid: ['http://p/bad']
|
||||
});
|
||||
});
|
||||
|
||||
test('only the entries failing the predicate are removed, each conditionally on what was read', async () => {
|
||||
const rows = [rowOf(listed), rowOf(unlisted), { uri: { value: 'http://p/bad' }, snapshot: { value: '{oops' } }];
|
||||
const ctx = fakeCtx(rows);
|
||||
const removed = await sweepEntries(ctx, { dataset: 'settings', keep: keepListed });
|
||||
expect(removed).toEqual([unlisted.id]);
|
||||
expect(ctx.calls.map(c => c.action)).toEqual(['triplestore.query', 'triplestore.update']);
|
||||
expect(ctx.calls[0].params.query).toBe(LIST_ENTRIES_QUERY);
|
||||
expect(ctx.calls[1].params.query).toBe(removeEntryIfSnapshotUpdate(unlisted.id, JSON.stringify(unlisted)));
|
||||
for (const { params } of ctx.calls) {
|
||||
expect(params.dataset).toBe('settings');
|
||||
expect(params.webId).toBe('system');
|
||||
}
|
||||
});
|
||||
|
||||
test('nothing to sweep, nothing written', async () => {
|
||||
const ctx = fakeCtx([rowOf(listed)]);
|
||||
expect(await sweepEntries(ctx, { dataset: 'settings', keep: keepListed })).toEqual([]);
|
||||
expect(ctx.calls.map(c => c.action)).toEqual(['triplestore.query']);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,39 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { PodResourcesHandlerMixin } = require('@activitypods/app');
|
||||
const { matchActivity, ACTIVITY_TYPES, OBJECT_TYPES } = require('@semapps/activitypub');
|
||||
const CONFIG = require('../config/config');
|
||||
const { upsertEntry, removeEntry, listEntries, sweepEntries } = require('./catalog-store');
|
||||
const { hasListedPublicIntent } = require('./public-intent');
|
||||
const { isInActorPod } = require('./actor-pod');
|
||||
const { createKeyedSerializer } = require('./keyed-serializer');
|
||||
const { neverThrowingMatcher, verifiedEmit, uriOf, hasAsType } = require('./emitted-activity');
|
||||
|
||||
function arrayOf(value) {
|
||||
return value === undefined || value === null ? [] : Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the catalog may hold and serve: an item addressed to the ActivityStreams Public collection
|
||||
* in `to` — public AND listed (`hasListedPublicIntent`, built on the public-read gate's own
|
||||
* predicate). An event with no addressing (another app's, one posted directly on the pod), one
|
||||
* addressed to chosen recipients, or a public-but-unlisted one (Public in `cc` only) is never
|
||||
* listed. Festipod's create path writes `to: as:Public` on its events and meeting points, so both
|
||||
* are listed. Judged on the object alone, as the public-read gate judges it.
|
||||
*/
|
||||
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',
|
||||
@@ -8,34 +41,200 @@ module.exports = {
|
||||
settings: {
|
||||
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event')
|
||||
},
|
||||
created() {
|
||||
this.serialize = createKeyedSerializer();
|
||||
},
|
||||
// Moleculer does not merge a custom schema key: declaring `activities` here replaces the mixin's
|
||||
// whole map. `create` and `update` keep the mixin's matchers (made never-throwing). Every `onEmit`
|
||||
// acts only on an activity `verifiedEmit` accepts (`emitted-activity.js`): re-read from the pod
|
||||
// with the app's credentials, attributed to the owner of the outbox the webhook fired on, its
|
||||
// object an `as:Event` of that owner's pod — and the actor passed on is that owner, never the
|
||||
// activity's own `actor`. Even then the activity only POINTS at the event: `sync` reads it again,
|
||||
// under the event's lock, and that read decides (see `sync`).
|
||||
activities: {
|
||||
create: {
|
||||
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.create.match),
|
||||
onEmit: verifiedEmit(
|
||||
'Create',
|
||||
async function (ctx, { objectUri }, actorUri) {
|
||||
await this.sync(ctx, objectUri, actorUri);
|
||||
},
|
||||
{ objectType: 'Event' }
|
||||
)
|
||||
},
|
||||
update: {
|
||||
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.update.match),
|
||||
onEmit: verifiedEmit(
|
||||
'Update',
|
||||
async function (ctx, { objectUri }, actorUri) {
|
||||
await this.sync(ctx, objectUri, actorUri);
|
||||
},
|
||||
{ objectType: 'Event' }
|
||||
)
|
||||
},
|
||||
// The mixin's delete handler never fires for a deleted event: it matches the
|
||||
// Tombstone's `formerType` against the COMPACTED type (`Event`) while the
|
||||
// Tombstone the Pod serves carries `as:Event` (verified 2026-10-12), and on a
|
||||
// match it would call `onUpdate` with the Tombstone instead of `onDelete`. Same
|
||||
// pattern here — an emitted Delete of a Tombstone whose former type is this
|
||||
// service's type — with both types compared EXPANDED, routed to `onDelete`.
|
||||
// The Tombstone may be EMBEDDED in the activity (the pod accepts a Delete of any URI
|
||||
// with an embedded Tombstone, verified 2026-10-12), so the match proves nothing:
|
||||
// `verifiedEmit` de-indexes only when the pod says the object is really gone.
|
||||
delete: {
|
||||
match: neverThrowingMatcher(async function (activity, fetcher) {
|
||||
const pattern = { type: ACTIVITY_TYPES.DELETE, object: { type: OBJECT_TYPES.TOMBSTONE } };
|
||||
const { match, dereferencedActivity } = await matchActivity(pattern, activity, fetcher);
|
||||
if (!match) return { match: false, dereferencedActivity };
|
||||
const tombstone = dereferencedActivity.object;
|
||||
const formerTypes = arrayOf(tombstone.formerType);
|
||||
if (formerTypes.length === 0) return { match: false, dereferencedActivity };
|
||||
try {
|
||||
const [former, own] = await Promise.all([
|
||||
this.broker.call('jsonld.parser.expandTypes', { types: formerTypes, context: tombstone['@context'] }),
|
||||
this.broker.call('jsonld.parser.expandTypes', { types: this.settings.type })
|
||||
]);
|
||||
return { match: former.some(type => own.includes(type)), dereferencedActivity };
|
||||
} catch (e) {
|
||||
this.logger.warn(`Catalog could not read the former type of ${uriOf(tombstone)}: ${e.message}`);
|
||||
return { match: false, dereferencedActivity };
|
||||
}
|
||||
}),
|
||||
onEmit: verifiedEmit('Delete', async function (ctx, { objectUri }, actorUri) {
|
||||
await this.sync(ctx, objectUri, actorUri);
|
||||
})
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Entries the catalog would not serve are removed from its store at startup, so it does not
|
||||
* keep copies of bodies that were never public (indexed before the listing rule existed). The
|
||||
* listing filters them anyway (`discover`); this only stops holding them. Never fatal.
|
||||
*/
|
||||
async started() {
|
||||
try {
|
||||
const removed = await sweepEntries(this.broker, { dataset: CONFIG.MAIN_DATASET, keep: isListable });
|
||||
if (removed.length > 0) {
|
||||
this.logger.info(`Catalog removed ${removed.length} entries not listed to the Public collection: ${removed.join(', ')}`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(`Catalog could not sweep entries not listed to the Public collection: ${e.message}`);
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
discover: {
|
||||
async handler() {
|
||||
// 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(item => isListable(item) && (owner === undefined || creatorOf(item) === owner));
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Collection',
|
||||
summary: 'Public Festipod events and meeting points',
|
||||
totalItems: this.index ? Object.keys(this.index).length : 0,
|
||||
items: this.index ? Object.values(this.index) : []
|
||||
totalItems: listed.length,
|
||||
items: listed
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
// Each mutation is the same composition, in this order: (1) the object must lie in the pod of
|
||||
// the actor whose outbox emitted the activity (`isInActorPod`) — no actor indexes, overwrites or
|
||||
// removes another actor's entry; (2) only then is the listing rule applied; (3) only then is the
|
||||
// store written. A handler never throws: it runs inside the watcher (see `neverThrowingMatcher`).
|
||||
methods: {
|
||||
async onCreate(ctx, resource) {
|
||||
if (!this.index) this.index = {};
|
||||
this.index[resource.id || resource['@id']] = resource;
|
||||
this.logger.info(`Catalog indexed new event ${resource.id || resource['@id']}`);
|
||||
/**
|
||||
* Where every notification about an event ends: under that event's lock (`keyed-serializer.js`),
|
||||
* read it NOW from the pod and bring its entry in line — gone (or no longer an `as:Event`) →
|
||||
* de-indexed; there → `onUpdate` (indexed if listed public, de-indexed otherwise). Notifications
|
||||
* run concurrently and in no guaranteed order; serialized with the read inside, the last one to
|
||||
* run acts on the newest state, so an older notification never overrides a newer one. A read
|
||||
* that fails (unreachable, refused) changes nothing; a sync that times out (keyed-serializer.js)
|
||||
* writes nothing once its time is out, and one whose write landed after it is followed by ONE
|
||||
* re-sync (`followUp: false`, so it never chains). Never throws.
|
||||
*/
|
||||
async sync(ctx, uri, actorUri, { followUp = true } = {}) {
|
||||
await this.guarded(`sync ${uri}`, () =>
|
||||
this.serialize(
|
||||
uri,
|
||||
async signal => {
|
||||
if (!this.isOwnedBy(uri, actorUri, 'sync')) return undefined;
|
||||
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: uri, actorUri });
|
||||
// Timed out while reading (the lock has moved on): a newer sync decides, this one writes nothing.
|
||||
if (signal.aborted) return undefined;
|
||||
if (status === 404 || status === 410 || (status === 200 && hasAsType(body, 'Tombstone'))) {
|
||||
await this.onDelete(ctx, uri, actorUri);
|
||||
} else if (status !== 200 || !body || uriOf(body) !== uri) {
|
||||
this.logger.warn(`Catalog left ${uri} as it was: the pod does not return it (HTTP ${status})`);
|
||||
return undefined;
|
||||
} else if (!hasAsType(body, 'Event')) {
|
||||
await this.onDelete(ctx, uri, actorUri);
|
||||
} else {
|
||||
await this.onUpdate(ctx, body, actorUri);
|
||||
}
|
||||
// The store write was sent right after the check above; if the time ran out before it
|
||||
// returned, it may have landed after a newer sync's.
|
||||
return { wroteLate: signal.aborted };
|
||||
},
|
||||
followUp
|
||||
? {
|
||||
// Only a sync whose write landed late is re-judged, once: the re-sync has no follow-up.
|
||||
onLateSettle: result => {
|
||||
if (result && result.wroteLate) this.sync(ctx, uri, actorUri, { followUp: false });
|
||||
}
|
||||
}
|
||||
: {}
|
||||
)
|
||||
);
|
||||
},
|
||||
async onUpdate(ctx, resource) {
|
||||
if (!this.index) this.index = {};
|
||||
this.index[resource.id || resource['@id']] = resource;
|
||||
this.logger.info(`Catalog updated event ${resource.id || resource['@id']}`);
|
||||
async onCreate(ctx, resource, actorUri) {
|
||||
const uri = uriOf(resource);
|
||||
await this.guarded(`index ${uri}`, async () => {
|
||||
if (!this.isOwnedBy(uri, actorUri, 'index')) return;
|
||||
if (!isListable(resource)) return this.logNotListed(uri);
|
||||
await upsertEntry(ctx, { dataset: CONFIG.MAIN_DATASET, uri, resource });
|
||||
this.logger.info(`Catalog indexed new event ${uri}`);
|
||||
});
|
||||
},
|
||||
async onDelete(ctx, resourceUri) {
|
||||
if (!this.index) return;
|
||||
delete this.index[resourceUri];
|
||||
this.logger.info(`Catalog removed event ${resourceUri}`);
|
||||
async onUpdate(ctx, resource, actorUri) {
|
||||
const uri = uriOf(resource);
|
||||
await this.guarded(`update ${uri}`, async () => {
|
||||
if (!this.isOwnedBy(uri, actorUri, 'update')) return;
|
||||
if (!isListable(resource)) {
|
||||
// No longer listed (or never was): it must not stay listed from an earlier version.
|
||||
await removeEntry(ctx, { dataset: CONFIG.MAIN_DATASET, uri });
|
||||
return this.logNotListed(uri);
|
||||
}
|
||||
await upsertEntry(ctx, { dataset: CONFIG.MAIN_DATASET, uri, resource });
|
||||
this.logger.info(`Catalog updated event ${uri}`);
|
||||
});
|
||||
},
|
||||
async onDelete(ctx, resourceUri, actorUri) {
|
||||
await this.guarded(`remove ${resourceUri}`, async () => {
|
||||
if (!this.isOwnedBy(resourceUri, actorUri, 'remove')) return;
|
||||
await removeEntry(ctx, { dataset: CONFIG.MAIN_DATASET, uri: resourceUri });
|
||||
this.logger.info(`Catalog removed event ${resourceUri}`);
|
||||
});
|
||||
},
|
||||
/** The ownership test of every mutation; a refusal is logged, as it may be an attempt. */
|
||||
isOwnedBy(uri, actorUri, operation) {
|
||||
if (isInActorPod(uri, actorUri)) return true;
|
||||
this.logger.warn(`Catalog refused to ${operation} ${uri}: not in the pod of ${actorUri}, whose outbox emitted it`);
|
||||
return false;
|
||||
},
|
||||
logNotListed(uri) {
|
||||
this.logger.info(`Catalog does not list ${uri}: not addressed to the Public collection in \`to\``);
|
||||
},
|
||||
async guarded(what, run) {
|
||||
try {
|
||||
await run();
|
||||
} catch (e) {
|
||||
this.logger.error(`Catalog could not ${what}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const catalog = require('./catalog.service');
|
||||
const { removeEntryUpdate, upsertEntryUpdate, LIST_ENTRIES_QUERY, removeEntryIfSnapshotUpdate } = require('./catalog-store');
|
||||
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const BOB = 'http://localhost:3000/bob';
|
||||
const AS_EVENT = 'https://www.w3.org/ns/activitystreams#Event';
|
||||
|
||||
const event = (owner, extra = {}) => ({ id: `${owner}/data/${extra.slug || 'e1'}`, type: 'Event', name: 'Concert', ...extra });
|
||||
const listedEvent = owner => event(owner, { to: 'as:Public' });
|
||||
|
||||
/** The service as moleculer would run it: its methods bound to one object with a recording logger. */
|
||||
function service({ broker } = {}) {
|
||||
const logs = { info: [], warn: [], error: [] };
|
||||
const svc = {
|
||||
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
|
||||
settings: { type: 'as:Event' },
|
||||
broker: broker || { call: async () => [] }
|
||||
};
|
||||
if (catalog.created) catalog.created.call(svc);
|
||||
for (const [name, fn] of Object.entries(catalog.methods)) svc[name] = fn.bind(svc);
|
||||
return { svc, logs };
|
||||
}
|
||||
|
||||
/** A moleculer context recording triplestore calls; `rows` answers queries; `fail` makes every call throw. */
|
||||
function store({ rows = [], fail = false } = {}) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
call: async (action, params) => {
|
||||
calls.push({ action, params });
|
||||
if (fail) throw new Error('triplestore down');
|
||||
return action === 'triplestore.query' ? rows : undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const queries = ctx => ctx.calls.map(c => c.params.query);
|
||||
|
||||
describe('catalog mutations — ownership first, then the listing rule, then the store', () => {
|
||||
test("the owner's listed public event is indexed (the normal case)", async () => {
|
||||
const { svc, logs } = service();
|
||||
const ctx = store();
|
||||
const e = listedEvent(ALICE);
|
||||
await svc.onCreate(ctx, e, ALICE);
|
||||
expect(queries(ctx)).toEqual([upsertEntryUpdate(e.id, e)]);
|
||||
expect(logs.info).toContain(`Catalog indexed new event ${e.id}`);
|
||||
});
|
||||
|
||||
test("the owner's event without listed public intent is never indexed", async () => {
|
||||
for (const e of [
|
||||
event(ALICE), // no addressing: another app's event, or one posted directly on the pod
|
||||
event(ALICE, { to: BOB }), // addressed to a friend
|
||||
event(ALICE, { to: BOB, cc: 'as:Public' }), // public but unlisted
|
||||
event(ALICE, { bto: 'as:Public' })
|
||||
]) {
|
||||
const { svc, logs } = service();
|
||||
const ctx = store();
|
||||
await svc.onCreate(ctx, e, ALICE);
|
||||
expect(ctx.calls).toEqual([]);
|
||||
expect(logs.info.some(m => m.includes('does not list'))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("an actor cannot index an object of another actor's pod, listed or not", async () => {
|
||||
const { svc, logs } = service();
|
||||
const ctx = store();
|
||||
await svc.onCreate(ctx, listedEvent(BOB), ALICE);
|
||||
expect(ctx.calls).toEqual([]);
|
||||
expect(logs.warn[0]).toContain(`Catalog refused to index ${BOB}/data/e1: not in the pod of ${ALICE}`);
|
||||
});
|
||||
|
||||
test("an update re-indexes the owner's listed event, and de-indexes it once it is no longer listed", async () => {
|
||||
const { svc } = service();
|
||||
const listed = listedEvent(ALICE);
|
||||
const ctx = store();
|
||||
await svc.onUpdate(ctx, listed, ALICE);
|
||||
expect(queries(ctx)).toEqual([upsertEntryUpdate(listed.id, listed)]);
|
||||
|
||||
const unlisted = event(ALICE, { to: BOB, cc: 'as:Public' });
|
||||
const ctx2 = store();
|
||||
await svc.onUpdate(ctx2, unlisted, ALICE);
|
||||
expect(queries(ctx2)).toEqual([removeEntryUpdate(unlisted.id)]);
|
||||
});
|
||||
|
||||
test("an update emitted by another actor writes nothing — neither an upsert nor a de-index", async () => {
|
||||
for (const e of [listedEvent(BOB), event(BOB)]) {
|
||||
const { svc, logs } = service();
|
||||
const ctx = store();
|
||||
await svc.onUpdate(ctx, e, ALICE);
|
||||
expect(ctx.calls).toEqual([]);
|
||||
expect(logs.warn[0]).toContain('Catalog refused to update');
|
||||
}
|
||||
});
|
||||
|
||||
test("the owner's deletion de-indexes; another actor's deletion is refused", async () => {
|
||||
const own = store();
|
||||
await service().svc.onDelete(own, `${ALICE}/data/e1`, ALICE);
|
||||
expect(queries(own)).toEqual([removeEntryUpdate(`${ALICE}/data/e1`)]);
|
||||
|
||||
const { svc, logs } = service();
|
||||
const foreign = store();
|
||||
await svc.onDelete(foreign, `${BOB}/data/e1`, ALICE);
|
||||
expect(foreign.calls).toEqual([]);
|
||||
expect(logs.warn[0]).toContain(`Catalog refused to remove ${BOB}/data/e1: not in the pod of ${ALICE}`);
|
||||
});
|
||||
|
||||
test("a URI that only looks like it is in the actor's pod is refused", async () => {
|
||||
for (const uri of [`${ALICE}/../bob/data/e1`, `${ALICE}/%2e%2e/bob/data/e1`, 'http://localhost:3000/alice2/data/e1']) {
|
||||
const ctx = store();
|
||||
await service().svc.onDelete(ctx, uri, ALICE);
|
||||
await service().svc.onCreate(ctx, { id: uri, type: 'Event', to: 'as:Public' }, ALICE);
|
||||
expect(ctx.calls).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('without a known emitting actor, nothing is written', async () => {
|
||||
const ctx = store();
|
||||
await service().svc.onCreate(ctx, listedEvent(ALICE), undefined);
|
||||
await service().svc.onUpdate(ctx, listedEvent(ALICE), undefined);
|
||||
await service().svc.onDelete(ctx, `${ALICE}/data/e1`, undefined);
|
||||
expect(ctx.calls).toEqual([]);
|
||||
});
|
||||
|
||||
test('a store failure is logged, never thrown into the webhook path', async () => {
|
||||
const { svc, logs } = service();
|
||||
const ctx = store({ fail: true });
|
||||
await expect(svc.onCreate(ctx, listedEvent(ALICE), ALICE)).resolves.toBeUndefined();
|
||||
await expect(svc.onUpdate(ctx, listedEvent(ALICE), ALICE)).resolves.toBeUndefined();
|
||||
await expect(svc.onUpdate(ctx, event(ALICE), ALICE)).resolves.toBeUndefined();
|
||||
await expect(svc.onDelete(ctx, `${ALICE}/data/e1`, ALICE)).resolves.toBeUndefined();
|
||||
expect(logs.error).toHaveLength(4);
|
||||
expect(logs.error[0]).toContain('triplestore down');
|
||||
});
|
||||
|
||||
test('a malformed resource is refused or logged, never thrown', async () => {
|
||||
const { svc } = service();
|
||||
const ctx = store();
|
||||
await expect(svc.onCreate(ctx, undefined, ALICE)).resolves.toBeUndefined();
|
||||
await expect(svc.onUpdate(ctx, { type: 'Event', to: 'as:Public' }, ALICE)).resolves.toBeUndefined();
|
||||
expect(ctx.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog activity handlers — re-read from the pod, acting for the outbox owner', () => {
|
||||
// A ctx that answers `pod-resources.get` from a map of uri → { status, body } (the pod re-read),
|
||||
// and records `triplestore.query`/`triplestore.update` (the store writes), answering queries []`.
|
||||
function emitCtx(pod = {}) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
call: async (action, params) => {
|
||||
calls.push({ action, params });
|
||||
if (action === 'pod-resources.get') return pod[params.resourceUri] || { status: 404 };
|
||||
return action === 'triplestore.query' ? [] : undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
const writes = ctx => ctx.calls.filter(c => c.action === 'triplestore.update').map(c => c.params.query);
|
||||
// A Create/Update/Delete activity stored under Alice's outbox, naming an object in her pod.
|
||||
const act = (type, objectUri) => ({ id: `${ALICE}/outbox/${type}`, type, actor: ALICE, object: objectUri });
|
||||
|
||||
test("a forged activity on Alice's outbox naming Bob's event is refused: nothing is written", async () => {
|
||||
const { svc, logs } = service();
|
||||
const bobs = listedEvent(BOB);
|
||||
// Webhook body: a Create/Update/Delete whose EMBEDDED object is Bob's event, claimed public.
|
||||
// The watcher resolved actorUri = Alice (her outbox). No valid activity in Alice's pod names it.
|
||||
const ctx = emitCtx();
|
||||
await catalog.activities.create.onEmit.call(svc, ctx, { type: 'Create', actor: ALICE, object: bobs }, ALICE);
|
||||
await catalog.activities.update.onEmit.call(svc, ctx, { type: 'Update', actor: ALICE, object: bobs }, ALICE);
|
||||
await catalog.activities.delete.onEmit.call(svc, ctx, { type: 'Delete', actor: ALICE, object: { id: bobs.id, type: 'Tombstone' } }, ALICE);
|
||||
expect(writes(ctx)).toEqual([]);
|
||||
expect(logs.warn).toHaveLength(3); // each ignored with a reason
|
||||
});
|
||||
|
||||
test("an activity that the pod does not actually store (webhook-only) is refused", async () => {
|
||||
const { svc, logs } = service();
|
||||
const e = listedEvent(ALICE);
|
||||
const ctx = emitCtx(); // pod returns 404 for the activity
|
||||
await catalog.activities.create.onEmit.call(svc, ctx, act('Create', e.id), ALICE);
|
||||
expect(writes(ctx)).toEqual([]);
|
||||
expect(logs.warn[0]).toContain('ignored a Create');
|
||||
});
|
||||
|
||||
test("the owner's own, really-stored activities go through with the STORED object", async () => {
|
||||
const { svc } = service();
|
||||
const e = listedEvent(ALICE);
|
||||
// The webhook claims a bogus name; the pod holds the real one — the stored object must win.
|
||||
const stored = { ...e, name: 'the real stored name' };
|
||||
const create = act('Create', e.id);
|
||||
const del = act('Delete', e.id);
|
||||
const ctx = emitCtx({
|
||||
[create.id]: { status: 200, body: create },
|
||||
[del.id]: { status: 200, body: del },
|
||||
[e.id]: { status: 200, body: stored } // for the Create re-read; the Delete re-read also hits it
|
||||
});
|
||||
await catalog.activities.create.onEmit.call(svc, ctx, { ...create, object: { ...e, name: 'bogus' } }, ALICE);
|
||||
// The object is still live → the Delete is not honoured (the deletion is not true).
|
||||
await catalog.activities.delete.onEmit.call(svc, ctx, del, ALICE);
|
||||
expect(writes(ctx)).toEqual([upsertEntryUpdate(e.id, stored)]);
|
||||
|
||||
// Now the pod says the object is gone → the Delete de-indexes.
|
||||
const ctx2 = emitCtx({ [del.id]: { status: 200, body: del }, [e.id]: { status: 404 } });
|
||||
await catalog.activities.delete.onEmit.call(svc, ctx2, del, ALICE);
|
||||
expect(writes(ctx2)).toEqual([removeEntryUpdate(e.id)]);
|
||||
});
|
||||
|
||||
test('an embedded Tombstone of an event matches the delete handler (it is then judged by ownership)', async () => {
|
||||
const expand = t => (t === 'as:Event' || t === 'Event' ? AS_EVENT : t);
|
||||
const broker = { call: async (action, { types }) => [].concat(types).map(expand) };
|
||||
const { svc } = service({ broker });
|
||||
const activity = { type: 'Delete', object: { id: `${BOB}/data/e1`, type: 'Tombstone', formerType: 'as:Event' } };
|
||||
const result = await catalog.activities.delete.match.call(svc, activity, async () => false);
|
||||
expect(result.match).toBe(true);
|
||||
});
|
||||
|
||||
test('a matcher that fails reports no match instead of throwing', async () => {
|
||||
const { svc, logs } = service();
|
||||
const failingFetcher = async () => {
|
||||
throw new Error('proxy unreachable');
|
||||
};
|
||||
for (const key of ['create', 'update', 'delete']) {
|
||||
const activity = { type: key[0].toUpperCase() + key.slice(1), object: `${ALICE}/data/e1` };
|
||||
const result = await catalog.activities[key].match.call(svc, activity, failingFetcher);
|
||||
expect(result.match).toBe(false);
|
||||
}
|
||||
expect(logs.error).toHaveLength(3);
|
||||
expect(logs.error[0]).toContain('proxy unreachable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog — the decision is taken on the event read when acting, one notification at a time', () => {
|
||||
const act = (type, objectUri, n = '') => ({ id: `${ALICE}/outbox/${type}${n}`, type, actor: ALICE, object: objectUri });
|
||||
|
||||
test('CONCURRENT updates: an older "public" notification never re-lists an event made private', async () => {
|
||||
const e = listedEvent(ALICE);
|
||||
let current = e; // what the pod holds
|
||||
const entries = new Map(); // the catalog store
|
||||
const upd1 = act('Update', e.id, 1);
|
||||
const upd2 = act('Update', e.id, 2);
|
||||
const ctx = {
|
||||
call: async (action, params) => {
|
||||
if (action === 'pod-resources.get') {
|
||||
if (params.resourceUri === upd1.id) return { status: 200, body: upd1 };
|
||||
if (params.resourceUri === upd2.id) return { status: 200, body: upd2 };
|
||||
if (params.resourceUri === e.id) return { status: 200, body: current };
|
||||
return { status: 404 };
|
||||
}
|
||||
if (action === 'triplestore.update') {
|
||||
if (params.query === upsertEntryUpdate(e.id, e)) {
|
||||
await new Promise(resolve => setTimeout(resolve, 30)); // a slow store write
|
||||
entries.set(e.id, e);
|
||||
} else if (params.query === removeEntryUpdate(e.id)) {
|
||||
entries.delete(e.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const { svc } = service();
|
||||
const first = catalog.activities.update.onEmit.call(svc, ctx, upd1, ALICE); // notified while listed
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
current = event(ALICE, { name: 'now private' }); // the owner unlists it; a second notification follows
|
||||
const second = catalog.activities.update.onEmit.call(svc, ctx, upd2, ALICE);
|
||||
await Promise.all([first, second]);
|
||||
expect(entries.has(e.id)).toBe(false);
|
||||
});
|
||||
|
||||
test('a store write that lands after its timeout is re-judged: the newer de-listing wins', async () => {
|
||||
const { createKeyedSerializer } = require('./keyed-serializer');
|
||||
const e = listedEvent(ALICE);
|
||||
let current = e;
|
||||
const entries = new Map();
|
||||
let upserts = 0;
|
||||
const upd1 = act('Update', e.id, 1);
|
||||
const upd2 = act('Update', e.id, 2);
|
||||
const ctx = {
|
||||
call: async (action, params) => {
|
||||
if (action === 'pod-resources.get') {
|
||||
if (params.resourceUri === upd1.id) return { status: 200, body: upd1 };
|
||||
if (params.resourceUri === upd2.id) return { status: 200, body: upd2 };
|
||||
if (params.resourceUri === e.id) return { status: 200, body: current };
|
||||
return { status: 404 };
|
||||
}
|
||||
if (action === 'triplestore.update') {
|
||||
if (params.query === upsertEntryUpdate(e.id, e)) {
|
||||
if (upserts++ === 0) await new Promise(resolve => setTimeout(resolve, 120)); // sent in time, lands late
|
||||
entries.set(e.id, e);
|
||||
} else if (params.query === removeEntryUpdate(e.id)) {
|
||||
entries.delete(e.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const { svc } = service();
|
||||
svc.serialize = createKeyedSerializer({ timeoutMs: 50 });
|
||||
const first = catalog.activities.update.onEmit.call(svc, ctx, upd1, ALICE);
|
||||
await new Promise(resolve => setTimeout(resolve, 60));
|
||||
current = event(ALICE, { name: 'now private' });
|
||||
const second = catalog.activities.update.onEmit.call(svc, ctx, upd2, ALICE);
|
||||
await Promise.all([first, second]);
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
expect(entries.has(e.id)).toBe(false);
|
||||
});
|
||||
|
||||
test('a slow pod and NO late write: a timed-out sync schedules nothing (no self-re-queuing loop)', async () => {
|
||||
const { createKeyedSerializer } = require('./keyed-serializer');
|
||||
const e = listedEvent(ALICE);
|
||||
const upd = act('Update', e.id, 'loop');
|
||||
let eventReads = 0;
|
||||
let writes = 0;
|
||||
const ctx = {
|
||||
call: async (action, params) => {
|
||||
if (action === 'pod-resources.get') {
|
||||
if (params.resourceUri === upd.id) return { status: 200, body: upd };
|
||||
if (params.resourceUri === e.id) {
|
||||
eventReads++;
|
||||
await new Promise(resolve => setTimeout(resolve, 120)); // every read slower than the limit
|
||||
return { status: 200, body: e };
|
||||
}
|
||||
return { status: 404 };
|
||||
}
|
||||
if (action === 'triplestore.update') writes++;
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const { svc, logs } = service();
|
||||
svc.serialize = createKeyedSerializer({ timeoutMs: 50 });
|
||||
await catalog.activities.update.onEmit.call(svc, ctx, upd, ALICE);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
expect(eventReads).toBe(2); // the verification read and the one sync read — nothing re-queued
|
||||
expect(writes).toBe(0);
|
||||
expect(logs.error).toHaveLength(1); // the one timeout
|
||||
});
|
||||
|
||||
test('a public Note stored at the URI is never indexed (the stored type is re-checked)', async () => {
|
||||
const { svc, logs } = service();
|
||||
const note = { id: `${ALICE}/data/n1`, type: 'Note', to: 'as:Public', content: 'not an event' };
|
||||
const create = act('Create', note.id);
|
||||
const calls = [];
|
||||
const ctx = {
|
||||
call: async (action, params) => {
|
||||
calls.push(action);
|
||||
if (action === 'pod-resources.get') {
|
||||
if (params.resourceUri === create.id) return { status: 200, body: create };
|
||||
if (params.resourceUri === note.id) return { status: 200, body: note };
|
||||
return { status: 404 };
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
await catalog.activities.create.onEmit.call(svc, ctx, create, ALICE);
|
||||
expect(calls.filter(a => a.startsWith('triplestore'))).toEqual([]);
|
||||
expect(logs.warn[0]).toContain('is not a Event');
|
||||
});
|
||||
|
||||
test('an unreadable event (pod error) is left as it was: nothing written', async () => {
|
||||
const e = listedEvent(ALICE);
|
||||
const update = act('Update', e.id);
|
||||
let objectReads = 0;
|
||||
const calls = [];
|
||||
const ctx = {
|
||||
call: async (action, params) => {
|
||||
calls.push(action);
|
||||
if (action !== 'pod-resources.get') return [];
|
||||
if (params.resourceUri === update.id) return { status: 200, body: update };
|
||||
objectReads += 1;
|
||||
// The verification read sees the event; the read taken when acting fails.
|
||||
return objectReads === 1 ? { status: 200, body: e } : { status: 502 };
|
||||
}
|
||||
};
|
||||
const { svc, logs } = service();
|
||||
await catalog.activities.update.onEmit.call(svc, ctx, update, ALICE);
|
||||
expect(calls.filter(a => a.startsWith('triplestore'))).toEqual([]);
|
||||
expect(logs.warn[0]).toContain('HTTP 502');
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog listing — only listed public items are served, shape unchanged', () => {
|
||||
const rowOf = item => ({ uri: { value: item.id }, snapshot: { value: JSON.stringify(item) } });
|
||||
|
||||
test('entries already stored that are not listed public are filtered out at read', async () => {
|
||||
const listed = listedEvent(ALICE);
|
||||
const meetingPoint = { ...listedEvent(ALICE), id: `${ALICE}/data/mp`, 'as:context': listed.id };
|
||||
const stored = [
|
||||
rowOf(event(BOB, { slug: 'private', content: 'secret address 12 rue X' })),
|
||||
rowOf(listed),
|
||||
rowOf(event(BOB, { slug: 'friend', to: ALICE, content: 'only for my friend' })),
|
||||
rowOf(event(BOB, { slug: 'unlisted', cc: 'as:Public' })),
|
||||
rowOf(meetingPoint)
|
||||
];
|
||||
const { svc } = service();
|
||||
const result = await catalog.actions.discover.handler.call(svc, store({ rows: stored }));
|
||||
expect(Object.keys(result).sort()).toEqual(['@context', 'items', 'summary', 'totalItems', 'type']);
|
||||
expect(result['@context']).toBe('https://www.w3.org/ns/activitystreams');
|
||||
expect(result.type).toBe('Collection');
|
||||
expect(result.items).toEqual([listed, meetingPoint]);
|
||||
expect(result.totalItems).toBe(2);
|
||||
expect(JSON.stringify(result)).not.toContain('secret address');
|
||||
});
|
||||
});
|
||||
|
||||
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) } });
|
||||
|
||||
test('the sweep removes exactly the unlisted entries, conditionally on what it read', async () => {
|
||||
const listed = listedEvent(ALICE);
|
||||
const secret = event(BOB, { slug: 'private', content: 'secret address' });
|
||||
const unlisted = event(BOB, { slug: 'unlisted', cc: 'as:Public' });
|
||||
const broker = store({ rows: [rowOf(listed), rowOf(secret), rowOf(unlisted)] });
|
||||
const { svc, logs } = service({ broker });
|
||||
await catalog.started.call(svc);
|
||||
expect(queries(broker)).toEqual([
|
||||
LIST_ENTRIES_QUERY,
|
||||
removeEntryIfSnapshotUpdate(secret.id, JSON.stringify(secret)),
|
||||
removeEntryIfSnapshotUpdate(unlisted.id, JSON.stringify(unlisted))
|
||||
]);
|
||||
expect(logs.info[0]).toContain('Catalog removed 2 entries not listed to the Public collection');
|
||||
});
|
||||
|
||||
test('a failing sweep is logged and never prevents the start', async () => {
|
||||
const { svc, logs } = service({ broker: store({ fail: true }) });
|
||||
await expect(catalog.started.call(svc)).resolves.toBeUndefined();
|
||||
expect(logs.error[0]).toContain('Catalog could not sweep');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
const urlJoin = require('url-join');
|
||||
const CONFIG = require('../config/config');
|
||||
const { requestUri } = require('./base-path');
|
||||
|
||||
// Serves the app actor at its canonical uri (`/as/actor/app`) with a Solid-OIDC dual
|
||||
// personality, discriminated by the request's `JsonLdContext` header — same contract as
|
||||
@@ -28,11 +29,12 @@ module.exports = {
|
||||
|
||||
if (isOidcClientIdDocument) {
|
||||
// The client_id must equal the fetched URI exactly. `req.url` is rewritten to
|
||||
// the alias remainder by moleculer-web; the raw path survives in `originalUrl`.
|
||||
const requestPath = String((req && (req.originalUrl || req.url)) || '/as/actor/app').split('?')[0];
|
||||
// the alias remainder by moleculer-web; the raw path, base path included, survives
|
||||
// in `originalUrl`.
|
||||
const requestPath = String((req && (req.originalUrl || req.url)) || '').split('?')[0];
|
||||
return {
|
||||
'@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'],
|
||||
client_id: urlJoin(CONFIG.HOME_URL, requestPath),
|
||||
client_id: requestPath ? requestUri(CONFIG.HOME_URL, requestPath) : urlJoin(CONFIG.HOME_URL, 'as/actor/app'),
|
||||
redirect_uris: [urlJoin(CONFIG.FRONT_URL, 'login')],
|
||||
client_name: CONFIG.APP_NAME,
|
||||
client_uri: CONFIG.FRONT_URL
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// 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
|
||||
// already carries a value for the predicate (written by its creator or by another app), or the pod
|
||||
// refused to create the collection — and the mixin then runs `new URL(undefined)`, which throws. The
|
||||
// webhook call that runs the watcher is not awaited by the platform, so that throw ended the whole
|
||||
// backend process (observed 2026-10-12, an event POSTed with `apods:attendees` already set). Here,
|
||||
// nothing attached means no group to create.
|
||||
|
||||
/** The action definition; runs bound to a service with the mixin's settings and `getGroupSlugFromCollectionUri`. */
|
||||
const guardedCreateAndAttach = {
|
||||
async handler(ctx) {
|
||||
const { resourceUri, actorUri } = ctx.params;
|
||||
const { attachPredicate, collectionOptions, createWacGroup } = this.settings;
|
||||
|
||||
const collectionUri = await ctx.call('pod-collections.createAndAttach', {
|
||||
resourceUri,
|
||||
attachPredicate,
|
||||
collectionOptions,
|
||||
actorUri
|
||||
});
|
||||
if (!collectionUri) {
|
||||
this.logger.info(`No ${attachPredicate} collection attached to ${resourceUri} (already carries one, or creation refused)`);
|
||||
return undefined;
|
||||
}
|
||||
// The service's own follow-up for a collection it has just created (attendees: record it as
|
||||
// Festipod's, see public-read-ledger.js) — right after the attach, so a failure of what follows
|
||||
// (the WAC group) cannot skip it.
|
||||
if (typeof this.onCollectionAttached === 'function') {
|
||||
await this.onCollectionAttached(ctx, { resourceUri, collectionUri, actorUri });
|
||||
}
|
||||
|
||||
if (createWacGroup) {
|
||||
await ctx.call('pod-wac-groups.create', {
|
||||
groupSlug: this.getGroupSlugFromCollectionUri(collectionUri),
|
||||
actorUri
|
||||
});
|
||||
}
|
||||
return collectionUri;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { guardedCreateAndAttach };
|
||||
@@ -0,0 +1,155 @@
|
||||
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';
|
||||
const E = `${ALICE}/data/events/e1`;
|
||||
const C = `${ALICE}/data/e1-attendees`;
|
||||
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, recordFails = false } = {}) {
|
||||
const logs = { info: [], warn: [], error: [] };
|
||||
const calls = [];
|
||||
const ctx = {
|
||||
call: async (action, params) => {
|
||||
calls.push({ action, params });
|
||||
if (action === 'pod-resources.get') {
|
||||
const body = pod[params.resourceUri];
|
||||
return body ? { ok: true, status: 200, body } : { ok: false, status: 404 };
|
||||
}
|
||||
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;
|
||||
}
|
||||
};
|
||||
const svc = {
|
||||
name: schema.name,
|
||||
settings: { ...PodCollectionsHandlerMixin.settings, ...schema.settings },
|
||||
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
|
||||
actions: {}
|
||||
};
|
||||
for (const [name, fn] of Object.entries({ ...PodCollectionsHandlerMixin.methods, ...(schema.methods || {}) })) svc[name] = fn.bind(svc);
|
||||
const actions = { ...PodCollectionsHandlerMixin.actions, ...schema.actions };
|
||||
for (const [name, def] of Object.entries(actions)) {
|
||||
svc.actions[name] = (params, opts) => def.handler.call(svc, { ...opts, params, call: ctx.call });
|
||||
}
|
||||
const emit = (key, hint, actorUri) => schema.activities[key].onEmit.call(svc, ctx, hint, actorUri);
|
||||
return { svc, ctx, calls, logs, emit };
|
||||
}
|
||||
|
||||
const created = objectUri => ({ id: CREATE, type: 'Create', actor: ALICE, object: objectUri });
|
||||
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, 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',
|
||||
'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 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', '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 () => {
|
||||
const { calls, emit } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', name: 'private' } }, { attached: C });
|
||||
await emit('create', created(E), ALICE);
|
||||
expect(calls.some(c => c.action === 'public-read.reconcile' && c.params.resourceUri === E)).toBe(true);
|
||||
expect(calls.some(c => c.action === 'public-read.grant')).toBe(false);
|
||||
});
|
||||
|
||||
test('nothing attached (already carries a collection): no WAC group, no throw, still reconciled', async () => {
|
||||
const { calls, emit, logs } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', 'apods:attendees': C } }, { attached: undefined });
|
||||
await emit('create', created(E), ALICE);
|
||||
expect(actions(calls)).not.toContain('pod-wac-groups.create');
|
||||
expect(actions(calls)).not.toContain('public-read.recordCreated'); // not Festipod's collection: never revoked
|
||||
expect(actions(calls)).toContain('public-read.reconcile');
|
||||
expect(logs.error).toEqual([]);
|
||||
});
|
||||
|
||||
test("a forged Create naming another actor's event, or a stored Note, touches nothing", async () => {
|
||||
const malloryEvent = `${MALLORY}/data/events/e`;
|
||||
let w = bind(attendees, {}, { attached: C });
|
||||
await w.emit('create', { type: 'Create', actor: ALICE, object: { id: malloryEvent, type: 'Event' } }, ALICE);
|
||||
expect(actions(w.calls).filter(a => a !== 'pod-resources.get')).toEqual([]);
|
||||
w = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Note', to: 'as:Public' } }, { attached: C });
|
||||
await w.emit('create', created(E), ALICE);
|
||||
expect(actions(w.calls).filter(a => a !== 'pod-resources.get')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts — the webhook is a hint, and nothing attached creates no group', () => {
|
||||
const profile = { id: PROFILE, type: ['as:Profile', 'vcard:Individual'] };
|
||||
|
||||
test('the normal case: the collection is attached to the STORED profile, for the outbox owner', async () => {
|
||||
const { calls, emit } = bind(contacts, { [CREATE]: created(PROFILE), [PROFILE]: profile }, { attached: `${ALICE}/data/contacts` });
|
||||
// The body claims Mallory created it: the outbox owner (Alice), re-read from the pod, is who counts.
|
||||
await emit('create', { ...created(PROFILE), actor: MALLORY }, ALICE);
|
||||
const attach = calls.find(c => c.action === 'pod-collections.createAndAttach');
|
||||
expect(attach.params).toMatchObject({ resourceUri: PROFILE, actorUri: ALICE });
|
||||
expect(actions(calls)).toContain('pod-wac-groups.create');
|
||||
});
|
||||
|
||||
test('a Create the pod does not store attaches nothing', async () => {
|
||||
const { calls, emit } = bind(contacts, { [PROFILE]: profile }, { attached: `${ALICE}/data/contacts` });
|
||||
await emit('create', { type: 'Create', actor: ALICE, object: profile }, ALICE);
|
||||
expect(actions(calls)).not.toContain('pod-collections.createAndAttach');
|
||||
});
|
||||
|
||||
test('a stored object that is not a Profile attaches nothing', async () => {
|
||||
const { calls, emit } = bind(contacts, { [CREATE]: created(PROFILE), [PROFILE]: { id: PROFILE, type: 'Event' } }, { attached: `${ALICE}/data/contacts` });
|
||||
await emit('create', created(PROFILE), ALICE);
|
||||
expect(actions(calls)).not.toContain('pod-collections.createAndAttach');
|
||||
});
|
||||
|
||||
test('nothing attached (the profile already carries contacts): no WAC group, and nothing thrown', async () => {
|
||||
const { calls, emit, logs } = bind(contacts, { [CREATE]: created(PROFILE), [PROFILE]: profile }, { attached: undefined });
|
||||
await expect(emit('create', created(PROFILE), ALICE)).resolves.toBeUndefined();
|
||||
expect(actions(calls)).toContain('pod-collections.createAndAttach');
|
||||
expect(actions(calls)).not.toContain('pod-wac-groups.create');
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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)');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,17 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
|
||||
const { PodCollectionsHandlerMixin, PodResourcesHandlerMixin } = require('@activitypods/app');
|
||||
const CONFIG = require('../config/config');
|
||||
const { neverThrowingMatcher, verifiedEmit } = require('./emitted-activity');
|
||||
const { guardedCreateAndAttach } = require('./collection-attach');
|
||||
|
||||
// Attaches an `apods:contacts` collection (and a WAC group of the same members) to the `as:Profile`
|
||||
// a user creates, as the platform's collection handler does — with two differences that keep the
|
||||
// webhook a hint (see emitted-activity.js):
|
||||
// - the handler runs only for a Create `verifiedEmit` accepts: re-read from the pod as the app,
|
||||
// attributed to the owner of the outbox the webhook fired on, its object an `as:Profile` of that
|
||||
// owner's pod, re-read too. The mixin's handler would act on the object embedded in the
|
||||
// (unauthenticated) webhook body, attributed to the activity's self-declared `actor`;
|
||||
// - `createAndAttach` creates no WAC group when nothing was attached (collection-attach.js).
|
||||
module.exports = {
|
||||
name: 'contacts',
|
||||
mixins: [PodCollectionsHandlerMixin],
|
||||
@@ -13,5 +23,23 @@ module.exports = {
|
||||
summary: 'Festipod contacts'
|
||||
},
|
||||
createWacGroup: true
|
||||
},
|
||||
// Replaces the mixin's whole map (moleculer does not merge a custom schema key). The mixin's
|
||||
// update and delete handlers are left out on purpose: they never matched here (they require an
|
||||
// `onUpdate` this service does not have).
|
||||
activities: {
|
||||
create: {
|
||||
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.create.match),
|
||||
onEmit: verifiedEmit(
|
||||
'Create',
|
||||
async function (ctx, { object }, actorUri) {
|
||||
await this.onCreate(ctx, object, actorUri);
|
||||
},
|
||||
{ objectType: 'Profile' }
|
||||
)
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
createAndAttach: guardedCreateAndAttach
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,11 @@ const path = require('path');
|
||||
const { CoreService } = require('@semapps/core');
|
||||
const { apods, notify, interop, oidc, skos } = require('@semapps/ontologies');
|
||||
const CONFIG = require('../../config/config');
|
||||
const { createPublicSurfaceGate } = require('../public-surface');
|
||||
const { basePathOf, underBasePath } = require('../base-path');
|
||||
|
||||
// Festipod's own routes live under the base path, as SemApps' do (see base-path.js).
|
||||
const BASE_PATH = basePathOf(CONFIG.HOME_URL);
|
||||
|
||||
module.exports = {
|
||||
mixins: [CoreService],
|
||||
@@ -20,9 +25,16 @@ 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: 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: [
|
||||
{
|
||||
path: '/app',
|
||||
path: underBasePath(BASE_PATH, '/app'),
|
||||
aliases: {
|
||||
'GET /': {
|
||||
action: 'app.get',
|
||||
@@ -40,7 +52,7 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/as/actor/app',
|
||||
path: underBasePath(BASE_PATH, '/as/actor/app'),
|
||||
aliases: {
|
||||
'GET /': {
|
||||
// Served by a standalone service (not `app.get`): the Solid-OIDC client_id
|
||||
@@ -58,7 +70,7 @@ module.exports = {
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/catalog',
|
||||
path: underBasePath(BASE_PATH, '/catalog'),
|
||||
aliases: {
|
||||
'GET /': 'catalog.discover'
|
||||
},
|
||||
@@ -71,7 +83,10 @@ module.exports = {
|
||||
}
|
||||
]
|
||||
},
|
||||
webacl: false,
|
||||
void: false
|
||||
void: false,
|
||||
// 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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// What the backend may believe about an activity its watcher reports, or a server delivers to the
|
||||
// app's inbox — the check in front of every Festipod handler fed by that watcher (public-read,
|
||||
// catalog, attendees, contacts) or by that inbox (registration). A delivered body, like a webhook
|
||||
// body, is only a pointer.
|
||||
//
|
||||
// THE WEBHOOK IS A HINT, NOT A SOURCE. The webhook route (`/.webhooks/:uuid`) is unauthenticated: the
|
||||
// listener only matches the uuid, the watcher derives the box owner from the body's `target`, and an
|
||||
// activity or object EMBEDDED in the body is used as written, never re-read (platform behaviour,
|
||||
// @activitypods/app 2.3.2 `pod-activities-watcher.processWebhook`, @semapps/solid listener
|
||||
// `transfer`). Nothing in the body is trusted here except as a pointer: the activity is re-read by
|
||||
// its id from the pod, the object by its id, both through the box owner's access, and the handlers
|
||||
// act only on what the pod returns.
|
||||
//
|
||||
// WHO EMITTED IT. A resource lies in an actor's pod only if that actor (or an app it authorised)
|
||||
// wrote it: the pod refuses to create or modify a resource under another actor's path (see
|
||||
// `actor-pod.js`). So:
|
||||
// - an OUTBOX notification names its emitter: the owner of that outbox (`verifiedEmit`). The
|
||||
// activity must lie in that owner's pod and the stored activity must name that owner as `actor`;
|
||||
// - an INBOX notification does not (`verifiedReceive`): the emitter is the stored activity's own
|
||||
// `actor`, believed only because the activity lies in THAT actor's pod — someone else's claim
|
||||
// about an actor cannot be stored there;
|
||||
// - a DELIVERY to the app's own inbox names its emitter (`verifiedDelivery`): the inbox accepts it
|
||||
// only signed by the activity's `actor` (@semapps/activitypub 1.2.7 `inbox.post` refuses an actor
|
||||
// other than the signer, INVALID_ACTOR). Nothing else in the body is believed: the activity must
|
||||
// lie in that actor's pod and the copy stored there name them.
|
||||
//
|
||||
// An activity is accepted only when all of this holds:
|
||||
// 1. it is identified by a URI (an embedded activity without an id was never stored);
|
||||
// 2. for an outbox notification, that URI is strictly inside the emitter's pod;
|
||||
// 3. the pod returns it at that URI, as a resource with that id;
|
||||
// 4. its stored type is the one the handler expects, and it names exactly one `actor`: the emitter
|
||||
// (outbox), or an actor whose pod holds the activity (inbox);
|
||||
// 5. it names exactly one object, by URI, lying where the handler requires (the emitter's pod for
|
||||
// a resource the emitter creates, updates or deletes; the inbox owner's pod for an activity
|
||||
// about the inbox owner's object; anywhere when the handler checks the location itself);
|
||||
// 6. for a Delete, the pod says the object is gone (404/410) or serves a Tombstone — a live object
|
||||
// means the deletion is not true; otherwise the pod returns the object at that URI, as a
|
||||
// resource with that id, declaring the ActivityStreams type the handler acts on (a Note is not
|
||||
// an Event, whatever the notification says).
|
||||
//
|
||||
// WHAT IT DOES NOT DO: order notifications. Handlers run concurrently, one per webhook; this check
|
||||
// reads the pod BEFORE the handler acts, so on its own an older notification's action can still land
|
||||
// after a newer one's. A handler whose decision depends on the object's current state therefore takes
|
||||
// its own read INSIDE `keyed-serializer.js`, keyed by the object — that, not this check, is what makes
|
||||
// a replayed or reordered notification harmless.
|
||||
|
||||
const { isInActorPod } = require('./actor-pod');
|
||||
|
||||
const AS = 'https://www.w3.org/ns/activitystreams#';
|
||||
|
||||
const listOf = value => (value === undefined || value === null ? [] : Array.isArray(value) ? value : [value]);
|
||||
|
||||
/** The URI a value names: a string, or a node's `id`/`@id`; undefined otherwise. */
|
||||
function uriOf(value) {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value && typeof value === 'object') {
|
||||
const id = value.id || value['@id'];
|
||||
return typeof id === 'string' ? id : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The single URI a property holds (one value, a URI or a node with an id); undefined otherwise. */
|
||||
function singleUriOf(value) {
|
||||
const values = listOf(value);
|
||||
return values.length === 1 ? uriOf(values[0]) : undefined;
|
||||
}
|
||||
|
||||
/** The ActivityStreams types a node declares, as full IRIs (`Create`, `as:Create` and the IRI agree). */
|
||||
function asTypesOf(node) {
|
||||
if (!node || typeof node !== 'object') return [];
|
||||
return listOf(node.type || node['@type']).map(type => {
|
||||
if (typeof type !== 'string') return undefined;
|
||||
if (type.startsWith(AS)) return type;
|
||||
if (type.startsWith('as:')) return AS + type.slice('as:'.length);
|
||||
return type.includes(':') ? type : AS + type;
|
||||
});
|
||||
}
|
||||
|
||||
// `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']);
|
||||
|
||||
/** `objectOwnerUri` value for a handler that checks where the object lies itself. */
|
||||
const ANY_POD = Symbol('any pod');
|
||||
|
||||
/**
|
||||
* verifyActivity({ activity, type, emitterUri, objectOwnerUri, objectType, read }) →
|
||||
* { ok: true, activity, emitterUri, objectUri, object } (object: the stored object; undefined for a Delete)
|
||||
* { ok: false, reason }
|
||||
*
|
||||
* `activity` is what the watcher or the app's inbox handed over (a URI or a node — only its id is
|
||||
* used). `type` is the activity type the handler expects. `emitterUri` is the owner of the outbox the
|
||||
* notification names, the `actor` the app's inbox verified as the signer of a delivery, or `null` for
|
||||
* an inbox notification (the emitter is then the stored activity's actor). The object must lie in the
|
||||
* pod of `objectOwnerUri`, or anywhere with `ANY_POD`. `objectType` is the type the stored object
|
||||
* must declare — an ActivityStreams name or a full IRI; not checked for a Delete. `read(uri)` reads a
|
||||
* resource as the box owner — for a delivery, as its emitter — and resolves `{ status, body }`
|
||||
* (`status` 0 when the request itself failed). Never throws for a refusal; a throwing `read` propagates.
|
||||
*/
|
||||
async function verifyActivity({ activity, type, emitterUri, objectOwnerUri, objectType, read }) {
|
||||
if (!ACTIVITY_TYPES.has(type)) throw new Error(`Unsupported activity type ${type}`);
|
||||
if (objectOwnerUri === undefined) throw new Error('objectOwnerUri is required (a URI, or ANY_POD)');
|
||||
if (type !== 'Delete' && !objectType) throw new Error(`objectType is required for a ${type}`);
|
||||
const refuse = reason => ({ ok: false, reason });
|
||||
|
||||
const activityUri = uriOf(activity);
|
||||
if (!activityUri) return refuse('the activity has no id');
|
||||
if (emitterUri !== null && !isInActorPod(activityUri, emitterUri)) {
|
||||
return refuse(`the activity ${activityUri} is not in the pod of ${emitterUri}`);
|
||||
}
|
||||
|
||||
const storedActivity = await read(activityUri);
|
||||
if (storedActivity.status !== 200 || !storedActivity.body) {
|
||||
return refuse(`the pod does not return the activity ${activityUri} (HTTP ${storedActivity.status})`);
|
||||
}
|
||||
const stored = storedActivity.body;
|
||||
if (uriOf(stored) !== activityUri) return refuse(`the pod returned another resource for ${activityUri}`);
|
||||
if (!hasAsType(stored, type)) return refuse(`the stored activity ${activityUri} is not a ${type}`);
|
||||
const storedActor = singleUriOf(stored.actor);
|
||||
let emitter = emitterUri;
|
||||
if (emitter === null) {
|
||||
if (!storedActor || !isInActorPod(activityUri, storedActor)) {
|
||||
return refuse(`the stored activity ${activityUri} is not in the pod of the actor it names`);
|
||||
}
|
||||
emitter = storedActor;
|
||||
} else if (storedActor !== emitter) {
|
||||
return refuse(`the stored activity ${activityUri} is not attributed to ${emitter}`);
|
||||
}
|
||||
|
||||
const objectUri = singleUriOf(stored.object);
|
||||
if (!objectUri) return refuse(`the stored activity ${activityUri} has no single object id`);
|
||||
if (objectOwnerUri !== ANY_POD && !isInActorPod(objectUri, objectOwnerUri)) {
|
||||
return refuse(`the object ${objectUri} is not in the pod of ${objectOwnerUri}`);
|
||||
}
|
||||
|
||||
const storedObject = await read(objectUri);
|
||||
if (type === 'Delete') {
|
||||
const gone = storedObject.status === 404 || storedObject.status === 410;
|
||||
const tombstone = storedObject.status === 200 && hasAsType(storedObject.body, 'Tombstone');
|
||||
if (!gone && !tombstone) return refuse(`the object ${objectUri} is not deleted (HTTP ${storedObject.status})`);
|
||||
return { ok: true, activity: stored, emitterUri: emitter, objectUri, object: undefined };
|
||||
}
|
||||
if (storedObject.status !== 200 || !storedObject.body) {
|
||||
return refuse(`the pod does not return the object ${objectUri} (HTTP ${storedObject.status})`);
|
||||
}
|
||||
if (uriOf(storedObject.body) !== objectUri) return refuse(`the pod returned another resource for ${objectUri}`);
|
||||
if (!hasAsType(storedObject.body, objectType)) return refuse(`the stored object ${objectUri} is not a ${objectType}`);
|
||||
return { ok: true, activity: stored, emitterUri: emitter, objectUri, object: storedObject.body };
|
||||
}
|
||||
|
||||
/**
|
||||
* The outbox case: an activity `actorUri` emitted about an object in its own pod (a resource it
|
||||
* created, updated or deleted).
|
||||
*/
|
||||
function verifyEmittedActivity({ activity, actorUri, type, objectType, read }) {
|
||||
return verifyActivity({ activity, type, emitterUri: actorUri, objectOwnerUri: actorUri, objectType, read });
|
||||
}
|
||||
|
||||
/** `read` for the checks: a GET through the platform's `pod-resources.get`, as the box owner. */
|
||||
function podReader(ctx, actorUri) {
|
||||
return async uri => {
|
||||
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: uri, actorUri });
|
||||
return { status: typeof status === 'number' ? status : 0, body };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A matcher that never throws: on error it logs and reports no match. The watcher runs every
|
||||
* service's matchers for an activity in sequence without catching, inside a webhook call the
|
||||
* platform does not await — a throw would skip the handlers after this one and end the process.
|
||||
* The match itself runs on the webhook's (untrusted) data; it only routes, the verified handler decides.
|
||||
*/
|
||||
function neverThrowingMatcher(match) {
|
||||
return async function (activity, fetcher) {
|
||||
try {
|
||||
return await match.call(this, activity, fetcher);
|
||||
} catch (e) {
|
||||
this.logger.error(`${this.name} could not match activity ${uriOf(activity)}: ${e.message}`);
|
||||
return { match: false, dereferencedActivity: activity };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs `handle` (bound to the service) on an accepted verdict; logs a refusal or a failure, never throws. */
|
||||
function verifiedHandler(box, type, handle, verify) {
|
||||
return async function (ctx, activity, actorUri) {
|
||||
try {
|
||||
const verdict = await verify(activity, actorUri, podReader(ctx, actorUri));
|
||||
if (!verdict.ok) {
|
||||
this.logger.warn(`${this.name} ignored a ${type} reported in the ${box} of ${actorUri}: ${verdict.reason}`);
|
||||
return;
|
||||
}
|
||||
await handle.call(this, ctx, verdict, actorUri);
|
||||
} catch (e) {
|
||||
this.logger.error(`${this.name} could not process a ${type} reported in the ${box} of ${actorUri}: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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}`);
|
||||
return verifiedHandler('outbox', type, handle, (activity, actorUri, read) =>
|
||||
verifyActivity({
|
||||
activity,
|
||||
type,
|
||||
emitterUri: actorUri,
|
||||
objectOwnerUri: objectIn === 'any' ? ANY_POD : actorUri,
|
||||
objectType,
|
||||
read
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An `onReceive` (inbox handler) that runs `handle(ctx, verdict, actorUri)` only for an activity
|
||||
* `verifyActivity` accepts as emitted by the actor it names, about an object in the pod of
|
||||
* `actorUri`, the inbox owner. `verdict.emitterUri` is that emitter.
|
||||
*/
|
||||
function verifiedReceive(type, handle, { objectType } = {}) {
|
||||
return verifiedHandler('inbox', type, handle, (activity, actorUri, read) =>
|
||||
verifyActivity({ activity, type, emitterUri: null, objectOwnerUri: actorUri, objectType, read })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An `onReceive` for the APP's own inbox (`@semapps/activitypub` `ActivitiesHandlerMixin`, whose
|
||||
* third argument is the inbox owner, the app): runs `handle(ctx, verdict, emitterUri)` only for an
|
||||
* activity `verifyActivity` accepts as emitted by the delivered activity's `actor`, the signer the
|
||||
* inbox verified. Before anything is read, the activity's URI must lie in that actor's pod; the
|
||||
* activity and its object are then read through that actor's pod (`podReader`), the stored activity
|
||||
* having to name that same actor. `objectType` and `objectIn` are those of `verifiedEmit`. With
|
||||
* `objectIn: 'any'`, an object outside the actor's pod is read through the proxy THEIR actor document
|
||||
* declares — for an actor on another server, an answer that server chooses: a handler acting on such
|
||||
* an object reads it again where it lies (registration's `meetingPointOf`).
|
||||
*/
|
||||
function verifiedDelivery(type, handle, { objectType, objectIn = 'emitter' } = {}) {
|
||||
if (objectIn !== 'emitter' && objectIn !== 'any') throw new Error(`Unknown objectIn ${objectIn}`);
|
||||
return async function (ctx, activity, inboxOwnerUri) {
|
||||
try {
|
||||
const claimed = activity && typeof activity === 'object' ? singleUriOf(activity.actor) : undefined;
|
||||
if (!claimed) {
|
||||
this.logger.warn(`${this.name} ignored a ${type} delivered to ${inboxOwnerUri}: it names no single actor`);
|
||||
return;
|
||||
}
|
||||
const verdict = await verifyActivity({
|
||||
activity,
|
||||
type,
|
||||
emitterUri: claimed,
|
||||
objectOwnerUri: objectIn === 'any' ? ANY_POD : claimed,
|
||||
objectType,
|
||||
read: podReader(ctx, claimed)
|
||||
});
|
||||
if (!verdict.ok) {
|
||||
this.logger.warn(`${this.name} ignored a ${type} delivered to ${inboxOwnerUri} as from ${claimed}: ${verdict.reason}`);
|
||||
return;
|
||||
}
|
||||
await handle.call(this, ctx, verdict, verdict.emitterUri);
|
||||
} catch (e) {
|
||||
this.logger.error(`${this.name} could not process a ${type} delivered to ${inboxOwnerUri}: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
verifyActivity,
|
||||
verifyEmittedActivity,
|
||||
podReader,
|
||||
neverThrowingMatcher,
|
||||
verifiedEmit,
|
||||
verifiedReceive,
|
||||
verifiedDelivery,
|
||||
uriOf,
|
||||
singleUriOf,
|
||||
hasAsType,
|
||||
ANY_POD
|
||||
};
|
||||
@@ -0,0 +1,391 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const { verifyEmittedActivity, verifyActivity, verifiedEmit, verifiedReceive, verifiedDelivery, neverThrowingMatcher, ANY_POD } = require('./emitted-activity');
|
||||
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const BOB = 'http://localhost:3000/bob';
|
||||
|
||||
// The activity and object URIs a genuine Festipod creation produces: both under the actor's pod.
|
||||
const ACT = `${ALICE}/outbox/act-1`;
|
||||
const OBJ = `${ALICE}/data/events/e1`;
|
||||
|
||||
/** A `read(uri)` built from a map of uri → { status, body }; a missing uri answers 404. */
|
||||
const reader = pod => async uri => pod[uri] || { status: 404, body: undefined };
|
||||
|
||||
const createActivity = { id: ACT, type: 'Create', actor: ALICE, object: OBJ };
|
||||
const event = (extra = {}) => ({ id: OBJ, type: 'Event', name: 'Concert', ...extra });
|
||||
|
||||
describe('verifyEmittedActivity — the webhook is a hint; the pod is the truth', () => {
|
||||
test('the normal case: a Create emitted by the actor, re-read, is accepted with the STORED object', async () => {
|
||||
const stored = event({ to: 'as:Public', name: 'the real, stored name' });
|
||||
const read = reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: stored } });
|
||||
// The webhook copy carries a DIFFERENT, attacker-chosen object body — it must be ignored.
|
||||
const webhookActivity = { id: ACT, type: 'Create', actor: ALICE, object: { id: OBJ, type: 'Event', to: 'as:Public', name: 'forged' } };
|
||||
const r = await verifyEmittedActivity({ activity: webhookActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.objectUri).toBe(OBJ);
|
||||
expect(r.object.name).toBe('the real, stored name');
|
||||
});
|
||||
|
||||
test('the activity is re-read even when the webhook embeds a full activity node', async () => {
|
||||
const calls = [];
|
||||
const read = async uri => {
|
||||
calls.push(uri);
|
||||
return uri === ACT ? { status: 200, body: createActivity } : { status: 200, body: event() };
|
||||
};
|
||||
const embedded = { id: ACT, type: 'Create', actor: ALICE, object: event() };
|
||||
const r = await verifyEmittedActivity({ activity: embedded, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(calls).toEqual([ACT, OBJ]); // both re-read from the pod, nothing taken from the body
|
||||
});
|
||||
|
||||
test('FORGERY: a victim\'s private event named through the attacker\'s outbox is refused', async () => {
|
||||
// The attacker (Alice) owns her webhook URL; she forges a Create naming her own outbox as
|
||||
// target and a VICTIM\'s private event as the object, addressed to Public. The watcher resolves
|
||||
// actorUri = Alice (her outbox). The embedded object claims BOB\'s event is public.
|
||||
const victimEvent = `${BOB}/data/events/private`;
|
||||
const forged = { id: `${ALICE}/outbox/forged`, type: 'Create', actor: ALICE, object: { id: victimEvent, type: 'Event', to: 'as:Public' } };
|
||||
// Even if the pod happens to return the forged activity (it is in Alice\'s pod), its object is
|
||||
// in BOB\'s pod → refused at the object-ownership check, before any read of BOB\'s event.
|
||||
const read = reader({ [`${ALICE}/outbox/forged`]: { status: 200, body: forged } });
|
||||
const r = await verifyEmittedActivity({ activity: forged, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('not in the pod of');
|
||||
});
|
||||
|
||||
test('a Create the pod does not actually store (only in the webhook body) is refused', async () => {
|
||||
const read = reader({}); // the pod returns 404 for the activity
|
||||
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('does not return the activity');
|
||||
});
|
||||
|
||||
test('a stored activity attributed to someone else is refused', async () => {
|
||||
const read = reader({ [ACT]: { status: 200, body: { id: ACT, type: 'Create', actor: BOB, object: OBJ } } });
|
||||
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('not attributed to');
|
||||
});
|
||||
|
||||
test('a stored activity of the wrong type is refused', async () => {
|
||||
const read = reader({ [ACT]: { status: 200, body: { id: ACT, type: 'Announce', actor: ALICE, object: OBJ } } });
|
||||
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('is not a Create');
|
||||
});
|
||||
|
||||
test('an activity with no id (never stored) is refused before any read', async () => {
|
||||
let reads = 0;
|
||||
const read = async () => { reads++; return { status: 404 }; };
|
||||
const r = await verifyEmittedActivity({ activity: { type: 'Create', actor: ALICE, object: event() }, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('no id');
|
||||
expect(reads).toBe(0);
|
||||
});
|
||||
|
||||
test('a Delete is accepted only when the pod says the object is gone or a Tombstone', async () => {
|
||||
const del = { id: `${ALICE}/outbox/del`, type: 'Delete', actor: ALICE, object: OBJ };
|
||||
const act = { [`${ALICE}/outbox/del`]: { status: 200, body: del } };
|
||||
// Object gone → accepted.
|
||||
let r = await verifyEmittedActivity({ activity: del, actorUri: ALICE, type: 'Delete', objectType: 'Event', read: reader({ ...act, [OBJ]: { status: 404 } }) });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.objectUri).toBe(OBJ);
|
||||
// Tombstone → accepted.
|
||||
r = await verifyEmittedActivity({ activity: del, actorUri: ALICE, type: 'Delete', objectType: 'Event', read: reader({ ...act, [OBJ]: { status: 200, body: { id: OBJ, type: 'Tombstone' } } }) });
|
||||
expect(r.ok).toBe(true);
|
||||
// Object still live → refused (the deletion is not true).
|
||||
r = await verifyEmittedActivity({ activity: del, actorUri: ALICE, type: 'Delete', objectType: 'Event', read: reader({ ...act, [OBJ]: { status: 200, body: event() } }) });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('not deleted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifiedEmit — runs the handler only on an accepted activity, never throws', () => {
|
||||
function svc(pod) {
|
||||
const logs = { info: [], warn: [], error: [] };
|
||||
const ran = [];
|
||||
const service = {
|
||||
name: 'test',
|
||||
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
|
||||
};
|
||||
const ctx = { call: async (action, { resourceUri }) => (pod[resourceUri] || { status: 404 }) };
|
||||
const onEmit = verifiedEmit('Create', async function (_ctx, verdict, actorUri) {
|
||||
ran.push({ objectUri: verdict.objectUri, actorUri });
|
||||
}, { objectType: 'Event' }).bind(service);
|
||||
return { onEmit, ctx, logs, ran };
|
||||
}
|
||||
|
||||
test('an accepted Create runs the handler with the stored object and the watcher\'s actor', async () => {
|
||||
const { onEmit, ctx, ran } = svc({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: event() } });
|
||||
await onEmit(ctx, createActivity, ALICE);
|
||||
expect(ran).toEqual([{ objectUri: OBJ, actorUri: ALICE }]);
|
||||
});
|
||||
|
||||
test('a refused Create logs a warning and does NOT run the handler', async () => {
|
||||
const { onEmit, ctx, logs, ran } = svc({}); // pod returns 404 for the activity
|
||||
await onEmit(ctx, createActivity, ALICE);
|
||||
expect(ran).toEqual([]);
|
||||
expect(logs.warn).toHaveLength(1);
|
||||
expect(logs.warn[0]).toContain('ignored a Create');
|
||||
});
|
||||
|
||||
test('a throwing read is caught and logged, never propagated', async () => {
|
||||
const service = { name: 'test', logger: { info() {}, warn() {}, error(m) { this._e = m; } } };
|
||||
const onEmit = verifiedEmit('Create', async () => {}, { objectType: 'Event' }).bind(service);
|
||||
const ctx = { call: async () => { throw new Error('proxy down'); } };
|
||||
await expect(onEmit(ctx, createActivity, ALICE)).resolves.toBeUndefined();
|
||||
expect(service.logger._e).toContain('proxy down');
|
||||
});
|
||||
});
|
||||
|
||||
describe('neverThrowingMatcher', () => {
|
||||
test('a throwing matcher reports no match and logs', async () => {
|
||||
const service = { name: 'test', logger: { error(m) { this._e = m; } } };
|
||||
const wrapped = neverThrowingMatcher(async () => { throw new Error('boom'); }).bind(service);
|
||||
const r = await wrapped({ id: ACT }, async () => false);
|
||||
expect(r.match).toBe(false);
|
||||
expect(service.logger._e).toContain('boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the stored object must be of the type the handler acts on', () => {
|
||||
test('a public Note re-read at the object URI is refused for an Event handler; an Event is accepted', async () => {
|
||||
const note = { id: OBJ, type: 'Note', to: 'as:Public', content: 'not an event' };
|
||||
let r = await verifyEmittedActivity({
|
||||
activity: createActivity,
|
||||
actorUri: ALICE,
|
||||
type: 'Create',
|
||||
objectType: 'Event',
|
||||
read: reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: note } })
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('is not a Event');
|
||||
for (const type of ['Event', 'as:Event', 'https://www.w3.org/ns/activitystreams#Event', ['Event', 'apods:MeetingPoint']]) {
|
||||
r = await verifyEmittedActivity({
|
||||
activity: createActivity,
|
||||
actorUri: ALICE,
|
||||
type: 'Create',
|
||||
objectType: 'Event',
|
||||
read: reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: { id: OBJ, type } } })
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('a Profile handler refuses an Event and accepts a Profile', async () => {
|
||||
const pod = body => reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body } });
|
||||
const asProfile = { activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Profile' };
|
||||
expect((await verifyEmittedActivity({ ...asProfile, read: pod(event()) })).ok).toBe(false);
|
||||
expect((await verifyEmittedActivity({ ...asProfile, read: pod({ id: OBJ, type: ['as:Profile', 'vcard:Individual'] }) })).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('a type is required for anything but a Delete (a forgotten check is a programming error)', async () => {
|
||||
await expect(verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', read: reader({}) })).rejects.toThrow('objectType is required');
|
||||
});
|
||||
|
||||
test('the pod answering with another resource than the activity asked for is refused', async () => {
|
||||
const read = reader({ [ACT]: { status: 200, body: { ...createActivity, id: `${ALICE}/outbox/other` } }, [OBJ]: { status: 200, body: event() } });
|
||||
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('another resource');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyActivity — an activity RECEIVED in an inbox (emitter = the actor whose pod holds it)', () => {
|
||||
// Bob joins Alice's meeting point: the Join lies in Bob's pod, its object in Alice's pod; the
|
||||
// notification comes from ALICE's inbox (Alice is the box owner, `actorUri`).
|
||||
const MP = `${ALICE}/data/events/mp1`;
|
||||
const JOIN = `${BOB}/data/activities/join-1`;
|
||||
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP, to: ALICE };
|
||||
const meetingPoint = { id: MP, type: 'Event', 'apods:attendees': `${ALICE}/data/collections/mp1-attendees` };
|
||||
const received = read => verifyActivity({ activity: JOIN, type: 'Join', emitterUri: null, objectOwnerUri: ALICE, objectType: 'Event', read });
|
||||
|
||||
test('the normal case: a Join stored in the joiner\'s pod, about the inbox owner\'s meeting point', async () => {
|
||||
const r = await received(reader({ [JOIN]: { status: 200, body: join }, [MP]: { status: 200, body: meetingPoint } }));
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.emitterUri).toBe(BOB);
|
||||
expect(r.object).toEqual(meetingPoint);
|
||||
});
|
||||
|
||||
test('an activity naming an actor whose pod does not hold it is refused (Mallory cannot join as Bob)', async () => {
|
||||
const MALLORY = 'http://localhost:3000/mallory';
|
||||
const forgedUri = `${MALLORY}/data/activities/join-as-bob`;
|
||||
const read = reader({ [forgedUri]: { status: 200, body: { ...join, id: forgedUri, actor: BOB } }, [MP]: { status: 200, body: meetingPoint } });
|
||||
const r = await verifyActivity({ activity: forgedUri, type: 'Join', emitterUri: null, objectOwnerUri: ALICE, objectType: 'Event', read });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('not in the pod of the actor it names');
|
||||
});
|
||||
|
||||
test('a Join about an object outside the inbox owner\'s pod is refused (an embedded attendees URI never reaches a write)', async () => {
|
||||
const elsewhere = `${BOB}/data/events/bobs-mp`;
|
||||
const read = reader({ [JOIN]: { status: 200, body: { ...join, object: { id: elsewhere, type: 'Event', 'apods:attendees': 'http://localhost:3000/carol/data/c' } } } });
|
||||
const r = await received(read);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain(`not in the pod of ${ALICE}`);
|
||||
});
|
||||
|
||||
test('an activity with several actors, or a Leave where a Join is expected, is refused', async () => {
|
||||
let r = await received(reader({ [JOIN]: { status: 200, body: { ...join, actor: [BOB, ALICE] } }, [MP]: { status: 200, body: meetingPoint } }));
|
||||
expect(r.ok).toBe(false);
|
||||
r = await received(reader({ [JOIN]: { status: 200, body: { ...join, type: 'Leave' } }, [MP]: { status: 200, body: meetingPoint } }));
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('a location rule must be stated: ANY_POD explicitly, never by omission', async () => {
|
||||
await expect(verifyActivity({ activity: JOIN, type: 'Join', emitterUri: BOB, objectType: 'Event', read: reader({}) })).rejects.toThrow('objectOwnerUri is required');
|
||||
const read = reader({ [JOIN]: { status: 200, body: join }, [MP]: { status: 200, body: meetingPoint } });
|
||||
const r = await verifyActivity({ activity: JOIN, type: 'Join', emitterUri: BOB, objectOwnerUri: ANY_POD, objectType: 'Event', read });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifiedReceive / verifiedEmit with objectIn "any"', () => {
|
||||
const MP = `${ALICE}/data/events/mp1`;
|
||||
const JOIN = `${BOB}/data/activities/join-1`;
|
||||
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP };
|
||||
const pod = { [JOIN]: { status: 200, body: join }, [MP]: { status: 200, body: { id: MP, type: 'Event' } } };
|
||||
const service = () => ({ name: 'test', logger: { info() {}, warn(m) { this.w = m; }, error(m) { this.e = m; } } });
|
||||
const ctx = { call: async (action, { resourceUri }) => pod[resourceUri] || { status: 404 } };
|
||||
|
||||
test('an inbox Join runs the handler with the emitter read from the pod, not from the webhook body', async () => {
|
||||
const ran = [];
|
||||
const onReceive = verifiedReceive('Join', async (_ctx, verdict, actorUri) => ran.push([verdict.emitterUri, verdict.objectUri, actorUri]), { objectType: 'Event' }).bind(service());
|
||||
// The webhook body claims Carol joined; the stored activity says Bob.
|
||||
await onReceive(ctx, { id: JOIN, type: 'Join', actor: 'http://localhost:3000/carol', object: MP }, ALICE);
|
||||
expect(ran).toEqual([[BOB, MP, ALICE]]);
|
||||
});
|
||||
|
||||
test('an outbox Join about an event in another pod passes with objectIn "any", and is refused by default', async () => {
|
||||
const ran = [];
|
||||
const handle = async (_ctx, verdict) => ran.push(verdict.objectUri);
|
||||
await verifiedEmit('Join', handle, { objectType: 'Event', objectIn: 'any' }).call(service(), ctx, JOIN, BOB);
|
||||
const svc = service();
|
||||
await verifiedEmit('Join', handle, { objectType: 'Event' }).call(svc, ctx, JOIN, BOB);
|
||||
expect(ran).toEqual([MP]);
|
||||
expect(svc.logger.w).toContain(`not in the pod of ${BOB}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifiedEmit — a Join from a participant\'s outbox', () => {
|
||||
const MP = `${ALICE}/data/events/mp1`;
|
||||
const JOIN = `${BOB}/outbox/join-1`;
|
||||
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP };
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifiedDelivery — a Join delivered to the app's inbox: the body's actor is a claim", () => {
|
||||
const APP = 'http://localhost:3005/as/actor/app';
|
||||
const MALLORY = 'http://localhost:3000/mallory';
|
||||
const MP = `${ALICE}/data/events/mp1`;
|
||||
const JOIN = `${BOB}/data/activities/join-1`;
|
||||
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP, to: APP };
|
||||
const meetingPoint = { id: MP, type: 'Event', context: `${ALICE}/data/events/e1` };
|
||||
|
||||
/** A pod answering `stored` (uri → body), recording every read as [uri, actorUri]. */
|
||||
function setup(stored = { [JOIN]: join, [MP]: meetingPoint }) {
|
||||
const reads = [];
|
||||
const ctx = {
|
||||
call: async (_action, { resourceUri, actorUri }) => {
|
||||
reads.push([resourceUri, actorUri]);
|
||||
const body = stored[resourceUri];
|
||||
return body ? { status: 200, body } : { status: 404 };
|
||||
}
|
||||
};
|
||||
const logs = { warn: [], error: [] };
|
||||
const service = { name: 'test', logger: { info() {}, warn: m => logs.warn.push(m), error: m => logs.error.push(m) } };
|
||||
const ran = [];
|
||||
const deliver = (body, options = { objectType: 'Event', objectIn: 'any' }, handle = async (_ctx, verdict, emitterUri) => ran.push([emitterUri, verdict.object])) =>
|
||||
verifiedDelivery('Join', handle, options).call(service, ctx, body, APP);
|
||||
return { reads, logs, ran, deliver };
|
||||
}
|
||||
|
||||
test("the normal case: the claimed actor's pod holds the activity naming them — the handler runs for them, on the STORED object, everything read through their pod", async () => {
|
||||
const w = setup();
|
||||
await w.deliver({ ...join, object: { id: `${ALICE}/data/events/other`, type: 'Event' } });
|
||||
expect(w.ran).toEqual([[BOB, meetingPoint]]);
|
||||
expect(w.reads).toEqual([[JOIN, BOB], [MP, BOB]]);
|
||||
expect(w.logs.warn).toEqual([]);
|
||||
});
|
||||
|
||||
test("a body claiming another actor for an activity of Bob's pod is refused before anything is read", async () => {
|
||||
const w = setup();
|
||||
await w.deliver({ ...join, actor: MALLORY });
|
||||
expect(w.ran).toEqual([]);
|
||||
expect(w.reads).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes(`not in the pod of ${MALLORY}`))).toBe(true);
|
||||
});
|
||||
|
||||
test("an activity held in Mallory's pod claiming Bob as its actor is refused before anything is read", async () => {
|
||||
const forged = `${MALLORY}/data/activities/join-1`;
|
||||
const w = setup({ [forged]: { ...join, id: forged }, [MP]: meetingPoint });
|
||||
await w.deliver({ ...join, id: forged });
|
||||
expect(w.ran).toEqual([]);
|
||||
expect(w.reads).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes(`not in the pod of ${BOB}`))).toBe(true);
|
||||
});
|
||||
|
||||
test("an activity of Bob's pod whose stored copy names someone else is refused", async () => {
|
||||
const w = setup({ [JOIN]: { ...join, actor: ALICE }, [MP]: meetingPoint });
|
||||
await w.deliver(join);
|
||||
expect(w.ran).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes('not attributed to'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a body naming no single actor, or no body at all, is refused without a read', async () => {
|
||||
for (const body of [{ ...join, actor: undefined }, { ...join, actor: [BOB, ALICE] }, JOIN, undefined]) {
|
||||
const w = setup();
|
||||
await w.deliver(body);
|
||||
expect(w.ran).toEqual([]);
|
||||
expect(w.reads).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes('names no single actor'))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("an activity the claimed actor's pod does not hold is refused", async () => {
|
||||
const w = setup({ [MP]: meetingPoint });
|
||||
await w.deliver(join);
|
||||
expect(w.ran).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes('does not return the activity'))).toBe(true);
|
||||
});
|
||||
|
||||
test('by default the object must lie in the emitter\'s pod; objectIn "any" lets the handler check it', async () => {
|
||||
const w = setup();
|
||||
await w.deliver(join, { objectType: 'Event' });
|
||||
expect(w.ran).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes(`not in the pod of ${BOB}`))).toBe(true);
|
||||
});
|
||||
|
||||
test('a failing handler or read is logged, never thrown', async () => {
|
||||
const w = setup();
|
||||
await w.deliver(join, { objectType: 'Event', objectIn: 'any' }, async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
expect(w.logs.error.some(m => m.includes('boom'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
// One-at-a-time execution PER KEY, with a time limit — the ordering primitive behind every decision
|
||||
// the backend takes about one pod object (grant or revoke its public read, index or de-index it in the
|
||||
// catalog, add or remove an attendee).
|
||||
//
|
||||
// WHY. The platform runs the handlers of every webhook as soon as it arrives, without awaiting the
|
||||
// previous one. Two notifications about the same object therefore run CONCURRENTLY: each reads, then
|
||||
// acts — and one's action can land between the other's read and action.
|
||||
//
|
||||
// WHAT IT GUARANTEES — and what it does not:
|
||||
// - works for one key never overlap; each starts once the previous one for that key has settled
|
||||
// (finished, failed, or timed out), in the order they ENTERED `serialize` — which is the order the
|
||||
// handlers got there (after their own verification reads), NOT the order the user acted or the
|
||||
// pod emitted the activities;
|
||||
// - so it makes a decision order-proof only when the work derives that decision from state it reads
|
||||
// INSIDE: every pod change is notified after it happened, so whichever work runs last started
|
||||
// after the last change and acts on a state at least that recent (public-read `reconcile`, catalog
|
||||
// `sync`);
|
||||
// - a decision taken from a notification's own content (a Join versus a Leave) gains no ordering
|
||||
// from this; it must compare that content with recorded state under the lock
|
||||
// (`attendance-ledger.js`).
|
||||
//
|
||||
// BOUNDED. A work that has not settled after `timeoutMs` (default `DEFAULT_TIMEOUT_MS`) is abandoned:
|
||||
// its caller's promise rejects with a `SerializedWorkTimeout`, its `signal` is aborted, and the key is
|
||||
// released so the next work — the next notification — re-decides from a fresh read. A pod request that
|
||||
// never answers can then block one object for at most that long, never until restart. The abandoned
|
||||
// work cannot be stopped, so each work checks `signal.aborted` immediately before every write and
|
||||
// gives up once it is set. A write ALREADY SENT when the time ran out can still land after the next
|
||||
// work has read the state. So when an abandoned work finally settles, `onLateSettle(value)` (if given)
|
||||
// is called with what the work resolved (undefined if it rejected): the CALLER decides from it whether
|
||||
// a write actually landed late and, only then, queues one fresh decision for the key — a re-decision
|
||||
// that must not itself schedule another (no chain while the pod stays slow). A work that never
|
||||
// settles never wrote late.
|
||||
//
|
||||
// Keys are independent (different objects run concurrently); an idle key holds no memory.
|
||||
|
||||
// 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, 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;
|
||||
|
||||
class SerializedWorkTimeout extends Error {
|
||||
constructor(key, timeoutMs) {
|
||||
super(`serialized work for ${key} timed out after ${timeoutMs} ms: released, the next notification re-decides`);
|
||||
this.name = 'SerializedWorkTimeout';
|
||||
this.key = key;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* createKeyedSerializer({ timeoutMs }) → serialize(key, work, { onLateSettle })
|
||||
*
|
||||
* `serialize(key, work)` runs `work(signal)` once every work previously queued for `key` has settled,
|
||||
* and resolves or rejects as `work` does — or rejects with a `SerializedWorkTimeout` (and aborts
|
||||
* `signal`) when `work` has not settled within `timeoutMs`; `onLateSettle(value)` is then called if
|
||||
* and when that abandoned work settles, with its resolved value (undefined when it rejected). `serialize.pending()` is the number of keys with queued work.
|
||||
*/
|
||||
function createKeyedSerializer({ timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
||||
const tails = new Map();
|
||||
|
||||
function bounded(key, work, onLateSettle) {
|
||||
const controller = new AbortController();
|
||||
let timer;
|
||||
const expiry = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new SerializedWorkTimeout(key, timeoutMs));
|
||||
}, timeoutMs);
|
||||
if (typeof timer.unref === 'function') timer.unref();
|
||||
});
|
||||
const run = Promise.resolve().then(() => work(controller.signal));
|
||||
if (onLateSettle) {
|
||||
const late = value => {
|
||||
if (!controller.signal.aborted) return;
|
||||
try {
|
||||
onLateSettle(value);
|
||||
} catch {
|
||||
// a re-decision that cannot even be queued changes nothing; the next notification decides
|
||||
}
|
||||
};
|
||||
run.then(late, () => late(undefined));
|
||||
}
|
||||
// `race` handles `run` too, so a late rejection of an abandoned work is never unhandled.
|
||||
return Promise.race([run, expiry]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
function serialize(key, work, { onLateSettle } = {}) {
|
||||
const previous = tails.get(key) || Promise.resolve();
|
||||
const result = previous.then(() => bounded(key, work, onLateSettle));
|
||||
// The chain continues whatever `work` does; the tail never rejects.
|
||||
const tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
tails.set(key, tail);
|
||||
tail.then(() => {
|
||||
if (tails.get(key) === tail) tails.delete(key);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
serialize.pending = () => tails.size;
|
||||
return serialize;
|
||||
}
|
||||
|
||||
module.exports = { createKeyedSerializer, SerializedWorkTimeout, DEFAULT_TIMEOUT_MS };
|
||||
@@ -0,0 +1,156 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const { createKeyedSerializer, SerializedWorkTimeout, DEFAULT_TIMEOUT_MS } = require('./keyed-serializer');
|
||||
|
||||
const tick = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
describe('createKeyedSerializer — one work at a time per key', () => {
|
||||
test('works for one key never overlap and run in submission order, even when the first is slower', async () => {
|
||||
const serialize = createKeyedSerializer();
|
||||
const trace = [];
|
||||
const work = (name, ms) => async () => {
|
||||
trace.push(`start ${name}`);
|
||||
await tick(ms);
|
||||
trace.push(`end ${name}`);
|
||||
return name;
|
||||
};
|
||||
const results = await Promise.all([serialize('E', work('first', 30)), serialize('E', work('second', 1))]);
|
||||
expect(results).toEqual(['first', 'second']);
|
||||
expect(trace).toEqual(['start first', 'end first', 'start second', 'end second']);
|
||||
});
|
||||
|
||||
test('different keys run concurrently', async () => {
|
||||
const serialize = createKeyedSerializer();
|
||||
const trace = [];
|
||||
const work = (name, ms) => async () => {
|
||||
trace.push(`start ${name}`);
|
||||
await tick(ms);
|
||||
trace.push(`end ${name}`);
|
||||
};
|
||||
await Promise.all([serialize('E1', work('a', 20)), serialize('E2', work('b', 1))]);
|
||||
expect(trace.slice(0, 2).sort()).toEqual(['start a', 'start b']);
|
||||
expect(trace.indexOf('end b')).toBeLessThan(trace.indexOf('end a'));
|
||||
});
|
||||
|
||||
test('a failing work rejects its own caller but does not block the next one for its key', async () => {
|
||||
const serialize = createKeyedSerializer();
|
||||
const first = serialize('E', async () => {
|
||||
throw new Error('pod down');
|
||||
});
|
||||
const second = serialize('E', async () => 'ran');
|
||||
await expect(first).rejects.toThrow('pod down');
|
||||
await expect(second).resolves.toBe('ran');
|
||||
});
|
||||
|
||||
test('an idle key holds no memory', async () => {
|
||||
const serialize = createKeyedSerializer();
|
||||
await Promise.all([serialize('E1', async () => {}), serialize('E2', async () => {})]);
|
||||
await tick(0);
|
||||
expect(serialize.pending()).toBe(0);
|
||||
});
|
||||
|
||||
test('THE RACE IT CLOSES: the decision is taken on a read made when acting, so the newest state wins', async () => {
|
||||
// Two notifications about one event: it was public, then made private. Each handler reads the
|
||||
// event then acts. Unserialized, the first handler's read (public) can precede the second's while
|
||||
// its action lands last: grant-after-revoke. Serialized with the read INSIDE, the last action
|
||||
// follows a read of the newest state.
|
||||
const scenario = async runFor => {
|
||||
let podState = 'public';
|
||||
const acl = [];
|
||||
const handler = actDelay => async () => {
|
||||
const seen = podState; // the read, taken when acting
|
||||
await tick(actDelay); // the pod's latency before the action lands
|
||||
acl.push(seen === 'public' ? 'grant' : 'revoke');
|
||||
};
|
||||
const first = runFor(handler(30)); // slow: its action lands late
|
||||
await tick(5); // the first handler has read "public" and is acting
|
||||
podState = 'private'; // the owner switches the event; its notification arrives
|
||||
const second = runFor(handler(1));
|
||||
await Promise.all([first, second]);
|
||||
return acl[acl.length - 1];
|
||||
};
|
||||
// Unserialized (the baseline): the stale grant lands last.
|
||||
expect(await scenario(work => work())).toBe('grant');
|
||||
// Serialized per object: the second handler reads after the first has acted.
|
||||
const serialize = createKeyedSerializer();
|
||||
expect(await scenario(work => serialize('E', work))).toBe('revoke');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createKeyedSerializer — bounded: a stalled work never holds its key forever', () => {
|
||||
test('a work that never settles is abandoned after the timeout: its caller is told, the next work runs', async () => {
|
||||
const serialize = createKeyedSerializer({ timeoutMs: 50 });
|
||||
const stalled = serialize('E', () => new Promise(() => {})); // a pod request that never answers
|
||||
let ran = false;
|
||||
const next = serialize('E', async () => {
|
||||
ran = true;
|
||||
});
|
||||
await expect(stalled).rejects.toBeInstanceOf(SerializedWorkTimeout);
|
||||
await next;
|
||||
expect(ran).toBe(true);
|
||||
await tick(0);
|
||||
expect(serialize.pending()).toBe(0);
|
||||
});
|
||||
|
||||
test('the abandoned work sees its signal aborted, so it can give up before writing', async () => {
|
||||
const serialize = createKeyedSerializer({ timeoutMs: 20 });
|
||||
let wrote = false;
|
||||
const slow = serialize('E', async signal => {
|
||||
await tick(60); // the read that took too long
|
||||
if (signal.aborted) return;
|
||||
wrote = true;
|
||||
});
|
||||
await expect(slow).rejects.toBeInstanceOf(SerializedWorkTimeout);
|
||||
await tick(80);
|
||||
expect(wrote).toBe(false);
|
||||
});
|
||||
|
||||
test('a work within the limit is untouched, and the default limit is 30 s', async () => {
|
||||
const serialize = createKeyedSerializer({ timeoutMs: 100 });
|
||||
await expect(serialize('E', async signal => (signal.aborted ? 'aborted' : 'done'))).resolves.toBe('done');
|
||||
expect(DEFAULT_TIMEOUT_MS).toBe(30000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createKeyedSerializer — a write that lands after its timeout is re-decided', () => {
|
||||
test('onLateSettle runs once the abandoned work settles, and its re-decision runs after the newer work', async () => {
|
||||
const serialize = createKeyedSerializer({ timeoutMs: 20 });
|
||||
const order = [];
|
||||
const redecided = new Promise(resolve => {
|
||||
const slow = serialize(
|
||||
'E',
|
||||
async () => {
|
||||
await tick(60); // a write sent before the timeout, landing after it
|
||||
order.push('late write landed');
|
||||
return { wroteLate: true };
|
||||
},
|
||||
{ onLateSettle: value => value.wroteLate && serialize('E', async () => resolve(order.push('re-decision'))) }
|
||||
);
|
||||
slow.catch(() => order.push('timed out'));
|
||||
serialize('E', async () => order.push('newer work'));
|
||||
});
|
||||
await redecided;
|
||||
expect(order).toEqual(['timed out', 'newer work', 'late write landed', 're-decision']);
|
||||
});
|
||||
|
||||
test('onLateSettle is not called for a work that finished in time', async () => {
|
||||
const serialize = createKeyedSerializer({ timeoutMs: 100 });
|
||||
let called = false;
|
||||
await serialize('E', async () => {}, { onLateSettle: () => (called = true) });
|
||||
await tick(5);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createKeyedSerializer — onLateSettle receives what the late work resolved', () => {
|
||||
test('the value of a late work is handed over (undefined when it rejected), so the caller can tell a late write', async () => {
|
||||
const serialize = createKeyedSerializer({ timeoutMs: 20 });
|
||||
const seen = [];
|
||||
const done = new Promise(resolve => {
|
||||
serialize('A', async () => (await tick(50), { wroteLate: false }), { onLateSettle: v => seen.push(['A', v]) }).catch(() => {});
|
||||
serialize('B', async () => { await tick(50); throw new Error('pod down'); }, { onLateSettle: v => { seen.push(['B', v]); resolve(); } }).catch(() => {});
|
||||
});
|
||||
await done;
|
||||
await tick(10);
|
||||
expect(seen).toEqual([['A', { wroteLate: false }], ['B', undefined]]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
// 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:4005/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 = {}, proxied = {}, timeoutMs } = {}) {
|
||||
const calls = [];
|
||||
const logs = { info: [], warn: [], error: [] };
|
||||
const logger = { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) };
|
||||
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}`]);
|
||||
// `proxied[reader][uri]`: what the reader's OWN server answers for a resource outside the
|
||||
// reader's pod — the platform reads it through the proxy the reader's actor document declares.
|
||||
if (proxied[params.actorUri] && params.resourceUri in proxied[params.actorUri] && !params.resourceUri.startsWith(`${params.actorUri}/`)) {
|
||||
return { ok: true, status: 200, body: proxied[params.actorUri][params.resourceUri] };
|
||||
}
|
||||
return read(params.resourceUri);
|
||||
case 'pod-collections.getItems': {
|
||||
// As the platform does: the body's items, whatever the status — `[]` on a failed read.
|
||||
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);
|
||||
|
||||
/** Delivers an activity to the app's inbox, as the participant's pod does: the body is all the handler gets. */
|
||||
const deliver = (key, body) => registration.activities[key].onReceive.call(registrationSvc, ctx, body, APP);
|
||||
|
||||
return { ctx, calls, state, logs, registration: registrationSvc, participations: participationsSvc, deliver };
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// Festipod's PUBLIC intent, as the backend reads it on a created resource.
|
||||
//
|
||||
// The signal is ActivityStreams public addressing: the ActivityStreams Public collection in
|
||||
// `to` or `cc`. Festipod's create path writes it on every event and meeting point it creates
|
||||
// (`src/shared/refine/providers/activitypods.ts`, the `toValues` of those two bindings), and
|
||||
// the pod stores it on the object as written (observed 2026-10-12: stored as `"to": "as:Public"`;
|
||||
// the outbox `Create` the pod generates for a container POST carries no addressing at all).
|
||||
//
|
||||
// What it is NOT: provenance. The decision does not depend on which client created the
|
||||
// resource, only on whether the resource says it is public. An as:Event without public
|
||||
// addressing — created by another app, posted directly on the pod, addressed to chosen
|
||||
// recipients only, or a future Festipod record in a protected or private scope — never matches.
|
||||
|
||||
const AS_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
|
||||
|
||||
// The three equivalent spellings of the Public collection: the full IRI and the two compacted
|
||||
// forms JSON-LD compaction against the ActivityStreams context can produce.
|
||||
const PUBLIC_FORMS = new Set([AS_PUBLIC, 'as:Public', 'Public']);
|
||||
|
||||
// `to` and `cc`, under the compacted names and their prefixed and full-IRI spellings (a
|
||||
// representation compacted against a context that does not define the short terms).
|
||||
const ADDRESSING_KEYS = [
|
||||
'to',
|
||||
'cc',
|
||||
'as:to',
|
||||
'as:cc',
|
||||
'https://www.w3.org/ns/activitystreams#to',
|
||||
'https://www.w3.org/ns/activitystreams#cc'
|
||||
];
|
||||
|
||||
const listOf = value => (value === undefined || value === null ? [] : Array.isArray(value) ? value : [value]);
|
||||
|
||||
/** The address a `to`/`cc` entry names: a bare IRI string, or a node carrying `id`/`@id`. */
|
||||
const addressOf = entry => {
|
||||
if (typeof entry === 'string') return entry;
|
||||
if (entry && typeof entry === 'object') {
|
||||
const id = entry.id || entry['@id'];
|
||||
return typeof id === 'string' ? id : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* True when this ONE JSON-LD resource (an object or an activity) is addressed to the
|
||||
* ActivityStreams Public collection in `to` or `cc`. Anything else is false: no addressing,
|
||||
* addressing to actors or collections only, Public in `bto`/`bcc`/`audience`, a non-object input.
|
||||
*/
|
||||
const isAddressedToPublic = resource => {
|
||||
if (!resource || typeof resource !== 'object') return false;
|
||||
return ADDRESSING_KEYS.some(key => listOf(resource[key]).some(entry => PUBLIC_FORMS.has(addressOf(entry))));
|
||||
};
|
||||
|
||||
/**
|
||||
* hasPublicIntent(object, activity?) → boolean.
|
||||
*
|
||||
* `object` is the created resource as stored on the pod (what the app's activity watcher hands
|
||||
* to `onCreate`: the dereferenced `object` of the `Create`). `activity` is optional: the `Create`
|
||||
* itself, when the caller holds it. True when either one is addressed to the Public collection
|
||||
* (see `isAddressedToPublic`); false when neither is — a missing argument counts as not addressed.
|
||||
*
|
||||
* This is the ONLY gate in front of making a resource world-readable or listing it publicly:
|
||||
* a caller that grants or publishes on a creation must ask this first.
|
||||
*/
|
||||
const hasPublicIntent = (object, activity) => isAddressedToPublic(object) || isAddressedToPublic(activity);
|
||||
|
||||
// ── LISTED public: what a public index may list ───────────────────────────────────────────────
|
||||
//
|
||||
// ActivityStreams addressing distinguishes two public audiences by WHERE the Public collection is
|
||||
// named, the convention fediverse servers follow: in `to`, the resource is public AND listed — meant
|
||||
// to show up in public timelines and indexes; in `cc` only, it is public but UNLISTED — anyone who
|
||||
// has its link may read it, but nothing should advertise it. The public-read grant serves both
|
||||
// (`hasPublicIntent`); a public index — Festipod's catalog — may list only the first.
|
||||
|
||||
// `to` only, under the same three spellings as above.
|
||||
const TO_KEYS = ['to', 'as:to', 'https://www.w3.org/ns/activitystreams#to'];
|
||||
|
||||
/**
|
||||
* True when this ONE JSON-LD resource names the ActivityStreams Public collection in `to` (any of
|
||||
* its spellings, as a bare IRI or a node). Public in `cc` only — unlisted — is false, as is
|
||||
* everything `isAddressedToPublic` refuses.
|
||||
*/
|
||||
const isListedToPublic = resource => {
|
||||
if (!resource || typeof resource !== 'object') return false;
|
||||
return TO_KEYS.some(key => listOf(resource[key]).some(entry => PUBLIC_FORMS.has(addressOf(entry))));
|
||||
};
|
||||
|
||||
/**
|
||||
* hasListedPublicIntent(object, activity?) → boolean.
|
||||
*
|
||||
* Public intent (`hasPublicIntent`, same arguments) AND listed: the Public collection in `to` of
|
||||
* the object or of the activity. Built on `hasPublicIntent`, so it can never be true where that one
|
||||
* is false: nothing is listed that the public-read gate would not have accepted.
|
||||
*/
|
||||
const hasListedPublicIntent = (object, activity) =>
|
||||
hasPublicIntent(object, activity) && (isListedToPublic(object) || isListedToPublic(activity));
|
||||
|
||||
module.exports = { AS_PUBLIC, isAddressedToPublic, hasPublicIntent, isListedToPublic, hasListedPublicIntent };
|
||||
@@ -0,0 +1,128 @@
|
||||
const { test, expect } = require('bun:test');
|
||||
const { AS_PUBLIC, isAddressedToPublic, hasPublicIntent } = require('./public-intent');
|
||||
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const FRIEND = 'http://localhost:3000/bob';
|
||||
|
||||
test('an object addressed to the Public collection matches, in each of its three spellings', () => {
|
||||
// The pod's own representation (observed): `"to": "as:Public"`.
|
||||
expect(hasPublicIntent({ type: 'Event', to: 'as:Public' })).toBe(true);
|
||||
expect(hasPublicIntent({ type: 'Event', to: AS_PUBLIC })).toBe(true);
|
||||
expect(hasPublicIntent({ type: 'Event', to: 'Public' })).toBe(true);
|
||||
});
|
||||
|
||||
test('public addressing matches in cc, in a list, and as a node', () => {
|
||||
expect(hasPublicIntent({ to: [FRIEND], cc: 'as:Public' })).toBe(true);
|
||||
expect(hasPublicIntent({ to: [FRIEND, AS_PUBLIC] })).toBe(true);
|
||||
expect(hasPublicIntent({ to: { id: AS_PUBLIC } })).toBe(true);
|
||||
expect(hasPublicIntent({ to: [{ '@id': 'as:Public' }] })).toBe(true);
|
||||
expect(hasPublicIntent({ 'as:to': 'as:Public' })).toBe(true);
|
||||
expect(hasPublicIntent({ 'https://www.w3.org/ns/activitystreams#cc': { '@id': AS_PUBLIC } })).toBe(true);
|
||||
});
|
||||
|
||||
test('an object with no addressing never matches — whoever created it', () => {
|
||||
// A private event posted directly on the pod, or created by another app: no `to`/`cc`.
|
||||
expect(hasPublicIntent({ id: `${ALICE}/data/1`, type: 'Event', name: 'Dîner privé' })).toBe(false);
|
||||
// A meeting point without addressing is not public either: the signal, not the shape, decides.
|
||||
expect(hasPublicIntent({ type: 'Event', 'as:context': `${ALICE}/data/0` })).toBe(false);
|
||||
});
|
||||
|
||||
test('addressing to chosen recipients only never matches', () => {
|
||||
expect(hasPublicIntent({ type: 'Event', to: [FRIEND] })).toBe(false);
|
||||
expect(hasPublicIntent({ type: 'Event', to: FRIEND, cc: `${ALICE}/followers` })).toBe(false);
|
||||
// A protected-scope record addressed to the creator's contacts collection.
|
||||
expect(hasPublicIntent({ type: 'Event', to: `${ALICE}/contacts` })).toBe(false);
|
||||
});
|
||||
|
||||
test('Public anywhere but to/cc does not match', () => {
|
||||
expect(hasPublicIntent({ bto: AS_PUBLIC })).toBe(false);
|
||||
expect(hasPublicIntent({ bcc: 'as:Public' })).toBe(false);
|
||||
expect(hasPublicIntent({ audience: AS_PUBLIC })).toBe(false);
|
||||
// The IRI as a plain value of another predicate is not addressing.
|
||||
expect(hasPublicIntent({ name: AS_PUBLIC, content: 'as:Public' })).toBe(false);
|
||||
});
|
||||
|
||||
test('look-alike addresses do not match', () => {
|
||||
expect(hasPublicIntent({ to: 'https://www.w3.org/ns/activitystreams#PublicX' })).toBe(false);
|
||||
expect(hasPublicIntent({ to: 'public' })).toBe(false);
|
||||
expect(hasPublicIntent({ to: `${ALICE}/Public` })).toBe(false);
|
||||
expect(hasPublicIntent({ to: { name: 'as:Public' } })).toBe(false);
|
||||
});
|
||||
|
||||
test('the creating activity counts when the caller holds it', () => {
|
||||
const privateObject = { type: 'Event', to: [FRIEND] };
|
||||
expect(hasPublicIntent(privateObject, { type: 'Create', to: AS_PUBLIC })).toBe(true);
|
||||
expect(hasPublicIntent(privateObject, { type: 'Create', to: [FRIEND] })).toBe(false);
|
||||
// The Create the pod generates for a container POST carries no addressing (observed).
|
||||
expect(hasPublicIntent({ type: 'Event' }, { type: 'Create', actor: ALICE, object: `${ALICE}/data/1` })).toBe(false);
|
||||
});
|
||||
|
||||
test('missing or non-object input is not public', () => {
|
||||
expect(hasPublicIntent(undefined)).toBe(false);
|
||||
expect(hasPublicIntent(null, undefined)).toBe(false);
|
||||
expect(hasPublicIntent(`${ALICE}/data/1`)).toBe(false);
|
||||
expect(isAddressedToPublic({ to: null, cc: [] })).toBe(false);
|
||||
});
|
||||
|
||||
// ── Listed public: what the catalog may list ─────────────────────────────────────────────────
|
||||
|
||||
const { isListedToPublic, hasListedPublicIntent } = require('./public-intent');
|
||||
|
||||
test('Public in `to` is listed, in each spelling and shape', () => {
|
||||
// The pod's own representation of a Festipod event (observed): `"to": "as:Public"`.
|
||||
expect(hasListedPublicIntent({ type: 'Event', to: 'as:Public' })).toBe(true);
|
||||
expect(hasListedPublicIntent({ type: 'Event', to: AS_PUBLIC })).toBe(true);
|
||||
expect(hasListedPublicIntent({ type: 'Event', to: 'Public' })).toBe(true);
|
||||
expect(hasListedPublicIntent({ to: [FRIEND, AS_PUBLIC], cc: `${ALICE}/followers` })).toBe(true);
|
||||
expect(hasListedPublicIntent({ to: { id: AS_PUBLIC } })).toBe(true);
|
||||
expect(hasListedPublicIntent({ 'as:to': [{ '@id': 'as:Public' }] })).toBe(true);
|
||||
expect(hasListedPublicIntent({ 'https://www.w3.org/ns/activitystreams#to': AS_PUBLIC })).toBe(true);
|
||||
});
|
||||
|
||||
test('Public in `cc` only is public but UNLISTED: readable, never listed', () => {
|
||||
const unlisted = { type: 'Event', to: [FRIEND], cc: 'as:Public' };
|
||||
expect(hasPublicIntent(unlisted)).toBe(true);
|
||||
expect(hasListedPublicIntent(unlisted)).toBe(false);
|
||||
expect(hasListedPublicIntent({ cc: AS_PUBLIC })).toBe(false);
|
||||
expect(hasListedPublicIntent({ 'as:cc': { '@id': 'as:Public' } })).toBe(false);
|
||||
});
|
||||
|
||||
test('no addressing, chosen recipients, bto/bcc/audience and look-alikes are never listed', () => {
|
||||
for (const resource of [
|
||||
{ id: `${ALICE}/data/1`, type: 'Event', name: 'Dîner privé' },
|
||||
{ type: 'Event', to: [FRIEND] },
|
||||
{ type: 'Event', to: `${ALICE}/contacts` },
|
||||
{ bto: AS_PUBLIC },
|
||||
{ bcc: 'as:Public' },
|
||||
{ audience: AS_PUBLIC },
|
||||
{ name: AS_PUBLIC, content: 'as:Public' },
|
||||
{ to: 'https://www.w3.org/ns/activitystreams#PublicX' },
|
||||
{ to: 'public' },
|
||||
{ to: `${ALICE}/Public` },
|
||||
{ to: { name: 'as:Public' } },
|
||||
undefined,
|
||||
null,
|
||||
`${ALICE}/data/1`
|
||||
]) {
|
||||
expect(hasListedPublicIntent(resource)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('the creating activity counts the same way: `to` lists, `cc` does not', () => {
|
||||
const noAddressing = { type: 'Event' };
|
||||
expect(hasListedPublicIntent(noAddressing, { type: 'Create', to: AS_PUBLIC })).toBe(true);
|
||||
expect(hasListedPublicIntent(noAddressing, { type: 'Create', cc: AS_PUBLIC })).toBe(false);
|
||||
expect(hasListedPublicIntent(noAddressing, { type: 'Create', to: [FRIEND] })).toBe(false);
|
||||
});
|
||||
|
||||
test('listed implies public: never true where hasPublicIntent is false', () => {
|
||||
const addresses = [undefined, AS_PUBLIC, 'as:Public', 'Public', FRIEND, [FRIEND, 'as:Public'], { id: AS_PUBLIC }, 'public'];
|
||||
const shapes = [];
|
||||
for (const to of addresses) for (const cc of addresses) shapes.push({ to, cc });
|
||||
for (const object of shapes) {
|
||||
for (const activity of [undefined, ...shapes.slice(0, 8)]) {
|
||||
if (hasListedPublicIntent(object, activity)) expect(hasPublicIntent(object, activity)).toBe(true);
|
||||
}
|
||||
expect(isListedToPublic(object)).toBe(hasListedPublicIntent(object));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
// The one WAC shape Festipod adds to make a pod resource world-readable — `acl:Read` for
|
||||
// `acl:agentClass foaf:Agent` — plus how to read whether it is present and how to take exactly
|
||||
// that shape away again.
|
||||
//
|
||||
// How the pod's ACL API behaves (@semapps/webacl 1.2.7, read in setRights.ts + utils.ts, confirmed
|
||||
// live 2026-10-12): `PATCH {aclUri}` only ADDS triples; `PUT {aclUri}` REPLACES the resource's own
|
||||
// rights, computed as a diff over AGENT triples (`acl:agent`, `acl:agentClass`, `acl:agentGroup`,
|
||||
// `acl:origin`) keyed by the resource's own `#Read`/`#Write`/`#Append`/`#Control` authorizations.
|
||||
// Inherited `#Default*` rules come from the container, are not the resource's own, and are left
|
||||
// untouched by a PUT. An EMPTY own rule set is refused (400 "PUT rights cannot be empty").
|
||||
//
|
||||
// A Festipod event's only OWN rule is `#Read` with `acl:agentClass foaf:Agent` (the grant this
|
||||
// backend adds); the owner reads through the container's `#Default` rules, not an own rule. So
|
||||
// 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. 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
|
||||
// another own rule (a world-appendable collection, say) is left exactly as it was.
|
||||
//
|
||||
// INHERITED public read cannot be taken away here: a `#Default*` rule of the container is not the
|
||||
// resource's own, and a PUT on the resource's ACL leaves it in place. `hasOwnPublicRead` and
|
||||
// `hasInheritedPublicRead` tell the two apart so the caller can say which one it met.
|
||||
//
|
||||
// The platform's own `pod-permissions.remove` cannot do this: it matches the full foaf IRI against
|
||||
// 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');
|
||||
|
||||
const ACL = 'http://www.w3.org/ns/auth/acl#';
|
||||
const FOAF = 'http://xmlns.com/foaf/0.1/';
|
||||
const FOAF_AGENT = FOAF + 'Agent';
|
||||
const OWN_SUFFIXES = ['Read', 'Write', 'Append', 'Control'];
|
||||
const AGENT_PREDICATES = ['agent', 'agentClass', 'agentGroup', 'origin'];
|
||||
|
||||
/** Expands `acl:`/`foaf:` CURIEs and `{ '@id' }` / `{ id }` nodes to full IRIs; passes others through. */
|
||||
const expand = value => {
|
||||
const id = typeof value === 'object' && value !== null ? value['@id'] || value.id : value;
|
||||
if (typeof id !== 'string') return id;
|
||||
if (id.startsWith('acl:')) return ACL + id.slice('acl:'.length);
|
||||
if (id.startsWith('foaf:')) return FOAF + id.slice('foaf:'.length);
|
||||
return id;
|
||||
};
|
||||
|
||||
/** The id an authorization node carries (`@id` or `id`). */
|
||||
const authId = authorization =>
|
||||
(authorization && (authorization['@id'] || authorization.id)) || '';
|
||||
|
||||
/** The values of `acl:<name>` on an authorization, under its compacted or full-IRI key (raw, not expanded). */
|
||||
const rawValuesOf = (authorization, name) => [
|
||||
...arrayOf(authorization['acl:' + name]),
|
||||
...arrayOf(authorization[ACL + name])
|
||||
];
|
||||
|
||||
const expandedValuesOf = (authorization, name) => rawValuesOf(authorization, name).map(expand);
|
||||
|
||||
/** True when the authorization is a Read rule that lets any agent (foaf:Agent) read. */
|
||||
const isPublicRead = authorization =>
|
||||
expandedValuesOf(authorization, 'mode').includes(ACL + 'Read') &&
|
||||
expandedValuesOf(authorization, 'agentClass').includes(FOAF_AGENT);
|
||||
|
||||
/** True when one of the ACL's authorizations already lets any agent (foaf:Agent) read. */
|
||||
const grantsPublicRead = authorizations => arrayOf(authorizations).some(isPublicRead);
|
||||
|
||||
/** The fragment of an authorization's id (`Read` for `…#Read`, `DefaultRead` for an inherited rule). */
|
||||
const suffixOf = authorization => String(authId(authorization)).split('#')[1];
|
||||
|
||||
/** An authorization is the resource's OWN (not an inherited `#Default*`) when its id ends `#<suffix>`. */
|
||||
const isOwnAuthorization = authorization => OWN_SUFFIXES.includes(suffixOf(authorization));
|
||||
|
||||
/** True when the resource's OWN `#Read` rule lets any agent (foaf:Agent) read — what `revocationGraph` removes. */
|
||||
const hasOwnPublicRead = authorizations =>
|
||||
arrayOf(authorizations).some(authorization => suffixOf(authorization) === 'Read' && isPublicRead(authorization));
|
||||
|
||||
/** True when a rule that is NOT the resource's own (an inherited `#Default*`) lets any agent read. */
|
||||
const hasInheritedPublicRead = authorizations =>
|
||||
arrayOf(authorizations).some(authorization => !isOwnAuthorization(authorization) && isPublicRead(authorization));
|
||||
|
||||
/**
|
||||
* aclContext(aclUri) — the JSON-LD context to PUT with a revocation graph: the `acl:`/`foaf:`
|
||||
* prefixes and the id-typed agent/accessTo/mode terms, based at `aclUri` so `#Read` resolves to the
|
||||
* authorization fragment. Mirrors the pod's own `getAclContext` so the PUT round-trips identically.
|
||||
*/
|
||||
const aclContext = aclUri => ({
|
||||
'@base': aclUri,
|
||||
acl: ACL,
|
||||
foaf: FOAF,
|
||||
'acl:agent': { '@type': '@id' },
|
||||
'acl:agentClass': { '@type': '@id' },
|
||||
'acl:agentGroup': { '@type': '@id' },
|
||||
'acl:origin': { '@type': '@id' },
|
||||
'acl:mode': { '@type': '@id' },
|
||||
'acl:accessTo': { '@type': '@id' }
|
||||
});
|
||||
|
||||
/**
|
||||
* 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 = [];
|
||||
for (const authorization of arrayOf(authorizations)) {
|
||||
if (!isOwnAuthorization(authorization)) continue;
|
||||
const id = authId(authorization);
|
||||
const isReadRule = suffixOf(authorization) === 'Read';
|
||||
const node = { '@id': id, '@type': 'acl:Authorization', 'acl:accessTo': resourceUri };
|
||||
for (const mode of rawValuesOf(authorization, 'mode')) {
|
||||
node['acl:mode'] = node['acl:mode'] ? [].concat(node['acl:mode'], mode) : mode;
|
||||
}
|
||||
let kept = 0;
|
||||
for (const name of AGENT_PREDICATES) {
|
||||
const values = rawValuesOf(authorization, name).filter(
|
||||
value => !(isReadRule && name === 'agentClass' && expand(value) === FOAF_AGENT)
|
||||
);
|
||||
if (values.length > 0) {
|
||||
node['acl:' + name] = values.length === 1 ? values[0] : values;
|
||||
kept += values.length;
|
||||
}
|
||||
}
|
||||
if (kept > 0) graph.push(node);
|
||||
}
|
||||
// The pod refuses an empty own rule set; keep the owner as a reader (already true via the default).
|
||||
if (graph.length === 0) {
|
||||
graph.push({ '@id': '#Read', '@type': 'acl:Authorization', 'acl:accessTo': resourceUri, 'acl:mode': 'acl:Read', 'acl:agent': ownerUri });
|
||||
}
|
||||
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
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const { grantsPublicRead, hasOwnPublicRead, hasInheritedPublicRead, revocationGraph, isPublicRead, FOAF_AGENT } = require('./public-read-acl');
|
||||
|
||||
const ACL = 'http://www.w3.org/ns/auth/acl#';
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const E = `${ALICE}/data/events/e1`;
|
||||
const ACL_URI = `http://localhost:3000/_acl/alice/data/events/e1`;
|
||||
|
||||
const defaultRead = { '@id': '#DefaultRead', 'acl:mode': 'acl:Read', 'acl:agent': ALICE };
|
||||
const publicRead = (agentClass = 'foaf:Agent') => ({ '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': agentClass });
|
||||
|
||||
// Flatten the PUT graph's agent triples for assertions.
|
||||
const triples = doc => {
|
||||
const out = [];
|
||||
for (const n of doc['@graph']) for (const p of ['acl:agent', 'acl:agentClass', 'acl:agentGroup', 'acl:origin'])
|
||||
for (const o of [].concat(n[p] ?? [])) out.push({ auth: n['@id'], p, o });
|
||||
return out;
|
||||
};
|
||||
|
||||
describe('grantsPublicRead / isPublicRead', () => {
|
||||
test('detects the foaf:Agent read rule in both spellings', () => {
|
||||
expect(grantsPublicRead([defaultRead, publicRead('foaf:Agent')])).toBe(true);
|
||||
expect(grantsPublicRead([defaultRead, publicRead(FOAF_AGENT)])).toBe(true);
|
||||
expect(grantsPublicRead([defaultRead, publicRead({ '@id': FOAF_AGENT })])).toBe(true);
|
||||
});
|
||||
test('a non-public ACL is not public-read', () => {
|
||||
expect(grantsPublicRead([defaultRead])).toBe(false);
|
||||
expect(isPublicRead({ '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agent': ALICE })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revocationGraph — a PUT body that drops foaf:Agent, keeps the rest, never empty', () => {
|
||||
test('typical event (only own rule is #Read foaf:Agent): keeps owner, drops public, omits #Default', () => {
|
||||
const doc = revocationGraph([defaultRead, publicRead('foaf:Agent')], E, ALICE, ACL_URI);
|
||||
const ts = triples(doc);
|
||||
expect(ts.some(t => t.o === 'foaf:Agent' || t.o === FOAF_AGENT)).toBe(false);
|
||||
expect(ts.some(t => t.auth.includes('#Default'))).toBe(false); // PUT must not touch inherited defaults
|
||||
expect(ts.some(t => t.p === 'acl:agent' && t.o === ALICE && t.auth.includes('#Read'))).toBe(true);
|
||||
expect(doc['@context']['@base']).toBe(ACL_URI);
|
||||
});
|
||||
|
||||
test('full foaf IRI is dropped too', () => {
|
||||
const ts = triples(revocationGraph([publicRead(FOAF_AGENT)], E, ALICE, ACL_URI));
|
||||
expect(ts.some(t => t.o === FOAF_AGENT || t.o === 'foaf:Agent')).toBe(false);
|
||||
expect(ts.some(t => t.p === 'acl:agent' && t.o === ALICE)).toBe(true); // owner fallback, not empty
|
||||
});
|
||||
|
||||
test('a co-existing per-contact grant on #Read is preserved', () => {
|
||||
const carol = 'http://localhost:3000/carol';
|
||||
const node = { '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent', 'acl:agent': carol };
|
||||
const ts = triples(revocationGraph([node], E, ALICE, ACL_URI));
|
||||
expect(ts.some(t => t.p === 'acl:agent' && t.o === carol)).toBe(true);
|
||||
expect(ts.some(t => t.o === 'foaf:Agent' || t.o === FOAF_AGENT)).toBe(false);
|
||||
// The owner fallback is NOT added when another agent already keeps the set non-empty.
|
||||
expect(ts.some(t => t.p === 'acl:agent' && t.o === ALICE)).toBe(false);
|
||||
});
|
||||
|
||||
test('a separate own #Control rule is retained (only the public Read triple goes)', () => {
|
||||
const control = { '@id': '#Control', 'acl:mode': 'acl:Control', 'acl:agent': ALICE };
|
||||
const ts = triples(revocationGraph([control, publicRead('foaf:Agent')], E, ALICE, ACL_URI));
|
||||
expect(ts.some(t => t.auth.includes('#Control') && t.o === ALICE)).toBe(true);
|
||||
expect(ts.some(t => t.o === 'foaf:Agent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revocationGraph is NARROW — only the public READ goes', () => {
|
||||
test('a foaf:Agent on the own #Append rule (a world-appendable collection) is kept; only #Read loses it', () => {
|
||||
const append = { '@id': '#Append', 'acl:mode': 'acl:Append', 'acl:agentClass': 'foaf:Agent' };
|
||||
const ts = triples(revocationGraph([publicRead('foaf:Agent'), append], E, ALICE, ACL_URI));
|
||||
expect(ts.some(t => t.auth === '#Append' && t.p === 'acl:agentClass' && t.o === 'foaf:Agent')).toBe(true);
|
||||
expect(ts.some(t => t.auth === '#Read' && (t.o === 'foaf:Agent' || t.o === FOAF_AGENT))).toBe(false);
|
||||
});
|
||||
|
||||
test('a foaf:Agent on the own #Write rule is kept too, under either spelling', () => {
|
||||
const write = { '@id': '#Write', 'acl:mode': 'acl:Write', 'acl:agentClass': FOAF_AGENT };
|
||||
const ts = triples(revocationGraph([publicRead(FOAF_AGENT), write], E, ALICE, ACL_URI));
|
||||
expect(ts.some(t => t.auth === '#Write' && t.o === FOAF_AGENT)).toBe(true);
|
||||
expect(ts.some(t => t.auth === '#Read' && (t.o === 'foaf:Agent' || t.o === FOAF_AGENT))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('own versus inherited public read', () => {
|
||||
const inheritedPublic = { '@id': '#DefaultRead', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' };
|
||||
test('the own #Read rule is what a revocation can remove', () => {
|
||||
expect(hasOwnPublicRead([defaultRead, publicRead()])).toBe(true);
|
||||
expect(hasInheritedPublicRead([defaultRead, publicRead()])).toBe(false);
|
||||
});
|
||||
test('a public read inherited from the container is told apart (a PUT cannot remove it)', () => {
|
||||
expect(hasOwnPublicRead([inheritedPublic])).toBe(false);
|
||||
expect(hasInheritedPublicRead([inheritedPublic])).toBe(true);
|
||||
expect(grantsPublicRead([inheritedPublic])).toBe(true);
|
||||
});
|
||||
test('a foaf:Agent on another own rule is not a public read', () => {
|
||||
expect(hasOwnPublicRead([{ '@id': '#Append', 'acl:mode': 'acl:Append', 'acl:agentClass': 'foaf:Agent' }])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// Which anonymous-read grants are FESTIPOD's — the record that lets public-read revoke only what it
|
||||
// added.
|
||||
//
|
||||
// WHY. A pod resource can be world-readable for reasons that are not Festipod's: its owner shared it
|
||||
// publicly, another app did, or the pod made it so. When an event stops being public, Festipod must
|
||||
// take back the public read IT gave and leave any other one alone. The ACL cannot tell them apart (the
|
||||
// rule is the same `acl:Read` for `foaf:Agent`), so Festipod records each grant it makes, here, and
|
||||
// `revoke` acts only on a recorded one.
|
||||
//
|
||||
// WHAT COUNTS AS FESTIPOD'S:
|
||||
// - a grant `public-read.grant` added (the resource was not already world-readable);
|
||||
// - the public read the pod gives a collection Festipod itself created (an event's attendees
|
||||
// collection: the pod makes it world-readable at creation — observed 2026-10-12/13).
|
||||
// A resource already world-readable before Festipod touched it is never recorded, so never revoked.
|
||||
//
|
||||
// WHERE IT LIVES. Like the catalog (`catalog-store.js`): in the backend's main dataset, DEFAULT graph
|
||||
// (the one Fuseki persists), one triple per resource — subject the resource URI, one Festipod predicate,
|
||||
// the time of the grant. It survives a backend restart. Grants made before this record existed
|
||||
// (development data) are not in it, so they are not revoked.
|
||||
|
||||
const { iriRef } = require('./catalog-store');
|
||||
|
||||
const GRANTED_AT = 'http://festipod.org/publicReadGrantedAt';
|
||||
|
||||
/** Record (or re-record) that Festipod made `uri` world-readable. One update = one transaction. */
|
||||
function recordGrantUpdate(uri) {
|
||||
const s = iriRef(uri);
|
||||
return `
|
||||
DELETE { ${s} <${GRANTED_AT}> ?old }
|
||||
INSERT { ${s} <${GRANTED_AT}> ?now }
|
||||
WHERE { OPTIONAL { ${s} <${GRANTED_AT}> ?old } BIND(NOW() AS ?now) }
|
||||
`;
|
||||
}
|
||||
|
||||
/** Forget Festipod's grant on `uri` — only the ledger's predicate, nothing else about that URI. */
|
||||
function forgetGrantUpdate(uri) {
|
||||
return `DELETE WHERE { ${iriRef(uri)} <${GRANTED_AT}> ?at }`;
|
||||
}
|
||||
|
||||
/** Whether Festipod's grant on `uri` is recorded. */
|
||||
function grantedQuery(uri) {
|
||||
return `ASK { ${iriRef(uri)} <${GRANTED_AT}> ?at }`;
|
||||
}
|
||||
|
||||
// ── Store operations: `ctx` is a moleculer context (anything with `call`) ──
|
||||
|
||||
async function recordGrant(ctx, { dataset, uri }) {
|
||||
await ctx.call('triplestore.update', { query: recordGrantUpdate(uri), dataset, webId: 'system' });
|
||||
}
|
||||
|
||||
async function forgetGrant(ctx, { dataset, uri }) {
|
||||
await ctx.call('triplestore.update', { query: forgetGrantUpdate(uri), dataset, webId: 'system' });
|
||||
}
|
||||
|
||||
/** Resolves true only when the store answers the ASK with `true`. */
|
||||
async function isGrantedByFestipod(ctx, { dataset, uri }) {
|
||||
return (await ctx.call('triplestore.query', { query: grantedQuery(uri), dataset, webId: 'system' })) === true;
|
||||
}
|
||||
|
||||
module.exports = { GRANTED_AT, recordGrantUpdate, forgetGrantUpdate, grantedQuery, recordGrant, forgetGrant, isGrantedByFestipod };
|
||||
@@ -0,0 +1,331 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { PodResourcesHandlerMixin } = require('@activitypods/app');
|
||||
const CONFIG = require('../config/config');
|
||||
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,
|
||||
aclUriOf,
|
||||
readAcl,
|
||||
putOwnRights,
|
||||
FOAF_AGENT
|
||||
} = require('./public-read-acl');
|
||||
const { recordGrant, forgetGrant, isGrantedByFestipod } = require('./public-read-ledger');
|
||||
|
||||
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. */
|
||||
const attendeesCollectionOf = event => singleUriOf(event['apods:attendees'] ?? event[APODS_ATTENDEES]);
|
||||
|
||||
// Festipod creates its events and meeting points public: their bindings declare visibility
|
||||
// 'public', and its create path addresses each one to the ActivityStreams Public collection
|
||||
// (`src/shared/refine/providers/activitypods.ts`). This service owns the one WAC shape that makes
|
||||
// a pod resource world-readable — `acl:Read` for `acl:agentClass foaf:Agent`, nothing more — and
|
||||
// keeps it in step with the object's CURRENT public intent (`public-intent.js`), acting as the app
|
||||
// actor: an `as:Event` addressed to the Public collection is world-readable, one that is not is not.
|
||||
// Its attendees collection follows it both ways (the pod creates that collection world-readable).
|
||||
// It takes back ONLY the public reads Festipod gave — recorded in `public-read-ledger.js` — and leaves
|
||||
// any other one (the owner's, another app's) untouched.
|
||||
//
|
||||
// It learns of changes through the app's watcher on each user's outbox — the `Create` and `Update`
|
||||
// of any `as:Event`, whichever client made it and whatever its audience — and every notification
|
||||
// leads to the same `reconcile`: under the event's lock (`keyed-serializer.js`), read the event NOW,
|
||||
// then grant or revoke on the event and its collection. The notification itself only names the
|
||||
// object; whatever order notifications arrive or run in, the last action follows the newest read.
|
||||
module.exports = {
|
||||
name: 'public-read',
|
||||
mixins: [PodResourcesHandlerMixin],
|
||||
settings: {
|
||||
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event')
|
||||
},
|
||||
dependencies: ['pod-permissions'],
|
||||
created() {
|
||||
this.serialize = createKeyedSerializer();
|
||||
},
|
||||
// The mixin's default handlers would act on the activity's self-declared `actor` and on the
|
||||
// object embedded in the (unauthenticated) webhook body. Override the whole map: keep the
|
||||
// mixin's create/update matchers (made never-throwing), but every `onEmit` acts only on an
|
||||
// activity `verifiedEmit` accepts — re-read from the pod as the app, attributed to the owner of
|
||||
// the outbox the webhook fired on, its object an `as:Event` in that owner's pod — and even then
|
||||
// only as a pointer to the event `reconcile` reads again. No delete handler: when an event is
|
||||
// deleted its ACL goes with it.
|
||||
activities: {
|
||||
create: {
|
||||
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.create.match),
|
||||
onEmit: verifiedEmit(
|
||||
'Create',
|
||||
async function (ctx, { objectUri }, actorUri) {
|
||||
await this.onCreate(ctx, objectUri, actorUri);
|
||||
},
|
||||
{ objectType: 'Event' }
|
||||
)
|
||||
},
|
||||
update: {
|
||||
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.update.match),
|
||||
onEmit: verifiedEmit(
|
||||
'Update',
|
||||
async function (ctx, { objectUri }, actorUri) {
|
||||
await this.onUpdate(ctx, objectUri, actorUri);
|
||||
},
|
||||
{ objectType: 'Event' }
|
||||
)
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
/**
|
||||
* Brings the public read of an event of `actorUri`'s pod — and of its attendees collection — in
|
||||
* line with the event's current public intent. Serialized per event: the event is read when the
|
||||
* previous reconcile of the same event has finished, and that read decides. Resolves
|
||||
* `{ isPublic, targets }`, or undefined when the event could not be judged (gone, unreadable,
|
||||
* not an `as:Event`, not in the actor's pod). A failed grant or revoke is logged, never thrown.
|
||||
* A reconcile that timed out and then WROTE (a write in flight at the timeout that landed after
|
||||
* it) is followed by ONE fresh reconcile of the same event, which judges that late write against
|
||||
* the state as it then is; that re-decision schedules nothing more, and a timed-out reconcile that
|
||||
* wrote nothing late schedules nothing at all (see `keyed-serializer.js` `onLateSettle`).
|
||||
*/
|
||||
reconcile: {
|
||||
params: {
|
||||
resourceUri: { type: 'string' },
|
||||
actorUri: { type: 'string' }
|
||||
},
|
||||
async handler(ctx) {
|
||||
const { resourceUri, actorUri } = ctx.params;
|
||||
return this.serialize(resourceUri, signal => this.reconcileNow(ctx, resourceUri, actorUri, signal), {
|
||||
onLateSettle: result => {
|
||||
if (result && result.wroteLate) this.redecideOnce(ctx, resourceUri, actorUri);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
/** `grantNow` outside any lock (see there). */
|
||||
grant: {
|
||||
params: {
|
||||
resourceUri: { type: 'string' },
|
||||
actorUri: { type: 'string' }
|
||||
},
|
||||
async handler(ctx) {
|
||||
return this.grantNow(ctx, ctx.params.resourceUri, ctx.params.actorUri);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Records that Festipod created `resourceUri` (an event's attendees collection, right after it is
|
||||
* attached): whatever public read it has is Festipod's doing — the pod makes such collections
|
||||
* world-readable — so `revoke` may take it back.
|
||||
*/
|
||||
recordCreated: {
|
||||
params: {
|
||||
resourceUri: { type: 'string' },
|
||||
actorUri: { type: 'string' }
|
||||
},
|
||||
async handler(ctx) {
|
||||
const { resourceUri } = ctx.params;
|
||||
await recordGrant(ctx, { dataset: CONFIG.MAIN_DATASET, uri: resourceUri });
|
||||
this.logger.info(`Recorded ${resourceUri}, created by Festipod, as Festipod's to make public or not`);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
/** `revokeNow` outside any lock (see there). */
|
||||
revoke: {
|
||||
params: {
|
||||
resourceUri: { type: 'string' },
|
||||
actorUri: { type: 'string' }
|
||||
},
|
||||
async handler(ctx) {
|
||||
return this.revokeNow(ctx, ctx.params.resourceUri, ctx.params.actorUri);
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// The mixin's matchers fire only when these exist. Both take the object (or its URI) as a
|
||||
// POINTER: the decision is `reconcile`'s, on a read taken when acting. They never throw: the
|
||||
// watcher runs every matching handler of an activity in sequence without catching, inside a
|
||||
// webhook call the platform does not await.
|
||||
async onCreate(ctx, resource, actorUri) {
|
||||
await this.reconcileLogged(ctx, uriOf(resource), actorUri);
|
||||
},
|
||||
async onUpdate(ctx, resource, actorUri) {
|
||||
await this.reconcileLogged(ctx, uriOf(resource), actorUri);
|
||||
},
|
||||
async reconcileLogged(ctx, resourceUri, actorUri) {
|
||||
try {
|
||||
if (!resourceUri || !actorUri) throw new Error('no event or no actor to reconcile');
|
||||
await this.actions.reconcile({ resourceUri, actorUri }, { parentCtx: ctx });
|
||||
} catch (e) {
|
||||
this.logger.error(`Could not reconcile public read on ${resourceUri}: ${e.message}`);
|
||||
}
|
||||
},
|
||||
// The one re-decision after a late write: a plain serialized reconcile WITHOUT `onLateSettle`, so
|
||||
// whatever happens to it (even a timeout with a late write of its own) it schedules nothing more.
|
||||
async redecideOnce(ctx, resourceUri, actorUri) {
|
||||
try {
|
||||
await this.serialize(resourceUri, signal => this.reconcileNow(ctx, resourceUri, actorUri, signal));
|
||||
} catch (e) {
|
||||
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 (`public-read-acl.js` `readAcl`). */
|
||||
async readAcl(ctx, resourceUri, actorUri) {
|
||||
return readAcl(ctx, resourceUri, actorUri);
|
||||
},
|
||||
/**
|
||||
* Makes `resourceUri` world-readable and records it as FESTIPOD's grant — only when its ACL was
|
||||
* read and shows no public read: a resource already world-readable (the owner's share, another
|
||||
* app's) is left as it is and never recorded, and when the ACL cannot be read nothing is recorded
|
||||
* or written (the next notification retries). The record is written BEFORE the grant, so a
|
||||
* failure in between never leaves an unrecorded Festipod grant behind (which `revoke` would then
|
||||
* never take back). `signal` (from the event's lock) is checked immediately before each write;
|
||||
* the one write past it is re-recording a grant that LANDED late (a fact, see the code).
|
||||
* Resolves `true` when it added the grant, `false` otherwise; throws on a refusal or an unreadable ACL.
|
||||
*/
|
||||
async grantNow(ctx, resourceUri, actorUri, signal, late = {}) {
|
||||
const aborted = () => !!(signal && signal.aborted);
|
||||
// A write that RETURNS once the time has run out may have landed after a newer decision read.
|
||||
const wrote = () => {
|
||||
if (aborted()) late.wrote = true;
|
||||
};
|
||||
const acl = await this.readAcl(ctx, resourceUri, actorUri);
|
||||
if (!acl.authorizations) throw new Error(`the ACL of ${resourceUri} could not be read (HTTP ${acl.status}): nothing granted or recorded`);
|
||||
if (grantsPublicRead(acl.authorizations)) {
|
||||
this.logger.info(`${resourceUri} is already publicly readable`);
|
||||
return false;
|
||||
}
|
||||
const dataset = CONFIG.MAIN_DATASET;
|
||||
if (aborted()) return false;
|
||||
await recordGrant(ctx, { dataset, uri: resourceUri });
|
||||
wrote();
|
||||
if (aborted()) return false;
|
||||
const added = await ctx.call('pod-permissions.add', {
|
||||
uri: resourceUri,
|
||||
agentPredicate: 'acl:agentClass',
|
||||
agentUri: FOAF_AGENT,
|
||||
mode: 'acl:Read',
|
||||
actorUri
|
||||
});
|
||||
if (!added) {
|
||||
if (!aborted()) await forgetGrant(ctx, { dataset, uri: resourceUri }).catch(() => {});
|
||||
throw new Error(`The pod refused the public-read grant on ${resourceUri}`);
|
||||
}
|
||||
if (aborted()) {
|
||||
late.wrote = true;
|
||||
// The grant landed after the time ran out, and a newer decision may have run meanwhile and
|
||||
// forgotten the record (it saw no public read yet). A grant Festipod made must stay on record —
|
||||
// this writes a FACT, not a decision — so the re-decision that follows (`onLateSettle`) can
|
||||
// judge it against the current state and take it back if the event is no longer public.
|
||||
await recordGrant(ctx, { dataset, uri: resourceUri });
|
||||
this.logger.warn(`Public read on ${resourceUri} landed after its time ran out: re-recorded, to be re-decided`);
|
||||
return true;
|
||||
}
|
||||
this.logger.info(`Granted public read on ${resourceUri}`);
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* The reverse of `grantNow`, for a grant FESTIPOD made (recorded in the ledger) and only for it:
|
||||
* a public read Festipod did not add — the owner's, another app's, the pod's — is left untouched
|
||||
* and logged. Removes ONLY the `acl:agentClass foaf:Agent` triple of the resource's own `#Read`
|
||||
* rule, leaving every other OWN authorization (a `foaf:Agent` on another mode included) and the
|
||||
* inherited owner defaults in place, then forgets the record. When the ACL cannot be read, the
|
||||
* state is unknown: nothing changes and the record is KEPT, so a later notification retries.
|
||||
* Idempotent: with the own public-read rule already gone, it only forgets the record. A public
|
||||
* read INHERITED from the container cannot be removed this way: it is reported as a warning. Not
|
||||
* usable via `pod-permissions.remove` (see `public-read-acl.js`): it PUTs a reconstructed
|
||||
* own-rights graph, acting as the app actor (the same actor `grant` writes as, which holds
|
||||
* `acl:Control` on the resource). `signal` is checked immediately before each write. Resolves
|
||||
* `true` when it removed the rule, `false` otherwise; throws when the pod refuses the change.
|
||||
*/
|
||||
async revokeNow(ctx, resourceUri, actorUri, signal, late = {}) {
|
||||
const aborted = () => !!(signal && signal.aborted);
|
||||
const wrote = () => {
|
||||
if (aborted()) late.wrote = true;
|
||||
};
|
||||
const dataset = CONFIG.MAIN_DATASET;
|
||||
const acl = await this.readAcl(ctx, resourceUri, actorUri);
|
||||
if (!(await isGrantedByFestipod(ctx, { dataset, uri: resourceUri }))) {
|
||||
if (acl.authorizations && grantsPublicRead(acl.authorizations)) {
|
||||
this.logger.info(`${resourceUri} is publicly readable, but not through a grant Festipod made: left untouched`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!acl.authorizations) {
|
||||
this.logger.warn(`The ACL of ${resourceUri} could not be read (HTTP ${acl.status}): Festipod's grant kept, a later notification retries`);
|
||||
return false;
|
||||
}
|
||||
const inherited = hasInheritedPublicRead(acl.authorizations);
|
||||
if (!hasOwnPublicRead(acl.authorizations)) {
|
||||
if (aborted()) return false;
|
||||
await forgetGrant(ctx, { dataset, uri: resourceUri });
|
||||
wrote();
|
||||
if (inherited) {
|
||||
this.logger.warn(`${resourceUri} is publicly readable through a rule inherited from its container: not revocable here`);
|
||||
} else {
|
||||
this.logger.info(`${resourceUri} is not publicly readable: nothing to revoke`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const document = revocationGraph(acl.authorizations, resourceUri, actorUri, aclUriOf(resourceUri));
|
||||
if (aborted()) return false;
|
||||
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})`);
|
||||
}
|
||||
if (aborted()) {
|
||||
late.wrote = true; // the PUT landed after the time ran out: to be re-decided
|
||||
return true;
|
||||
}
|
||||
await forgetGrant(ctx, { dataset, uri: resourceUri });
|
||||
wrote();
|
||||
|
||||
this.logger.info(`Revoked public read on ${resourceUri}`);
|
||||
if (inherited) {
|
||||
this.logger.warn(`${resourceUri} stays publicly readable through a rule inherited from its container`);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
// Runs under the event's lock (see `reconcile`). The read here is the one that decides. Once
|
||||
// `signal` is aborted (the work timed out and the lock moved on) it writes nothing more; it
|
||||
// resolves `wroteLate: true` when a write it had sent returned after that (see `reconcile`).
|
||||
async reconcileNow(ctx, resourceUri, actorUri, signal) {
|
||||
if (!isInActorPod(resourceUri, actorUri)) {
|
||||
this.logger.warn(`Public read not reconciled on ${resourceUri}: not in the pod of ${actorUri}`);
|
||||
return undefined;
|
||||
}
|
||||
const { status, body } = await ctx.call('pod-resources.get', { resourceUri, actorUri });
|
||||
if (status === 404 || status === 410 || (status === 200 && hasAsType(body, 'Tombstone'))) {
|
||||
this.logger.info(`${resourceUri} is gone: its ACL went with it`);
|
||||
return undefined;
|
||||
}
|
||||
if (status !== 200 || !body || uriOf(body) !== resourceUri) {
|
||||
this.logger.warn(`Public read not reconciled on ${resourceUri}: the pod does not return it (HTTP ${status})`);
|
||||
return undefined;
|
||||
}
|
||||
if (!hasAsType(body, 'Event')) {
|
||||
this.logger.warn(`Public read not reconciled on ${resourceUri}: not an as:Event`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isPublic = hasPublicIntent(body);
|
||||
const targets = [resourceUri];
|
||||
const collectionUri = attendeesCollectionOf(body);
|
||||
if (collectionUri && isInActorPod(collectionUri, actorUri)) targets.push(collectionUri);
|
||||
else if (collectionUri) this.logger.warn(`${resourceUri} names an attendees collection outside its pod: left alone`);
|
||||
if (!isPublic) this.logger.info(`${resourceUri} is not addressed to the Public collection: no public read`);
|
||||
|
||||
const late = { wrote: false };
|
||||
for (const uri of targets) {
|
||||
if (signal && signal.aborted) break;
|
||||
try {
|
||||
if (isPublic) await this.grantNow(ctx, uri, actorUri, signal, late);
|
||||
else await this.revokeNow(ctx, uri, actorUri, signal, late);
|
||||
} catch (e) {
|
||||
this.logger.error(`Could not ${isPublic ? 'grant' : 'revoke'} public read on ${uri}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return { isPublic, targets, wroteLate: late.wrote };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,613 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const publicRead = require('./public-read.service');
|
||||
const { createKeyedSerializer } = require('./keyed-serializer');
|
||||
const { GRANTED_AT, grantedQuery, recordGrantUpdate, forgetGrantUpdate } = require('./public-read-ledger');
|
||||
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const BOB = 'http://localhost:3000/bob';
|
||||
const E = `${ALICE}/data/events/e1`;
|
||||
const C = `${ALICE}/data/collections/e1-attendees`;
|
||||
|
||||
const FOAF_AGENT = 'http://xmlns.com/foaf/0.1/Agent';
|
||||
const APP = 'http://localhost:3003/as/actor/app';
|
||||
// The ACL document shape `pod-permissions.get` returns (compacted with the acl:/foaf: prefixes).
|
||||
// Note: for an event, the owner's rights come from the container's inherited #Default rules — the
|
||||
// event's only OWN rule is #Read foaf:Agent (confirmed live 2026-10-12).
|
||||
const PUBLIC_READ_AUTH = { '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' };
|
||||
const DEFAULT_READ = { '@id': '#DefaultRead', 'acl:mode': 'acl:Read', 'acl:agent': ALICE };
|
||||
// A revoke PUT returns 204; capture its body so tests can assert the reconstructed graph.
|
||||
const okProxy = captured => params => {
|
||||
captured.put = params;
|
||||
return { status: 204 };
|
||||
};
|
||||
|
||||
const publicEvent = (extra = {}) => ({ id: E, type: 'Event', to: 'as:Public', ...extra });
|
||||
const privateEvent = (extra = {}) => ({ id: E, type: 'Event', name: 'now private', ...extra });
|
||||
|
||||
/** `pod-resources.get` answering from a map of uri → stored body (404 for anything else). */
|
||||
const stored = (...bodies) => ({ resourceUri }) => {
|
||||
const body = bodies.find(b => b.id === resourceUri);
|
||||
return body ? { ok: true, status: 200, body } : { ok: false, status: 404 };
|
||||
};
|
||||
|
||||
/**
|
||||
* The backend's grant ledger, answering exactly the queries `public-read-ledger.js` builds.
|
||||
* `granted` lists the resources Festipod is recorded as having made public.
|
||||
*/
|
||||
function ledgerCall(granted, action, params) {
|
||||
const match = /<([^>]+)> <http:\/\/festipod\.org\/publicReadGrantedAt>/.exec(params.query);
|
||||
if (!match) throw new Error(`unexpected store call ${params.query}`);
|
||||
const uri = match[1];
|
||||
if (action === 'triplestore.query' && params.query === grantedQuery(uri)) return granted.has(uri);
|
||||
if (action === 'triplestore.update' && params.query === recordGrantUpdate(uri)) return void granted.add(uri);
|
||||
if (action === 'triplestore.update' && params.query === forgetGrantUpdate(uri)) return void granted.delete(uri);
|
||||
throw new Error(`unexpected store call ${params.query}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The service with its actions+methods bound to a recording logger, over a programmable pod and a
|
||||
* grant ledger (by default: Festipod made both the event and its collection public).
|
||||
*/
|
||||
function service(pod, { granted = [E, C], timeoutMs } = {}) {
|
||||
const logs = { info: [], warn: [], error: [] };
|
||||
const calls = [];
|
||||
const ledger = new Set(granted);
|
||||
const svc = {
|
||||
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
|
||||
actions: {}
|
||||
};
|
||||
if (publicRead.created) publicRead.created.call(svc);
|
||||
if (timeoutMs) svc.serialize = createKeyedSerializer({ timeoutMs });
|
||||
const ctx = {
|
||||
call: async (action, params) => {
|
||||
calls.push({ action, params });
|
||||
if (action.startsWith('triplestore.')) return ledgerCall(ledger, action, params);
|
||||
if (action === 'pod-permissions.fetch' && !pod['pod-permissions.fetch']) {
|
||||
// The ACL read, served from a test's `pod-permissions.get` (authorizations, or `false` = a failed read).
|
||||
const uri = params.url.replace(/^(https?:\/\/[^/]+)\/_acl/, '$1');
|
||||
const authorizations = pod['pod-permissions.get'] ? await pod['pod-permissions.get']({ uri }) : undefined;
|
||||
return authorizations === false || authorizations === undefined ? { status: 500 } : { status: 200, body: { '@graph': authorizations } };
|
||||
}
|
||||
return pod[action] ? pod[action](params) : undefined;
|
||||
}
|
||||
};
|
||||
// Bind actions so `this.actions.reconcile|grant|revoke(...)` work from the methods.
|
||||
for (const [name, def] of Object.entries(publicRead.actions)) {
|
||||
svc.actions[name] = (params, opts) => def.handler.call(svc, { ...opts, params, call: ctx.call });
|
||||
}
|
||||
for (const [name, fn] of Object.entries(publicRead.methods)) svc[name] = fn.bind(svc);
|
||||
return { svc, ctx, logs, calls, ledger };
|
||||
}
|
||||
|
||||
// The agent triples a PUT revocation graph carries, flattened to {auth, p, o} for assertions.
|
||||
function putTriples(putParams) {
|
||||
const graph = JSON.parse(putParams.body)['@graph'];
|
||||
const out = [];
|
||||
for (const node of graph) {
|
||||
for (const p of ['acl:agent', 'acl:agentClass', 'acl:agentGroup', 'acl:origin']) {
|
||||
for (const o of [].concat(node[p] ?? [])) out.push({ auth: node['@id'], p, o });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('public-read.onUpdate — the grant and the ACL stay consistent with visibility', () => {
|
||||
test('private → public: grants acl:Read to foaf:Agent (and is idempotent)', async () => {
|
||||
const pod = { 'pod-resources.get': stored(publicEvent()), 'pod-permissions.get': () => [DEFAULT_READ], 'pod-permissions.add': () => true };
|
||||
const { svc, ctx, calls } = service(pod);
|
||||
await svc.onUpdate(ctx, publicEvent(), ALICE);
|
||||
const add = calls.find(c => c.action === 'pod-permissions.add');
|
||||
expect(add).toBeDefined();
|
||||
expect(add.params).toMatchObject({ uri: E, agentPredicate: 'acl:agentClass', agentUri: FOAF_AGENT, mode: 'acl:Read' });
|
||||
expect(calls.some(c => c.action === 'signature.proxy.query')).toBe(false);
|
||||
});
|
||||
|
||||
test('already public twin: grant finds the rule present and adds nothing', async () => {
|
||||
const pod = {
|
||||
'pod-resources.get': stored(publicEvent()),
|
||||
'pod-permissions.get': () => [DEFAULT_READ, PUBLIC_READ_AUTH],
|
||||
'pod-permissions.add': () => {
|
||||
throw new Error('must not add');
|
||||
}
|
||||
};
|
||||
const { svc, ctx, calls } = service(pod);
|
||||
await svc.onUpdate(ctx, publicEvent(), ALICE);
|
||||
expect(calls.some(c => c.action === 'pod-permissions.add')).toBe(false);
|
||||
});
|
||||
|
||||
test('public → private: PUTs a graph that drops foaf:Agent and keeps the owner (set not empty)', async () => {
|
||||
const captured = {};
|
||||
const pod = {
|
||||
'pod-resources.get': stored(privateEvent()),
|
||||
'pod-permissions.get': () => [DEFAULT_READ, PUBLIC_READ_AUTH],
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': okProxy(captured)
|
||||
};
|
||||
const { svc, ctx, calls } = service(pod);
|
||||
await svc.onUpdate(ctx, privateEvent(), ALICE);
|
||||
const put = calls.find(c => c.action === 'signature.proxy.query');
|
||||
expect(put).toBeDefined();
|
||||
expect(put.params).toMatchObject({ method: 'PUT', url: `http://localhost:3000/_acl/alice/data/events/e1`, actorUri: APP });
|
||||
const triples = putTriples(captured.put);
|
||||
// No foaf:Agent anywhere in the PUT; the #Default rule is NOT re-emitted (a PUT leaves it be).
|
||||
expect(triples.some(t => t.p === 'acl:agentClass' && (t.o === 'foaf:Agent' || t.o === FOAF_AGENT))).toBe(false);
|
||||
expect(triples.some(t => t.auth.includes('#Default'))).toBe(false);
|
||||
// The own set is non-empty: the owner is kept as acl:agent of #Read.
|
||||
expect(triples.some(t => t.p === 'acl:agent' && t.o === ALICE && t.auth.includes('#Read'))).toBe(true);
|
||||
});
|
||||
|
||||
test('public → private when the ACL stored the FULL foaf IRI: still dropped', async () => {
|
||||
const captured = {};
|
||||
const fullIriAuth = { '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': FOAF_AGENT };
|
||||
const pod = {
|
||||
'pod-resources.get': stored(privateEvent()),
|
||||
'pod-permissions.get': () => [DEFAULT_READ, fullIriAuth],
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': okProxy(captured)
|
||||
};
|
||||
const { svc, ctx } = service(pod);
|
||||
await svc.onUpdate(ctx, privateEvent(), ALICE);
|
||||
expect(putTriples(captured.put).some(t => t.o === FOAF_AGENT || t.o === 'foaf:Agent')).toBe(false);
|
||||
});
|
||||
|
||||
test('a per-contact own grant is preserved when the public rule is removed', async () => {
|
||||
const captured = {};
|
||||
const friend = 'http://localhost:3000/carol';
|
||||
const readWithFriend = { '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent', 'acl:agent': friend };
|
||||
const pod = {
|
||||
'pod-resources.get': stored(privateEvent()),
|
||||
'pod-permissions.get': () => [DEFAULT_READ, readWithFriend],
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': okProxy(captured)
|
||||
};
|
||||
const { svc, ctx } = service(pod);
|
||||
await svc.onUpdate(ctx, privateEvent(), ALICE);
|
||||
const triples = putTriples(captured.put);
|
||||
expect(triples.some(t => t.p === 'acl:agent' && t.o === friend)).toBe(true); // friend kept
|
||||
expect(triples.some(t => t.o === 'foaf:Agent' || t.o === FOAF_AGENT)).toBe(false); // public dropped
|
||||
});
|
||||
|
||||
test('update to private on an already-private event: nothing to revoke, no PUT', async () => {
|
||||
const pod = {
|
||||
'pod-resources.get': stored(privateEvent({ name: 'still private' })),
|
||||
'pod-permissions.get': () => [DEFAULT_READ],
|
||||
'signature.proxy.query': () => {
|
||||
throw new Error('must not PUT');
|
||||
}
|
||||
};
|
||||
const { svc, ctx, calls } = service(pod);
|
||||
await svc.onUpdate(ctx, privateEvent({ name: 'still private' }), ALICE);
|
||||
expect(calls.some(c => c.action === 'signature.proxy.query')).toBe(false);
|
||||
});
|
||||
|
||||
test('a pod refusal of the revoke PUT is logged, never thrown', async () => {
|
||||
const pod = {
|
||||
'pod-resources.get': stored(privateEvent()),
|
||||
'pod-permissions.get': () => [DEFAULT_READ, PUBLIC_READ_AUTH],
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': () => ({ status: 400 })
|
||||
};
|
||||
const { svc, ctx, logs } = service(pod);
|
||||
await expect(svc.onUpdate(ctx, privateEvent(), ALICE)).resolves.toBeUndefined();
|
||||
expect(logs.error.some(m => m.includes('HTTP 400'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a pod failure is logged, never thrown (the watcher is not awaited)', async () => {
|
||||
const pod = {
|
||||
'pod-resources.get': stored(publicEvent()),
|
||||
'pod-permissions.get': () => {
|
||||
throw new Error('pod down');
|
||||
}
|
||||
};
|
||||
const { svc, ctx, logs } = service(pod);
|
||||
await expect(svc.onUpdate(ctx, publicEvent(), ALICE)).resolves.toBeUndefined();
|
||||
expect(logs.error[0]).toContain('pod down');
|
||||
});
|
||||
|
||||
test('a failing read of the event itself is logged, never thrown', async () => {
|
||||
const pod = {
|
||||
'pod-resources.get': () => {
|
||||
throw new Error('proxy unreachable');
|
||||
}
|
||||
};
|
||||
const { svc, ctx, logs } = service(pod);
|
||||
await expect(svc.onUpdate(ctx, publicEvent(), ALICE)).resolves.toBeUndefined();
|
||||
expect(logs.error[0]).toContain('proxy unreachable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('public-read.onCreate — grants only on public intent', () => {
|
||||
test('a created public event is granted', async () => {
|
||||
const pod = { 'pod-resources.get': stored(publicEvent()), 'pod-permissions.get': () => [DEFAULT_READ], 'pod-permissions.add': () => true };
|
||||
const { svc, ctx, calls } = service(pod);
|
||||
await svc.onCreate(ctx, publicEvent(), ALICE);
|
||||
expect(calls.some(c => c.action === 'pod-permissions.add')).toBe(true);
|
||||
});
|
||||
|
||||
test('a created private event is not granted', async () => {
|
||||
const pod = { 'pod-resources.get': stored(privateEvent({ name: 'private' })), 'pod-permissions.get': () => [DEFAULT_READ] };
|
||||
const { svc, ctx, calls, logs } = service(pod);
|
||||
await svc.onCreate(ctx, privateEvent({ name: 'private' }), ALICE);
|
||||
expect(calls.some(c => c.action === 'pod-permissions.add')).toBe(false);
|
||||
expect(calls.some(c => c.action === 'signature.proxy.query')).toBe(false);
|
||||
expect(logs.info.some(m => m.includes('not addressed to the Public collection'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public-read.reconcile — decides on the STORED event, read when acting', () => {
|
||||
test('the notification\'s copy is only a pointer: a "public" copy of an event the pod holds private grants nothing', async () => {
|
||||
const pod = { 'pod-resources.get': stored(privateEvent()), 'pod-permissions.get': () => [DEFAULT_READ], 'pod-permissions.add': () => true };
|
||||
const { svc, ctx, calls } = service(pod);
|
||||
await svc.onUpdate(ctx, publicEvent(), ALICE);
|
||||
expect(calls.some(c => c.action === 'pod-permissions.add')).toBe(false);
|
||||
});
|
||||
|
||||
test('a public Note stored at the URI is not an event: nothing granted (the type is re-checked)', async () => {
|
||||
const note = { id: E, type: 'Note', to: 'as:Public' };
|
||||
const pod = { 'pod-resources.get': stored(note), 'pod-permissions.get': () => [DEFAULT_READ], 'pod-permissions.add': () => true };
|
||||
const { svc, ctx, calls, logs } = service(pod);
|
||||
await svc.onCreate(ctx, E, ALICE);
|
||||
expect(calls.some(c => c.action.startsWith('pod-permissions'))).toBe(false);
|
||||
expect(logs.warn[0]).toContain('not an as:Event');
|
||||
});
|
||||
|
||||
test("an event outside the actor's pod is neither read nor touched", async () => {
|
||||
const bobs = `${BOB}/data/events/e1`;
|
||||
const { svc, ctx, calls, logs } = service({});
|
||||
await svc.onUpdate(ctx, bobs, ALICE);
|
||||
expect(calls).toEqual([]);
|
||||
expect(logs.warn[0]).toContain(`not in the pod of ${ALICE}`);
|
||||
});
|
||||
|
||||
test('a deleted event is left alone (its ACL went with it)', async () => {
|
||||
const { svc, ctx, calls } = service({ 'pod-resources.get': () => ({ status: 404 }) });
|
||||
await svc.onUpdate(ctx, E, ALICE);
|
||||
expect(calls.map(c => c.action)).toEqual(['pod-resources.get']);
|
||||
});
|
||||
|
||||
test('CONCURRENT notifications: the newest state wins (no grant lands after the revoke)', async () => {
|
||||
// A stateful pod: the event's addressing, and whether its ACL holds the public-read rule.
|
||||
// The grant is slow (the pod's latency); the event is made private while the first handler acts.
|
||||
let current = publicEvent();
|
||||
let aclIsPublic = false;
|
||||
const pod = {
|
||||
'pod-resources.get': ({ resourceUri }) => (resourceUri === E ? { status: 200, body: current } : { status: 404 }),
|
||||
'pod-permissions.get': () => (aclIsPublic ? [DEFAULT_READ, PUBLIC_READ_AUTH] : [DEFAULT_READ]),
|
||||
'pod-permissions.add': async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
aclIsPublic = true;
|
||||
return true;
|
||||
},
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': ({ body }) => {
|
||||
aclIsPublic = putTriples({ body }).some(t => t.o === 'foaf:Agent' || t.o === FOAF_AGENT);
|
||||
return { status: 204 };
|
||||
}
|
||||
};
|
||||
const { svc, ctx } = service(pod);
|
||||
const first = svc.onUpdate(ctx, publicEvent(), ALICE); // notified while public
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
current = privateEvent(); // the owner makes it private; a second notification follows
|
||||
const second = svc.onUpdate(ctx, privateEvent(), ALICE);
|
||||
await Promise.all([first, second]);
|
||||
expect(aclIsPublic).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("public-read.reconcile — the event's attendees collection follows its visibility, both ways", () => {
|
||||
const COLLECTION_DEFAULT = { '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' };
|
||||
|
||||
test('public: the event AND its collection are granted (the collection already is: nothing added)', async () => {
|
||||
const granted = [];
|
||||
const pod = {
|
||||
'pod-resources.get': stored(publicEvent({ 'apods:attendees': C })),
|
||||
'pod-permissions.get': ({ uri }) => (uri === C ? [COLLECTION_DEFAULT] : [DEFAULT_READ]),
|
||||
'pod-permissions.add': ({ uri }) => granted.push(uri)
|
||||
};
|
||||
const { svc, ctx, calls } = service(pod);
|
||||
await svc.onUpdate(ctx, E, ALICE);
|
||||
expect(calls.filter(c => c.action === 'pod-permissions.fetch').map(c => c.params.url)).toEqual([
|
||||
'http://localhost:3000/_acl/alice/data/events/e1',
|
||||
'http://localhost:3000/_acl/alice/data/collections/e1-attendees'
|
||||
]);
|
||||
expect(granted).toEqual([E]);
|
||||
});
|
||||
|
||||
test('private: the public read of the event AND of its collection are revoked', async () => {
|
||||
const puts = [];
|
||||
const pod = {
|
||||
'pod-resources.get': stored(privateEvent({ 'apods:attendees': C })),
|
||||
'pod-permissions.get': ({ uri }) => (uri === C ? [COLLECTION_DEFAULT] : [DEFAULT_READ, PUBLIC_READ_AUTH]),
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': params => {
|
||||
puts.push(params.url);
|
||||
return { status: 204 };
|
||||
}
|
||||
};
|
||||
const { svc, ctx } = service(pod);
|
||||
await svc.onUpdate(ctx, E, ALICE);
|
||||
expect(puts).toEqual(['http://localhost:3000/_acl/alice/data/events/e1', 'http://localhost:3000/_acl/alice/data/collections/e1-attendees']);
|
||||
});
|
||||
|
||||
test('private → public: the collection is granted back', async () => {
|
||||
const granted = [];
|
||||
const pod = {
|
||||
'pod-resources.get': stored(publicEvent({ 'http://activitypods.org/ns/core#attendees': { id: C } })),
|
||||
'pod-permissions.get': () => [DEFAULT_READ],
|
||||
'pod-permissions.add': ({ uri }) => granted.push(uri)
|
||||
};
|
||||
const { svc, ctx } = service(pod);
|
||||
await svc.onUpdate(ctx, E, ALICE);
|
||||
expect(granted).toEqual([E, C]);
|
||||
});
|
||||
|
||||
test('a collection named outside the event\'s pod is left alone', async () => {
|
||||
const foreign = `${BOB}/data/collections/x`;
|
||||
const pod = { 'pod-resources.get': stored(privateEvent({ 'apods:attendees': foreign })), 'pod-permissions.get': () => [DEFAULT_READ] };
|
||||
const { svc, ctx, calls, logs } = service(pod);
|
||||
await svc.onUpdate(ctx, E, ALICE);
|
||||
expect(calls.some(c => c.params && c.params.uri === foreign)).toBe(false);
|
||||
expect(logs.warn.some(m => m.includes('outside its pod'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a public read INHERITED from the container cannot be revoked: reported, no PUT', async () => {
|
||||
const inherited = { '@id': '#DefaultRead', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' };
|
||||
const pod = {
|
||||
'pod-resources.get': stored(privateEvent({ 'apods:attendees': C })),
|
||||
'pod-permissions.get': ({ uri }) => (uri === C ? [inherited] : [DEFAULT_READ]),
|
||||
'signature.proxy.query': () => {
|
||||
throw new Error('must not PUT');
|
||||
}
|
||||
};
|
||||
const { svc, ctx, logs } = service(pod);
|
||||
await svc.onUpdate(ctx, E, ALICE);
|
||||
expect(logs.warn.some(m => m.includes(`${C} is publicly readable through a rule inherited`))).toBe(true);
|
||||
expect(logs.error).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public-read — revokes ONLY a public read Festipod granted (the persisted grant ledger)', () => {
|
||||
test('a public read Festipod did not add (owner, another app) is left untouched and logged', async () => {
|
||||
const pod = {
|
||||
'pod-resources.get': stored(privateEvent()),
|
||||
'pod-permissions.get': () => [DEFAULT_READ, PUBLIC_READ_AUTH],
|
||||
'signature.proxy.query': () => {
|
||||
throw new Error('must not PUT');
|
||||
}
|
||||
};
|
||||
const { svc, ctx, calls, logs } = service(pod, { granted: [] });
|
||||
await svc.onUpdate(ctx, privateEvent(), ALICE);
|
||||
expect(calls.some(c => c.action === 'signature.proxy.query')).toBe(false);
|
||||
expect(logs.info.some(m => m.includes('not through a grant Festipod made: left untouched'))).toBe(true);
|
||||
expect(logs.error).toEqual([]);
|
||||
});
|
||||
|
||||
test('a grant is recorded BEFORE it is made, and a revocation forgets it', async () => {
|
||||
let aclIsPublic = false;
|
||||
let recordedWhenAdded;
|
||||
const pod = {
|
||||
'pod-resources.get': stored(publicEvent()),
|
||||
'pod-permissions.get': () => (aclIsPublic ? [DEFAULT_READ, PUBLIC_READ_AUTH] : [DEFAULT_READ]),
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': () => {
|
||||
aclIsPublic = false;
|
||||
return { status: 204 };
|
||||
}
|
||||
};
|
||||
const w = service(pod, { granted: [] });
|
||||
pod['pod-permissions.add'] = () => {
|
||||
recordedWhenAdded = w.ledger.has(E);
|
||||
aclIsPublic = true;
|
||||
return true;
|
||||
};
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE);
|
||||
expect(recordedWhenAdded).toBe(true);
|
||||
expect(w.ledger.has(E)).toBe(true);
|
||||
pod['pod-resources.get'] = stored(privateEvent());
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE);
|
||||
expect(aclIsPublic).toBe(false);
|
||||
expect(w.ledger.has(E)).toBe(false);
|
||||
});
|
||||
|
||||
test('already world-readable before Festipod: nothing granted, nothing recorded', async () => {
|
||||
const pod = { 'pod-resources.get': stored(publicEvent()), 'pod-permissions.get': () => [DEFAULT_READ, PUBLIC_READ_AUTH] };
|
||||
const w = service(pod, { granted: [] });
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE);
|
||||
expect(w.ledger.has(E)).toBe(false);
|
||||
});
|
||||
|
||||
test('a grant the pod refuses is not left in the record', async () => {
|
||||
const pod = { 'pod-resources.get': stored(publicEvent()), 'pod-permissions.get': () => [DEFAULT_READ], 'pod-permissions.add': () => false };
|
||||
const w = service(pod, { granted: [] });
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE);
|
||||
expect(w.ledger.has(E)).toBe(false);
|
||||
expect(w.logs.error.some(m => m.includes('refused the public-read grant'))).toBe(true);
|
||||
});
|
||||
|
||||
test('recordCreated records a collection Festipod created, without depending on an ACL read', async () => {
|
||||
const w = service({}, { granted: [] }); // no ACL reachable at all
|
||||
expect(await w.svc.actions.recordCreated({ resourceUri: C, actorUri: ALICE })).toBe(true);
|
||||
expect([...w.ledger]).toEqual([C]);
|
||||
});
|
||||
|
||||
test('the ledger predicate is Festipod\'s own', () => {
|
||||
expect(GRANTED_AT).toBe('http://festipod.org/publicReadGrantedAt');
|
||||
expect(grantedQuery(E)).toBe(`ASK { <${E}> <${GRANTED_AT}> ?at }`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public-read — an unreadable ACL is UNKNOWN, never "not public" (second review)', () => {
|
||||
const OWNER_SHARE = [DEFAULT_READ, PUBLIC_READ_AUTH];
|
||||
|
||||
test('revoke on a transient ACL failure keeps Festipod\'s record; the next notification revokes', async () => {
|
||||
let gets = 0;
|
||||
let puts = 0;
|
||||
const pod = {
|
||||
'pod-resources.get': stored(privateEvent()),
|
||||
'pod-permissions.get': () => (gets++ === 0 ? false : OWNER_SHARE), // one non-200, then the real ACL
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': () => {
|
||||
puts++;
|
||||
return { status: 204 };
|
||||
}
|
||||
};
|
||||
const w = service(pod, { granted: [E] });
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE);
|
||||
expect(puts).toBe(0);
|
||||
expect(w.ledger.has(E)).toBe(true); // kept: the state was unknown
|
||||
expect(w.logs.warn.some(m => m.includes('could not be read'))).toBe(true);
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE);
|
||||
expect(puts).toBe(1);
|
||||
expect(w.ledger.has(E)).toBe(false);
|
||||
});
|
||||
|
||||
test("grant on a transient ACL failure records nothing, so the owner's own share is never taken for Festipod's", async () => {
|
||||
let gets = 0;
|
||||
let puts = 0;
|
||||
let current = publicEvent();
|
||||
const pod = {
|
||||
'pod-resources.get': () => ({ status: 200, body: current }),
|
||||
'pod-permissions.get': () => (gets++ === 0 ? false : OWNER_SHARE), // the owner already shared it publicly
|
||||
'pod-permissions.add': () => true,
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': () => {
|
||||
puts++;
|
||||
return { status: 204 };
|
||||
}
|
||||
};
|
||||
const w = service(pod, { granted: [] });
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE); // ACL unreadable: no grant, no record
|
||||
expect(w.ledger.has(E)).toBe(false);
|
||||
current = privateEvent();
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE);
|
||||
expect(puts).toBe(0); // the owner's public share is left untouched
|
||||
});
|
||||
});
|
||||
|
||||
describe('public-read — a timed-out reconcile writes nothing more (second review)', () => {
|
||||
test('a stale "public" reconcile stalled in its ACL read neither records nor grants after a newer "private" one', async () => {
|
||||
let reads = 0;
|
||||
let aclGets = 0;
|
||||
let aclPublic = false;
|
||||
const pod = {
|
||||
'pod-resources.get': () => ({ status: 200, body: reads++ === 0 ? publicEvent() : privateEvent() }),
|
||||
'pod-permissions.get': async () => {
|
||||
if (aclGets++ === 0) await new Promise(resolve => setTimeout(resolve, 120)); // stalls past the timeout
|
||||
return aclPublic ? [DEFAULT_READ, PUBLIC_READ_AUTH] : [DEFAULT_READ];
|
||||
},
|
||||
'pod-permissions.add': () => {
|
||||
aclPublic = true;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
const w = service(pod, { granted: [], timeoutMs: 50 });
|
||||
const stale = w.svc.reconcileLogged(w.ctx, E, ALICE); // reads: public
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
const newer = w.svc.reconcileLogged(w.ctx, E, ALICE); // reads: private (the owner's newer edit)
|
||||
await Promise.all([stale, newer]);
|
||||
await new Promise(resolve => setTimeout(resolve, 250)); // the stale one resumes; its re-decision runs
|
||||
expect(aclPublic).toBe(false);
|
||||
expect(w.ledger.has(E)).toBe(false);
|
||||
});
|
||||
|
||||
test('a stale "private" reconcile stalled in its ACL read does not revoke after a newer "public" one', async () => {
|
||||
let reads = 0;
|
||||
let aclGets = 0;
|
||||
let aclPublic = true;
|
||||
const pod = {
|
||||
'pod-resources.get': () => ({ status: 200, body: reads++ === 0 ? privateEvent() : publicEvent() }),
|
||||
'pod-permissions.get': async () => {
|
||||
if (aclGets++ === 0) await new Promise(resolve => setTimeout(resolve, 120));
|
||||
return aclPublic ? [DEFAULT_READ, PUBLIC_READ_AUTH] : [DEFAULT_READ];
|
||||
},
|
||||
'pod-permissions.add': () => {
|
||||
aclPublic = true;
|
||||
return true;
|
||||
},
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': () => {
|
||||
aclPublic = false;
|
||||
return { status: 204 };
|
||||
}
|
||||
};
|
||||
const w = service(pod, { granted: [E], timeoutMs: 50 });
|
||||
const stale = w.svc.reconcileLogged(w.ctx, E, ALICE); // reads: private
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
const newer = w.svc.reconcileLogged(w.ctx, E, ALICE); // reads: public
|
||||
await Promise.all([stale, newer]);
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
expect(aclPublic).toBe(true);
|
||||
});
|
||||
|
||||
test('a grant that lands AFTER its timeout is undone by the re-decision that follows', async () => {
|
||||
let current = publicEvent();
|
||||
let aclPublic = false;
|
||||
let adds = 0;
|
||||
const pod = {
|
||||
'pod-resources.get': () => ({ status: 200, body: current }),
|
||||
'pod-permissions.get': () => (aclPublic ? [DEFAULT_READ, PUBLIC_READ_AUTH] : [DEFAULT_READ]),
|
||||
'pod-permissions.add': async () => {
|
||||
if (adds++ === 0) await new Promise(resolve => setTimeout(resolve, 120)); // sent in time, lands late
|
||||
aclPublic = true;
|
||||
return true;
|
||||
},
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': () => {
|
||||
aclPublic = false;
|
||||
return { status: 204 };
|
||||
}
|
||||
};
|
||||
const w = service(pod, { granted: [], timeoutMs: 50 });
|
||||
const stale = w.svc.reconcileLogged(w.ctx, E, ALICE);
|
||||
await new Promise(resolve => setTimeout(resolve, 60)); // timed out with its PATCH in flight
|
||||
current = privateEvent();
|
||||
const newer = w.svc.reconcileLogged(w.ctx, E, ALICE); // runs before the late PATCH lands
|
||||
await Promise.all([stale, newer]);
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
expect(aclPublic).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public-read — late-settle handling only after a late WRITE, and never chained (round-4 review)', () => {
|
||||
test('a slow pod and NO late write: a timed-out reconcile schedules nothing', async () => {
|
||||
let reads = 0;
|
||||
const pod = {
|
||||
'pod-resources.get': async () => {
|
||||
reads++;
|
||||
await new Promise(resolve => setTimeout(resolve, 120));
|
||||
return { status: 200, body: publicEvent() };
|
||||
},
|
||||
'pod-permissions.get': () => [DEFAULT_READ, PUBLIC_READ_AUTH]
|
||||
};
|
||||
const w = service(pod, { granted: [], timeoutMs: 50 });
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
expect(reads).toBe(1);
|
||||
});
|
||||
|
||||
test('a re-decision after a late write schedules no other, even when it writes late itself', async () => {
|
||||
let reads = 0;
|
||||
let aclPublic = false;
|
||||
const slow = () => new Promise(resolve => setTimeout(resolve, 120));
|
||||
const pod = {
|
||||
// The owner keeps flipping the event: every read sees the other visibility.
|
||||
'pod-resources.get': () => ({ status: 200, body: reads++ % 2 === 0 ? publicEvent() : privateEvent() }),
|
||||
'pod-permissions.get': () => (aclPublic ? [DEFAULT_READ, PUBLIC_READ_AUTH] : [DEFAULT_READ]),
|
||||
'pod-permissions.add': async () => {
|
||||
await slow(); // every write lands after the limit
|
||||
aclPublic = true;
|
||||
return true;
|
||||
},
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': async () => {
|
||||
await slow();
|
||||
aclPublic = false;
|
||||
return { status: 204 };
|
||||
}
|
||||
};
|
||||
const w = service(pod, { granted: [], timeoutMs: 50 });
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE);
|
||||
await new Promise(resolve => setTimeout(resolve, 1200));
|
||||
expect(reads).toBe(2); // the reconcile and its ONE re-decision — no chain
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
// The backend's whole HTTP surface, as an allow-list checked in front of every route.
|
||||
//
|
||||
// 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)
|
||||
// GET /as/actor/app the app actor and its Solid-OIDC Client ID Document — the pod
|
||||
// GET /catalog the public catalog — the Festipod front end
|
||||
// GET /public-key/<id> the app actor's PUBLIC key — every pod verifying an app signature
|
||||
// GET /interop/access-need-group/<id>, /interop/access-need/<id>
|
||||
// what the app asks for — the pod, at install and consent
|
||||
// GET /.well-known/context.jsonld the JSON-LD context the app's documents reference — the pod
|
||||
// GET /.well-known/nodeinfo, /nodeinfo/2.1
|
||||
// server discovery (links the app actor) — the pod
|
||||
// POST /as/actor/app/inbox activities pods deliver to the app (HTTP-signature checked by the platform)
|
||||
// POST /.webhooks/<id> the pods' notifications to the app's watcher (see emitted-activity.js)
|
||||
// HEAD follows GET; a CORS preflight (OPTIONS) is judged on the method it announces.
|
||||
//
|
||||
// WHERE. The paths above are relative to the backend's base path (`/` locally, `/api/` on Coolify —
|
||||
// see base-path.js), except the two `/.well-known` ones, which SemApps mounts at the origin root
|
||||
// whatever the base path. A path outside the base path is refused like any other.
|
||||
//
|
||||
// Everything else is refused, notably: `/key` (private keys), `/` and every other container listing,
|
||||
// `/interop/*-registration|*-grant`, `/sparql` (also unmounted, `core.service.js`), `/proxy` (the
|
||||
// signing proxy — the app uses it internally only), `/.well-known/webfinger` (unused), the app
|
||||
// actor's other collections, and every write through the LDP routes.
|
||||
|
||||
// moleculer-web turns a thrown error's `code` into the HTTP status (it is not a direct dependency).
|
||||
const { MoleculerClientError } = require('moleculer').Errors;
|
||||
|
||||
// One path segment as the backend mints them (UUIDs): no dot, so no `.`/`..` segment; no `%`, so no
|
||||
// encoded separator or traversal; no empty segment.
|
||||
const ID = '[A-Za-z0-9][A-Za-z0-9_-]*';
|
||||
|
||||
const READ = ['GET', 'HEAD'];
|
||||
|
||||
const PUBLIC_ENDPOINTS = [
|
||||
{ methods: READ, path: /^\/app$/ },
|
||||
{ methods: READ, path: /^\/as\/actor\/app$/ },
|
||||
{ methods: READ, path: /^\/catalog$/ },
|
||||
{ methods: READ, path: new RegExp(`^/public-key/${ID}$`) },
|
||||
{ methods: READ, path: new RegExp(`^/interop/access-need-group/${ID}$`) },
|
||||
{ methods: READ, path: new RegExp(`^/interop/access-need/${ID}$`) },
|
||||
{ methods: READ, path: /^\/nodeinfo\/2\.1$/ },
|
||||
{ methods: ['POST'], path: /^\/as\/actor\/app\/inbox$/ },
|
||||
{ methods: ['POST'], path: new RegExp(`^/\\.webhooks/${ID}$`) }
|
||||
];
|
||||
|
||||
/** The endpoints at the origin root, whatever the base path. */
|
||||
const ROOT_ENDPOINTS = [
|
||||
{ methods: READ, path: /^\/\.well-known\/context\.jsonld$/ },
|
||||
{ methods: READ, path: /^\/\.well-known\/nodeinfo$/ }
|
||||
];
|
||||
|
||||
/**
|
||||
* isPublicRequest(method, path, basePath = '/') → boolean. True only when `method` (upper case) on
|
||||
* `path` (the request path without its query string, as the gateway routes it) is in `ROOT_ENDPOINTS`,
|
||||
* or lies under `basePath` (as `basePathOf` returns it) and, relative to it, is in `PUBLIC_ENDPOINTS`.
|
||||
*/
|
||||
function isPublicRequest(method, path, basePath = '/') {
|
||||
if (typeof method !== 'string' || typeof path !== 'string') return false;
|
||||
const verb = method.toUpperCase();
|
||||
const listed = (endpoints, p) => endpoints.some(endpoint => endpoint.methods.includes(verb) && endpoint.path.test(p));
|
||||
if (listed(ROOT_ENDPOINTS, path)) return true;
|
||||
if (!path.startsWith(basePath)) return false;
|
||||
return listed(PUBLIC_ENDPOINTS, path.slice(basePath.length - 1));
|
||||
}
|
||||
|
||||
/** The path the gateway routes on: moleculer-web's `parsedUrl` (query stripped, trailing `/` trimmed). */
|
||||
function routedPathOf(req) {
|
||||
if (typeof req.parsedUrl === 'string') return req.parsedUrl;
|
||||
const raw = String(req.originalUrl || req.url || '').split('?')[0];
|
||||
return raw.length > 1 && raw.endsWith('/') ? raw.slice(0, -1) : raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* createPublicSurfaceGate(basePath = '/') → publicSurfaceGate(req, res, next), a moleculer-web global
|
||||
* middleware (`api.use`, which the gateway puts in front of every route, including the ones services
|
||||
* add at runtime). Passes a public request (`isPublicRequest` under `basePath`) through; answers any
|
||||
* other with 404 before authentication or any action runs.
|
||||
*/
|
||||
function createPublicSurfaceGate(basePath = '/') {
|
||||
return function publicSurfaceGate(req, res, next) {
|
||||
const method = req.method === 'OPTIONS' ? req.headers && req.headers['access-control-request-method'] : req.method;
|
||||
if (isPublicRequest(method, routedPathOf(req), basePath)) return next();
|
||||
return next(new MoleculerClientError('Not found', 404, 'NOT_FOUND'));
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { isPublicRequest, createPublicSurfaceGate, PUBLIC_ENDPOINTS, ROOT_ENDPOINTS };
|
||||
@@ -0,0 +1,242 @@
|
||||
const path = require('path');
|
||||
const { test, expect, describe, afterEach } = require('bun:test');
|
||||
const { ServiceBroker } = require('moleculer');
|
||||
const { isPublicRequest, createPublicSurfaceGate } = require('./public-surface');
|
||||
const { basePathOf } = require('./base-path');
|
||||
|
||||
const UUID = 'd9b5f8ca-b2e2-41eb-af83-64e5ef1d4b20';
|
||||
|
||||
describe('the core service mounts the gate in front of every route', () => {
|
||||
test('core.service.js wires the gate, for its own base path, as a global gateway middleware, and no SPARQL endpoint', () => {
|
||||
const core = require('./core/core.service.js');
|
||||
const CONFIG = require('../config/config');
|
||||
const base = basePathOf(CONFIG.HOME_URL);
|
||||
expect(core.settings.api.use).toHaveLength(1);
|
||||
const [gate] = core.settings.api.use;
|
||||
expect(gate.name).toBe('publicSurfaceGate');
|
||||
let passed;
|
||||
gate({ method: 'GET', parsedUrl: `${base}catalog`, headers: {} }, {}, err => (passed = err === undefined));
|
||||
expect(passed).toBe(true);
|
||||
gate({ method: 'GET', parsedUrl: `${base}key`, headers: {} }, {}, err => (passed = err === undefined));
|
||||
expect(passed).toBe(false);
|
||||
expect(core.settings.sparqlEndpoint).toBe(false);
|
||||
});
|
||||
|
||||
test("core.service.js mounts Festipod's own routes under its base path", () => {
|
||||
const core = require('./core/core.service.js');
|
||||
const CONFIG = require('../config/config');
|
||||
const base = basePathOf(CONFIG.HOME_URL);
|
||||
expect(core.settings.api.routes.map(route => route.path)).toEqual([`${base}app`, `${base}as/actor/app`, `${base}catalog`]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPublicRequest — the allow-list', () => {
|
||||
test('every endpoint a pod or the Festipod front end calls is public', () => {
|
||||
for (const [method, p] of [
|
||||
['GET', '/app'],
|
||||
['GET', '/as/actor/app'],
|
||||
['GET', '/catalog'],
|
||||
['GET', `/public-key/${UUID}`],
|
||||
['HEAD', `/public-key/${UUID}`],
|
||||
['GET', `/interop/access-need-group/${UUID}`],
|
||||
['GET', `/interop/access-need/${UUID}`],
|
||||
['GET', '/.well-known/context.jsonld'],
|
||||
['GET', '/.well-known/nodeinfo'],
|
||||
['GET', '/nodeinfo/2.1'],
|
||||
['POST', '/as/actor/app/inbox'],
|
||||
['POST', `/.webhooks/${UUID}`]
|
||||
]) {
|
||||
expect(`${method} ${p} ${isPublicRequest(method, p)}`).toBe(`${method} ${p} true`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the app's private keys, its registrations and grants, every listing and the SPARQL endpoint are refused", () => {
|
||||
for (const p of [
|
||||
'/key',
|
||||
`/key/${UUID}`,
|
||||
'/', // the root container: lists every container of the settings dataset
|
||||
'/public-key', // container listing
|
||||
'/interop',
|
||||
`/interop/application-registration/${UUID}`,
|
||||
`/interop/access-grant/${UUID}`,
|
||||
`/interop/data-grant/${UUID}`,
|
||||
'/interop/access-need',
|
||||
'/sparql',
|
||||
'/as/actor/app/outbox',
|
||||
'/as/actor/app/inbox', // GET: nobody reads the app's inbox
|
||||
'/as/actor/app/followers',
|
||||
'/as/actor',
|
||||
'/.well-known/webfinger',
|
||||
`/.webhooks/${UUID}`, // GET
|
||||
'/proxy'
|
||||
]) {
|
||||
expect(`GET ${p} ${isPublicRequest('GET', p)}`).toBe(`GET ${p} false`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every write outside the two delivery endpoints is refused', () => {
|
||||
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
for (const p of ['/app', '/catalog', '/as/actor/app', `/public-key/${UUID}`, `/interop/access-need/${UUID}`, '/key', '/', '/sparql', '/proxy', '/as/actor/app/outbox']) {
|
||||
expect(`${method} ${p} ${isPublicRequest(method, p)}`).toBe(`${method} ${p} false`);
|
||||
}
|
||||
}
|
||||
expect(isPublicRequest('PUT', `/.webhooks/${UUID}`)).toBe(false);
|
||||
expect(isPublicRequest('DELETE', '/as/actor/app/inbox')).toBe(false);
|
||||
});
|
||||
|
||||
test('a path that only resembles a public one is refused (traversal, encoding, extra segments, case)', () => {
|
||||
for (const p of [
|
||||
`/public-key/../key/${UUID}`,
|
||||
'/public-key/..',
|
||||
`/public-key/%2e%2e/key/${UUID}`,
|
||||
`/public-key/${UUID}%2F..%2Fkey`,
|
||||
`/public-key/${UUID}/extra`,
|
||||
`//public-key/${UUID}`,
|
||||
`/Public-Key/${UUID}`,
|
||||
'/app/',
|
||||
'/apps',
|
||||
'/catalog/x',
|
||||
`/interop/access-need/${UUID}/../../key`,
|
||||
'/.well-known/context.jsonldx'
|
||||
]) {
|
||||
expect(`${p} ${isPublicRequest('GET', p)}`).toBe(`${p} false`);
|
||||
}
|
||||
expect(isPublicRequest(undefined, '/app')).toBe(false);
|
||||
expect(isPublicRequest('GET', undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPublicRequest — under a base path (the Coolify layout: the backend at /api/ on the front end origin)', () => {
|
||||
const BASE = '/api/';
|
||||
|
||||
test('every public endpoint is public under the base path, the /.well-known ones at the origin root', () => {
|
||||
for (const [method, p] of [
|
||||
['GET', '/api/app'],
|
||||
['GET', '/api/as/actor/app'],
|
||||
['GET', '/api/catalog'],
|
||||
['GET', `/api/public-key/${UUID}`],
|
||||
['GET', `/api/interop/access-need-group/${UUID}`],
|
||||
['GET', `/api/interop/access-need/${UUID}`],
|
||||
['GET', '/api/nodeinfo/2.1'],
|
||||
['POST', '/api/as/actor/app/inbox'],
|
||||
['POST', `/api/.webhooks/${UUID}`],
|
||||
['GET', '/.well-known/context.jsonld'],
|
||||
['GET', '/.well-known/nodeinfo']
|
||||
]) {
|
||||
expect(`${method} ${p} ${isPublicRequest(method, p, BASE)}`).toBe(`${method} ${p} true`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the same endpoints outside the base path, and the /.well-known ones under it, are refused', () => {
|
||||
for (const [method, p] of [
|
||||
['GET', '/app'],
|
||||
['GET', '/as/actor/app'],
|
||||
['GET', '/catalog'],
|
||||
['GET', `/public-key/${UUID}`],
|
||||
['POST', '/as/actor/app/inbox'],
|
||||
['POST', `/.webhooks/${UUID}`],
|
||||
['GET', '/api/.well-known/context.jsonld'],
|
||||
['GET', '/api/.well-known/nodeinfo'],
|
||||
['GET', '/api'],
|
||||
['GET', '/apiapp'],
|
||||
['GET', `/apipublic-key/${UUID}`]
|
||||
]) {
|
||||
expect(`${method} ${p} ${isPublicRequest(method, p, BASE)}`).toBe(`${method} ${p} false`);
|
||||
}
|
||||
});
|
||||
|
||||
test('what is refused at the root is refused under the base path', () => {
|
||||
for (const p of ['/', '/key', `/key/${UUID}`, '/sparql', '/proxy', `/interop/access-grant/${UUID}`, '/as/actor/app/outbox', `/public-key/../key/${UUID}`, `//public-key/${UUID}`]) {
|
||||
expect(`GET /api${p} ${isPublicRequest('GET', `/api${p}`, BASE)}`).toBe(`GET /api${p} false`);
|
||||
}
|
||||
expect(isPublicRequest('PUT', '/api/catalog', BASE)).toBe(false);
|
||||
expect(isPublicRequest('POST', '/api/app', BASE)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publicSurfaceGate — the middleware', () => {
|
||||
const publicSurfaceGate = createPublicSurfaceGate('/');
|
||||
const run = req => {
|
||||
let passed;
|
||||
publicSurfaceGate(req, {}, err => {
|
||||
passed = err === undefined ? true : err;
|
||||
});
|
||||
return passed;
|
||||
};
|
||||
|
||||
test('passes a public request, answers 404 to any other (before authentication runs)', () => {
|
||||
expect(run({ method: 'GET', parsedUrl: '/catalog', headers: {} })).toBe(true);
|
||||
const refusal = run({ method: 'GET', parsedUrl: '/key', headers: {} });
|
||||
expect(refusal).toBeInstanceOf(Error);
|
||||
expect(refusal.code).toBe(404);
|
||||
});
|
||||
|
||||
test('a CORS preflight is judged on the method it announces', () => {
|
||||
expect(run({ method: 'OPTIONS', parsedUrl: '/catalog', headers: { 'access-control-request-method': 'GET' } })).toBe(true);
|
||||
expect(run({ method: 'OPTIONS', parsedUrl: '/catalog', headers: { 'access-control-request-method': 'DELETE' } }).code).toBe(404);
|
||||
expect(run({ method: 'OPTIONS', parsedUrl: '/catalog', headers: {} }).code).toBe(404);
|
||||
});
|
||||
|
||||
test('without the gateway-parsed path, the raw URL is used without its query string', () => {
|
||||
expect(run({ method: 'GET', originalUrl: '/catalog?x=1', headers: {} })).toBe(true);
|
||||
expect(run({ method: 'GET', originalUrl: '/key?x=/catalog', headers: {} }).code).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// The mechanism, end to end: a real moleculer-web gateway (the version CoreService uses), with the
|
||||
// REAL @semapps/ldp catch-all route added at runtime as the LDP service adds it, in front of a stand-in
|
||||
// `ldp.api` whose every action answers as if it served the settings dataset. Status codes only.
|
||||
describe('the gate covers routes added at runtime — the LDP catch-all that served the private key', () => {
|
||||
const semappsCore = path.dirname(require.resolve('@semapps/core'));
|
||||
const ApiGateway = require(require.resolve('moleculer-web', { paths: [semappsCore] }));
|
||||
const ldpDir = path.dirname(require.resolve('@semapps/ldp/package.json', { paths: [semappsCore] }));
|
||||
const { default: getCatchAllRoute } = require(path.join(ldpDir, 'dist/routes/getCatchAllRoute.js'));
|
||||
|
||||
let broker;
|
||||
afterEach(async () => {
|
||||
if (broker) await broker.stop();
|
||||
broker = undefined;
|
||||
});
|
||||
|
||||
async function startGateway(use) {
|
||||
broker = new ServiceBroker({ logger: false });
|
||||
const answer = () => 'settings dataset content';
|
||||
broker.createService({
|
||||
name: 'ldp.api',
|
||||
actions: { get: answer, head: answer, post: answer, put: answer, patch: answer, delete: answer }
|
||||
});
|
||||
const gateway = broker.createService({
|
||||
mixins: [ApiGateway],
|
||||
settings: { port: 0, ip: '127.0.0.1', routes: [], ...(use ? { use } : {}) },
|
||||
methods: {
|
||||
// CoreService's: no credentials → anonymous.
|
||||
authenticate(ctx) {
|
||||
ctx.meta.webId = 'anon';
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
await broker.start();
|
||||
await broker.call('api.addRoute', { route: getCatchAllRoute('/', false) });
|
||||
return `http://127.0.0.1:${gateway.server.address().port}`;
|
||||
}
|
||||
|
||||
const status = async (base, method, p) => (await fetch(base + p, { method })).status;
|
||||
|
||||
test('BASELINE (no gate): the catch-all serves the key container to an anonymous caller', async () => {
|
||||
const base = await startGateway(undefined);
|
||||
expect(await status(base, 'GET', '/key')).toBe(200);
|
||||
});
|
||||
|
||||
test('with the gate: the key container, the root and any write are 404; a public key is served', async () => {
|
||||
const base = await startGateway([createPublicSurfaceGate('/')]);
|
||||
expect(await status(base, 'GET', '/key')).toBe(404);
|
||||
expect(await status(base, 'GET', `/key/${UUID}`)).toBe(404);
|
||||
expect(await status(base, 'GET', '/')).toBe(404);
|
||||
expect(await status(base, 'GET', `/interop/data-grant/${UUID}`)).toBe(404);
|
||||
expect(await status(base, 'PUT', `/public-key/${UUID}`)).toBe(404);
|
||||
expect(await status(base, 'DELETE', `/interop/access-need/${UUID}`)).toBe(404);
|
||||
expect(await status(base, 'GET', `/public-key/${UUID}`)).toBe(200);
|
||||
expect(await status(base, 'GET', `/interop/access-need/${UUID}`)).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -1,120 +1,418 @@
|
||||
const { ACTIVITY_TYPES, OBJECT_TYPES } = require('@semapps/activitypub');
|
||||
const { PodActivitiesHandlerMixin } = require('@activitypods/app');
|
||||
const { ACTIVITY_TYPES, ActivitiesHandlerMixin, matchActivity } = require('@semapps/activitypub');
|
||||
const { arrayOf } = require('@semapps/ldp');
|
||||
const CONFIG = require('../config/config');
|
||||
const { isInActorPod } = require('./actor-pod');
|
||||
const { createKeyedSerializer } = require('./keyed-serializer');
|
||||
const { isNewerAct, lastAttendance, persistMember, recordAttendance } = require('./attendance-ledger');
|
||||
const { neverThrowingMatcher, verifiedDelivery, singleUriOf, uriOf, hasAsType } = 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';
|
||||
|
||||
// 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`];
|
||||
return value && typeof value === 'object' ? value['@value'] : value;
|
||||
};
|
||||
|
||||
/** 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));
|
||||
|
||||
/** 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: an `as:Event` whose `dc:creator` owns the pod it lies in. A meeting point
|
||||
* is anchored to its event by `as:context`; an event signed up to directly is its own anchor. Only on
|
||||
* the copy the creator's pod serves (`meetingPointOf`) is that creator believed; on the copy read
|
||||
* through the participant's pod, it only names where to read that one. Resolves `{ ok, eventUri, creatorUri }` or a refusal.
|
||||
*/
|
||||
function meetingPointTarget(meetingPoint, meetingPointUri) {
|
||||
const eventUri = linkOf(meetingPoint, 'context', 'as:context', `${AS}context`) ?? meetingPointUri;
|
||||
const creatorUri = linkOf(meetingPoint, 'dc:creator', DC_CREATOR);
|
||||
if (!creatorUri) return { ok: false, reason: `the meeting point ${meetingPointUri} names no single dc:creator` };
|
||||
if (!isInActorPod(meetingPointUri, creatorUri)) {
|
||||
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 target (`pod-notifications.send`, rendered in the
|
||||
* creator's language): always ANONYMOUS, linking to the event, or to its meeting points when the target
|
||||
* is one. Every value travels as a template PARAMETER, never inside the template, so no user text is
|
||||
* compiled.
|
||||
*
|
||||
* Never names the participant: naming would need a trustworthy signal that the creator is one of the
|
||||
* participant's contacts, and the only one the app can read — the participant's `apods:contacts` list —
|
||||
* 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, isMeetingPoint) {
|
||||
const joined = change === 'join';
|
||||
const nameParam = isMeetingPoint ? 'meetingPointName' : 'eventName';
|
||||
const subject = isMeetingPoint
|
||||
? { en: 'your meeting point', fr: 'votre point de rencontre' }
|
||||
: { en: 'your event', fr: 'votre événement' };
|
||||
const title = {
|
||||
en: `A participant ${joined ? 'joined' : 'left'} ${subject.en} "{{{${nameParam}}}}"`,
|
||||
fr: `Un participant a ${joined ? 'rejoint' : 'quitté'} ${subject.fr} « {{{${nameParam}}}} »`
|
||||
};
|
||||
const link = isMeetingPoint ? '/events/{{encodeUri eventUri}}/meeting-points' : '/events/{{encodeUri eventUri}}';
|
||||
return { title, actions: [{ caption: { en: 'View', fr: 'Voir' }, link }] };
|
||||
}
|
||||
|
||||
// Participation (decision_2026-10-29_participation-privacy-mechanism; its delivery:
|
||||
// decision_2026-11-03_participation-delivered-to-the-app): a person joins (leaves) a meeting point with an `as:Join`
|
||||
// (`as:Leave`) posted to their OWN outbox, naming the meeting point, addressed to the app actor alone —
|
||||
// so only its author and the app read it. Their pod delivers it to the app's inbox; the creator's inbox
|
||||
// receives nothing. The backend acts on RECEPTION in the app's inbox (`onReceive` of the semapps
|
||||
// `ActivitiesHandlerMixin`, as the app framework handles an app registration), and records:
|
||||
// - in the meeting point's `apods:attendees` collection (the creator's pod, acting as the creator):
|
||||
// the participant's MEMBER URN, never their WebID — `member-urn.js`, computed from the secret in
|
||||
// their profile. A Join whose author's profile carries no usable secret is refused, nothing written;
|
||||
// - 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 delivery that reports the act is a pointer (emitted-activity.js, `verifiedDelivery`): the handler
|
||||
// acts only on the activity and the meeting point as the pods store them, and only for the people they
|
||||
// really name:
|
||||
// - the participant is the delivered activity's `actor` — the app's inbox accepts a delivery only
|
||||
// signed by its actor — and the activity must lie in that actor's pod, its stored copy naming them:
|
||||
// any server may deliver, nobody acts for someone else;
|
||||
// - the meeting point is the copy its creator's pod serves (`meetingPointOf`), never the one read
|
||||
// through the participant's server; the creator is its `dc:creator`, accepted only when the meeting
|
||||
// point lies in that creator's pod (`meetingPointTarget`);
|
||||
// - the collections written to are the ones the STORED meeting point and the participant's STORED
|
||||
// profile name, and must lie in the pod of whoever owns them.
|
||||
// Joins and leaves of one meeting point are processed one at a time (keyed-serializer.js), and the
|
||||
// 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, 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],
|
||||
mixins: [ActivitiesHandlerMixin],
|
||||
dependencies: ['participations'],
|
||||
created() {
|
||||
this.serialize = createKeyedSerializer();
|
||||
},
|
||||
async started() {
|
||||
this.logger.info('Registration watcher subscribed');
|
||||
this.logger.info("Registration listens to the Joins and Leaves delivered to the app's inbox");
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* The Event's `apods:attendees` collection is attached asynchronously by
|
||||
* PodCollectionsHandlerMixin on create, so a Join/Leave processed in that window sees no
|
||||
* collection. Returning immediately would drop the activity forever (the client overlay
|
||||
* shows joined, nothing reconciles). Re-fetch the Event a bounded number of times until the
|
||||
* collection appears; only then give up.
|
||||
* Handles one verified Join/Leave (`change`: 'join' | 'leave') of `participantUri`, its verified
|
||||
* emitter: resolves the meeting point and the participant, applies the act, then notifies the
|
||||
* creator when the act changed the participant's state. A refusal is logged; nothing is written.
|
||||
*/
|
||||
async fetchAttendeesCollectionUri(ctx, event, actorUri) {
|
||||
if (event['apods:attendees'] || !event.id) return event['apods:attendees'];
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
async onParticipation(ctx, change, { activity, objectUri: meetingPointUri, object: claimedMeetingPoint }, participantUri) {
|
||||
const refuse = reason => this.logger.warn(`${this.name} refused the ${change} ${uriOf(activity)} of ${participantUri}: ${reason}`);
|
||||
const claimed = meetingPointTarget(claimedMeetingPoint, meetingPointUri);
|
||||
if (!claimed.ok) return refuse(claimed.reason);
|
||||
const target = await this.meetingPointOf(ctx, meetingPointUri, claimed.creatorUri);
|
||||
if (!target.ok) return refuse(target.reason);
|
||||
const { meetingPoint } = target;
|
||||
const participant = await this.participantOf(ctx, participantUri);
|
||||
if (!participant.ok) return refuse(participant.reason);
|
||||
const outcome = await this.updateAttendance(ctx, {
|
||||
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 meeting point as the pod that holds it serves it. The verification read it through the
|
||||
* participant's pod — for a participant on another server, through the proxy THEIR actor document
|
||||
* declares, which answers what it wants (another meeting point's list, another name). Only the
|
||||
* creator's pod speaks for the meeting point: it is read again there, through the creator's access
|
||||
* (a GET to that pod, signed by the app), and the act uses that copy alone. It must still be an
|
||||
* `as:Event` anchored by `as:context` whose `dc:creator` is that same creator. Resolves
|
||||
* `{ ok, meetingPoint, eventUri, creatorUri }` or a refusal.
|
||||
*/
|
||||
async meetingPointOf(ctx, meetingPointUri, creatorUri) {
|
||||
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: meetingPointUri, actorUri: creatorUri });
|
||||
if (status !== 200 || !body || uriOf(body) !== meetingPointUri) {
|
||||
return { ok: false, reason: `the pod of ${creatorUri} does not return the meeting point ${meetingPointUri} (HTTP ${status})` };
|
||||
}
|
||||
if (!hasAsType(body, 'Event')) return { ok: false, reason: `the meeting point ${meetingPointUri} its pod returns is not an Event` };
|
||||
const target = meetingPointTarget(body, meetingPointUri);
|
||||
if (!target.ok) return target;
|
||||
if (target.creatorUri !== creatorUri) {
|
||||
return { ok: false, reason: `the meeting point ${meetingPointUri} names ${target.creatorUri} as its creator in its pod, not ${creatorUri}` };
|
||||
}
|
||||
return { ok: true, meetingPoint: body, eventUri: target.eventUri, creatorUri };
|
||||
},
|
||||
/**
|
||||
* The participant as their pod describes them, read as the participant: their actor document
|
||||
* (its `url` — the `as:Profile`, which must lie in their pod — and `apods:contacts`), then the
|
||||
* 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
|
||||
* Join/Leave processed in that window sees none: the meeting point is read again a bounded number
|
||||
* of times until it names one; only then give up. The collection must lie in the same pod.
|
||||
*/
|
||||
async attendeesCollectionOf(ctx, meetingPoint, meetingPointUri, actorUri) {
|
||||
let collectionUri = linkOf(meetingPoint, 'apods:attendees', APODS_ATTENDEES);
|
||||
for (let attempt = 0; !collectionUri && attempt < 3; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
try {
|
||||
const { ok, body } = await ctx.call('pod-resources.get', { resourceUri: event.id, actorUri });
|
||||
if (ok && body && body['apods:attendees']) return body['apods:attendees'];
|
||||
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: meetingPointUri, actorUri });
|
||||
if (status === 200 && body) collectionUri = linkOf(body, 'apods:attendees', APODS_ATTENDEES);
|
||||
} catch (e) {
|
||||
this.logger.warn(`Could not re-fetch ${event.id} while waiting for its attendees collection: ${e.message}`);
|
||||
this.logger.warn(`Could not re-read ${meetingPointUri} while waiting for its attendees collection: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
if (collectionUri && !isInActorPod(collectionUri, actorUri)) {
|
||||
this.logger.warn(`${meetingPointUri} names an attendees collection outside its pod: ignored`);
|
||||
return undefined;
|
||||
}
|
||||
return collectionUri;
|
||||
},
|
||||
/**
|
||||
* 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
|
||||
* meantime (on a read taken before the late write landed), the collection shows the older act's
|
||||
* effect until the participant acts again. It is deliberately NOT corrected from the ledger
|
||||
* afterwards: when nothing superseded the late act (a rejoin, say), the late write is right and it
|
||||
* 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, 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 ${participantUri} carries no pod stamp (published): it cannot be ordered, unchanged`);
|
||||
return undefined;
|
||||
}
|
||||
return this.serialize(meetingPointUri, async signal => {
|
||||
const dataset = CONFIG.MAIN_DATASET;
|
||||
const last = await lastAttendance(ctx, { dataset, meetingPointUri, participantUri });
|
||||
if (!isNewerAct(act, last)) {
|
||||
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, creatorUri);
|
||||
if (!collectionUri) {
|
||||
this.logger.error(`${meetingPointUri} has no attendees collection: the ${change} ${act.activityUri} of ${participantUri} is NOT recorded, the next act retries`);
|
||||
return undefined;
|
||||
}
|
||||
if (signal.aborted) return undefined;
|
||||
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 target (a meeting point or an event) that a participant joined or left
|
||||
* it — ALWAYS anonymously.
|
||||
* The participant is never named: the only membership signal the app can read (the participant's
|
||||
* `apods:contacts` list) is forgeable, so naming cannot be trusted (platform defect reported
|
||||
* upstream, see `notificationTemplate`). The notification carries no `activity`: given one,
|
||||
* `pod-notifications.send` fetches the emitter's profile. A creator acting on their own meeting
|
||||
* point or event is not notified. A failure is logged, never thrown: the act stands.
|
||||
*/
|
||||
async notifyCreator(ctx, { change, meetingPointUri, meetingPoint, eventUri, creatorUri, participantUri }) {
|
||||
const isMeetingPoint = eventUri !== meetingPointUri;
|
||||
try {
|
||||
if (creatorUri === participantUri) {
|
||||
this.logger.info(`${participantUri} acted on their own ${meetingPointUri}: no notification`);
|
||||
return;
|
||||
}
|
||||
this.logger.info(`Notifying ${creatorUri} of a ${change} on ${meetingPointUri}, anonymously`);
|
||||
await ctx.call('pod-notifications.send', {
|
||||
template: notificationTemplate(change, isMeetingPoint),
|
||||
recipientUri: creatorUri,
|
||||
context: meetingPointUri,
|
||||
[isMeetingPoint ? 'meetingPointName' : 'eventName']: 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: {
|
||||
join: {
|
||||
match: {
|
||||
type: ACTIVITY_TYPES.JOIN,
|
||||
object: {
|
||||
type: OBJECT_TYPES.EVENT
|
||||
}
|
||||
},
|
||||
async onEmit(ctx, activity, actorUri) {
|
||||
const event = activity.object;
|
||||
const organizerUri = event['dc:creator'];
|
||||
if (!organizerUri) return;
|
||||
|
||||
const { body: actor } = await ctx.call('pod-resources.get', { resourceUri: actorUri, actorUri });
|
||||
if (!actor || !actor['apods:contacts']) return;
|
||||
|
||||
const contactsGroupUri = await ctx.call('pod-wac-groups.getUriFromCollectionUri', {
|
||||
collectionUri: actor['apods:contacts']
|
||||
});
|
||||
|
||||
await ctx.call('pod-wac-groups.addMember', {
|
||||
groupUri: contactsGroupUri,
|
||||
memberUri: organizerUri,
|
||||
actorUri
|
||||
});
|
||||
},
|
||||
async onReceive(ctx, activity, actorUri) {
|
||||
const event = activity.object;
|
||||
const attendeesCollectionUri = await this.fetchAttendeesCollectionUri(ctx, event, actorUri);
|
||||
|
||||
if (!attendeesCollectionUri) {
|
||||
this.logger.warn(`No attendees collection on ${event.id}, skipping Join`);
|
||||
return;
|
||||
}
|
||||
|
||||
const attendees = await ctx.call('pod-collections.getItems', {
|
||||
collectionUri: attendeesCollectionUri,
|
||||
actorUri
|
||||
});
|
||||
|
||||
if (arrayOf(attendees).includes(activity.actor)) {
|
||||
this.logger.info(`${activity.actor} is already attending ${event.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.call('attendees.add', {
|
||||
collectionUri: attendeesCollectionUri,
|
||||
itemUri: activity.actor,
|
||||
actorUri
|
||||
});
|
||||
}
|
||||
match: neverThrowingMatcher(function (activity, fetcher) {
|
||||
return matchActivity(JOIN_PATTERN, activity, fetcher);
|
||||
}),
|
||||
// Delivered to the app's inbox: the participant is the verified emitter; the meeting point may lie
|
||||
// in any pod (its creator's — checked by `meetingPointTarget`).
|
||||
onReceive: verifiedDelivery(
|
||||
'Join',
|
||||
async function (ctx, verdict, participantUri) {
|
||||
await this.onParticipation(ctx, 'join', verdict, participantUri);
|
||||
},
|
||||
{ objectType: 'Event', objectIn: 'any' }
|
||||
)
|
||||
},
|
||||
leave: {
|
||||
match: {
|
||||
type: ACTIVITY_TYPES.LEAVE,
|
||||
object: {
|
||||
type: OBJECT_TYPES.EVENT
|
||||
}
|
||||
},
|
||||
async onReceive(ctx, activity, actorUri) {
|
||||
const event = activity.object;
|
||||
const attendeesCollectionUri = await this.fetchAttendeesCollectionUri(ctx, event, actorUri);
|
||||
|
||||
if (!attendeesCollectionUri) {
|
||||
this.logger.warn(`No attendees collection on ${event.id}, skipping Leave`);
|
||||
return;
|
||||
}
|
||||
|
||||
const attendees = await ctx.call('pod-collections.getItems', {
|
||||
collectionUri: attendeesCollectionUri,
|
||||
actorUri
|
||||
});
|
||||
|
||||
if (!arrayOf(attendees).includes(activity.actor)) {
|
||||
this.logger.info(`${activity.actor} is not attending ${event.id}, skipping Leave`);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.call('attendees.remove', {
|
||||
collectionUri: attendeesCollectionUri,
|
||||
itemUri: activity.actor,
|
||||
actorUri
|
||||
});
|
||||
}
|
||||
match: neverThrowingMatcher(function (activity, fetcher) {
|
||||
return matchActivity(LEAVE_PATTERN, activity, fetcher);
|
||||
}),
|
||||
onReceive: verifiedDelivery(
|
||||
'Leave',
|
||||
async function (ctx, verdict, participantUri) {
|
||||
await this.onParticipation(ctx, 'leave', verdict, participantUri);
|
||||
},
|
||||
{ objectType: 'Event', objectIn: 'any' }
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
services:
|
||||
fuseki:
|
||||
image: semapps/jena-fuseki-webacl
|
||||
container_name: fuseki
|
||||
volumes:
|
||||
- ./data/fuseki:/fuseki:z
|
||||
ports:
|
||||
- '3030:3030'
|
||||
expose:
|
||||
- '3030'
|
||||
environment:
|
||||
ADMIN_PASSWORD: admin
|
||||
|
||||
activitypods-backend:
|
||||
image: activitypods/backend
|
||||
container_name: activitypods-backend
|
||||
depends_on:
|
||||
- fuseki
|
||||
- redis
|
||||
restart: always
|
||||
expose:
|
||||
- '3000'
|
||||
volumes:
|
||||
- ./data/backend/logs:/app/backend/logs:z
|
||||
- ./data/backend/jwt:/app/backend/jwt:z
|
||||
- ./data/backend/uploads:/app/backend/uploads:z
|
||||
# Account deletion removes the account's dataset files and backups from disk, at the image's SEMAPPS_FUSEKI_BASE (../data/fuseki)
|
||||
- ./data/fuseki:/app/data/fuseki:z
|
||||
environment:
|
||||
SEMAPPS_JENA_PASSWORD: admin
|
||||
SEMAPPS_DEFAULT_LOCALE: en
|
||||
# Host network: the pod provider reaches redis on its published host port (REDIS_PORT, default 6379)
|
||||
SEMAPPS_QUEUE_SERVICE_URL: redis://localhost:${REDIS_PORT:-6379}/1
|
||||
SEMAPPS_REDIS_OIDC_PROVIDER_URL: redis://localhost:${REDIS_PORT:-6379}/2
|
||||
# Allow the Pod backend to access the app backend
|
||||
# See https://docs.docker.com/network/drivers/host/
|
||||
network_mode: 'host'
|
||||
|
||||
activitypods-frontend:
|
||||
image: activitypods/frontend
|
||||
container_name: activitypods-frontend
|
||||
depends_on:
|
||||
- activitypods-backend
|
||||
restart: always
|
||||
ports:
|
||||
- '5000:5000'
|
||||
expose:
|
||||
- '5000'
|
||||
environment:
|
||||
REACT_APP_CONFIG_URL: 'http://localhost:3000/.well-known/config.js'
|
||||
|
||||
mailcatcher:
|
||||
image: dockage/mailcatcher:0.7.1
|
||||
container_name: mailcatcher
|
||||
ports:
|
||||
- '1080:1080'
|
||||
- '1025:1025'
|
||||
restart: on-failure
|
||||
|
||||
redis:
|
||||
image: redis
|
||||
container_name: redis
|
||||
expose:
|
||||
- '6379'
|
||||
ports:
|
||||
- '${REDIS_PORT:-6379}:6379'
|
||||
volumes:
|
||||
- ./data/redis:/data:z
|
||||
command: ['redis-server', '--appendonly', 'yes']
|
||||
|
||||
arena:
|
||||
image: activitypods/arena
|
||||
container_name: arena
|
||||
restart: always
|
||||
volumes:
|
||||
- ./docker/arena-dev.json:/opt/arena/src/server/config/index.json:z
|
||||
depends_on:
|
||||
- redis
|
||||
expose:
|
||||
- '4567'
|
||||
ports:
|
||||
- '4567:4567'
|
||||
environment:
|
||||
REDIS_HOST: 'redis'
|
||||
@@ -0,0 +1,108 @@
|
||||
# Docker Compose definition used by Coolify to build and run Festipod straight from the
|
||||
# git repository: one stack for the branch it follows (the test environment). The images
|
||||
# are built on the Coolify server from the two Dockerfiles of this repository (the app at
|
||||
# the root, the backend in /backend); nothing is pulled from a registry.
|
||||
#
|
||||
# Before `docker compose build` and `up`, Coolify writes a .env file next to this file
|
||||
# with the variables defined in its UI plus generated ones. The generated ones used here:
|
||||
# SERVICE_FQDN_BACKEND / SERVICE_FQDN_FRONTEND host of each service, as set in its
|
||||
# Domains field
|
||||
# SERVICE_NAME_BACKEND / SERVICE_NAME_FESTIPOD_REDIS service names (Coolify suffixes
|
||||
# them with "-pr-<n>" in a preview)
|
||||
# A value starting with `${` becomes an editable variable in Coolify (with its default);
|
||||
# the other `${...}` references are only interpolated by docker compose.
|
||||
#
|
||||
# One base URL per environment, as for the other ActivityPods apps on this Coolify (it is
|
||||
# what lets a pull request preview live on a single host): the frontend's domain serves the
|
||||
# app, and the backend is served on the same host under `/api` and `/.well-known` — its
|
||||
# Domains field is `https://<host>/api,https://<host>/.well-known`. The proxy must pass those
|
||||
# paths through unchanged — Coolify's "Strip Prefixes" OFF, as on the other apps: the backend
|
||||
# mounts its routes under `/api` itself, from its base URL, and keeps its `/.well-known` ones
|
||||
# at the origin root (backend/services/base-path.js). With the prefixes stripped, every
|
||||
# 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. 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;
|
||||
# Fuseki then keeps the closed storage of the deleted dataset: re-creating one under the
|
||||
# SAME name fails every query with `ClosedException: already closed` until Fuseki restarts
|
||||
# (the shared one, on the test server) — or set new MAIN_DATASET / SETTINGS_DATASET names;
|
||||
# 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
|
||||
# runs inside the stack (on the stack's private network only, under a name that cannot be
|
||||
# confused with the shared one).
|
||||
|
||||
services:
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
restart: always
|
||||
depends_on:
|
||||
- festipod-redis
|
||||
expose:
|
||||
- '3003'
|
||||
# Coolify adds the stack's own network to every service; only the backend also needs
|
||||
# the shared one, where Fuseki lives.
|
||||
networks:
|
||||
- coolify
|
||||
environment:
|
||||
SEMAPPS_APP_NAME: ${APP_NAME:-Festipod}
|
||||
SEMAPPS_APP_DESCRIPTION: ${APP_DESCRIPTION:-Points de rencontre sur les événements publics}
|
||||
SEMAPPS_APP_LANG: ${APP_LANG:-en,fr}
|
||||
SEMAPPS_HOME_URL: https://${SERVICE_FQDN_BACKEND}/api/
|
||||
SEMAPPS_FRONT_URL: https://${SERVICE_FQDN_FRONTEND}
|
||||
SEMAPPS_PORT: '3003'
|
||||
SEMAPPS_SHAPE_REPOSITORY_URL: ${SHAPE_REPOSITORY_URL:-https://shapes.activitypods.org/}
|
||||
SEMAPPS_SPARQL_ENDPOINT: ${SPARQL_ENDPOINT}
|
||||
SEMAPPS_JENA_USER: ${JENA_USER:-admin}
|
||||
SEMAPPS_JENA_PASSWORD: ${JENA_PASSWORD}
|
||||
# A pair of datasets named after DATASET_PREFIX and the backend's service name, e.g.
|
||||
# festipod-backend / settings-festipod-backend (…-backend-pr-<n> in a preview). Two
|
||||
# non-preview stacks on the same Fuseki would share them: give the second its own
|
||||
# DATASET_PREFIX (or MAIN_DATASET / SETTINGS_DATASET).
|
||||
SEMAPPS_MAIN_DATASET: ${MAIN_DATASET:-${DATASET_PREFIX:-festipod}-${SERVICE_NAME_BACKEND}}
|
||||
SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME: ${SETTINGS_DATASET:-settings-${DATASET_PREFIX:-festipod}-${SERVICE_NAME_BACKEND}}
|
||||
SEMAPPS_REDIS_CACHE_URL: redis://${SERVICE_NAME_FESTIPOD_REDIS}:6379/0
|
||||
SEMAPPS_QUEUE_SERVICE_URL: redis://${SERVICE_NAME_FESTIPOD_REDIS}:6379/1
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: always
|
||||
depends_on:
|
||||
- backend
|
||||
expose:
|
||||
- '3000'
|
||||
# Read by the server on every request to /festipod-config.json, not inlined at build
|
||||
# time: changing one needs a restart, not a rebuild.
|
||||
environment:
|
||||
PORT: '3000'
|
||||
VITE_BACKEND_URL: https://${SERVICE_FQDN_BACKEND}/api
|
||||
# The Pod provider the login screen offers in one click; any other can be typed in.
|
||||
VITE_POD_PROVIDER_URL: ${POD_PROVIDER_URL:-https://mypod.store}
|
||||
|
||||
festipod-redis:
|
||||
image: redis:7-alpine
|
||||
restart: always
|
||||
command: ['redis-server', '--appendonly', 'yes']
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
|
||||
networks:
|
||||
coolify:
|
||||
external: true
|
||||
@@ -1,29 +0,0 @@
|
||||
services:
|
||||
frontend:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "4000:3000"
|
||||
environment:
|
||||
- PORT=3000
|
||||
- NODE_ENV=production
|
||||
- VITE_BACKEND_URL=${VITE_BACKEND_URL:-http://localhost:3003}
|
||||
- VITE_BACKEND_CLIENT_ID=${VITE_BACKEND_CLIENT_ID:-}
|
||||
- FESTIPOD_SHARED_WALLET_PASSWORD=${FESTIPOD_SHARED_WALLET_PASSWORD:-}
|
||||
- FESTIPOD_SHARED_WALLET_FILE=${FESTIPOD_SHARED_WALLET_FILE:-}
|
||||
- FESTIPOD_SHARED_WALLET_FILE_BASE64=${FESTIPOD_SHARED_WALLET_FILE_BASE64:-}
|
||||
- FESTIPOD_AUTO_SEED=${FESTIPOD_AUTO_SEED:-}
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3003:3003"
|
||||
env_file:
|
||||
- path: ./backend/.env
|
||||
required: false
|
||||
environment:
|
||||
- SEMAPPS_HOME_URL=${SEMAPPS_HOME_URL:-http://localhost:3003}
|
||||
- SEMAPPS_FRONT_URL=${SEMAPPS_FRONT_URL:-http://localhost:4000}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"queues": [
|
||||
{
|
||||
"name": "sendMail",
|
||||
"hostId": "activitypods",
|
||||
"host": "redis",
|
||||
"port": 6379,
|
||||
"db": 1
|
||||
},
|
||||
{
|
||||
"name": "remotePost",
|
||||
"hostId": "activitypods",
|
||||
"host": "redis",
|
||||
"port": 6379,
|
||||
"db": 1
|
||||
},
|
||||
{
|
||||
"name": "processOutbox",
|
||||
"hostId": "activitypods",
|
||||
"host": "redis",
|
||||
"port": 6379,
|
||||
"db": 1
|
||||
},
|
||||
{
|
||||
"name": "processInbox",
|
||||
"hostId": "activitypods",
|
||||
"host": "redis",
|
||||
"port": 6379,
|
||||
"db": 1
|
||||
},
|
||||
{
|
||||
"name": "webhookPost",
|
||||
"hostId": "activitypods",
|
||||
"host": "redis",
|
||||
"port": 6379,
|
||||
"db": 1
|
||||
},
|
||||
{
|
||||
"name": "remotePost",
|
||||
"hostId": "festipod",
|
||||
"host": "redis",
|
||||
"port": 6379,
|
||||
"db": 15
|
||||
},
|
||||
{
|
||||
"name": "processOutbox",
|
||||
"hostId": "festipod",
|
||||
"host": "redis",
|
||||
"port": 6379,
|
||||
"db": 15
|
||||
},
|
||||
{
|
||||
"name": "processInbox",
|
||||
"hostId": "festipod",
|
||||
"host": "redis",
|
||||
"port": 6379,
|
||||
"db": 15
|
||||
},
|
||||
{
|
||||
"name": "registerListener",
|
||||
"hostId": "festipod",
|
||||
"host": "redis",
|
||||
"port": 6379,
|
||||
"db": 15
|
||||
},
|
||||
{
|
||||
"name": "timeout",
|
||||
"hostId": "festipod",
|
||||
"host": "redis",
|
||||
"port": 6379,
|
||||
"db": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+3
-1
@@ -4,13 +4,15 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "portless festipod bun --hot src/index.ts",
|
||||
"dev": "bun --hot src/index.ts",
|
||||
"start": "NODE_ENV=production bun src/index.ts",
|
||||
"build": "bun run build.ts",
|
||||
"test:cucumber": "bun run cucumber:run && bun run cucumber:report && bun run features:parse && bun run steps:extract",
|
||||
"cucumber:run": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json",
|
||||
"test:auth-setup": "bun scripts/setup-test-auth.ts",
|
||||
"test:e2e-flow": "bun scripts/e2e-login-flow.ts",
|
||||
"test:e2e-revocation": "bun scripts/e2e-revocation.ts",
|
||||
"test:e2e-rights": "bun scripts/e2e-rights-matrix.ts",
|
||||
"cucumber:report": "bun scripts/parse-test-results.ts",
|
||||
"features:parse": "bun scripts/parse-features.ts",
|
||||
"steps:extract": "bun scripts/extract-step-definitions.ts",
|
||||
|
||||
File diff suppressed because one or more lines are too long
+950
-1046
File diff suppressed because it is too large
Load Diff
@@ -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 = process.env.VITE_POD_PROVIDER_URL || 'http://localhost:3000';
|
||||
const A_NAME = `diaginv${TS}a`;
|
||||
const B_NAME = `diaginv${TS}b`;
|
||||
const config = { podProviderUrl: POD, backendUrl: process.env.BACKEND_URL || process.env.VITE_BACKEND_URL || 'http://localhost:3005' };
|
||||
|
||||
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); });
|
||||
+38
-54
@@ -9,24 +9,35 @@
|
||||
* runs it end-to-end and fails on any UNEXPECTED ≥400, so the regression is
|
||||
* caught without a human driving the browser.
|
||||
*
|
||||
* ORIGIN. The OIDC redirect_uri is registered for `http://localhost:4000/login`
|
||||
* ORIGIN. The OIDC redirect_uri is registered for the app origin `/login` (default http://localhost:4005)
|
||||
* only (the app's FRONT_URL), so the app MUST be served at APP_URL — do not
|
||||
* serve a private instance on an ephemeral port: the pod rejects the redirect.
|
||||
*
|
||||
* ONE LOGIN DRIVE. The sign-in is `driveLogin` from `e2e-rights-matrix-support.ts`,
|
||||
* shared with the rights matrix: a state machine over OBSERVED hops (it must
|
||||
* leave the app for the pod, the consent Accept is clicked once stable and the
|
||||
* page must then leave the consent screen, and the shell counts only with the
|
||||
* bottom navigation shown and no picker). The previous local loop could report
|
||||
* "shell reached" while still on the app origin `/` — the user never consented, the app
|
||||
* was never installed. `authenticated` also requires the install to be visible
|
||||
* on the pod (the user's registered Event container answers 200).
|
||||
*
|
||||
* The two benign ≥400 classes (the pod notification WebSocket channel, and an
|
||||
* anonymous WAC profile read) are classified, never fatal. Anything else fails.
|
||||
*/
|
||||
|
||||
import { chromium, type Page } from 'playwright';
|
||||
import { podLogin, readResource, type ApodsStackConfig } from '../src/shared/support/activitypodsStack';
|
||||
import type { ApodsStackConfig } from '../src/shared/support/activitypodsStack';
|
||||
import { driveLogin, installedContainer, loginWithRetry, type DriveContext } from './e2e-rights-matrix-support';
|
||||
|
||||
const strip = (u: string): string => u.replace(/\/+$/, '');
|
||||
const BACKEND: string = strip(process.env.BACKEND_URL || 'http://localhost:3003');
|
||||
const APP: string = strip(process.env.APP_URL || 'http://localhost:4000');
|
||||
const BACKEND: string = strip(process.env.BACKEND_URL || process.env.VITE_BACKEND_URL || 'http://localhost:3005');
|
||||
const APP: string = strip(process.env.APP_URL || `http://localhost:${process.env.PORT || 4005}`);
|
||||
const config: ApodsStackConfig = {
|
||||
podProviderUrl: strip(process.env.POD_PROVIDER_URL || 'http://localhost:3000'),
|
||||
podProviderUrl: strip(process.env.POD_PROVIDER_URL || process.env.VITE_POD_PROVIDER_URL || 'http://localhost:3000'),
|
||||
backendUrl: BACKEND,
|
||||
};
|
||||
const POD_FRONT: string = strip(process.env.POD_FRONTEND_URL || 'http://localhost:5000');
|
||||
|
||||
const USER = `flow${Date.now()}`;
|
||||
const PASS = 'Test1test';
|
||||
@@ -37,9 +48,9 @@ interface HttpError { url: string; status: number; method: string; body: string
|
||||
const redirects: string[] = [];
|
||||
const errors4xx: HttpError[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
const hops: string[] = [];
|
||||
/** The shared login drive's context: it writes the observed hops; HTTP and page errors are collected by `wire` below. */
|
||||
const drive: DriveContext = { app: APP, podFront: POD_FRONT, hops: [], errors: [], consoleErrors: [], pageErrors: [], deadline: DEADLINE };
|
||||
|
||||
const clean = (s: string): string => s.replace('http://localhost:', ':').replace('http://127.0.0.1:', ':127.');
|
||||
const bodyText = (page: Page): Promise<string> =>
|
||||
page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim());
|
||||
const snap = (page: Page, name: string): Promise<void> =>
|
||||
@@ -65,46 +76,6 @@ function wire(page: Page): void {
|
||||
page.on('pageerror', e => consoleErrors.push('pageerror: ' + String(e.message).slice(0, 300)));
|
||||
}
|
||||
|
||||
/** Drive picker → pod login → consent until the app shell is reached. */
|
||||
async function driveLogin(page: Page): Promise<boolean> {
|
||||
let authorizeSeen = 0;
|
||||
for (let i = 0; i < 20 && Date.now() < DEADLINE; i++) {
|
||||
const url = page.url();
|
||||
if (hops[hops.length - 1] !== clean(url)) hops.push(clean(url));
|
||||
|
||||
if (url.includes(':5000') && (await page.locator('input[name="username"]').count().catch(() => 0))) {
|
||||
hops.push(' [login] submitting credentials');
|
||||
await page.locator('input[name="username"]').fill(USER).catch(() => {});
|
||||
await page.locator('input[name="password"]').fill(PASS).catch(() => {});
|
||||
const btn = page.getByRole('button', { name: /SE CONNECTER|Se connecter|Sign in|Connexion/i });
|
||||
if (await btn.count()) await btn.first().click().catch(() => {});
|
||||
else await page.locator('button[type=submit]').first().click().catch(() => {});
|
||||
await page.waitForTimeout(3500);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (url.includes(':5000/authorize')) {
|
||||
authorizeSeen++;
|
||||
const acc = page.getByRole('button', { name: /^ACCEPTER$|^Accepter$|^Accept$/i });
|
||||
let visible = await acc.first().isVisible({ timeout: 5000 }).catch(() => false);
|
||||
hops.push(` [authorize #${authorizeSeen}] accept visible=${visible}`);
|
||||
await snap(page, 'login-flow-consent');
|
||||
if (visible) { await acc.first().click().catch(() => {}); hops.push(' [authorize] clicked accept'); }
|
||||
if (authorizeSeen >= 3) { hops.push(' [LOOP] authorize revisited 3x — consent loop'); return false; }
|
||||
await page.waitForTimeout(5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (url.startsWith(APP)) {
|
||||
if (url.includes('/login')) { await page.waitForTimeout(5000); continue; }
|
||||
const picker = await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 0);
|
||||
if (!picker) { hops.push(' [app] shell reached'); return true; }
|
||||
}
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function createEvent(page: Page): Promise<boolean> {
|
||||
await page.goto(`${APP}/events/new`, { waitUntil: 'domcontentloaded' }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
@@ -128,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);
|
||||
@@ -142,19 +116,20 @@ function classify(e: HttpError): string | null {
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// Sign in (signup fallback) via the shared helper — the UI login then uses the same creds.
|
||||
await podLogin(config, USER, PASS);
|
||||
// Sign in (signup fallback, retried: a fresh signup can outlive the helper's
|
||||
// timeout while succeeding server-side) — the UI login then uses the same creds.
|
||||
const session = await loginWithRetry(config, USER, PASS);
|
||||
|
||||
const browser = await chromium.launch({ headless: true, args: ['--allow-insecure-localhost', '--disable-web-security'] });
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
wire(page);
|
||||
|
||||
await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
await page.getByRole('button', { name: /Se connecter avec localhost:3000/i }).click();
|
||||
const authenticated = await driveLogin(page);
|
||||
// The shared drive opens the app, clicks the pod's sign-in button and follows the observed hops.
|
||||
const login = await driveLogin(page, 'flow', USER, PASS, drive);
|
||||
await snap(page, 'login-flow-after-login');
|
||||
const install = login.ok ? await installedContainer(session) : { ok: false, actual: 'not checked: the sign-in drive failed' };
|
||||
const authenticated = login.ok && install.ok;
|
||||
const eventCreated = authenticated ? await createEvent(page) : false;
|
||||
await snap(page, 'login-flow-after-event');
|
||||
const meetingPointCreated = eventCreated ? await createMeetingPoint(page) : false;
|
||||
@@ -164,15 +139,24 @@ 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,
|
||||
meetingPointCreated,
|
||||
redirects,
|
||||
hops,
|
||||
hops: drive.hops,
|
||||
benign4xx: benign,
|
||||
fatal4xx: fatal,
|
||||
consoleErrors,
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* Revocation probe: drives the REAL local ActivityPods stack through the browser
|
||||
* and asserts that Festipod notices when its registration on the user's pod is
|
||||
* revoked, instead of staying in a "connected" view whose writes cannot land.
|
||||
*
|
||||
* THREE PHASES, one fresh user:
|
||||
*
|
||||
* 1. CURRENT — picker → pod login → consent → app. The app-status probe runs on
|
||||
* load (the GET of the pod's `/.well-known/app-status` is observed on the
|
||||
* wire, with its body, and the app logs `[AppStatus] probe (load) → current`);
|
||||
* a focus + visibilitychange right after does NOT probe at once (one-minute
|
||||
* throttle); an event is created and read back from the pod; a
|
||||
* visibilitychange inside the minute is deferred, not dropped — its probe
|
||||
* lands at the end of the minute and reads current.
|
||||
* 2. REVOKED — the registration is removed on the pod
|
||||
* (`POST {pod}/.auth-agent/app-registrations/remove`, the user's own token).
|
||||
* Without any reload, a window focus inside the minute — the user coming
|
||||
* back from the pod's tab — must, by the end of that minute, read the
|
||||
* revocation (`installed:false`) and LOG THE USER OUT: the picker with the
|
||||
* notice that the app's authorization was removed, NO redirect to the
|
||||
* consent screen (a deliberate revocation is not sent back to consent), no
|
||||
* session left (Back and a fresh load stay on the picker; the event-creation
|
||||
* drive that worked in phase 1 lands on the picker, never on success, and
|
||||
* nothing with that title reaches the pod).
|
||||
* 3. RECOVERED — signing in again from the picker goes through
|
||||
* `registerApp` → the consent screen; accepting it brings the user back to a
|
||||
* connected app whose probe reads current again, and a write lands. The
|
||||
* pod's own session survived the app's logout, so this sign-in shows no pod
|
||||
* login form: the sign-in click leads straight to consent.
|
||||
*
|
||||
* Both sign-ins are the shared drive `driveLogin` (`e2e-rights-matrix-support.ts`).
|
||||
*
|
||||
* Nothing is stubbed: the probe result is whatever the pod answers. The probe
|
||||
* is triggered only through the app's real paths (page load, window focus,
|
||||
* visibilitychange); its one-minute throttle is untouched.
|
||||
*
|
||||
* ORIGIN. As for `e2e-login-flow.ts`: the OIDC redirect_uri is registered for
|
||||
* the app origin `/login` (default http://localhost:4005) only, so the app MUST be served at APP_URL.
|
||||
*
|
||||
* Exit code 0 only when every phase holds; the JSON report says which did not.
|
||||
*/
|
||||
|
||||
import { chromium, type Page } from 'playwright';
|
||||
import {
|
||||
linkId,
|
||||
podLogin,
|
||||
readResource,
|
||||
recordUri,
|
||||
type ApodsSession,
|
||||
type ApodsStackConfig,
|
||||
} from '../src/shared/support/activitypodsStack';
|
||||
import { driveLogin, type DriveContext } from './e2e-rights-matrix-support';
|
||||
|
||||
const strip = (u: string): string => u.replace(/\/+$/, '');
|
||||
const BACKEND: string = strip(process.env.BACKEND_URL || process.env.VITE_BACKEND_URL || 'http://localhost:3005');
|
||||
const APP: string = strip(process.env.APP_URL || `http://localhost:${process.env.PORT || 4005}`);
|
||||
const config: ApodsStackConfig = {
|
||||
podProviderUrl: strip(process.env.POD_PROVIDER_URL || process.env.VITE_POD_PROVIDER_URL || 'http://localhost:3000'),
|
||||
backendUrl: BACKEND,
|
||||
};
|
||||
const POD_FRONT = strip(process.env.POD_FRONTEND_URL || 'http://localhost:5000');
|
||||
|
||||
const USER = `revoke${Date.now()}`;
|
||||
const PASS = 'Test1test';
|
||||
const DEADLINE = Date.now() + 10 * 60 * 1000;
|
||||
|
||||
type Phase = 'current' | 'revoked' | 'recovered';
|
||||
let phase: Phase = 'current';
|
||||
|
||||
interface StatusSeen { phase: Phase; status: number; body: unknown }
|
||||
interface HttpError { phase: Phase; url: string; status: number; method: string; body: string }
|
||||
|
||||
const statusSeen: StatusSeen[] = [];
|
||||
const probeLogs: Array<{ phase: Phase; at: number; text: string }> = [];
|
||||
const errors4xx: HttpError[] = [];
|
||||
const writes: Array<{ phase: Phase; method: string; url: string; status: number }> = [];
|
||||
const consoleErrors: string[] = [];
|
||||
const redirects: string[] = [];
|
||||
/** The shared sign-in drive's context (`driveLogin`): it writes the observed hops, tagged by phase; errors are collected by `wire` below. */
|
||||
const drive: DriveContext = { app: APP, podFront: POD_FRONT, hops: [], errors: [], consoleErrors: [], pageErrors: [], deadline: DEADLINE };
|
||||
|
||||
const clean = (s: string): string => s.replace('http://localhost:', ':').replace('http://127.0.0.1:', ':127.');
|
||||
const bodyText = (page: Page): Promise<string> =>
|
||||
page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim());
|
||||
const snap = (page: Page, name: string): Promise<void> =>
|
||||
page.screenshot({ path: `/tmp/${name}.png`, fullPage: true }).then(() => undefined).catch(() => undefined);
|
||||
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
function wire(page: Page): void {
|
||||
let last = '';
|
||||
page.on('framenavigated', f => {
|
||||
if (f === page.mainFrame() && f.url() !== last) {
|
||||
redirects.push(`[${phase}] ${clean(last || '(start)')} -> ${clean(f.url())}`);
|
||||
last = f.url();
|
||||
}
|
||||
});
|
||||
page.on('response', r => {
|
||||
const method = r.request()?.method?.() ?? '?';
|
||||
const url = r.url();
|
||||
if (method === 'GET' && url.endsWith('/.well-known/app-status')) {
|
||||
const at = phase;
|
||||
r.json()
|
||||
.then(body => statusSeen.push({ phase: at, status: r.status(), body }))
|
||||
.catch(() => statusSeen.push({ phase: at, status: r.status(), body: '(unreadable)' }));
|
||||
}
|
||||
if ((method === 'POST' || method === 'PUT' || method === 'PATCH') && url.startsWith(config.podProviderUrl)) {
|
||||
writes.push({ phase, method, url: clean(url), status: r.status() });
|
||||
}
|
||||
if (r.status() >= 400) {
|
||||
const at = phase;
|
||||
r.text()
|
||||
.then(b => errors4xx.push({ phase: at, url, status: r.status(), method, body: b.slice(0, 300) }))
|
||||
.catch(() => errors4xx.push({ phase: at, url, status: r.status(), method, body: '(unreadable)' }));
|
||||
}
|
||||
});
|
||||
page.on('console', m => {
|
||||
const text = m.text();
|
||||
if (text.includes('[AppStatus]')) probeLogs.push({ phase, at: Date.now(), text: text.slice(0, 400) });
|
||||
if (m.type() === 'error') consoleErrors.push(`[${phase}] ${text.slice(0, 300)}`);
|
||||
});
|
||||
page.on('pageerror', e => consoleErrors.push(`[${phase}] pageerror: ${String(e.message).slice(0, 300)}`));
|
||||
}
|
||||
|
||||
const onApp = (page: Page): boolean => page.url().startsWith(APP);
|
||||
const pickerShown = async (page: Page): Promise<boolean> =>
|
||||
(await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 0)) > 0;
|
||||
const whereIs = async (page: Page): Promise<string> =>
|
||||
!onApp(page)
|
||||
? (page.url().startsWith(`${POD_FRONT}/authorize`) ? 'consent' : `elsewhere:${clean(page.url())}`)
|
||||
: (await pickerShown(page)) ? 'picker' : 'still-connected';
|
||||
|
||||
/** The event-creation drive from `e2e-login-flow.ts`, reporting what the app showed. */
|
||||
async function createEvent(page: Page, title: string): Promise<{ eventUri?: string; successToast: boolean; finalUrl: string }> {
|
||||
let successToast = false;
|
||||
const toastWatch = setInterval(() => {
|
||||
void page.locator('text=Événement relayé').count().then(n => { if (n) successToast = true; }).catch(() => {});
|
||||
}, 250);
|
||||
try {
|
||||
await page.goto(`${APP}/events/new`, { waitUntil: 'domcontentloaded' }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
await page.locator('input[placeholder="Donnez un nom à votre événement"]').fill(title, { timeout: 3000 }).catch(() => {});
|
||||
const dates = page.locator('input[type="date"]');
|
||||
if (await dates.count().catch(() => 0)) await dates.first().fill('2026-12-20', { timeout: 3000 }).catch(() => {});
|
||||
await page.getByRole('button', { name: /Suivant/ }).click({ timeout: 3000 }).catch(() => {});
|
||||
await page.waitForTimeout(1200);
|
||||
const next = page.getByRole('button', { name: /Suivant/ });
|
||||
if (await next.count().catch(() => 0)) await next.click({ timeout: 3000 }).catch(() => {});
|
||||
await page.waitForTimeout(1200);
|
||||
await page.getByRole('button', { name: /Relayer l'événement/ }).click({ timeout: 3000 }).catch(() => {});
|
||||
await page.waitForTimeout(5000);
|
||||
} finally {
|
||||
clearInterval(toastWatch);
|
||||
}
|
||||
const finalUrl = page.url();
|
||||
const m = finalUrl.startsWith(`${APP}/events/`) ? /\/events\/([^/?#]+)/.exec(finalUrl.slice(APP.length)) : null;
|
||||
const segment = m?.[1];
|
||||
const eventUri = segment && segment !== 'new' ? decodeURIComponent(segment) : undefined;
|
||||
return { eventUri, successToast, finalUrl };
|
||||
}
|
||||
|
||||
/** Does the user's pod hold an event with this title? Read with the user's own token. */
|
||||
async function podHoldsEventTitled(session: ApodsSession, title: string): Promise<boolean> {
|
||||
const container = await readResource(session, `${session.storage}/as/event`).catch(() => undefined);
|
||||
if (!container) return false;
|
||||
if (JSON.stringify(container).includes(title)) return true;
|
||||
const raw = container['ldp:contains'] ?? container['contains'];
|
||||
const members = (Array.isArray(raw) ? raw : raw ? [raw] : []).map(linkId).filter((u): u is string => !!u);
|
||||
for (const uri of members) {
|
||||
const rec = await readResource(session, uri).catch(() => undefined);
|
||||
if (rec && JSON.stringify(rec).includes(title)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Wait until a predicate holds or the bound elapses. */
|
||||
async function waitFor(pred: () => boolean | Promise<boolean>, ms: number): Promise<boolean> {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
if (await pred()) return true;
|
||||
await sleep(250);
|
||||
}
|
||||
return pred();
|
||||
}
|
||||
|
||||
/** Fire the real DOM event a probe listener is attached to. */
|
||||
const fire = (page: Page, trigger: 'focus' | 'visibility'): Promise<void> =>
|
||||
page.evaluate((which) => {
|
||||
if (which === 'focus') window.dispatchEvent(new Event('focus'));
|
||||
else document.dispatchEvent(new Event('visibilitychange'));
|
||||
}, trigger).catch(() => {});
|
||||
|
||||
const lastProbeAt = (): number =>
|
||||
Math.max(0, ...probeLogs.filter(l => l.text.includes('probe (')).map(l => l.at));
|
||||
|
||||
/**
|
||||
* The probe's own schedule, exercised live and never shortened: fire `trigger`
|
||||
* less than a minute after the app's last probe, check that NO probe starts at
|
||||
* once (throttle), then wait for the deferred probe of that trigger to land
|
||||
* with the expected verdict at the end of the minute (not dropped).
|
||||
*/
|
||||
async function deferredProbe(page: Page, trigger: 'focus' | 'visibility', verdict: RegExp): Promise<{
|
||||
firedAfterLastProbeMs: number; immediate: boolean; probed: boolean; gapFromLastProbeMs?: number;
|
||||
}> {
|
||||
const last = lastProbeAt();
|
||||
const firedAfterLastProbeMs = Date.now() - last;
|
||||
const before = probeLogs.length;
|
||||
await fire(page, trigger);
|
||||
await page.waitForTimeout(3000);
|
||||
const immediate = probeLogs.slice(before).some(l => l.text.includes('probe ('));
|
||||
const pattern = new RegExp(`probe \\(${trigger}\\) → ${verdict.source}`);
|
||||
const probed = await waitFor(() => probeLogs.slice(before).some(l => pattern.test(l.text)), 75_000);
|
||||
const hit = probeLogs.slice(before).find(l => pattern.test(l.text));
|
||||
return { firedAfterLastProbeMs, immediate, probed, gapFromLastProbeMs: hit ? hit.at - last : undefined };
|
||||
}
|
||||
|
||||
const isCurrent = (b: unknown): boolean =>
|
||||
typeof b === 'object' && b !== null && (b as Record<string, unknown>)['installed'] === true && (b as Record<string, unknown>)['upgradeNeeded'] === false;
|
||||
|
||||
/** Benign ≥400 by design (same classes as e2e-login-flow.ts). */
|
||||
function benign(e: HttpError): boolean {
|
||||
if (e.url.includes('/.notifications/WebSocketChannel2023')) return true;
|
||||
if (e.status === 403 && e.method === 'GET' && /\/data\//.test(e.url)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const user = await podLogin(config, USER, PASS);
|
||||
const appDescription = await readResource(undefined, `${BACKEND}/app`);
|
||||
const appUri = recordUri(appDescription);
|
||||
if (!appUri) throw new Error('GET /app carries no id (the app actor URI)');
|
||||
|
||||
const browser = await chromium.launch({ headless: true, args: ['--allow-insecure-localhost', '--disable-web-security'] });
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
wire(page);
|
||||
|
||||
// ── Phase 1: CURRENT ────────────────────────────────────────────────────
|
||||
// The shared drive opens the app, clicks the pod's sign-in button and follows
|
||||
// the observed hops (pod login → consent → shell, bottom navigation shown).
|
||||
const connected = (await driveLogin(page, 'current', USER, PASS, drive)).ok;
|
||||
const probeRanCurrent = connected && await waitFor(
|
||||
() => statusSeen.some(s => s.phase === 'current' && s.status === 200 && isCurrent(s.body))
|
||||
&& probeLogs.some(l => l.phase === 'current' && /probe \(load\) → current/.test(l.text)),
|
||||
15000,
|
||||
);
|
||||
// Throttle, observed through the real listeners: focus + visibilitychange
|
||||
// seconds after the load probe must not issue another status request.
|
||||
const beforeNudge = statusSeen.filter(s => s.phase === 'current').length;
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
}).catch(() => {});
|
||||
await page.waitForTimeout(3000);
|
||||
const afterNudge = statusSeen.filter(s => s.phase === 'current').length;
|
||||
const throttled = beforeNudge >= 1 && afterNudge === beforeNudge;
|
||||
const titleA = `Concert courant ${USER}`;
|
||||
const writeA = connected ? await createEvent(page, titleA) : { successToast: false, finalUrl: page.url() } as { eventUri?: string; successToast: boolean; finalUrl: string };
|
||||
const writeAOnPod = writeA.eventUri ? await podHoldsEventTitled(user, titleA) : false;
|
||||
await snap(page, 'revocation-current-after-write');
|
||||
// A visibilitychange right after the write's reload: deferred, then current.
|
||||
const visibilityProbe = connected ? await deferredProbe(page, 'visibility', /current/) : undefined;
|
||||
|
||||
// ── Phase 2: REVOKED ────────────────────────────────────────────────────
|
||||
phase = 'revoked';
|
||||
const revoke = await fetch(`${config.podProviderUrl}/.auth-agent/app-registrations/remove`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${user.token}` },
|
||||
body: JSON.stringify({ appUri }),
|
||||
});
|
||||
const revokeResult = { status: revoke.status, body: (await revoke.text()).slice(0, 300) };
|
||||
// Removal is processed by the pod and the app backend; give it a moment to settle.
|
||||
await sleep(3000);
|
||||
|
||||
// (a) FOCUS, no reload — the user revokes in the pod's tab and comes back
|
||||
// within the minute of the last probe: the deferred focus probe must read the
|
||||
// revocation and take the app off the connected view.
|
||||
const focusProbe = await deferredProbe(page, 'focus', /stale \(not-installed\)/);
|
||||
const leftByFocus = await waitFor(async () => !onApp(page) || (await pickerShown(page)), 15000);
|
||||
const leftByFocusTo = await whereIs(page);
|
||||
await snap(page, 'revocation-revoked-left-by-focus');
|
||||
|
||||
const notice = page.locator('[data-testid="auth-picker-notice"]');
|
||||
const noticeShown = onApp(page) && (await notice.count().catch(() => 0)) > 0;
|
||||
const noticeText = noticeShown ? ((await notice.first().innerText().catch(() => '')) as string).replace(/\s+/g, ' ').trim() : '';
|
||||
|
||||
// (b) No session left: Back must not return to the connected view, a fresh
|
||||
// load must stay on the picker, and the same event-creation drive that worked
|
||||
// in phase 1 must not reach success (it lands on the picker).
|
||||
await page.goBack({ waitUntil: 'domcontentloaded' }).catch(() => {});
|
||||
await page.waitForTimeout(2000);
|
||||
const stillPickerAfterBack = (await whereIs(page)) === 'picker';
|
||||
await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' }).catch(() => {});
|
||||
await page.waitForTimeout(2500);
|
||||
const stillPickerAfterReload = (await whereIs(page)) === 'picker';
|
||||
const titleB = `Concert revoque ${USER}`;
|
||||
const writeB = await createEvent(page, titleB);
|
||||
await snap(page, 'revocation-revoked-after-write-attempt');
|
||||
const leftAfterReload = await waitFor(async () => !onApp(page) || (await pickerShown(page)), 15000);
|
||||
const leftAfterReloadTo = await whereIs(page);
|
||||
const writeBOnPod = await podHoldsEventTitled(user, titleB);
|
||||
const consentRedirectInRevoked = redirects.some(r => r.startsWith('[revoked]') && r.includes(`${clean(POD_FRONT)}/authorize`));
|
||||
const pageWritesInRevoked = writes.filter(w => w.phase === 'revoked');
|
||||
await snap(page, 'revocation-revoked-left');
|
||||
|
||||
// ── Phase 3: RECOVERED ──────────────────────────────────────────────────
|
||||
phase = 'recovered';
|
||||
// Sign in again from the picker: login → registerApp → consent → app.
|
||||
const signInAgain = page.getByRole('button', { name: /Se connecter avec localhost:3000/i });
|
||||
let reconnected = false;
|
||||
if (leftAfterReloadTo === 'picker' && (await signInAgain.count().catch(() => 0))) {
|
||||
// The same drive: from the app's root (the picker, no session left), sign-in click → pod login → consent → shell.
|
||||
reconnected = (await driveLogin(page, 'recovered', USER, PASS, drive)).ok;
|
||||
}
|
||||
const consentReachedOnSignIn = redirects.some(r => r.startsWith('[recovered]') && r.includes(`${clean(POD_FRONT)}/authorize`));
|
||||
const probeReadsCurrentAgain = reconnected && await waitFor(
|
||||
() => statusSeen.some(s => s.phase === 'recovered' && s.status === 200 && isCurrent(s.body)),
|
||||
15000,
|
||||
);
|
||||
const titleC = `Concert retabli ${USER}`;
|
||||
const writeC = reconnected ? await createEvent(page, titleC) : { successToast: false, finalUrl: page.url() } as { eventUri?: string; successToast: boolean; finalUrl: string };
|
||||
const writeCOnPod = writeC.eventUri ? await podHoldsEventTitled(user, titleC) : false;
|
||||
await snap(page, 'revocation-recovered-after-write');
|
||||
|
||||
await ctx.close();
|
||||
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.
|
||||
const deferredOnTime = (p?: { immediate: boolean; probed: boolean; gapFromLastProbeMs?: number }): boolean =>
|
||||
!!p && !p.immediate && p.probed && p.gapFromLastProbeMs !== undefined
|
||||
&& p.gapFromLastProbeMs >= 58_000 && p.gapFromLastProbeMs <= 75_000;
|
||||
const positive = {
|
||||
connected,
|
||||
probeRanCurrent,
|
||||
throttled,
|
||||
statusRequestsAfterNudge: afterNudge,
|
||||
write: { ...writeA, onPod: writeAOnPod },
|
||||
visibilityProbe,
|
||||
ok: connected && probeRanCurrent && throttled && !!writeA.eventUri && writeA.successToast && writeAOnPod
|
||||
&& deferredOnTime(visibilityProbe),
|
||||
};
|
||||
const negative = {
|
||||
revoke: revokeResult,
|
||||
focusProbe,
|
||||
leftByFocus,
|
||||
leftByFocusTo,
|
||||
noticeShown,
|
||||
noticeText,
|
||||
consentRedirectInRevoked,
|
||||
pageWritesInRevoked,
|
||||
stillPickerAfterBack,
|
||||
stillPickerAfterReload,
|
||||
leftAfterReload,
|
||||
leftAfterReloadTo,
|
||||
writeAttempt: { ...writeB, onPod: writeBOnPod },
|
||||
ok: (revoke.status === 200 || revoke.status === 204)
|
||||
&& deferredOnTime(focusProbe) && leftByFocus && leftByFocusTo === 'picker'
|
||||
&& noticeShown && noticeText.includes('retirée')
|
||||
&& !consentRedirectInRevoked && !pageWritesInRevoked.length
|
||||
&& stillPickerAfterBack && stillPickerAfterReload && leftAfterReloadTo === 'picker'
|
||||
&& !writeB.eventUri && !writeB.successToast && !writeBOnPod,
|
||||
};
|
||||
const recovered = {
|
||||
reconnected,
|
||||
consentReachedOnSignIn,
|
||||
probeReadsCurrentAgain,
|
||||
write: { ...writeC, onPod: writeCOnPod },
|
||||
ok: reconnected && consentReachedOnSignIn && probeReadsCurrentAgain && !!writeC.eventUri && writeC.successToast && writeCOnPod,
|
||||
};
|
||||
// Phase 2's ≥400s are the revocation's own consequences; the other phases must be clean.
|
||||
const fatal = errors4xx.filter(e => e.phase !== 'revoked' && !benign(e));
|
||||
const report = {
|
||||
user: USER,
|
||||
appUri,
|
||||
accountDeleted,
|
||||
positive,
|
||||
negative,
|
||||
recovered,
|
||||
statusBodies: statusSeen,
|
||||
probeLogs,
|
||||
writes,
|
||||
revokedPhase4xx: errors4xx.filter(e => e.phase === 'revoked').map(e => `${e.status} ${e.method} ${clean(e.url)}`),
|
||||
fatal4xx: fatal,
|
||||
redirects,
|
||||
hops: drive.hops,
|
||||
consoleErrors,
|
||||
ok: positive.ok && negative.ok && recovered.ok && fatal.length === 0,
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(report.ok ? 0 : 1);
|
||||
})().catch(err => {
|
||||
console.log(JSON.stringify({ ok: false, fatal: String(err).slice(0, 400), phase, statusSeen, probeLogs, redirects, errors4xx, consoleErrors }, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,524 @@
|
||||
/**
|
||||
* Support for `e2e-rights-matrix.ts`: the per-line report, raw HTTP with a
|
||||
* user's token (or none), account bootstrap with programmatic app install, and
|
||||
* the browser drives (pod login + consent, event and meeting-point creation).
|
||||
*
|
||||
* WHY NOT IMPORT THE BANKED SCRIPTS. `e2e-login-flow.ts` and `e2e-revocation.ts`
|
||||
* run their whole probe at import time (top-level IIFE) and their drives read
|
||||
* module-level user constants, so their helpers cannot be imported. The drives
|
||||
* below are the same sequences, parameterised by the user who drives. The other
|
||||
* direction works: `e2e-login-flow.ts` and `e2e-revocation.ts` import `driveLogin`
|
||||
* (the login-flow probe also the install check) from here, so the sign-in drive
|
||||
* exists once.
|
||||
*
|
||||
* NEVER A SILENT PASS. A line's check is a function returning `{ ok, actual }`;
|
||||
* a throw, a timeout or an unreachable host is recorded as FAIL with the error
|
||||
* as the actual value. A refusal is matched on an explicit status only
|
||||
* (`refused`), so a 404, a 500 or a network error (status 0) never counts as one.
|
||||
*/
|
||||
|
||||
import type { Frame, Page } from 'playwright';
|
||||
import {
|
||||
linkId,
|
||||
podLogin,
|
||||
readResource,
|
||||
recordUri,
|
||||
type ApodsSession,
|
||||
type ApodsStackConfig,
|
||||
type JsonRecord,
|
||||
} from '../src/shared/support/activitypodsStack';
|
||||
|
||||
// ── Report ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** How a line weighs on the exit code: only `hard` lines can fail the run. */
|
||||
export type LineKind = 'hard' | 'known-deviation' | 'known-defect' | 'manual' | 'info';
|
||||
export type LineStatus = 'PASS' | 'FAIL' | 'KNOWN-DEVIATION' | 'KNOWN-DEFECT' | 'MANUAL-CHECK' | 'INFO';
|
||||
|
||||
export interface CheckOutcome {
|
||||
ok: boolean;
|
||||
actual: string;
|
||||
/** Raw evidence (status codes, bodies, ids) printed under the line. */
|
||||
evidence?: string;
|
||||
}
|
||||
|
||||
export interface LineResult {
|
||||
id: string;
|
||||
group: string;
|
||||
name: string;
|
||||
kind: LineKind;
|
||||
expected: string;
|
||||
actual: string;
|
||||
status: LineStatus;
|
||||
evidence?: string;
|
||||
/** A tracked classification printed with a FAIL (it never turns it into a pass). */
|
||||
finding?: string;
|
||||
/** A loud notice: a known line whose observed behaviour changed. */
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
const errorText = (e: unknown): string => (e instanceof Error ? `${e.name}: ${e.message}` : String(e)).slice(0, 300);
|
||||
|
||||
export class Matrix {
|
||||
readonly lines: LineResult[] = [];
|
||||
|
||||
/** A hard assertion: PASS only when the check returns ok; anything else is FAIL. */
|
||||
async hard(
|
||||
id: string, group: string, name: string, expected: string,
|
||||
check: () => Promise<CheckOutcome>,
|
||||
opts: { finding?: string } = {},
|
||||
): Promise<boolean> {
|
||||
let outcome: CheckOutcome;
|
||||
try {
|
||||
outcome = await check();
|
||||
} catch (e) {
|
||||
outcome = { ok: false, actual: `ERROR ${errorText(e)}` };
|
||||
}
|
||||
const status: LineStatus = outcome.ok ? 'PASS' : 'FAIL';
|
||||
this.push({
|
||||
id, group, name, kind: 'hard', expected, actual: outcome.actual, status, evidence: outcome.evidence,
|
||||
finding: status === 'FAIL' ? opts.finding : undefined,
|
||||
});
|
||||
return outcome.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tracked, non-fatal line (known deviation or known defect). `classify`
|
||||
* receives the observation and says whether the tracked behaviour still holds;
|
||||
* when it does not, the line carries a loud notice (promote it).
|
||||
*/
|
||||
async known(
|
||||
kind: 'known-deviation' | 'known-defect', id: string, group: string, name: string, expected: string,
|
||||
observe: () => Promise<CheckOutcome & { notice?: string }>,
|
||||
): Promise<void> {
|
||||
let outcome: CheckOutcome & { notice?: string };
|
||||
try {
|
||||
outcome = await observe();
|
||||
} catch (e) {
|
||||
outcome = { ok: false, actual: `ERROR ${errorText(e)}`, notice: 'the observation itself failed — the tracked behaviour is unverified this run' };
|
||||
}
|
||||
this.push({
|
||||
id, group, name, kind, expected, actual: outcome.actual,
|
||||
status: kind === 'known-defect' ? 'KNOWN-DEFECT' : 'KNOWN-DEVIATION',
|
||||
evidence: outcome.evidence, notice: outcome.notice,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' });
|
||||
}
|
||||
|
||||
/** An informational line: never affects the exit code; `notice` flags an observation that broke its stated premise. */
|
||||
info(id: string, group: string, name: string, actual: string, notice?: string): void {
|
||||
this.push({ id, group, name, kind: 'info', expected: '(informational)', actual, status: 'INFO', notice });
|
||||
}
|
||||
|
||||
private push(line: LineResult): void {
|
||||
this.lines.push(line);
|
||||
const mark = line.status === 'FAIL' ? 'FAIL' : line.status;
|
||||
console.error(`[matrix] ${mark.padEnd(15)} ${line.id} — ${line.name} :: ${line.actual}`);
|
||||
}
|
||||
|
||||
get hardFailures(): LineResult[] {
|
||||
return this.lines.filter(l => l.kind === 'hard' && l.status === 'FAIL');
|
||||
}
|
||||
|
||||
print(header: string): void {
|
||||
const out: string[] = [];
|
||||
out.push('', '═'.repeat(100), `RIGHTS MATRIX — ${header}`, '═'.repeat(100));
|
||||
let group = '';
|
||||
for (const l of this.lines) {
|
||||
if (l.group !== group) { group = l.group; out.push('', `── ${group} ${'─'.repeat(Math.max(0, 95 - group.length))}`); }
|
||||
out.push(`${l.status.padEnd(15)} ${l.id} ${l.name}`);
|
||||
out.push(`${' '.repeat(16)}expected: ${l.expected}`);
|
||||
out.push(`${' '.repeat(16)}actual: ${l.actual}`);
|
||||
if (l.evidence) out.push(`${' '.repeat(16)}evidence: ${l.evidence}`);
|
||||
if (l.finding) out.push(`${' '.repeat(16)}finding: ${l.finding}`);
|
||||
if (l.notice) out.push(`${' '.repeat(16)}!!! NOTICE: ${l.notice}`);
|
||||
}
|
||||
const count = (s: LineStatus): number => this.lines.filter(l => l.status === s).length;
|
||||
const notices = this.lines.filter(l => l.notice);
|
||||
out.push('', '═'.repeat(100));
|
||||
out.push(`hard lines: ${this.lines.filter(l => l.kind === 'hard').length} — PASS ${count('PASS')}, FAIL ${count('FAIL')}; `
|
||||
+ `KNOWN-DEVIATION ${count('KNOWN-DEVIATION')}, KNOWN-DEFECT ${count('KNOWN-DEFECT')}, MANUAL-CHECK ${count('MANUAL-CHECK')}, INFO ${count('INFO')}`);
|
||||
for (const l of this.hardFailures) out.push(`FAILED: ${l.id} — ${l.name}${l.finding ? ` [${l.finding.split(':')[0]}]` : ''}`);
|
||||
for (const l of notices) {
|
||||
out.push('', '!'.repeat(100), `!!! ${l.id} (${l.status}): ${l.notice}`, '!'.repeat(100));
|
||||
}
|
||||
out.push(`VERDICT: ${this.hardFailures.length === 0 ? 'PASS — every hard line holds' : `FAIL — ${this.hardFailures.length} hard line(s) failed`}`);
|
||||
out.push('═'.repeat(100));
|
||||
console.log(out.join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
/** Throw a named prerequisite failure (the line then FAILs, it is never skipped). */
|
||||
export function need<T>(value: T | undefined | null | false, what: string): T {
|
||||
if (value === undefined || value === null || value === false) throw new Error(`prerequisite missing: ${what}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Is `status` one of the explicit refusals `allowed`? 0 (unreachable), 404, 5xx never are. */
|
||||
export const refused = (status: number, allowed: number[]): boolean => allowed.includes(status);
|
||||
|
||||
// ── HTTP ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface HttpResult { status: number; text: string; headers: Headers; error?: string }
|
||||
|
||||
/**
|
||||
* One request, bounded. A network failure or timeout comes back as status 0
|
||||
* with the error text — never thrown, never a status a refusal could match.
|
||||
*/
|
||||
export async function http(
|
||||
method: string,
|
||||
url: string,
|
||||
opts: { token?: string; body?: string; contentType?: string; accept?: string; timeoutMs?: number } = {},
|
||||
): Promise<HttpResult> {
|
||||
const headers = new Headers({ Accept: opts.accept ?? 'application/ld+json' });
|
||||
if (opts.token) headers.set('Authorization', `Bearer ${opts.token}`);
|
||||
if (opts.contentType) headers.set('Content-Type', opts.contentType);
|
||||
try {
|
||||
const r = await fetch(url, { method, headers, body: opts.body, signal: AbortSignal.timeout(opts.timeoutMs ?? 15000) });
|
||||
return { status: r.status, text: await r.text(), headers: r.headers };
|
||||
} catch (e) {
|
||||
return { status: 0, text: '', headers: new Headers(), error: errorText(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export const brief = (r: HttpResult): string =>
|
||||
r.status === 0 ? `unreachable (${r.error})` : `HTTP ${r.status}${r.text ? ` ${r.text.replace(/\s+/g, ' ').slice(0, 160)}` : ''}`;
|
||||
|
||||
export function parseJson(text: string): JsonRecord | undefined {
|
||||
try {
|
||||
const v: unknown = JSON.parse(text);
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as JsonRecord) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const arrayOf = (value: unknown): unknown[] =>
|
||||
value === undefined || value === null ? [] : Array.isArray(value) ? value : [value];
|
||||
|
||||
/** The member URIs of an attendees collection body (`items`, no `totalItems` on this platform). */
|
||||
export function itemUris(record: JsonRecord | undefined): string[] {
|
||||
if (!record) return [];
|
||||
return arrayOf(record['items'] ?? record['as:items']).map(linkId).filter((u): u is string => !!u);
|
||||
}
|
||||
|
||||
export const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
/** Poll `probe` until `done` holds or the bound elapses; returns the last value either way. */
|
||||
export async function poll<T>(probe: () => Promise<T>, done: (v: T) => boolean, ms: number, every = 1000): Promise<T> {
|
||||
const end = Date.now() + ms;
|
||||
let v = await probe();
|
||||
while (!done(v) && Date.now() < end) {
|
||||
await sleep(every);
|
||||
v = await probe();
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// ── Accounts ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `podLogin` with retries: a fresh pod signup can outlive the helper's 15 s
|
||||
* timeout while still succeeding server-side, so a retry logs in.
|
||||
*/
|
||||
export async function loginWithRetry(config: ApodsStackConfig, username: string, password: string): Promise<ApodsSession> {
|
||||
let last: unknown;
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
try {
|
||||
return await podLogin(config, username, password);
|
||||
} catch (e) {
|
||||
last = e;
|
||||
await sleep(5000);
|
||||
}
|
||||
}
|
||||
throw new Error(`podLogin(${username}) failed after retries: ${errorText(last)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmatic app install (the smoke's install step): register the app ACTOR
|
||||
* URI with every access need of its group, then wait for the registered Event
|
||||
* container to exist.
|
||||
*/
|
||||
export async function installApp(config: ApodsStackConfig, session: ApodsSession): Promise<{ ok: boolean; actual: string }> {
|
||||
const description = await readResource(undefined, `${config.backendUrl}/app`);
|
||||
const appUri = need(recordUri(description), 'app actor URI (id of GET /app)');
|
||||
const groupUri = need(linkId(description['interop:hasAccessNeedGroup']), 'access-need group URI');
|
||||
const group = await readResource(undefined, groupUri);
|
||||
const body = JSON.stringify({
|
||||
appUri,
|
||||
acceptedAccessNeeds: arrayOf(group['interop:hasAccessNeed']),
|
||||
acceptedSpecialRights: arrayOf(group['apods:hasSpecialRights']),
|
||||
});
|
||||
const reg = await http('POST', `${config.podProviderUrl}/.auth-agent/app-registrations/register`, {
|
||||
token: session.token, body, contentType: 'application/json', accept: 'application/json', timeoutMs: 30000,
|
||||
});
|
||||
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, 90000, 500,
|
||||
);
|
||||
return { ok: container.status === 200, actual: `register → HTTP ${reg.status}; ${session.storage}/as/event → ${brief(container).slice(0, 40)}` };
|
||||
}
|
||||
|
||||
/** The profile document an actor links (`url`), read from the actor document. */
|
||||
export async function profileUriOf(webId: string): Promise<string | undefined> {
|
||||
const r = await http('GET', webId);
|
||||
return linkId(parseJson(r.text)?.['url']);
|
||||
}
|
||||
|
||||
// ── Browser drives ────────────────────────────────────────────────────────
|
||||
|
||||
export interface UiHttpError { who: string; url: string; status: number; method: string; body: string }
|
||||
export interface UiPageError { who: string; url: string; message: string; stack: string }
|
||||
|
||||
export interface DriveContext {
|
||||
app: string;
|
||||
podFront: string;
|
||||
hops: string[];
|
||||
errors: UiHttpError[];
|
||||
consoleErrors: string[];
|
||||
/** Uncaught exceptions thrown in a page, with the page URL at the time. */
|
||||
pageErrors: UiPageError[];
|
||||
deadline: number;
|
||||
}
|
||||
|
||||
const clean = (s: string): string => s.replace('http://localhost:', ':').replace('http://127.0.0.1:', ':127.');
|
||||
|
||||
export const bodyText = (page: Page): Promise<string> =>
|
||||
page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim());
|
||||
|
||||
/** Record every ≥400 response, console error and uncaught page error of a page, tagged by user. */
|
||||
export function wire(page: Page, who: string, ctx: DriveContext): void {
|
||||
page.on('response', r => {
|
||||
if (r.status() >= 400) {
|
||||
const method = r.request()?.method?.() ?? '?';
|
||||
r.text()
|
||||
.then(b => ctx.errors.push({ who, url: r.url(), status: r.status(), method, body: b.slice(0, 300) }))
|
||||
.catch(() => ctx.errors.push({ who, url: r.url(), status: r.status(), method, body: '(unreadable)' }));
|
||||
}
|
||||
});
|
||||
page.on('console', m => { if (m.type() === 'error') ctx.consoleErrors.push(`[${who}] ${m.text().slice(0, 300)}`); });
|
||||
page.on('pageerror', e => ctx.pageErrors.push({ who, url: page.url(), message: String(e.message).slice(0, 300), stack: String(e.stack ?? '').slice(0, 600) }));
|
||||
}
|
||||
|
||||
/** What a UI drive is EXPECTED to be refused, per user — every entry an exact URL. */
|
||||
export interface ExpectedRefusals {
|
||||
/** This run's profile documents: a profile answers only whom it grants (the Alice/Bob rule). */
|
||||
profiles: ReadonlySet<string>;
|
||||
/** Each user's OWN `pim:preferencesFile`: read by the app with its app token, which the pod does not grant (VERIFIED 2026-10-13). */
|
||||
ownPreferences: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The expected ≥400 responses of a UI drive, each an exact request: the pod's
|
||||
* push channel creation (POST …/.notifications/WebSocketChannel2023 → 403),
|
||||
* a GET 403 on one of this run's profile documents, and a GET 403 on the
|
||||
* signed-in user's OWN preferences file. Anything else — another 403 under
|
||||
* `/data/` included — is unexpected.
|
||||
*/
|
||||
export function benignUiError(e: UiHttpError, expected: ExpectedRefusals): string | null {
|
||||
if (e.method === 'POST' && e.status === 403 && e.url.endsWith('/.notifications/WebSocketChannel2023')) return 'push-channel creation 403';
|
||||
if (e.method === 'GET' && e.status === 403 && expected.profiles.has(e.url)) return 'profile-document read 403 (WAC)';
|
||||
if (e.method === 'GET' && e.status === 403 && expected.ownPreferences.get(e.who) === e.url) return 'own preferences-file read 403 (app token not granted)';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The uncaught page errors explained by an expected refusal: the live
|
||||
* provider's `subscribe` rejects with "Forbidden" when the pod refuses its
|
||||
* push channel — exactly one per refused channel POST of the same user
|
||||
* (VERIFIED 2026-10-13: Bob 2/2, Alice 4/4, Carol 4/4). The throw site is
|
||||
* identified by its two top stack frames, learnt from errors whose async stack
|
||||
* still reaches `subscribe` (half of them are cut before that frame, from the
|
||||
* very same site). Every other page error, and any surplus, is unexpected.
|
||||
*/
|
||||
export function splitPageErrors(errors: UiPageError[], http: UiHttpError[]): { expected: UiPageError[]; unexpected: UiPageError[] } {
|
||||
const channelRefusals = new Map<string, number>();
|
||||
for (const e of http) {
|
||||
if (e.method === 'POST' && e.status === 403 && e.url.endsWith('/.notifications/WebSocketChannel2023')) {
|
||||
channelRefusals.set(e.who, (channelRefusals.get(e.who) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const site = (e: UiPageError): string =>
|
||||
e.stack.split('\n').slice(1, 3).map(l => l.trim().replace(/^at async /, 'at ')).join(' < ');
|
||||
const subscribeSites = new Set(errors.filter(e => e.message === 'Forbidden' && /\bsubscribe\b/.test(e.stack)).map(site));
|
||||
const expected: UiPageError[] = [];
|
||||
const unexpected: UiPageError[] = [];
|
||||
for (const e of errors) {
|
||||
const budget = channelRefusals.get(e.who) ?? 0;
|
||||
if (e.message === 'Forbidden' && subscribeSites.has(site(e)) && budget > 0) {
|
||||
channelRefusals.set(e.who, budget - 1);
|
||||
expected.push(e);
|
||||
} else {
|
||||
unexpected.push(e);
|
||||
}
|
||||
}
|
||||
return { expected, unexpected };
|
||||
}
|
||||
|
||||
/** A user's own preferences file (`pim:preferencesFile` of the actor document, read with her token). */
|
||||
export async function preferencesFileOf(session: ApodsSession): Promise<string | undefined> {
|
||||
const r = await http('GET', session.webId, { token: session.token });
|
||||
return linkId(parseJson(r.text)?.['pim:preferencesFile']);
|
||||
}
|
||||
|
||||
export interface LoginOutcome { ok: boolean; detail: string }
|
||||
|
||||
/**
|
||||
* App picker → pod login → consent → back in the app shell, as a sequence of
|
||||
* OBSERVED facts — never a fixed delay, never the mere absence of the picker.
|
||||
*
|
||||
* The real hop sequence (observed 2026-10-13): the sign-in click leaves the
|
||||
* app for the pod's login form (:5000/login) — unless the pod's session is
|
||||
* still open (a sign-out of the app keeps it: the revocation probe's second
|
||||
* sign-in), in which case no form is shown and the hops below start at once;
|
||||
* after the credentials the
|
||||
* browser comes BACK to the app's OIDC callback (<app origin>/login?code…), and only
|
||||
* then — when the app is not yet registered on this pod — does the app send it
|
||||
* to the pod's consent screen (:5000/authorize), which navigates a second time
|
||||
* right after it opens. Accept leads back to the app, then to the shell.
|
||||
*
|
||||
* H1 (false "shell reached"): while the app is on its own origin between those
|
||||
* hops it renders neither the picker nor the shell. The shell therefore counts
|
||||
* only once the browser has been to the pod, the URL is past /login, and the
|
||||
* bottom navigation is visible with no picker.
|
||||
* H2 (accept never landing): Accept is clicked only once it has been attached,
|
||||
* visible and enabled for a full second (the page's second navigation is over),
|
||||
* and the click is then VERIFIED by the page leaving the consent screen; if it
|
||||
* does not, the drive fails with what the page showed — it never re-clicks.
|
||||
*/
|
||||
export async function driveLogin(page: Page, who: string, username: string, password: string, ctx: DriveContext): Promise<LoginOutcome> {
|
||||
const t0 = Date.now();
|
||||
const at = (): string => `+${((Date.now() - t0) / 1000).toFixed(1)}s`;
|
||||
const hop = (h: string): void => { ctx.hops.push(`[${who}] ${at()} ${h}`); };
|
||||
// The hop log covers this drive only: the listener is removed when the drive ends, so a page
|
||||
// that signs in twice (the revocation probe) does not log its navigations twice.
|
||||
const onNavigated = (f: Frame): void => { if (f === page.mainFrame()) hop(`→ ${clean(f.url())}`); };
|
||||
page.on('framenavigated', onNavigated);
|
||||
try {
|
||||
const fail = async (why: string): Promise<LoginOutcome> => {
|
||||
const text = (await bodyText(page)).slice(0, 240);
|
||||
hop(`FAIL ${why} | at ${clean(page.url())} | page: ${text}`);
|
||||
return { ok: false, detail: `${why} (at ${clean(page.url())}; page: ${text.slice(0, 120)})` };
|
||||
};
|
||||
const onApp = (): boolean => page.url().startsWith(ctx.app);
|
||||
const accept = page.getByRole('button', { name: /^ACCEPTER$|^Accepter$|^Accept$/i });
|
||||
const consentReady = async (): Promise<boolean> =>
|
||||
page.url().startsWith(`${ctx.podFront}/authorize`)
|
||||
&& (await accept.first().isVisible().catch(() => false))
|
||||
&& (await accept.first().isEnabled().catch(() => false));
|
||||
const shellReady = async (): Promise<boolean> =>
|
||||
onApp() && !new URL(page.url()).pathname.startsWith('/login')
|
||||
&& (await page.getByText('Découvrir', { exact: true }).first().isVisible().catch(() => false))
|
||||
&& (await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 1)) === 0;
|
||||
|
||||
await page.goto(`${ctx.app}/`, { waitUntil: 'domcontentloaded' });
|
||||
const signIn = page.getByRole('button', { name: /Se connecter avec localhost:3000/i });
|
||||
if (!(await signIn.first().waitFor({ state: 'visible', timeout: 30000 }).then(() => true, () => false))) return fail('the sign-in button never appeared');
|
||||
await signIn.first().click();
|
||||
if (!(await page.waitForURL(u => !u.href.startsWith(ctx.app), { timeout: 30000 }).then(() => true, () => false))) {
|
||||
return fail('the sign-in click never left the app for the pod');
|
||||
}
|
||||
|
||||
// At the pod: its login form when no pod session is open — or, when one still is (signing out
|
||||
// of the APP keeps the pod's session), no form at all: straight on to consent or to the shell.
|
||||
const userField = page.locator('input[name="username"]');
|
||||
const atPod = await poll(
|
||||
async () => ((await userField.isVisible().catch(() => false)) ? 'login' : (await consentReady()) ? 'consent' : (await shellReady()) ? 'shell' : 'pending'),
|
||||
v => v !== 'pending', 30000, 250,
|
||||
);
|
||||
if (atPod === 'pending') return fail('after the sign-in click, neither the pod login form, the consent screen nor the app shell appeared');
|
||||
if (atPod === 'login') {
|
||||
await userField.fill(username);
|
||||
await page.locator('input[name="password"]').fill(password);
|
||||
const submit = page.getByRole('button', { name: /SE CONNECTER|Se connecter|Sign in|Connexion/i });
|
||||
await ((await submit.count()) ? submit.first() : page.locator('button[type=submit]').first()).click();
|
||||
hop('credentials submitted');
|
||||
} else {
|
||||
hop(`no pod login asked (the pod session is still open) → ${atPod}`);
|
||||
}
|
||||
|
||||
// After login: the consent screen (app not yet registered here) or straight to the shell (already registered).
|
||||
const next = await poll(
|
||||
async () => ((await consentReady()) ? 'consent' : (await shellReady()) ? 'shell' : 'pending'),
|
||||
v => v !== 'pending', 60000, 250,
|
||||
);
|
||||
if (next === 'pending') return fail('after login, neither the consent screen nor the app shell appeared');
|
||||
if (next === 'consent') {
|
||||
// Stable for a full second (4 consecutive observations): the second navigation of the page is over.
|
||||
let stable = 0;
|
||||
const settleEnd = Date.now() + 15000;
|
||||
while (stable < 4 && Date.now() < settleEnd) {
|
||||
stable = (await consentReady()) ? stable + 1 : 0;
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
if (stable < 4) return fail('the consent Accept button never stayed visible and enabled');
|
||||
hop('consent ready (Accept attached, visible, enabled for 1 s)');
|
||||
await accept.first().click();
|
||||
hop('accept clicked');
|
||||
if (!(await page.waitForURL(u => !u.href.startsWith(`${ctx.podFront}/authorize`), { timeout: 30000 }).then(() => true, () => false))) {
|
||||
return fail('Accept was clicked but the page never left the consent screen');
|
||||
}
|
||||
hop('left the consent screen');
|
||||
if ((await poll(shellReady, v => v, 45000, 250)) !== true) return fail('back from consent, the app shell never appeared');
|
||||
}
|
||||
hop(`shell reached (bottom navigation, no picker) via ${next === 'consent' ? 'consent' : 'an existing registration'}`);
|
||||
return { ok: true, detail: `shell reached in ${at()} via ${next === 'consent' ? 'the consent screen' : 'an existing registration (no consent asked)'}` };
|
||||
} finally {
|
||||
page.off('framenavigated', onNavigated);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the app installed on this user's pod? The registered Event container
|
||||
* exists only once the app registration went through (consent or programmatic
|
||||
* install). Polled: the registration is confirmed asynchronously.
|
||||
*/
|
||||
export async function installedContainer(session: ApodsSession, timeoutMs = 30000): Promise<CheckOutcome> {
|
||||
const uri = `${session.storage}/as/event`;
|
||||
const r = await poll(() => http('GET', uri, { token: session.token }), v => v.status === 200, timeoutMs, 1000);
|
||||
return r.status === 200
|
||||
? { ok: true, actual: `${clean(uri)} → HTTP 200` }
|
||||
: { ok: false, actual: `${clean(uri)} → ${r.status === 0 ? `unreachable (${r.error})` : `HTTP ${r.status}`} after ${timeoutMs / 1000} s — the app is NOT installed on this pod (consent never completed / registration missing)` };
|
||||
}
|
||||
|
||||
/** The banked event-creation drive; the event URI is read off the detail route it lands on. */
|
||||
export async function createEventUi(page: Page, app: string, title: string): Promise<{ eventUri?: string; finalUrl: string }> {
|
||||
await page.goto(`${app}/events/new`, { waitUntil: 'domcontentloaded' }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
await page.locator('input[placeholder="Donnez un nom à votre événement"]').fill(title, { timeout: 10000 }).catch(() => {});
|
||||
const dates = page.locator('input[type="date"]');
|
||||
if (await dates.count().catch(() => 0)) await dates.first().fill('2026-12-20', { timeout: 3000 }).catch(() => {});
|
||||
await page.getByRole('button', { name: /Suivant/ }).click({ timeout: 5000 }).catch(() => {});
|
||||
await page.waitForTimeout(1200);
|
||||
const next = page.getByRole('button', { name: /Suivant/ });
|
||||
if (await next.count().catch(() => 0)) await next.click({ timeout: 5000 }).catch(() => {});
|
||||
await page.waitForTimeout(1200);
|
||||
await page.getByRole('button', { name: /Relayer l'événement/ }).click({ timeout: 5000 }).catch(() => {});
|
||||
const landed = await poll(async () => page.url(), u => /\/events\/[^/]+$/.test(new URL(u).pathname) && !u.includes('/events/new'), 15000, 500);
|
||||
const m = landed.startsWith(`${app}/events/`) ? /\/events\/([^/?#]+)/.exec(landed.slice(app.length)) : null;
|
||||
const segment = m?.[1];
|
||||
return { eventUri: segment && segment !== 'new' ? decodeURIComponent(segment) : undefined, finalUrl: landed };
|
||||
}
|
||||
|
||||
/** The banked meeting-point drive, from the event detail page it starts on. */
|
||||
export async function createMeetingPointUi(page: Page, title: string): Promise<boolean> {
|
||||
const propose = page.getByRole('button', { name: /Proposer un point de rencontre/i });
|
||||
await propose.first().waitFor({ timeout: 15000 }).catch(() => {});
|
||||
if (!(await propose.count().catch(() => 0))) return false;
|
||||
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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,7 @@
|
||||
*
|
||||
* USAGE (reactive dev):
|
||||
* Terminal 1: pnpm run overlay:polyfill # or: pnpm run overlay:indexing
|
||||
* Terminal 2: bun run dev # portless festipod bun --hot src/index.ts
|
||||
* Terminal 2: pnpm dev # bun --hot src/index.ts
|
||||
* Edit the checkout's src → it lands in node_modules → RESTART dev to pick it up.
|
||||
*
|
||||
* pnpm run overlay:indexing --once # overlay + verify, no watch (CI / one-shot)
|
||||
|
||||
+139
-17
@@ -2,8 +2,8 @@
|
||||
/**
|
||||
* validate.ts — the Festipod validation matrix.
|
||||
*
|
||||
* SCOPE (2026-10-09, after the ActivityPods-first switch). Two layers are in
|
||||
* the gate:
|
||||
* SCOPE (2026-10-09, after the ActivityPods-first switch; e2e probes added
|
||||
* 2026-10-13). Four layers are in the gate:
|
||||
*
|
||||
* (a) Festipod @ui — the UI layer. The `@ui` tag does not exist in this repo;
|
||||
* the layer is exactly the complement of the polyfill/broker tags, and its
|
||||
@@ -23,6 +23,25 @@
|
||||
* Requires the stack to be UP (the app server and Chromium are started
|
||||
* here); an unreachable stack fails the layer rather than skipping it.
|
||||
*
|
||||
* (c) Festipod e2e rights matrix — `bun run test:e2e-rights`: three fresh
|
||||
* users plus anonymous, every feature with positive AND negative access
|
||||
* lines (public surface, public reads, refused writes, profile privacy,
|
||||
* catalog and public intent, discovery, join/leave and counters,
|
||||
* visibility toggles). Exit 0 only when every hard line holds.
|
||||
*
|
||||
* (d) Festipod e2e revocation — `bun run test:e2e-revocation`: the app
|
||||
* notices its registration being revoked on the pod, logs the user out,
|
||||
* and recovers through consent.
|
||||
*
|
||||
* (c) and (d) drive the browser through the pod's login and consent, so
|
||||
* they need the WHOLE local stack, the app served at APP_URL included
|
||||
* (the OIDC redirect_uri is registered for that origin only). They are
|
||||
* STACK-GATED: the stack is probed first; when any part is unreachable
|
||||
* both are SKIPPED — loudly, the unreachable URL named in the matrix and
|
||||
* in the verdict, never silently — and when it is up they run, and a
|
||||
* non-zero exit fails the layer. A fully-down stack still fails
|
||||
* `validate` through (b), which never skips.
|
||||
*
|
||||
* SUSPENDED, NOT RUN HERE (documented skip, code preserved — nothing deleted):
|
||||
* - `@data`, `@e2e`, `@multibrowser`, `@shared-wallet`, `@smoke` scenarios are
|
||||
* polyfill/broker-bound: they sign the shared NextGraph wallet through the
|
||||
@@ -34,7 +53,8 @@
|
||||
* `rule_tests-validate-festipod-not-the-sdk`).
|
||||
*
|
||||
* A smoke layer that cannot reach its stack FAILS, loudly — it never silently
|
||||
* skips. Exit code is non-zero if any layer fails.
|
||||
* skips. A skipped e2e probe never counts as passed: it is reported as
|
||||
* SKIPPED with its reason. Exit code is non-zero if any layer fails.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
@@ -51,12 +71,34 @@ const APODS_TAGS = "@e2e-activitypods and not @suspendu";
|
||||
|
||||
const TIMEOUT_UI_MS = 15 * 60 * 1000; // 15 min
|
||||
const TIMEOUT_APODS_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_RIGHTS_MS = 25 * 60 * 1000; // 25 min (the matrix bounds itself at 15 min of UI drives)
|
||||
const TIMEOUT_REVOCATION_MS = 15 * 60 * 1000; // 15 min (the probe bounds itself at 10 min)
|
||||
|
||||
/** The local stack, with the same defaults and env overrides as the e2e scripts. */
|
||||
const strip = (u: string): string => u.replace(/\/+$/, "");
|
||||
const POD_PROVIDER = strip(process.env.POD_PROVIDER_URL || process.env.VITE_POD_PROVIDER_URL || 'http://localhost:3000');
|
||||
const POD_FRONT = strip(process.env.POD_FRONTEND_URL || "http://localhost:5000");
|
||||
const BACKEND = strip(process.env.BACKEND_URL || process.env.VITE_BACKEND_URL || 'http://localhost:3005');
|
||||
const APP = strip(process.env.APP_URL || `http://localhost:${process.env.PORT || 4005}`);
|
||||
const FUSEKI = strip(process.env.FUSEKI_URL || "http://localhost:3030");
|
||||
|
||||
/** What the browser-driven e2e probes need answering 200 — every piece of the local stack. */
|
||||
const E2E_STACK: Array<[string, string]> = [
|
||||
// The triplestore both servers store into: with it down they still answer their static routes
|
||||
// (observed 2026-10-13: the pod's OpenID configuration 200 while every sign-up failed).
|
||||
["triplestore (fuseki)", `${FUSEKI}/`],
|
||||
["pod provider", `${POD_PROVIDER}/.well-known/openid-configuration`],
|
||||
["pod frontend (login + consent)", `${POD_FRONT}/`],
|
||||
["Festipod backend", `${BACKEND}/app`],
|
||||
["Festipod app at APP_URL (the only registered OIDC redirect origin)", `${APP}/`],
|
||||
];
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface StepResult {
|
||||
label: string;
|
||||
status: "passed" | "failed" | "error";
|
||||
status: "passed" | "failed" | "error" | "skipped";
|
||||
/** Failure lines; for a skipped layer, why it was skipped. */
|
||||
failures: string[];
|
||||
exitCode: number;
|
||||
durationMs: number;
|
||||
@@ -75,14 +117,20 @@ function cucumberArgs(config: string, tags: string): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
function runStep(label: string, args: string[], timeoutMs: number): StepResult {
|
||||
function runStep(
|
||||
label: string,
|
||||
args: string[],
|
||||
timeoutMs: number,
|
||||
command = "node",
|
||||
extract: (output: string) => string[] = extractFailures,
|
||||
): StepResult {
|
||||
const t0 = Date.now();
|
||||
console.log(`\n${"═".repeat(64)}`);
|
||||
console.log(`▶ ${label}`);
|
||||
console.log(` node ${args.join(" ")}`);
|
||||
console.log(` ${command} ${args.join(" ")}`);
|
||||
console.log(`${"═".repeat(64)}`);
|
||||
|
||||
const result = spawnSync("node", args, {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: REPO_DIR,
|
||||
env: process.env,
|
||||
encoding: "utf-8",
|
||||
@@ -107,12 +155,68 @@ function runStep(label: string, args: string[], timeoutMs: number): StepResult {
|
||||
return {
|
||||
label,
|
||||
status: (result.status ?? 1) === 0 ? "passed" : "failed",
|
||||
failures: extractFailures(combined),
|
||||
failures: extract(combined),
|
||||
exitCode: result.status ?? 1,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** A stack-gated layer that did not run: reported, never counted as passed. */
|
||||
function skippedStep(label: string, reasons: string[]): StepResult {
|
||||
console.log(`\n${"═".repeat(64)}`);
|
||||
console.log(`⏭️ ${label} — SKIPPED: the local stack is down`);
|
||||
for (const r of reasons) console.log(` ↳ ${r}`);
|
||||
console.log(`${"═".repeat(64)}`);
|
||||
return { label, status: "skipped", failures: reasons, exitCode: 0, durationMs: 0 };
|
||||
}
|
||||
|
||||
/** Every part of the e2e stack that does not answer 200, as "name: url → status". Empty when the stack is up. */
|
||||
async function e2eStackDown(): Promise<string[]> {
|
||||
const down: string[] = [];
|
||||
for (const [name, url] of E2E_STACK) {
|
||||
let status: number | string;
|
||||
try {
|
||||
status = (await fetch(url, { signal: AbortSignal.timeout(5000) })).status;
|
||||
} catch (e) {
|
||||
status = `unreachable (${e instanceof Error ? e.message : String(e)})`;
|
||||
}
|
||||
if (status !== 200) down.push(`${name}: ${url} → ${typeof status === "number" ? `HTTP ${status}` : status}`);
|
||||
}
|
||||
return down;
|
||||
}
|
||||
|
||||
/** The rights matrix's verdict lines: every failed hard line, any notice, and the verdict. */
|
||||
function extractMatrixFailures(output: string): string[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => /^(FAILED: |VERDICT: FAIL|!!! (?!NOTICE:)\S)/.test(l) || l.includes("RIGHTS MATRIX — ABORTED"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The revocation probe's JSON report (pretty-printed: its top-level braces are
|
||||
* the only ones at column 0): the phases that did not hold, and unexpected ≥400s.
|
||||
*/
|
||||
function extractRevocationFailures(output: string): string[] {
|
||||
const start = output.lastIndexOf("\n{\n") + 1;
|
||||
const end = output.indexOf("\n}", start);
|
||||
try {
|
||||
const report = JSON.parse(output.slice(start, end + 2)) as Record<string, unknown>;
|
||||
if (report["ok"] === true) return [];
|
||||
const failures: string[] = [];
|
||||
for (const phase of ["positive", "negative", "recovered"]) {
|
||||
const p = report[phase] as { ok?: unknown } | undefined;
|
||||
if (p && p.ok !== true) failures.push(`phase ${phase}: ok=${String(p.ok)}`);
|
||||
}
|
||||
const fatal = report["fatal4xx"];
|
||||
if (Array.isArray(fatal) && fatal.length) failures.push(`${fatal.length} unexpected ≥400 response(s)`);
|
||||
if (typeof report["fatal"] === "string") failures.push(`aborted: ${report["fatal"]}`);
|
||||
return failures.length ? failures : ["ok=false"];
|
||||
} catch {
|
||||
return ["no JSON report found in the output"];
|
||||
}
|
||||
}
|
||||
|
||||
/** The failure lines worth surfacing: failed scenario names and the summary. */
|
||||
function extractFailures(output: string): string[] {
|
||||
const lines = output.split("\n");
|
||||
@@ -139,31 +243,49 @@ function printMatrix(steps: StepResult[]): void {
|
||||
console.log("╚══════════════════════════════════════════════════════════╝\n");
|
||||
const maxLabel = Math.max(...steps.map((s) => s.label.length));
|
||||
for (const step of steps) {
|
||||
const icon = step.status === "passed" ? "✅" : step.status === "failed" ? "❌" : "⚠️ ";
|
||||
console.log(` ${icon} ${step.label.padEnd(maxLabel + 2)} [${fmtDuration(step.durationMs)}]`);
|
||||
const icon = step.status === "passed" ? "✅" : step.status === "failed" ? "❌" : step.status === "skipped" ? "⏭️ " : "⚠️ ";
|
||||
const time = step.status === "skipped" ? "SKIPPED — local stack down" : fmtDuration(step.durationMs);
|
||||
console.log(` ${icon} ${step.label.padEnd(maxLabel + 2)} [${time}]`);
|
||||
for (const f of step.failures) console.log(` ↳ ${f}`);
|
||||
}
|
||||
console.log("");
|
||||
console.log(" ℹ️ @data/@e2e/@multibrowser/@shared-wallet/@smoke : suspendus (workstream NextGraph).");
|
||||
const failed = steps.filter((s) => s.status !== "passed");
|
||||
console.log(failed.length === 0
|
||||
? " 🟢 ALL LAYERS PASSED"
|
||||
: ` 🔴 ${failed.length} LAYER(S) FAILED: ${failed.map((s) => s.label).join(", ")}`);
|
||||
const failed = steps.filter((s) => s.status === "failed" || s.status === "error");
|
||||
const skipped = steps.filter((s) => s.status === "skipped");
|
||||
if (skipped.length) {
|
||||
console.log(` 🟡 ${skipped.length} LAYER(S) SKIPPED (local stack down — NOT validated): ${skipped.map((s) => s.label).join(", ")}`);
|
||||
}
|
||||
console.log(failed.length > 0
|
||||
? ` 🔴 ${failed.length} LAYER(S) FAILED: ${failed.map((s) => s.label).join(", ")}`
|
||||
: skipped.length
|
||||
? " 🟢 NO LAYER FAILED — but the skipped layers above were not run"
|
||||
: " 🟢 ALL LAYERS PASSED");
|
||||
console.log(` ⏱ Total: ${fmtDuration(steps.reduce((sum, s) => sum + s.durationMs, 0))}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// ─── Main ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function main(): void {
|
||||
async function main(): Promise<void> {
|
||||
console.log(`🔍 Festipod — validation (repo: ${REPO_DIR})`);
|
||||
|
||||
const steps: StepResult[] = [];
|
||||
steps.push(runStep("festipod:@ui", cucumberArgs("cucumber.json", UI_TAGS), TIMEOUT_UI_MS));
|
||||
steps.push(runStep("festipod:@e2e-activitypods", cucumberArgs("cucumber.smoke.json", APODS_TAGS), TIMEOUT_APODS_MS));
|
||||
|
||||
// Stack-gated e2e probes: one stack probe, then both run or both are skipped (loudly).
|
||||
const down = await e2eStackDown();
|
||||
if (down.length) {
|
||||
const reasons = [...down, "start the stack: concept `data-layer`, `cookbook_local-activitypods-platform.md`"];
|
||||
steps.push(skippedStep("festipod:e2e-rights", reasons));
|
||||
steps.push(skippedStep("festipod:e2e-revocation", reasons));
|
||||
} else {
|
||||
steps.push(runStep("festipod:e2e-rights", ["run", "test:e2e-rights"], TIMEOUT_RIGHTS_MS, "bun", extractMatrixFailures));
|
||||
steps.push(runStep("festipod:e2e-revocation", ["run", "test:e2e-revocation"], TIMEOUT_REVOCATION_MS, "bun", extractRevocationFailures));
|
||||
}
|
||||
|
||||
printMatrix(steps);
|
||||
process.exit(steps.some((s) => s.status !== "passed") ? 1 : 0);
|
||||
process.exit(steps.some((s) => s.status === "failed" || s.status === "error") ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
void main();
|
||||
|
||||
+44
-4
@@ -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>
|
||||
|
||||
+126
-3
@@ -10,6 +10,10 @@
|
||||
* `clientId` and `${origin}/login` as `redirectUri`. Every session the flow
|
||||
* establishes is published to `utils/currentSession` — the module store the
|
||||
* data provider's dispatch reads, independent of where each mounts in the tree.
|
||||
* While signed in, the app-status probe (`auth/appStatusProbe`) re-checks the
|
||||
* registration on load and on focus/visibility: a revoked or stale app re-runs
|
||||
* the provider's `registerApp` (its consent redirect is the recovery) instead of
|
||||
* staying in a "connected" view whose writes cannot land.
|
||||
*
|
||||
* 2. NEXTGRAPH (suspended at the root, kept for the URL variant). The legacy
|
||||
* `ensureIdentity()` flow: the SDK mounts its own full-screen barrier and owns
|
||||
@@ -24,7 +28,7 @@
|
||||
* remains reachable.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { ensureIdentity } from '@ng-eventually/polyfill';
|
||||
import { startNgSession } from '../shared/utils/ngSession';
|
||||
import { setCurrentPrincipal } from '../shared/utils/currentPrincipal';
|
||||
@@ -39,8 +43,19 @@ import {
|
||||
safeRedirectPath,
|
||||
} from './auth/activityPodsAuth';
|
||||
import { PodProviderPicker } from './auth/PodProviderPicker';
|
||||
import { handleStaleReading, type StaleReason } from './auth/appStatusProbe';
|
||||
import { useAppStatusProbe } from './auth/useAppStatusProbe';
|
||||
|
||||
type ActivityPodsAuthState = 'checking' | 'authenticated' | 'unauthenticated' | 'error';
|
||||
/**
|
||||
* `reauthorizing`: the app-status probe found the registration revoked or stale
|
||||
* and `registerApp` is sending the browser to the consent screen — the app view
|
||||
* is taken down rather than left looking connected.
|
||||
*/
|
||||
type ActivityPodsAuthState = 'checking' | 'authenticated' | 'unauthenticated' | 'reauthorizing' | 'error';
|
||||
|
||||
/** Shown on the picker when the app was removed from the pod and the user was logged out. */
|
||||
const REGISTRATION_LOST_NOTICE =
|
||||
'L’autorisation de Festipod a été retirée de votre Pod : vous avez été déconnecté. Vous pouvez vous reconnecter à tout moment.';
|
||||
|
||||
type CallbackResult = Awaited<ReturnType<AuthProvider['handleCallback']>>;
|
||||
|
||||
@@ -86,6 +101,22 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
// ActivityPods flow state.
|
||||
const [apState, setApState] = useState<ActivityPodsAuthState>('checking');
|
||||
const [apError, setApError] = useState<string | null>(null);
|
||||
// Why the picker is back after a session existed (registration lost).
|
||||
const [apNotice, setApNotice] = useState<string | null>(null);
|
||||
// Set when the app-status probe took the connected view down: the consent
|
||||
// redirect is under way ('reauthorizing'), or the app was removed from the
|
||||
// pod and the user logged out ('registration-lost'). Should the logout itself
|
||||
// fail the token stays stored, and without this any later route change — a
|
||||
// Back, a link — would re-derive "authenticated" from it and bring back a
|
||||
// view that can neither read nor write. Lives for the
|
||||
// page: the consent and the picker's sign-in both leave it for the pod, and a
|
||||
// reload probes afresh.
|
||||
const blockedRef = useRef<{ kind: 'reauthorizing'; reason: StaleReason } | { kind: 'registration-lost' } | null>(null);
|
||||
// One recovery at a time: the probe already serializes its own, this also
|
||||
// covers repeated clicks on the reauthorizing screen's retry (each would
|
||||
// start its own consent redirect). A `registerApp` that never settles keeps
|
||||
// it set — the same limit as a hung probe run; a reload clears it.
|
||||
const recoveringRef = useRef(false);
|
||||
|
||||
const apAuthProvider = useMemo(() => {
|
||||
if (!activityPodsEnabled) return undefined;
|
||||
@@ -188,10 +219,13 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
const session = provider.getSession();
|
||||
if (session) {
|
||||
const blocked = blockedRef.current;
|
||||
if (session && !blocked) {
|
||||
setCurrentPrincipal(session.webId);
|
||||
setCurrentSession({ webId: session.webId, token: session.token });
|
||||
if (!cancelled) setApState('authenticated');
|
||||
} else if (session && blocked?.kind === 'reauthorizing') {
|
||||
if (!cancelled) setApState('reauthorizing');
|
||||
} else {
|
||||
setCurrentSession(undefined);
|
||||
if (!cancelled) setApState('unauthenticated');
|
||||
@@ -209,6 +243,70 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
return () => { cancelled = true; };
|
||||
}, [activityPodsEnabled, apAuthProvider, demoMode, navigate, route.page]);
|
||||
|
||||
// A revoked or stale registration. Revoked (the app was removed from the
|
||||
// pod): log out through the provider and land on the picker with a notice —
|
||||
// no consent redirect. Out of date (grants to update): re-run the provider's
|
||||
// registration flow, whose consent redirect IS the recovery; if that cannot
|
||||
// run, stay connected and let the next probe retry.
|
||||
const handleStaleRegistration = useCallback(async (reason: StaleReason) => {
|
||||
const provider = apAuthProvider;
|
||||
if (!provider || recoveringRef.current) return;
|
||||
recoveringRef.current = true;
|
||||
let handled: Awaited<ReturnType<typeof handleStaleReading>>;
|
||||
try {
|
||||
handled = await handleStaleReading(reason, {
|
||||
getWebId: () => provider.getSession()?.webId,
|
||||
registerApp: (webId) => provider.registerApp(webId),
|
||||
logout: async () => { await provider.logout({}); },
|
||||
});
|
||||
} finally {
|
||||
recoveringRef.current = false;
|
||||
}
|
||||
const { resolution, detail } = handled;
|
||||
console.info(`[AppStatus] handled (${reason}) → ${detail} → ${resolution}`);
|
||||
switch (resolution) {
|
||||
case 'stay-connected': {
|
||||
// From the probe this changes nothing; from the reauthorizing screen's
|
||||
// retry (a consent given meanwhile) it brings the app back — with the
|
||||
// session published again, or the data layer would serve demo data
|
||||
// behind a connected-looking app.
|
||||
const session = provider.getSession();
|
||||
if (!session) {
|
||||
blockedRef.current = null;
|
||||
setCurrentSession(undefined);
|
||||
setApState('unauthenticated');
|
||||
return;
|
||||
}
|
||||
blockedRef.current = null;
|
||||
setCurrentPrincipal(session.webId);
|
||||
setCurrentSession({ webId: session.webId, token: session.token });
|
||||
setApState('authenticated');
|
||||
return;
|
||||
}
|
||||
case 'reauthorizing':
|
||||
blockedRef.current = { kind: 'reauthorizing', reason };
|
||||
setApState('reauthorizing');
|
||||
return;
|
||||
case 'signed-out':
|
||||
blockedRef.current = null;
|
||||
setCurrentSession(undefined);
|
||||
setApState('unauthenticated');
|
||||
return;
|
||||
case 'registration-lost':
|
||||
blockedRef.current = { kind: 'registration-lost' };
|
||||
setCurrentSession(undefined);
|
||||
setApNotice(REGISTRATION_LOST_NOTICE);
|
||||
setApState('unauthenticated');
|
||||
return;
|
||||
}
|
||||
}, [apAuthProvider]);
|
||||
|
||||
useAppStatusProbe({
|
||||
provider: apAuthProvider,
|
||||
active: activityPodsEnabled && !demoMode && apState === 'authenticated',
|
||||
onStale: handleStaleRegistration,
|
||||
});
|
||||
|
||||
// Once identified, leave the welcome screen for the app home.
|
||||
useEffect(() => {
|
||||
if (apState === 'authenticated' && route.page === 'welcome') {
|
||||
@@ -265,6 +363,30 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
if (apState === 'checking') {
|
||||
return null;
|
||||
}
|
||||
if (apState === 'reauthorizing') {
|
||||
return (
|
||||
<div id="auth-reauthorizing" role="status" className="app-card" style={{ margin: '2rem 1rem' }}>
|
||||
<h1 className="app-title">Autorisation à renouveler</h1>
|
||||
<p className="app-text">
|
||||
Festipod n’est plus autorisé sur votre Pod, ou son autorisation doit être
|
||||
mise à jour. Redirection vers votre Pod pour la renouveler…
|
||||
</p>
|
||||
{/* The redirect can be interrupted (Back, a page restored from the
|
||||
browser's cache): this screen must never be a dead end. */}
|
||||
<button
|
||||
type="button"
|
||||
className="app-btn"
|
||||
onClick={() => {
|
||||
const blocked = blockedRef.current;
|
||||
void handleStaleRegistration(blocked?.kind === 'reauthorizing' ? blocked.reason : 'not-installed');
|
||||
}}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
Continuer vers mon Pod
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (apState === 'unauthenticated') {
|
||||
// Seed the post-login destination: an explicit (validated) `?redirect=`
|
||||
// wins; otherwise a deep link the user opened lands them back on that page
|
||||
@@ -280,6 +402,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
<PodProviderPicker
|
||||
authProvider={apAuthProvider!}
|
||||
redirect={loginRedirect}
|
||||
notice={apNotice ?? undefined}
|
||||
onDemo={() => {
|
||||
setDemoMode(true);
|
||||
navigate('/home');
|
||||
|
||||
@@ -11,6 +11,8 @@ interface PodProviderPickerProps {
|
||||
* so the picker never reads the raw URL itself.
|
||||
*/
|
||||
redirect: string;
|
||||
/** Why the user is back on the picker (e.g. the app's registration was revoked). */
|
||||
notice?: string;
|
||||
onDemo?: () => void;
|
||||
}
|
||||
|
||||
@@ -21,7 +23,7 @@ interface PodProviderPickerProps {
|
||||
* offered as a one-click option. The user can also type any Solid Pod provider
|
||||
* URL. A "continue offline" button keeps the local demo fallback reachable.
|
||||
*/
|
||||
export function PodProviderPicker({ authProvider, redirect, onDemo }: PodProviderPickerProps) {
|
||||
export function PodProviderPicker({ authProvider, redirect, notice, onDemo }: PodProviderPickerProps) {
|
||||
const { podProviderUrl } = resolveActivityPodsAuthConfig(window.location.origin);
|
||||
const [customUrl, setCustomUrl] = useState('');
|
||||
|
||||
@@ -68,6 +70,17 @@ export function PodProviderPicker({ authProvider, redirect, onDemo }: PodProvide
|
||||
Connectez-vous avec votre Pod pour découvrir et créer des points de rencontre.
|
||||
</Text>
|
||||
|
||||
{notice && (
|
||||
<p
|
||||
role="status"
|
||||
data-testid="auth-picker-notice"
|
||||
className="app-text"
|
||||
style={{ textAlign: 'center', fontSize: 14, marginBottom: 24, color: '#b45309' }}
|
||||
>
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{defaultProviderHost && (
|
||||
<Button
|
||||
variant="primary"
|
||||
|
||||
@@ -14,9 +14,9 @@ test('clientId uses explicit backendClientId when set', () => {
|
||||
});
|
||||
|
||||
test('clientId derives the actor uri from backendUrl when backendClientId is empty', () => {
|
||||
setRuntimeConfig({ backendUrl: 'http://localhost:3003', backendClientId: '' });
|
||||
setRuntimeConfig({ backendUrl: 'http://localhost:3005', backendClientId: '' });
|
||||
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
|
||||
expect(cfg.clientId).toBe('http://localhost:3003/as/actor/app');
|
||||
expect(cfg.clientId).toBe('http://localhost:3005/as/actor/app');
|
||||
expect(cfg.redirectUri).toBe('http://localhost:5000/login');
|
||||
});
|
||||
|
||||
@@ -27,9 +27,9 @@ test('clientId falls back to the origin actor uri when nothing is configured', (
|
||||
});
|
||||
|
||||
test('clientId does not double the slash on a trailing-slash backendUrl', () => {
|
||||
setRuntimeConfig({ backendUrl: 'http://localhost:3003/' });
|
||||
setRuntimeConfig({ backendUrl: 'http://localhost:3005/' });
|
||||
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
|
||||
expect(cfg.clientId).toBe('http://localhost:3003/as/actor/app');
|
||||
expect(cfg.clientId).toBe('http://localhost:3005/as/actor/app');
|
||||
});
|
||||
|
||||
test('podProviderUrl is passed through when set', () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user