Compare commits
3 Commits
5e3e821e2a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e2df2478b | |||
| ea359488b6 | |||
| 223059c629 |
@@ -45,6 +45,12 @@ NODE_ENV=
|
||||
|
||||
# Le backend lit son propre backend/.env (voir backend/.env.example).
|
||||
|
||||
# ── 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:3005
|
||||
|
||||
@@ -8,7 +8,7 @@ against: 66616484b96fb2227cd8cdf02321aacfb4811365
|
||||
|
||||
Festipod is a mobile-first web application: users create **meeting points** grafted onto public events, and sign up to them. Its entire persistence goes through Refine, behind a **backend seam** that exists so the app can support several Refine backends and let the user switch between them at runtime. This package is one of the two backends targeted; the other is a Refine provider over NextGraph plus its polyfill. The seam's own contract — what a backend must answer before it can be added — lives in code, in `src/shared/refine/providers/types.ts`.
|
||||
|
||||
The seam is wired since 2026-10-09 (T04.e): this backend is the registered default, and Festipod reads and writes through it — `useList` over the shared `as:Event` container (split into events and meeting points by the bindings' `isInstance`), creates and updates through the data provider, and the embedded `apods:attendees` collection through the participation binding's `membersOf`. Joins/leaves are the one write beside Refine (the package implements no `custom`): an `as:Join`/`as:Leave` posted to the user's own outbox by `src/shared/data/outbox.ts`, naming the meeting point with no addressee, so that only its author and the app can read it. What the list below declares is therefore measured where a runtime check exists and declared elsewhere; `against:` names the observation's commit, and the package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped.
|
||||
The seam is wired since 2026-10-09 (T04.e): this backend is the registered default, and Festipod reads and writes through it — `useList` over the shared `as:Event` container (split into events and meeting points by the bindings' `isInstance`), creates and updates through the data provider, and the embedded `apods:attendees` collection through the participation binding's `membersOf`. Joins/leaves are the one write beside Refine (the package implements no `custom`): an `as:Join`/`as:Leave` posted to the user's own outbox by `src/shared/data/outbox.ts`, naming the meeting point and addressed to the Festipod app actor alone, so that only its author and the app can read it; the pod delivers it to the app's inbox. What the list below declares is therefore measured where a runtime check exists and declared elsewhere; `against:` names the observation's commit, and the package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped.
|
||||
|
||||
## Consumed surface
|
||||
|
||||
|
||||
@@ -19,20 +19,20 @@ One fixed port pair per app on localhost, the pod provider keeping 3000/5000:
|
||||
| portejunes backend / frontend | 3004 / 4004 |
|
||||
| **Festipod backend / app** (committed defaults) | **3005 / 4005** |
|
||||
| fuseki (admin/admin) | 3030 |
|
||||
| redis | 6379 |
|
||||
| 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`). 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).
|
||||
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. 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 6379 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).
|
||||
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, publish redis elsewhere and point `SEMAPPS_QUEUE_SERVICE_URL` at it in `backend/.env`.
|
||||
`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)
|
||||
|
||||
@@ -51,12 +51,20 @@ On a fresh pod, an `as:Event` POST lands at the pod root (`/alice/data/<uuid>`):
|
||||
- **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.
|
||||
|
||||
On a FRESH backend dataset (after a wipe), the app's actor and its access-need group are created in parallel by the mixin's boot and can race: the actor can end up without `interop:hasAccessNeedGroup` (symptom: `GET /app` links a group that 404s; consents register zero grants). `backend/services/app.service.js` heals this at boot by polling for the group (up to 60 s) and attaching it. If the backend is restarted while its dataset is wiped, wait for the heal before running consented flows — or simply re-check `GET /app` links a group that resolves 200.
|
||||
`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
|
||||
|
||||
|
||||
@@ -29,4 +29,5 @@ The rule states the **what**, never the **how** — and that is intended. Which
|
||||
|
||||
- [[knowledge_data-scopes-and-discovery]] — the scopes model this rule tightens (participation: public as an object, the identity link is protected)
|
||||
- [[decision_2026-10-29_participation-privacy-mechanism]] — the mechanism enforcing this rule on ActivityPods (private Join, hashed member URNs, contacts-readable participations collection)
|
||||
- [[decision_2026-11-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`)
|
||||
|
||||
+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.
|
||||
@@ -19,7 +19,7 @@ last_checked: 2026-10-19
|
||||
- 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` answers from an in-memory copy of the actor taken before the access-need group got linked, so it lacks `interop:hasAccessNeedGroup` until the backend restarts (the actor itself, `<base>as/actor/app`, is right). A real sign-in is unaffected — the pod reads the actor — but the rights matrix's programmatic install reads `/app`: restart the backend once after a first deployment.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ What was missing for Festipod's premise — discover other users' public events
|
||||
- `acl:Append` behaviour, as above.
|
||||
- The router's prefix stripping, with the second backend.
|
||||
- The three provider contributions.
|
||||
- Wiring the seam: DONE 2026-10-09 (T04 tree, tip `f8c2e3b`) — `FestipodDataContext` runs over Refine with the ActivityPods backend as the registered default; the join→collection loop is built but `@suspendu` until the app is installed on a pod (consent screen). Current state: `## Current gate state` above. The app-install blocker is CLEARED end-to-end 2026-10-12 (tips `3aa9131` → this state): the login → Solid-OIDC → consent → registration → app loop completes on the aligned stack, verified by an automated Playwright probe (fresh user: picker → login → consent → `/home` authenticated → event created → meeting point attached). The chain of causes, in the order they surfaced: (1) the pod provider's OIDC adapter resolves an unknown client_id by fetching it and requires a Solid-OIDC Client ID Document — `/app` serves it when the request carries the `JsonLdContext` header, the actor otherwise; (2) the platform stack has to run a coherent version — the app packages are pinned at npm latest (`@activitypods/app` 2.3.2, refine-providers 0.2.0) and the pod provider runs the release tag v2.3.12 (fresh pull + full data reset + the pod frontend needing `VITE_CONFIG_URL` in dev and redis remapped to :6390 per the cookbook); (3) on a cold start the actor's `interop:hasAccessNeedGroup` link races the access-need-group creation and is never attached — an idempotent heal in `backend/services/app.service.js` `started()` patches it when missing; (4) the auth provider's `registerApp` discovers the AppRegistration via a `Link: rel="interop:registeredAgent"` header the pod only returns when the requester's token identity (azp = the configured client_id) equals the registration's `registeredAgent`, which the pod stores under the app ACTOR uri — so the Solid-OIDC client_id IS the actor uri (`${backendUrl}/as/actor/app`), and `/as/actor/app` serves the actor / the Client ID Document by the same header trick through a standalone `client-id-doc` service (routing it to `app.get` deadlocked boot: the app service's own `awaitCreateComplete` polls the actor over HTTP while the service is still starting). The pinned contract carries a dated re-baseline section recording the registerApp/getAppStatus findings (authorized one-time re-read). The verify-compat shim was removed once the aligned stack made it redundant. A revoked app is now detected client-side (`4fc856f`, `d4fcb7e` — see `## T05`). The join→collection scenarios are re-enabled and green (5/5 smoke), and the full authenticated UI flow is banked as a durable gate: `bun run test:e2e-flow` (scripts/e2e-login-flow.ts — picker → login → consent → authenticated → event → meeting point, fresh user, benign-4xx classification, non-zero exit on any fatal ≥400). Platform fragility observed while un-suspending: hand-crafting an `apods:attendees` collection (instead of letting the backend attach it) crashed the festipod backend with an uncaught `Invalid URL` in `@activitypods/app` `pod-collections-handler.getGroupSlugFromCollectionUri(undefined)` — the scenario now reads the attached collection rather than building it; the crash path itself is platform-side.
|
||||
- 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()`: 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).
|
||||
|
||||
@@ -12,4 +12,4 @@ created: 2026-10-28
|
||||
|
||||
**Why:** La validation live du 2026-10-28 a montré que tout chemin de plateforme non exercé par une app de référence est un terrain miné : le provisioning holder-side navigateur (création de conteneur dans `as/collection` + PATCH `/_acl` avec un token de session Solid-OIDC `azp`) a révélé deux bugs fournisseurs (500 après création effective du conteneur ; hang sans écriture dans `webacl.resource.addRights`), alors que les chemins utilisés par les apps de référence fonctionnent en production. Aucune app de référence ne provisionne d'infrastructure depuis le frontend : toute écriture pod hors données (conteneur, ACL, groupe, collection) se fait **backend-side, signée app-actor** via `signature.proxy.query`.
|
||||
|
||||
**How to apply:** Références canoniques (2026-10-28, les mieux maintenues, ajoutées au dossier) : **`welcometomyplace`, `mutual-aid.app`, `portejunes`** — en priorité welcometomyplace, seule à exercer les patterns sociaux avancés. Secondaires : `mastopod` (access needs simples), `refine-providers/examples/antd-app` (frontend), et les services du app-framework (`activitypods/app-framework/app`). Avant toute écriture pod, vérifier le pattern dans ces sources ; s'il est absent des trois canoniques, ne pas coder : chercher une composition de patterns existants, et à défaut demander la décision utilisateur en nommant l'exception. Patterns welcometomyplace que Festipod réutilise : traiter un Join/Leave sur `onEmit` de l'outbox du participant (`registration.service.js`) ; collection lisible par un groupe = `pod-permissions.add({agentPredicate: 'acl:agentGroup', mode: 'acl:Read'})` sur une collection `pod-collections` (`attendees.service.js`), le groupe `/contacts` d'un utilisateur trouvé par `pod-wac-groups.getUriFromCollectionUri({collectionUri: apods:contacts})` — jamais un droit par élément de la liste `apods:contacts`, falsifiable par un `Accept` forgé ; notifier = `pod-notifications.send`, qui exige un backend en WebACL activé sur un dataset sécurisé, comme les trois apps de référence. Ne JAMAIS reprendre leur `addMember` de l'organisateur dans le groupe `/contacts` du participant : c'est un choix produit de welcometomyplace (événements privés), une fuite d'identité sous la règle de Festipod (`behavior_participation-listing-is-contacts-only`). Couvre l'usage de la plateforme, pas les choix produit propres à Festipod.
|
||||
**How to apply:** Références canoniques (2026-10-28, les mieux maintenues, ajoutées au dossier) : **`welcometomyplace`, `mutual-aid.app`, `portejunes`** — en priorité welcometomyplace, seule à exercer les patterns sociaux avancés. Secondaires : `mastopod` (access needs simples), `refine-providers/examples/antd-app` (frontend), et les services du app-framework (`activitypods/app-framework/app`). Avant toute écriture pod, vérifier le pattern dans ces sources ; s'il est absent des trois canoniques, ne pas coder : chercher une composition de patterns existants, et à défaut demander la décision utilisateur en nommant l'exception. Patterns welcometomyplace que Festipod réutilise : traiter une activité sur `onEmit` de l'outbox de son émetteur (catalogue, contacts, listes de participants) ; exception nommée (2026-11-03, sur avis du développeur d'ActivityPods, `decision_2026-11-03_participation-delivered-to-the-app`) : un Join/Leave de participation est adressé à l'acteur de l'app et traité à sa réception dans l'inbox de l'app (`onReceive` de l'`ActivitiesHandlerMixin` de semapps, le chemin du framework pour l'installation d'une app, `registration.service.js`) ; collection lisible par un groupe = `pod-permissions.add({agentPredicate: 'acl:agentGroup', mode: 'acl:Read'})` sur une collection `pod-collections` (`attendees.service.js`), le groupe `/contacts` d'un utilisateur trouvé par `pod-wac-groups.getUriFromCollectionUri({collectionUri: apods:contacts})` — jamais un droit par élément de la liste `apods:contacts`, falsifiable par un `Accept` forgé ; notifier = `pod-notifications.send`, qui exige un backend en WebACL activé sur un dataset sécurisé, comme les trois apps de référence. Ne JAMAIS reprendre leur `addMember` de l'organisateur dans le groupe `/contacts` du participant : c'est un choix produit de welcometomyplace (événements privés), une fuite d'identité sous la règle de Festipod (`behavior_participation-listing-is-contacts-only`). Couvre l'usage de la plateforme, pas les choix produit propres à Festipod.
|
||||
|
||||
@@ -16,5 +16,6 @@ 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,8 +14,11 @@
|
||||
// `pim:storage` (verified 2026-10-12: PATCH → 204, the profile then lists both) — so it proves
|
||||
// nothing about who owns a resource.
|
||||
//
|
||||
// `actorUri` must come from the server side — the actor whose outbox the webhook fired on — never
|
||||
// from the activity's own `actor` field, which the emitting client writes.
|
||||
// `actorUri` must come from the server side — the actor whose outbox a webhook fired on, or the
|
||||
// signer the app's inbox verified for a delivered activity — never from a field nothing vouches for,
|
||||
// such as a webhook body's `actor`, which the emitting client writes. One use takes an unvouched
|
||||
// value as a HINT only: registration's `meetingPointOf` reads a meeting point in the pod its first
|
||||
// copy names, and believes that pod only once the copy it serves names the same creator.
|
||||
|
||||
const WEB_PROTOCOLS = new Set(['http:', 'https:']);
|
||||
|
||||
|
||||
@@ -71,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
|
||||
@@ -84,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 {
|
||||
@@ -124,22 +122,22 @@ 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. The heal itself can ALSO lose the race: it may run before the
|
||||
// mixin has created the group (observed 2026-10-26 on a fresh dataset — the one-shot
|
||||
// heal found no group and silently did nothing), so it polls for the group instead of
|
||||
// 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 actorHasLink = async () => {
|
||||
const actor = await this.broker.call('ldp.resource.get', {
|
||||
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);
|
||||
};
|
||||
@@ -158,7 +156,7 @@ module.exports = {
|
||||
const deadline = Date.now() + 60000;
|
||||
for (;;) {
|
||||
try {
|
||||
if (await actorHasLink()) break;
|
||||
if (linksGroup(await readActor())) break;
|
||||
const group = await findGroup();
|
||||
if (group) {
|
||||
await this.broker.call('actors.attachAccessNeedGroup', {
|
||||
@@ -176,5 +174,15 @@ module.exports = {
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
// What the backend may believe about an activity its watcher reports — the check in front of every
|
||||
// Festipod handler fed by that watcher (public-read, catalog, attendees, contacts, registration).
|
||||
// What the backend may believe about an activity its watcher reports, or a server delivers to the
|
||||
// app's inbox — the check in front of every Festipod handler fed by that watcher (public-read,
|
||||
// catalog, attendees, contacts) or by that inbox (registration). A delivered body, like a webhook
|
||||
// body, is only a pointer.
|
||||
//
|
||||
// THE WEBHOOK IS A HINT, NOT A SOURCE. The webhook route (`/.webhooks/:uuid`) is unauthenticated: the
|
||||
// listener only matches the uuid, the watcher derives the box owner from the body's `target`, and an
|
||||
@@ -16,7 +18,11 @@
|
||||
// activity must lie in that owner's pod and the stored activity must name that owner as `actor`;
|
||||
// - an INBOX notification does not (`verifiedReceive`): the emitter is the stored activity's own
|
||||
// `actor`, believed only because the activity lies in THAT actor's pod — someone else's claim
|
||||
// about an actor cannot be stored there.
|
||||
// about an actor cannot be stored there;
|
||||
// - a DELIVERY to the app's own inbox names its emitter (`verifiedDelivery`): the inbox accepts it
|
||||
// only signed by the activity's `actor` (@semapps/activitypub 1.2.7 `inbox.post` refuses an actor
|
||||
// other than the signer, INVALID_ACTOR). Nothing else in the body is believed: the activity must
|
||||
// lie in that actor's pod and the copy stored there name them.
|
||||
//
|
||||
// An activity is accepted only when all of this holds:
|
||||
// 1. it is identified by a URI (an embedded activity without an id was never stored);
|
||||
@@ -87,13 +93,14 @@ const ANY_POD = Symbol('any pod');
|
||||
* { ok: true, activity, emitterUri, objectUri, object } (object: the stored object; undefined for a Delete)
|
||||
* { ok: false, reason }
|
||||
*
|
||||
* `activity` is what the watcher handed over (a URI or a node — only its id is used). `type` is the
|
||||
* activity type the handler expects. `emitterUri` is the owner of the outbox the notification names,
|
||||
* or `null` for an inbox notification (the emitter is then the stored activity's actor). The object
|
||||
* must lie in the pod of `objectOwnerUri`, or anywhere with `ANY_POD`. `objectType` is the type the
|
||||
* stored object must declare — an ActivityStreams name or a full IRI; not checked for a Delete.
|
||||
* `read(uri)` reads a resource as the box owner and resolves `{ status, body }` (`status` 0 when the
|
||||
* request itself failed). Never throws for a refusal; a throwing `read` propagates.
|
||||
* `activity` is what the watcher or the app's inbox handed over (a URI or a node — only its id is
|
||||
* used). `type` is the activity type the handler expects. `emitterUri` is the owner of the outbox the
|
||||
* notification names, the `actor` the app's inbox verified as the signer of a delivery, or `null` for
|
||||
* an inbox notification (the emitter is then the stored activity's actor). The object must lie in the
|
||||
* pod of `objectOwnerUri`, or anywhere with `ANY_POD`. `objectType` is the type the stored object
|
||||
* must declare — an ActivityStreams name or a full IRI; not checked for a Delete. `read(uri)` reads a
|
||||
* resource as the box owner — for a delivery, as its emitter — and resolves `{ status, body }`
|
||||
* (`status` 0 when the request itself failed). Never throws for a refusal; a throwing `read` propagates.
|
||||
*/
|
||||
async function verifyActivity({ activity, type, emitterUri, objectOwnerUri, objectType, read }) {
|
||||
if (!ACTIVITY_TYPES.has(type)) throw new Error(`Unsupported activity type ${type}`);
|
||||
@@ -227,6 +234,45 @@ function verifiedReceive(type, handle, { objectType } = {}) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An `onReceive` for the APP's own inbox (`@semapps/activitypub` `ActivitiesHandlerMixin`, whose
|
||||
* third argument is the inbox owner, the app): runs `handle(ctx, verdict, emitterUri)` only for an
|
||||
* activity `verifyActivity` accepts as emitted by the delivered activity's `actor`, the signer the
|
||||
* inbox verified. Before anything is read, the activity's URI must lie in that actor's pod; the
|
||||
* activity and its object are then read through that actor's pod (`podReader`), the stored activity
|
||||
* having to name that same actor. `objectType` and `objectIn` are those of `verifiedEmit`. With
|
||||
* `objectIn: 'any'`, an object outside the actor's pod is read through the proxy THEIR actor document
|
||||
* declares — for an actor on another server, an answer that server chooses: a handler acting on such
|
||||
* an object reads it again where it lies (registration's `meetingPointOf`).
|
||||
*/
|
||||
function verifiedDelivery(type, handle, { objectType, objectIn = 'emitter' } = {}) {
|
||||
if (objectIn !== 'emitter' && objectIn !== 'any') throw new Error(`Unknown objectIn ${objectIn}`);
|
||||
return async function (ctx, activity, inboxOwnerUri) {
|
||||
try {
|
||||
const claimed = activity && typeof activity === 'object' ? singleUriOf(activity.actor) : undefined;
|
||||
if (!claimed) {
|
||||
this.logger.warn(`${this.name} ignored a ${type} delivered to ${inboxOwnerUri}: it names no single actor`);
|
||||
return;
|
||||
}
|
||||
const verdict = await verifyActivity({
|
||||
activity,
|
||||
type,
|
||||
emitterUri: claimed,
|
||||
objectOwnerUri: objectIn === 'any' ? ANY_POD : claimed,
|
||||
objectType,
|
||||
read: podReader(ctx, claimed)
|
||||
});
|
||||
if (!verdict.ok) {
|
||||
this.logger.warn(`${this.name} ignored a ${type} delivered to ${inboxOwnerUri} as from ${claimed}: ${verdict.reason}`);
|
||||
return;
|
||||
}
|
||||
await handle.call(this, ctx, verdict, verdict.emitterUri);
|
||||
} catch (e) {
|
||||
this.logger.error(`${this.name} could not process a ${type} delivered to ${inboxOwnerUri}: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
verifyActivity,
|
||||
verifyEmittedActivity,
|
||||
@@ -234,6 +280,7 @@ module.exports = {
|
||||
neverThrowingMatcher,
|
||||
verifiedEmit,
|
||||
verifiedReceive,
|
||||
verifiedDelivery,
|
||||
uriOf,
|
||||
singleUriOf,
|
||||
hasAsType,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const { verifyEmittedActivity, verifyActivity, verifiedEmit, verifiedReceive, neverThrowingMatcher, ANY_POD } = require('./emitted-activity');
|
||||
const { verifyEmittedActivity, verifyActivity, verifiedEmit, verifiedReceive, verifiedDelivery, neverThrowingMatcher, ANY_POD } = require('./emitted-activity');
|
||||
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const BOB = 'http://localhost:3000/bob';
|
||||
@@ -265,7 +265,7 @@ describe('verifiedReceive / verifiedEmit with objectIn "any"', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifiedEmit — a Join from a participant\'s outbox (the registration flow)', () => {
|
||||
describe('verifiedEmit — a Join from a participant\'s outbox', () => {
|
||||
const MP = `${ALICE}/data/events/mp1`;
|
||||
const JOIN = `${BOB}/outbox/join-1`;
|
||||
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP };
|
||||
@@ -298,3 +298,94 @@ describe('verifiedEmit — a Join from a participant\'s outbox (the registration
|
||||
expect(service.logger.w).toContain('not attributed to');
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifiedDelivery — a Join delivered to the app's inbox: the body's actor is a claim", () => {
|
||||
const APP = 'http://localhost:3005/as/actor/app';
|
||||
const MALLORY = 'http://localhost:3000/mallory';
|
||||
const MP = `${ALICE}/data/events/mp1`;
|
||||
const JOIN = `${BOB}/data/activities/join-1`;
|
||||
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP, to: APP };
|
||||
const meetingPoint = { id: MP, type: 'Event', context: `${ALICE}/data/events/e1` };
|
||||
|
||||
/** A pod answering `stored` (uri → body), recording every read as [uri, actorUri]. */
|
||||
function setup(stored = { [JOIN]: join, [MP]: meetingPoint }) {
|
||||
const reads = [];
|
||||
const ctx = {
|
||||
call: async (_action, { resourceUri, actorUri }) => {
|
||||
reads.push([resourceUri, actorUri]);
|
||||
const body = stored[resourceUri];
|
||||
return body ? { status: 200, body } : { status: 404 };
|
||||
}
|
||||
};
|
||||
const logs = { warn: [], error: [] };
|
||||
const service = { name: 'test', logger: { info() {}, warn: m => logs.warn.push(m), error: m => logs.error.push(m) } };
|
||||
const ran = [];
|
||||
const deliver = (body, options = { objectType: 'Event', objectIn: 'any' }, handle = async (_ctx, verdict, emitterUri) => ran.push([emitterUri, verdict.object])) =>
|
||||
verifiedDelivery('Join', handle, options).call(service, ctx, body, APP);
|
||||
return { reads, logs, ran, deliver };
|
||||
}
|
||||
|
||||
test("the normal case: the claimed actor's pod holds the activity naming them — the handler runs for them, on the STORED object, everything read through their pod", async () => {
|
||||
const w = setup();
|
||||
await w.deliver({ ...join, object: { id: `${ALICE}/data/events/other`, type: 'Event' } });
|
||||
expect(w.ran).toEqual([[BOB, meetingPoint]]);
|
||||
expect(w.reads).toEqual([[JOIN, BOB], [MP, BOB]]);
|
||||
expect(w.logs.warn).toEqual([]);
|
||||
});
|
||||
|
||||
test("a body claiming another actor for an activity of Bob's pod is refused before anything is read", async () => {
|
||||
const w = setup();
|
||||
await w.deliver({ ...join, actor: MALLORY });
|
||||
expect(w.ran).toEqual([]);
|
||||
expect(w.reads).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes(`not in the pod of ${MALLORY}`))).toBe(true);
|
||||
});
|
||||
|
||||
test("an activity held in Mallory's pod claiming Bob as its actor is refused before anything is read", async () => {
|
||||
const forged = `${MALLORY}/data/activities/join-1`;
|
||||
const w = setup({ [forged]: { ...join, id: forged }, [MP]: meetingPoint });
|
||||
await w.deliver({ ...join, id: forged });
|
||||
expect(w.ran).toEqual([]);
|
||||
expect(w.reads).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes(`not in the pod of ${BOB}`))).toBe(true);
|
||||
});
|
||||
|
||||
test("an activity of Bob's pod whose stored copy names someone else is refused", async () => {
|
||||
const w = setup({ [JOIN]: { ...join, actor: ALICE }, [MP]: meetingPoint });
|
||||
await w.deliver(join);
|
||||
expect(w.ran).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes('not attributed to'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a body naming no single actor, or no body at all, is refused without a read', async () => {
|
||||
for (const body of [{ ...join, actor: undefined }, { ...join, actor: [BOB, ALICE] }, JOIN, undefined]) {
|
||||
const w = setup();
|
||||
await w.deliver(body);
|
||||
expect(w.ran).toEqual([]);
|
||||
expect(w.reads).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes('names no single actor'))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("an activity the claimed actor's pod does not hold is refused", async () => {
|
||||
const w = setup({ [MP]: meetingPoint });
|
||||
await w.deliver(join);
|
||||
expect(w.ran).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes('does not return the activity'))).toBe(true);
|
||||
});
|
||||
|
||||
test('by default the object must lie in the emitter\'s pod; objectIn "any" lets the handler check it', async () => {
|
||||
const w = setup();
|
||||
await w.deliver(join, { objectType: 'Event' });
|
||||
expect(w.ran).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes(`not in the pod of ${BOB}`))).toBe(true);
|
||||
});
|
||||
|
||||
test('a failing handler or read is logged, never thrown', async () => {
|
||||
const w = setup();
|
||||
await w.deliver(join, { objectType: 'Event', objectIn: 'any' }, async () => {
|
||||
throw new Error('boom');
|
||||
});
|
||||
expect(w.logs.error.some(m => m.includes('boom'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,7 +85,7 @@ const MEMBER = 'http://festipod.org/attendanceMember';
|
||||
* A collection (a key of `state.items`) answers a GET as the pod serves an unpaged collection: its
|
||||
* `items` inline.
|
||||
*/
|
||||
function world({ stored = {}, items = {}, acls = {}, ledger = {}, delays = {}, fail = {}, timeoutMs } = {}) {
|
||||
function world({ stored = {}, items = {}, acls = {}, ledger = {}, delays = {}, fail = {}, proxied = {}, timeoutMs } = {}) {
|
||||
const calls = [];
|
||||
const logs = { info: [], warn: [], error: [] };
|
||||
const logger = { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) };
|
||||
@@ -137,6 +137,11 @@ function world({ stored = {}, items = {}, acls = {}, ledger = {}, delays = {}, f
|
||||
return { id: APP };
|
||||
case 'pod-resources.get':
|
||||
await wait(delays[`read:${params.resourceUri}`]);
|
||||
// `proxied[reader][uri]`: what the reader's OWN server answers for a resource outside the
|
||||
// reader's pod — the platform reads it through the proxy the reader's actor document declares.
|
||||
if (proxied[params.actorUri] && params.resourceUri in proxied[params.actorUri] && !params.resourceUri.startsWith(`${params.actorUri}/`)) {
|
||||
return { ok: true, status: 200, body: proxied[params.actorUri][params.resourceUri] };
|
||||
}
|
||||
return read(params.resourceUri);
|
||||
case 'pod-collections.getItems': {
|
||||
// As the platform does: the body's items, whatever the status — `[]` on a failed read.
|
||||
@@ -263,10 +268,10 @@ function world({ stored = {}, items = {}, acls = {}, ledger = {}, delays = {}, f
|
||||
}
|
||||
for (const [name, fn] of Object.entries(registration.methods)) registrationSvc[name] = fn.bind(registrationSvc);
|
||||
|
||||
/** Notifies the registration service of an activity emitted in `actorUri`'s outbox. */
|
||||
const emit = (key, hint, actorUri) => registration.activities[key].onEmit.call(registrationSvc, ctx, hint, actorUri);
|
||||
/** Delivers an activity to the app's inbox, as the participant's pod does: the body is all the handler gets. */
|
||||
const deliver = (key, body) => registration.activities[key].onReceive.call(registrationSvc, ctx, body, APP);
|
||||
|
||||
return { ctx, calls, state, logs, registration: registrationSvc, participations: participationsSvc, emit };
|
||||
return { ctx, calls, state, logs, registration: registrationSvc, participations: participationsSvc, deliver };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
const { ACTIVITY_TYPES, matchActivity } = require('@semapps/activitypub');
|
||||
const { PodActivitiesHandlerMixin } = require('@activitypods/app');
|
||||
const { ACTIVITY_TYPES, ActivitiesHandlerMixin, matchActivity } = require('@semapps/activitypub');
|
||||
const { arrayOf } = require('@semapps/ldp');
|
||||
const CONFIG = require('../config/config');
|
||||
const { isInActorPod } = require('./actor-pod');
|
||||
const { createKeyedSerializer } = require('./keyed-serializer');
|
||||
const { isNewerAct, lastAttendance, persistMember, recordAttendance } = require('./attendance-ledger');
|
||||
const { neverThrowingMatcher, verifiedEmit, singleUriOf, uriOf } = require('./emitted-activity');
|
||||
const { neverThrowingMatcher, verifiedDelivery, singleUriOf, uriOf, hasAsType } = require('./emitted-activity');
|
||||
const { participationSecretOf, memberUrn } = require('./member-urn');
|
||||
const { readItems, expectItem } = require('./collection-items');
|
||||
|
||||
@@ -37,9 +36,12 @@ const firstStringOf = value =>
|
||||
|
||||
/**
|
||||
* What a Join/Leave targets: the stored object must be a meeting point — an `as:Event` (checked by
|
||||
* `verifiedEmit`) anchored to an event by `as:context` — whose creator is its `dc:creator`, believed
|
||||
* only because the meeting point lies in that creator's pod (the pod stamps the creator, and nobody
|
||||
* can store a resource in someone else's pod). Resolves `{ ok, eventUri, creatorUri }` or a refusal.
|
||||
* `verifiedDelivery`, and by `meetingPointOf` on the creator's copy) anchored to an event by
|
||||
* `as:context` — whose creator is its `dc:creator`, required to own the pod the meeting point lies
|
||||
* in. Only on the copy the creator's pod serves (`meetingPointOf`) is that creator believed (the pod
|
||||
* stamps the creator, and nobody can store a resource in someone else's pod); 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`);
|
||||
@@ -81,10 +83,12 @@ function notificationTemplate(change) {
|
||||
};
|
||||
}
|
||||
|
||||
// Participation (decision_2026-10-29_participation-privacy-mechanism): a person joins (leaves) a
|
||||
// meeting point with an `as:Join` (`as:Leave`) posted to their OWN outbox, naming the meeting point,
|
||||
// addressed to no one — so only its author and the app read it. The creator's inbox receives nothing.
|
||||
// The backend acts on EMISSION (`onEmit`, the welcometomyplace registration pattern), and records:
|
||||
// Participation (decision_2026-10-29_participation-privacy-mechanism; its delivery:
|
||||
// decision_2026-11-03_participation-delivered-to-the-app): a person joins (leaves) a meeting point with an `as:Join`
|
||||
// (`as:Leave`) posted to their OWN outbox, naming the meeting point, addressed to the app actor alone —
|
||||
// so only its author and the app read it. Their pod delivers it to the app's inbox; the creator's inbox
|
||||
// receives nothing. The backend acts on RECEPTION in the app's inbox (`onReceive` of the semapps
|
||||
// `ActivitiesHandlerMixin`, as the app framework handles an app registration), and records:
|
||||
// - in the meeting point's `apods:attendees` collection (the creator's pod, acting as the creator):
|
||||
// the participant's MEMBER URN, never their WebID — `member-urn.js`, computed from the secret in
|
||||
// their profile. A Join whose author's profile carries no usable secret is refused, nothing written;
|
||||
@@ -95,11 +99,15 @@ function notificationTemplate(change) {
|
||||
// contact of the participant" signal, and the only one the app can read (the participant's
|
||||
// `apods:contacts` list) is forgeable (platform defect reported upstream, see `notificationTemplate`).
|
||||
// The creator has no other right on the participant: they learn a count and opaque URNs.
|
||||
// The webhook that reports the act is a hint (emitted-activity.js): the handler acts only on the
|
||||
// activity and the meeting point as the pod stores them, and only for the people they really name:
|
||||
// - the participant is the owner of the outbox the webhook fired on, and the stored activity's actor;
|
||||
// - the creator is the meeting point's `dc:creator`, accepted only when the meeting point lies in
|
||||
// that creator's pod (`meetingPointTarget`);
|
||||
// The delivery that reports the act is a pointer (emitted-activity.js, `verifiedDelivery`): the handler
|
||||
// acts only on the activity and the meeting point as the pods store them, and only for the people they
|
||||
// really name:
|
||||
// - the participant is the delivered activity's `actor` — the app's inbox accepts a delivery only
|
||||
// signed by its actor — and the activity must lie in that actor's pod, its stored copy naming them:
|
||||
// any server may deliver, nobody acts for someone else;
|
||||
// - the meeting point is the copy its creator's pod serves (`meetingPointOf`), never the one read
|
||||
// through the participant's server; the creator is its `dc:creator`, accepted only when the meeting
|
||||
// point lies in that creator's pod (`meetingPointTarget`);
|
||||
// - the collections written to are the ones the STORED meeting point and the participant's STORED
|
||||
// profile name, and must lie in the pod of whoever owns them.
|
||||
// Joins and leaves of one meeting point are processed one at a time (keyed-serializer.js), and the
|
||||
@@ -115,24 +123,27 @@ function notificationTemplate(change) {
|
||||
// (the two writes are not atomic).
|
||||
module.exports = {
|
||||
name: 'registration',
|
||||
mixins: [PodActivitiesHandlerMixin],
|
||||
mixins: [ActivitiesHandlerMixin],
|
||||
dependencies: ['participations'],
|
||||
created() {
|
||||
this.serialize = createKeyedSerializer();
|
||||
},
|
||||
async started() {
|
||||
this.logger.info('Registration watcher subscribed');
|
||||
this.logger.info("Registration listens to the Joins and Leaves delivered to the app's inbox");
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* Handles one verified Join/Leave (`change`: 'join' | 'leave') of `participantUri`, the outbox
|
||||
* owner: resolves the meeting point and the participant, applies the act, then notifies the
|
||||
* Handles one verified Join/Leave (`change`: 'join' | 'leave') of `participantUri`, its verified
|
||||
* emitter: resolves the meeting point and the participant, applies the act, then notifies the
|
||||
* creator when the act changed the participant's state. A refusal is logged; nothing is written.
|
||||
*/
|
||||
async onParticipation(ctx, change, { activity, objectUri: meetingPointUri, object: meetingPoint }, participantUri) {
|
||||
async onParticipation(ctx, change, { activity, objectUri: meetingPointUri, object: claimedMeetingPoint }, participantUri) {
|
||||
const refuse = reason => this.logger.warn(`${this.name} refused the ${change} ${uriOf(activity)} of ${participantUri}: ${reason}`);
|
||||
const target = meetingPointTarget(meetingPoint, meetingPointUri);
|
||||
const claimed = meetingPointTarget(claimedMeetingPoint, meetingPointUri);
|
||||
if (!claimed.ok) return refuse(claimed.reason);
|
||||
const target = await this.meetingPointOf(ctx, meetingPointUri, claimed.creatorUri);
|
||||
if (!target.ok) return refuse(target.reason);
|
||||
const { meetingPoint } = target;
|
||||
const participant = await this.participantOf(ctx, participantUri);
|
||||
if (!participant.ok) return refuse(participant.reason);
|
||||
const outcome = await this.updateAttendance(ctx, {
|
||||
@@ -158,6 +169,28 @@ module.exports = {
|
||||
});
|
||||
}
|
||||
},
|
||||
/**
|
||||
* The meeting point as the pod that holds it serves it. The verification read it through the
|
||||
* participant's pod — for a participant on another server, through the proxy THEIR actor document
|
||||
* declares, which answers what it wants (another meeting point's list, another name). Only the
|
||||
* creator's pod speaks for the meeting point: it is read again there, through the creator's access
|
||||
* (a GET to that pod, signed by the app), and the act uses that copy alone. It must still be an
|
||||
* `as:Event` anchored by `as:context` whose `dc:creator` is that same creator. Resolves
|
||||
* `{ ok, meetingPoint, eventUri, creatorUri }` or a refusal.
|
||||
*/
|
||||
async meetingPointOf(ctx, meetingPointUri, creatorUri) {
|
||||
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: meetingPointUri, actorUri: creatorUri });
|
||||
if (status !== 200 || !body || uriOf(body) !== meetingPointUri) {
|
||||
return { ok: false, reason: `the pod of ${creatorUri} does not return the meeting point ${meetingPointUri} (HTTP ${status})` };
|
||||
}
|
||||
if (!hasAsType(body, 'Event')) return { ok: false, reason: `the meeting point ${meetingPointUri} its pod returns is not an Event` };
|
||||
const target = meetingPointTarget(body, meetingPointUri);
|
||||
if (!target.ok) return target;
|
||||
if (target.creatorUri !== creatorUri) {
|
||||
return { ok: false, reason: `the meeting point ${meetingPointUri} names ${target.creatorUri} as its creator in its pod, not ${creatorUri}` };
|
||||
}
|
||||
return { ok: true, meetingPoint: body, eventUri: target.eventUri, creatorUri };
|
||||
},
|
||||
/**
|
||||
* The participant as their pod describes them, read as the participant: their actor document
|
||||
* (its `url` — the `as:Profile`, which must lie in their pod — and `apods:contacts`), then the
|
||||
@@ -364,12 +397,12 @@ module.exports = {
|
||||
match: neverThrowingMatcher(function (activity, fetcher) {
|
||||
return matchActivity(JOIN_PATTERN, activity, fetcher);
|
||||
}),
|
||||
// The participant's outbox: `actorUri` is the participant; the meeting point may lie in any pod
|
||||
// (its creator's — checked by `meetingPointTarget`).
|
||||
onEmit: verifiedEmit(
|
||||
// Delivered to the app's inbox: the participant is the verified emitter; the meeting point may lie
|
||||
// in any pod (its creator's — checked by `meetingPointTarget`).
|
||||
onReceive: verifiedDelivery(
|
||||
'Join',
|
||||
async function (ctx, verdict, actorUri) {
|
||||
await this.onParticipation(ctx, 'join', verdict, actorUri);
|
||||
async function (ctx, verdict, participantUri) {
|
||||
await this.onParticipation(ctx, 'join', verdict, participantUri);
|
||||
},
|
||||
{ objectType: 'Event', objectIn: 'any' }
|
||||
)
|
||||
@@ -378,10 +411,10 @@ module.exports = {
|
||||
match: neverThrowingMatcher(function (activity, fetcher) {
|
||||
return matchActivity(LEAVE_PATTERN, activity, fetcher);
|
||||
}),
|
||||
onEmit: verifiedEmit(
|
||||
onReceive: verifiedDelivery(
|
||||
'Leave',
|
||||
async function (ctx, verdict, actorUri) {
|
||||
await this.onParticipation(ctx, 'leave', verdict, actorUri);
|
||||
async function (ctx, verdict, participantUri) {
|
||||
await this.onParticipation(ctx, 'leave', verdict, participantUri);
|
||||
},
|
||||
{ objectType: 'Event', objectIn: 'any' }
|
||||
)
|
||||
|
||||
@@ -45,8 +45,8 @@ function setup({ acts = [], stored = {}, ...options } = {}) {
|
||||
return world({ stored: { ...MEETING_POINTS, ...Object.fromEntries(acts.map(a => [a.id, a])), ...stored }, ...options });
|
||||
}
|
||||
|
||||
const emitJoin = (w, act, actorUri = act.actor) => w.emit('join', act, actorUri);
|
||||
const emitLeave = (w, act, actorUri = act.actor) => w.emit('leave', act, actorUri);
|
||||
const emitJoin = (w, act) => w.deliver('join', act);
|
||||
const emitLeave = (w, act) => w.deliver('leave', act);
|
||||
const attendees = (w, mp = MP1) => w.state.items[attendeesOf(mp)] || [];
|
||||
const participationsUri = (w, who = BOB) => {
|
||||
const link = w.state.stored[profileOf(who)][FP_PARTICIPATIONS];
|
||||
@@ -84,7 +84,7 @@ const protectedAcl = (uri, group = groupOf(BOB)) => [
|
||||
{ '@id': `http://localhost:3000/_acl${new URL(uri).pathname}#Control`, 'acl:mode': 'acl:Control', 'acl:agent': APP }
|
||||
];
|
||||
|
||||
describe("registration — a Join emitted in the participant's outbox", () => {
|
||||
describe("registration — a Join delivered to the app's inbox", () => {
|
||||
test('the normal case: the member URN joins the list, the meeting point joins the participant\'s collection, the act is recorded', async () => {
|
||||
const j = join(MP1);
|
||||
const w = setup({ acts: [j] });
|
||||
@@ -105,20 +105,59 @@ describe("registration — a Join emitted in the participant's outbox", () => {
|
||||
expect(w.logs.error).toEqual([]);
|
||||
});
|
||||
|
||||
test('the webhook body is a hint: its actor and its embedded object are ignored', async () => {
|
||||
test('the delivered body is a pointer: its embedded object is ignored, the stored meeting point is used', async () => {
|
||||
const j = join(MP1);
|
||||
const w = setup({ acts: [j] });
|
||||
await emitJoin(w, { ...j, actor: MALLORY, object: { id: MP2, type: 'Event', 'apods:attendees': `${MALLORY}/data/c` } }, BOB);
|
||||
await emitJoin(w, { ...j, object: { id: MP2, type: 'Event', 'apods:attendees': `${MALLORY}/data/c` } });
|
||||
expect(attendees(w)).toEqual([urnOf(BOB)]);
|
||||
expect(attendees(w, MP2)).toEqual([]);
|
||||
expect(w.state.items[`${MALLORY}/data/c`]).toBeUndefined();
|
||||
});
|
||||
|
||||
test('the act is handled on emission only: the creator\'s inbox has no handler', () => {
|
||||
expect(registration.activities.join.onReceive).toBeUndefined();
|
||||
expect(registration.activities.leave.onReceive).toBeUndefined();
|
||||
expect(typeof registration.activities.join.onEmit).toBe('function');
|
||||
expect(typeof registration.activities.leave.onEmit).toBe('function');
|
||||
test("nobody joins or leaves for someone else: a delivery claiming Mallory for Bob's activity, or Bob for Mallory's, writes nothing", async () => {
|
||||
const j = join(MP1);
|
||||
const forged = { ...join(MP1, T0, MALLORY), actor: BOB };
|
||||
const w = setup({ acts: [j, forged] });
|
||||
await emitJoin(w, { ...j, actor: MALLORY });
|
||||
await emitJoin(w, forged);
|
||||
expect(writes(w.calls)).toEqual([]);
|
||||
expect(attendees(w)).toEqual([]);
|
||||
});
|
||||
|
||||
test("the meeting point is the one its creator's pod serves, not the copy the participant's server hands over", async () => {
|
||||
// Bob's server answers the reads made through him: for MP1 (in Alice's pod), a forged copy
|
||||
// naming MP2's attendee list and a name of its choosing.
|
||||
const j = join(MP1);
|
||||
const forged = meetingPoint(MP1, { name: 'Forged title', 'apods:attendees': attendeesOf(MP2) });
|
||||
const w = setup({ acts: [j], proxied: { [BOB]: { [MP1]: forged } } });
|
||||
await emitJoin(w, j);
|
||||
expect(attendees(w)).toEqual([urnOf(BOB)]);
|
||||
expect(attendees(w, MP2)).toEqual([]);
|
||||
expect(w.state.notifications.map(n => n.meetingPointName)).toEqual(['Rendez-vous mp1']);
|
||||
expect(w.calls.some(c => c.action === 'pod-resources.get' && c.params.resourceUri === MP1 && c.params.actorUri === ALICE)).toBe(true);
|
||||
});
|
||||
|
||||
test("a meeting point its creator's pod does not serve, or serves under another creator, writes nothing", async () => {
|
||||
const j = join(MP1);
|
||||
for (const options of [
|
||||
{ fail: { read: { [MP1]: 404 } }, proxied: { [BOB]: { [MP1]: meetingPoint(MP1) } }, contains: `does not return the meeting point ${MP1}` },
|
||||
{ stored: { [MP1]: meetingPoint(MP1, { 'dc:creator': CAROL }) }, proxied: { [BOB]: { [MP1]: meetingPoint(MP1) } }, contains: `not in the pod of its creator ${CAROL}` },
|
||||
{ stored: { [MP1]: meetingPoint(MP1, { type: 'Note' }) }, proxied: { [BOB]: { [MP1]: meetingPoint(MP1) } }, contains: 'is not an Event' }
|
||||
]) {
|
||||
const { contains, stored = {}, ...rest } = options;
|
||||
const w = setup({ acts: [j], stored, ...rest });
|
||||
await emitJoin(w, j);
|
||||
expect(writes(w.calls)).toEqual([]);
|
||||
expect(w.logs.warn.some(m => m.includes(contains))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("the act is handled on reception in the app's inbox only: no outbox handler", () => {
|
||||
expect(registration.mixins).toContain(require('@semapps/activitypub').ActivitiesHandlerMixin);
|
||||
expect(registration.activities.join.onEmit).toBeUndefined();
|
||||
expect(registration.activities.leave.onEmit).toBeUndefined();
|
||||
expect(typeof registration.activities.join.onReceive).toBe('function');
|
||||
expect(typeof registration.activities.leave.onReceive).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -661,7 +700,7 @@ describe('registration — a participant who rewrites their profile secret betwe
|
||||
|
||||
// THE DEFECT the sticky member fixes: a Join whose list add lands but a later step fails is left
|
||||
// unrecorded; the participant rewrites their secret and joins again — that must not list a second URN.
|
||||
const onEmitWith = (w, ctx, key, act) => registration.activities[key].onEmit.call(w.registration, ctx, act, act.actor);
|
||||
const deliverWith = (w, ctx, key, act) => registration.activities[key].onReceive.call(w.registration, ctx, act, APP);
|
||||
|
||||
test('partial failure (the pod refuses the participations write after the list add) + secret rewrite + Join: ONE URN; a Leave empties the list', async () => {
|
||||
const j1 = join(MP1, T0, BOB, 1);
|
||||
@@ -697,13 +736,13 @@ describe('registration — a participant who rewrites their profile secret betwe
|
||||
return w.ctx.call(action, params);
|
||||
}
|
||||
};
|
||||
await onEmitWith(w, ctx, 'join', j1);
|
||||
await deliverWith(w, ctx, 'join', j1);
|
||||
expect(attendees(w)).toEqual([urnOf(BOB)]);
|
||||
expect(ledgerOf(w).activityUri).toBeUndefined();
|
||||
rotate(w, BOB, secretN(5));
|
||||
await onEmitWith(w, ctx, 'join', j2);
|
||||
await deliverWith(w, ctx, 'join', j2);
|
||||
expect(attendees(w)).toEqual([urnOf(BOB)]);
|
||||
await onEmitWith(w, ctx, 'leave', l);
|
||||
await deliverWith(w, ctx, 'leave', l);
|
||||
expect(attendees(w)).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -722,15 +761,15 @@ describe('registration — a participant who rewrites their profile secret betwe
|
||||
return w.ctx.call(action, params);
|
||||
}
|
||||
};
|
||||
const first = onEmitWith(w, ctx, 'join', j1); // abandoned at 80 ms, its member write still in flight
|
||||
const first = deliverWith(w, ctx, 'join', j1); // abandoned at 80 ms, its member write still in flight
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
rotate(w, BOB, secretN(6));
|
||||
await onEmitWith(w, ctx, 'join', j2); // persists, lists and records the NEW secret's URN
|
||||
await deliverWith(w, ctx, 'join', j2); // persists, lists and records the NEW secret's URN
|
||||
await first;
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
expect(attendees(w)).toEqual([urnWith(BOB, secretN(6))]);
|
||||
expect(ledgerOf(w).member).toBe(urnWith(BOB, secretN(6))); // the late write was a no-op
|
||||
await onEmitWith(w, ctx, 'leave', l);
|
||||
await deliverWith(w, ctx, 'leave', l);
|
||||
expect(attendees(w)).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
@@ -24,9 +24,14 @@ services:
|
||||
- ./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'
|
||||
@@ -58,7 +63,7 @@ services:
|
||||
expose:
|
||||
- '6379'
|
||||
ports:
|
||||
- '6379:6379'
|
||||
- '${REDIS_PORT:-6379}:6379'
|
||||
volumes:
|
||||
- ./data/redis:/data:z
|
||||
command: ['redis-server', '--appendonly', 'yes']
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
* meeting point through her app and Bob joins it by tapping (the forged-contact
|
||||
* group) — so EVERY participation goes the way the app does it: the app writes
|
||||
* the participant's secret on their profile with their app-session token (the
|
||||
* data provider's update), then posts the Join to their own outbox with no
|
||||
* addressee (decision_2026-10-29_participation-privacy-mechanism). Carol (a
|
||||
* data provider's update), then posts the Join to their own outbox, addressed
|
||||
* to the app actor alone (decision_2026-10-29_participation-privacy-mechanism,
|
||||
* decision_2026-11-03_participation-delivered-to-the-app). Carol (a
|
||||
* non-contact) and Bob (the creator) read the event through their own app.
|
||||
* Direct HTTP with each user's own token for the access-control lines (faster,
|
||||
* and precise about statuses). Carol installs the app programmatically (the
|
||||
@@ -781,9 +782,14 @@ async function ownActivity(session: ApodsSession, type: 'Join' | 'Leave', object
|
||||
return items.find(a => typeIs(a, type) && linkId(a['object']) === object);
|
||||
}
|
||||
|
||||
/** The addressing fields an activity carries — a participation activity must carry none. */
|
||||
/** The addressees an activity names, as `field=uri` — a participation activity names the app actor alone, in `to`. */
|
||||
const ADDRESSING = ['to', 'cc', 'bto', 'bcc', 'audience', 'as:to', 'as:cc', 'as:bto', 'as:bcc', 'as:audience'];
|
||||
const addressingOf = (activity: JsonRecord): string[] => ADDRESSING.filter(k => activity[k] !== undefined);
|
||||
const addressingOf = (activity: JsonRecord): string[] =>
|
||||
ADDRESSING.flatMap(k => arrayOf(activity[k]).map(v => `${k.replace(/^as:/, '')}=${linkId(v) ?? JSON.stringify(v)}`));
|
||||
const addressedToTheAppAlone = (activity: JsonRecord): boolean => {
|
||||
const addressees = addressingOf(activity);
|
||||
return addressees.length === 1 && addressees[0] === `to=${BACKEND}/as/actor/app`;
|
||||
};
|
||||
|
||||
/**
|
||||
* The pod's refusal of a COLLECTION: 401/403, or its 404 — which counts as one
|
||||
@@ -910,15 +916,14 @@ async function join(): Promise<void> {
|
||||
return { ok: !!back && own === sent, actual: `${reads.length} app-token read(s) after the PUT; secret returned: ${!!back} under the key ${JSON.stringify(key)}; her own read: ${own === sent ? 'same secret' : own ? 'ANOTHER secret' : 'none'}` };
|
||||
});
|
||||
|
||||
// THE JOIN: in her own outbox, addressed to no one; only she reads it.
|
||||
await m.hard('join.activity-shape', G, "Alice reads her own outbox: the Join names M and is addressed to NO ONE (not public, not the creator)", 'one Join, actor Alice, object M, no to/cc/bto/bcc/audience', async () => {
|
||||
// THE JOIN: in her own outbox, addressed to the app actor alone; only she and the app read it.
|
||||
await m.hard('join.activity-shape', G, "Alice reads her own outbox: the Join names M and is addressed to the app actor ALONE (not public, not the creator)", 'one Join, actor Alice, object M, to = the app actor, no other addressee', async () => {
|
||||
const alice = need(S.alice, 'Alice');
|
||||
const M = need(S.M, 'M');
|
||||
const act = await poll(() => ownActivity(alice, 'Join', M), v => !!v, 15000);
|
||||
if (!act) return { ok: false, actual: `no Join naming M in her outbox` };
|
||||
S.aliceJoinM = recordUri(act);
|
||||
const addressed = addressingOf(act);
|
||||
return { ok: linkId(act['actor']) === alice.webId && addressed.length === 0 && !!S.aliceJoinM, actual: `${S.aliceJoinM}: actor ${linkId(act['actor'])}, object ${linkId(act['object'])}, addressing fields: ${addressed.join(',') || 'none'}` };
|
||||
return { ok: linkId(act['actor']) === alice.webId && addressedToTheAppAlone(act) && !!S.aliceJoinM, actual: `${S.aliceJoinM}: actor ${linkId(act['actor'])}, object ${linkId(act['object'])}, addressees: ${addressingOf(act).join(',') || 'none'}` };
|
||||
});
|
||||
await m.hard('join.activity-author-reads', G, 'CONTROL: Alice (the author) GETs her Join', '200', async () => {
|
||||
const r = await http('GET', need(S.aliceJoinM, "Alice's Join (join.activity-shape)"), { token: need(S.alice, 'Alice').token });
|
||||
@@ -1101,9 +1106,9 @@ async function leave(): Promise<void> {
|
||||
await mpCard(page, TITLE_M).getByRole('button', { name: /✓ Inscrit/ }).waitFor({ timeout: 20000 }).catch(() => {});
|
||||
return tapMeetingPoint(page, TITLE_M, /✓ Inscrit/, /^S'inscrire$/);
|
||||
});
|
||||
await m.hard('leave.activity-shape', G, "Alice reads her own outbox: the Leave names M and is addressed to no one", 'a Leave, object M, no addressing field', async () => {
|
||||
await m.hard('leave.activity-shape', G, "Alice reads her own outbox: the Leave names M and is addressed to the app actor alone", 'a Leave, object M, to = the app actor, no other addressee', async () => {
|
||||
const act = await poll(() => ownActivity(need(S.alice, 'Alice'), 'Leave', need(S.M, 'M')), v => !!v, 15000);
|
||||
return { ok: !!act && addressingOf(act).length === 0, actual: act ? `${recordUri(act)}: addressing fields: ${addressingOf(act).join(',') || 'none'}` : 'no Leave naming M in her outbox' };
|
||||
return { ok: !!act && addressedToTheAppAlone(act), actual: act ? `${recordUri(act)}: addressees: ${addressingOf(act).join(',') || 'none'}` : 'no Leave naming M in her outbox' };
|
||||
});
|
||||
await m.hard('leave.collection', G, "Alice's member URN disappears from M's attendee list", 'items ∌ her URN within 30 s (read 200)', async () => {
|
||||
need(S.joined.M, 'the join on M landed (join.collection)');
|
||||
|
||||
@@ -32,8 +32,8 @@ Fonctionnalité: Smoke e2e de la pile ActivityPods locale
|
||||
# L'app s'installe sur le Pod (consentement de l'agent d'autorisation, ou pour un
|
||||
# scénario sans navigateur l'endpoint d'enregistrement — voir l'étape
|
||||
# « installée sur le Pod »). Une fois installée, l'activité `as:Join` postée sur
|
||||
# l'outbox de l'utilisateur, sans destinataire, est traitée par le backend à
|
||||
# l'émission : il inscrit dans la collection `apods:attendees` du point de
|
||||
# l'outbox de l'utilisateur, adressée à l'acteur de l'app seul, est livrée dans
|
||||
# l'inbox de l'app et traitée par le backend à sa réception : il inscrit dans la collection `apods:attendees` du point de
|
||||
# rencontre l'URN de membre dérivée du secret de participation du profil
|
||||
# (écrit avant le Join, comme le fait l'app) — jamais le WebID
|
||||
# (decision_2026-10-29_participation-privacy-mechanism). Le point de rencontre est créé dans le
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
participationSecretOf,
|
||||
podLogin,
|
||||
postOutboxActivity,
|
||||
appActorUri,
|
||||
readAttendees,
|
||||
readResource,
|
||||
recordUri,
|
||||
@@ -173,10 +174,14 @@ Given("l'application Festipod est installée sur le Pod de la session", async fu
|
||||
// Registration is confirmed asynchronously by the app backend (the Pod posts a
|
||||
// CREATE to the user's outbox), and the registered shape-tree containers come
|
||||
// with it. Wait for the Event DataRegistration before a scenario writes there.
|
||||
// The Accept header is required: the pod answers 500 to a container read in any other
|
||||
// format ("LDP containers can only be returned with JSON-LD format").
|
||||
const eventContainer = `${pod.storage}/as/event`;
|
||||
const end = Date.now() + 20000;
|
||||
while (Date.now() < end) {
|
||||
const status = await fetch(eventContainer, { headers: { Authorization: `Bearer ${pod.token}` } })
|
||||
const status = await fetch(eventContainer, {
|
||||
headers: { Accept: 'application/ld+json', Authorization: `Bearer ${pod.token}` },
|
||||
})
|
||||
.then(r => r.status)
|
||||
.catch(() => 0);
|
||||
if (status === 200) return;
|
||||
@@ -275,15 +280,16 @@ When('la session rejoint le point de rencontre', async function (this: FestipodW
|
||||
// decision_2026-10-29_participation-privacy-mechanism): the participation
|
||||
// secret on my profile first (the backend refuses a Join whose author's
|
||||
// profile carries none), then an as:Join naming the meeting point, posted to
|
||||
// my OWN outbox with NO addressee — not public, not addressed to the creator.
|
||||
// my OWN outbox and addressed to the app actor alone — not public, not
|
||||
// addressed to the creator (decision_2026-11-03_participation-delivered-to-the-app).
|
||||
await ensureParticipationSecret(pod);
|
||||
await postOutboxActivity(pod, 'Join', mpUri);
|
||||
await postOutboxActivity(pod, 'Join', mpUri, appActorUri(apodsStackConfig()));
|
||||
});
|
||||
|
||||
When('la session quitte le point de rencontre', async function (this: FestipodWorld) {
|
||||
const pod = session(this);
|
||||
const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');
|
||||
await postOutboxActivity(pod, 'Leave', mpUri);
|
||||
await postOutboxActivity(pod, 'Leave', mpUri, appActorUri(apodsStackConfig()));
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -400,7 +400,7 @@ const isSecret = (value: unknown) => typeof value === 'string' && /^[0-9a-f]{64}
|
||||
|
||||
// --- joining: the secret first, then a Join naming the meeting point --------------
|
||||
|
||||
test("the first join: a secret is generated and written to my profile BEFORE the Join is posted; the Join names the meeting point with no addressee; my participations collection, linked once the backend processed it, confirms it", async () => {
|
||||
test("the first join: a secret is generated and written to my profile BEFORE the Join is posted; the Join names the meeting point, addressed to the app actor alone; my participations collection, linked once the backend processed it, confirms it", async () => {
|
||||
await mount();
|
||||
|
||||
const served = server.reads;
|
||||
@@ -412,8 +412,8 @@ test("the first join: a secret is generated and written to my profile BEFORE the
|
||||
expect(server.profileWrites).toHaveLength(1);
|
||||
expect(Object.keys(server.profileWrites[0]!)).toEqual([FP_SECRET]);
|
||||
expect(isSecret(server.profileWrites[0]![FP_SECRET])).toBe(true);
|
||||
// Exactly these keys: no `to`, no `cc`.
|
||||
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Join', actor: ALICE, object: M }]);
|
||||
// Exactly these keys: `to` the app actor (the client id the backend URL derives), no `cc`.
|
||||
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Join', actor: ALICE, object: M, to: `${BACKEND}/as/actor/app` }]);
|
||||
|
||||
// The membership shows at once (overlay) while the backend has processed
|
||||
// nothing: my profile links no collection yet, the list is empty.
|
||||
@@ -493,7 +493,7 @@ test("a leave is never undone by a stale read: the membership is my participatio
|
||||
const served = server.reads;
|
||||
await latest!.leaveMeetingPoint(M);
|
||||
const leftAt = Date.now();
|
||||
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Leave', actor: ALICE, object: M }]);
|
||||
expect(server.posted).toEqual([{ '@context': 'https://www.w3.org/ns/activitystreams', type: 'Leave', actor: ALICE, object: M, to: `${BACKEND}/as/actor/app` }]);
|
||||
expect(server.profileWrites).toEqual([]);
|
||||
// The leave shows at once (overlay). The re-reads now land on STALE data —
|
||||
// the list still holds her URN, her collection still lists M: the count
|
||||
|
||||
@@ -65,12 +65,12 @@
|
||||
*
|
||||
* WHAT LIVES BESIDE REFINE. A participation is not a CRUD write and the
|
||||
* refine-providers package implements no `custom` method: joining or leaving
|
||||
* a meeting point POSTs an `as:Join`/`as:Leave` naming it, with no addressee,
|
||||
* to the user's own outbox (`data/outbox.ts`). The FIRST join ensures the
|
||||
* a meeting point POSTs an `as:Join`/`as:Leave` naming it, addressed to the app
|
||||
* actor alone, to the user's own outbox (`data/outbox.ts`). The FIRST join ensures the
|
||||
* participation secret on my profile beforehand — generated when absent and
|
||||
* written through the data provider's `update` — since the backend refuses a
|
||||
* Join whose author has none. The backend handles the activity on emission
|
||||
* and updates the attendee list and my participations collection
|
||||
* Join whose author has none. The backend handles the activity on its delivery
|
||||
* to the app's inbox and updates the attendee list and my participations collection
|
||||
* asynchronously — so the join/leave reflects in an optimistic overlay here,
|
||||
* confirmed by re-reading my participations collection (see
|
||||
* `settleMembershipActs` in `refineDerive.ts`), with a bounded schedule of
|
||||
@@ -103,7 +103,7 @@ import { fetchCatalogEvents, fetchCatalogMeetingPointIds } from '../data/catalog
|
||||
import { postJoinActivity, postLeaveActivity } from '../data/outbox';
|
||||
import { contactsCollectionOf, fetchCollectionMemberIds, postContactRequest, postEventInvitation } from '../data/contacts';
|
||||
import { useCurrentSession } from '../utils/currentSession';
|
||||
import { getBackendUrl } from '../utils/runtimeConfig';
|
||||
import { getBackendUrl, resolveActivityPodsAuthConfig } from '../utils/runtimeConfig';
|
||||
import { useCurrentPrincipal } from '../utils/currentPrincipal';
|
||||
import type { BootstrapResult } from '../utils/ngBootstrap';
|
||||
import { RefineProvider, useBackend } from '../refine/RefineProvider';
|
||||
@@ -187,6 +187,11 @@ interface MyParticipationsRead {
|
||||
}
|
||||
const NO_PARTICIPATIONS_READ: MyParticipationsRead = { generation: 0, attempted: 0, ids: [] };
|
||||
|
||||
/** The Festipod app actor, the only addressee of a Join or a Leave: the Solid-OIDC client id, which is the actor's URI. */
|
||||
function appActorUri(): string {
|
||||
return resolveActivityPodsAuthConfig(window.location.origin).clientId;
|
||||
}
|
||||
|
||||
/** What a contact's profile gives this session beyond their display fields. */
|
||||
interface ContactKeys {
|
||||
/** Their participation secret — what recognising them in an attendee list takes. */
|
||||
@@ -1178,7 +1183,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
console.log(`${logPrefix} joinMeetingPoint (refine):`, meetingPointId);
|
||||
// The secret first: without it the backend records nothing.
|
||||
await ensureParticipationSecret();
|
||||
await postJoinActivity({ meetingPointUri: mp.id, session });
|
||||
await postJoinActivity({ meetingPointUri: mp.id, session, appActorUri: appActorUri() });
|
||||
afterMembershipAct(mp.id, 'join');
|
||||
}, [logPrefix, allMeetingPoints, session, webId, ensureParticipationSecret, afterMembershipAct]);
|
||||
|
||||
@@ -1201,7 +1206,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
throw new Error(msg);
|
||||
}
|
||||
console.log(`${logPrefix} leaveMeetingPoint (refine):`, meetingPointId);
|
||||
await postLeaveActivity({ meetingPointUri: mp.id, session });
|
||||
await postLeaveActivity({ meetingPointUri: mp.id, session, appActorUri: appActorUri() });
|
||||
afterMembershipAct(mp.id, 'leave');
|
||||
}, [logPrefix, allMeetingPoints, myMeetingPointIds, session, webId, afterMembershipAct]);
|
||||
|
||||
|
||||
@@ -1028,7 +1028,7 @@ export const parsedFeatures: ParsedFeature[] = [
|
||||
}
|
||||
],
|
||||
"filePath": "src/modules/event/features/e2e-activitypods.feature",
|
||||
"rawContent": "# language: fr\n@EVENT @priority-1 @e2e-activitypods\nFonctionnalité: Smoke e2e de la pile ActivityPods locale\n En tant que développeur validant le branchement ActivityPods\n Je pilote la pile locale réelle (pod provider + backend Festipod + app servie)\n Afin de prouver que les flux du modèle passent réellement par le Pod.\n\n # PÉRIMÈTRE. Cette couche est DISTINCTE des suites `@data`/`@e2e`/`@multibrowser`\n # suspendues avec le workstream NextGraph : elle ne signe aucun wallet et ne monte\n # aucun broker. Elle n'existe que si la pile locale tourne — son mode d'emploi\n # reproductible est le concept `data-layer`,\n # `cookbook_local-activitypods-platform.md` (pod provider :3000, backend Festipod\n # :3005 par défaut). Le chargement de l'app reprend le motif `@e2e` du\n # repo (serveur réel + Playwright), pas le wallet partagé.\n\n Scénario: La pile locale est joignable et le backend sert son application\n Alors le pod provider local répond sur son endpoint OpenID\n Et le backend Festipod sert la description de son application\n\n @browser\n Scénario: L'application démarre en variante ActivityPods et propose la connexion au Pod\n Quand l'application Festipod est chargée depuis la pile locale\n Alors l'entrée de connexion au Pod est proposée\n Et aucune erreur runtime n'a été émise au chargement\n\n Scénario: Un événement et un point de rencontre rattaché par as:context sont créés sur le Pod\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Quand je crée un événement \"Concert smoke\" et un point de rencontre \"Rdv entrée\" rattaché par as:context\n Alors la lecture du point de rencontre expose son lien as:context vers l'événement\n Et l'événement et le point de rencontre partagent le conteneur as:Event\n\n # L'app s'installe sur le Pod (consentement de l'agent d'autorisation, ou pour un\n # scénario sans navigateur l'endpoint d'enregistrement — voir l'étape\n # « installée sur le Pod »). Une fois installée, l'activité `as:Join` postée sur\n # l'outbox de l'utilisateur, sans destinataire, est traitée par le backend à\n # l'émission : il inscrit dans la collection `apods:attendees` du point de\n # rencontre l'URN de membre dérivée du secret de participation du profil\n # (écrit avant le Join, comme le fait l'app) — jamais le WebID\n # (decision_2026-10-29_participation-privacy-mechanism). Le point de rencontre est créé dans le\n # conteneur enregistré (`as/event`) ; sa collection `apods:attendees` est attachée\n # par le backend lui-même, à la création — le scénario la lit, il ne la crée pas.\n Scénario: Rejoindre puis quitter un point de rencontre met à jour la collection apods:attendees\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et l'application Festipod est installée sur le Pod de la session\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors la collection apods:attendees contient la session\n Quand la session quitte le point de rencontre\n Alors la collection apods:attendees ne contient plus la session\n\n # Dépend du scénario précédent pour la mise en place, mais lit la collection\n # elle-même : le compteur est le total de la collection (jamais les profils\n # résolus) et une URN de membre, que rien ne résout, reste comptée et rendue\n # anonyme.\n Scénario: Le compteur et la liste des participants tiennent une référence non résolue\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et l'application Festipod est installée sur le Pod de la session\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors le compteur du point de rencontre compte cette inscription\n Et la liste des participants rend ce participant comme anonyme\n",
|
||||
"rawContent": "# language: fr\n@EVENT @priority-1 @e2e-activitypods\nFonctionnalité: Smoke e2e de la pile ActivityPods locale\n En tant que développeur validant le branchement ActivityPods\n Je pilote la pile locale réelle (pod provider + backend Festipod + app servie)\n Afin de prouver que les flux du modèle passent réellement par le Pod.\n\n # PÉRIMÈTRE. Cette couche est DISTINCTE des suites `@data`/`@e2e`/`@multibrowser`\n # suspendues avec le workstream NextGraph : elle ne signe aucun wallet et ne monte\n # aucun broker. Elle n'existe que si la pile locale tourne — son mode d'emploi\n # reproductible est le concept `data-layer`,\n # `cookbook_local-activitypods-platform.md` (pod provider :3000, backend Festipod\n # :3005 par défaut). Le chargement de l'app reprend le motif `@e2e` du\n # repo (serveur réel + Playwright), pas le wallet partagé.\n\n Scénario: La pile locale est joignable et le backend sert son application\n Alors le pod provider local répond sur son endpoint OpenID\n Et le backend Festipod sert la description de son application\n\n @browser\n Scénario: L'application démarre en variante ActivityPods et propose la connexion au Pod\n Quand l'application Festipod est chargée depuis la pile locale\n Alors l'entrée de connexion au Pod est proposée\n Et aucune erreur runtime n'a été émise au chargement\n\n Scénario: Un événement et un point de rencontre rattaché par as:context sont créés sur le Pod\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Quand je crée un événement \"Concert smoke\" et un point de rencontre \"Rdv entrée\" rattaché par as:context\n Alors la lecture du point de rencontre expose son lien as:context vers l'événement\n Et l'événement et le point de rencontre partagent le conteneur as:Event\n\n # L'app s'installe sur le Pod (consentement de l'agent d'autorisation, ou pour un\n # scénario sans navigateur l'endpoint d'enregistrement — voir l'étape\n # « installée sur le Pod »). Une fois installée, l'activité `as:Join` postée sur\n # l'outbox de l'utilisateur, adressée à l'acteur de l'app seul, est livrée dans\n # l'inbox de l'app et traitée par le backend à sa réception : il inscrit dans la collection `apods:attendees` du point de\n # rencontre l'URN de membre dérivée du secret de participation du profil\n # (écrit avant le Join, comme le fait l'app) — jamais le WebID\n # (decision_2026-10-29_participation-privacy-mechanism). Le point de rencontre est créé dans le\n # conteneur enregistré (`as/event`) ; sa collection `apods:attendees` est attachée\n # par le backend lui-même, à la création — le scénario la lit, il ne la crée pas.\n Scénario: Rejoindre puis quitter un point de rencontre met à jour la collection apods:attendees\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et l'application Festipod est installée sur le Pod de la session\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors la collection apods:attendees contient la session\n Quand la session quitte le point de rencontre\n Alors la collection apods:attendees ne contient plus la session\n\n # Dépend du scénario précédent pour la mise en place, mais lit la collection\n # elle-même : le compteur est le total de la collection (jamais les profils\n # résolus) et une URN de membre, que rien ne résout, reste comptée et rendue\n # anonyme.\n Scénario: Le compteur et la liste des participants tiennent une référence non résolue\n Étant donné une session Pod locale pour \"alice\" avec le mot de passe \"Test1test\"\n Et l'application Festipod est installée sur le Pod de la session\n Et un point de rencontre \"Rdv entrée\" rattaché à un événement, avec sa collection apods:attendees\n Quand la session rejoint le point de rencontre\n Alors le compteur du point de rencontre compte cette inscription\n Et la liste des participants rend ce participant comme anonyme\n",
|
||||
"screenIds": []
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,12 +5,15 @@ import type { OutboxActivityInput } from './outbox';
|
||||
const ACTOR = 'http://localhost:3000/alice';
|
||||
const OUTBOX = 'http://localhost:3000/alice/outbox';
|
||||
// What a Join names (decision_2026-10-29_participation-privacy-mechanism): the
|
||||
// meeting point itself, with no addressee.
|
||||
// meeting point itself; its only addressee is the app actor
|
||||
// (decision_2026-11-03_participation-delivered-to-the-app).
|
||||
const MEETING_POINT = 'http://localhost:3000/bob/data/events/concert-bar';
|
||||
const APP_ACTOR = 'http://localhost:3005/as/actor/app';
|
||||
|
||||
const input: OutboxActivityInput = {
|
||||
meetingPointUri: MEETING_POINT,
|
||||
session: { webId: ACTOR, token: 'tok-1' },
|
||||
appActorUri: APP_ACTOR,
|
||||
};
|
||||
|
||||
/** Installs a scripted fetch double, returning the calls it saw. */
|
||||
@@ -38,7 +41,7 @@ function jsonResponse(body: unknown, status = 200): Response {
|
||||
});
|
||||
}
|
||||
|
||||
test('postJoinActivity discovers the outbox from the actor document and posts a Join naming the meeting point, with no addressee', async () => {
|
||||
test('postJoinActivity discovers the outbox from the actor document and posts a Join naming the meeting point, addressed to the app actor alone', async () => {
|
||||
const double = installFetch((url) => {
|
||||
if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX, url: `${ACTOR}/as/profile` });
|
||||
return jsonResponse({}, 201);
|
||||
@@ -53,20 +56,21 @@ test('postJoinActivity discovers the outbox from the actor document and posts a
|
||||
const headers = postCall!.init.headers as Record<string, string>;
|
||||
expect(headers['Authorization']).toBe('Bearer tok-1');
|
||||
expect(headers['Content-Type']).toBe('application/ld+json');
|
||||
// Exactly these keys: no `to`, no `cc` — the activity stays readable by its
|
||||
// author and the app only.
|
||||
// Exactly these keys: `to` the app actor, no `cc`, not public — the activity
|
||||
// stays readable by its author and the app only.
|
||||
expect(JSON.parse(String(postCall!.init.body))).toEqual({
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Join',
|
||||
actor: ACTOR,
|
||||
object: MEETING_POINT,
|
||||
to: APP_ACTOR,
|
||||
});
|
||||
} finally {
|
||||
double.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('postLeaveActivity posts a Leave naming the meeting point, with no addressee', async () => {
|
||||
test('postLeaveActivity posts a Leave naming the meeting point, addressed to the app actor alone', async () => {
|
||||
const double = installFetch((url) => {
|
||||
if (url === ACTOR) return jsonResponse({ id: ACTOR, outbox: OUTBOX });
|
||||
return jsonResponse({}, 201);
|
||||
@@ -78,6 +82,7 @@ test('postLeaveActivity posts a Leave naming the meeting point, with no addresse
|
||||
type: 'Leave',
|
||||
actor: ACTOR,
|
||||
object: MEETING_POINT,
|
||||
to: APP_ACTOR,
|
||||
});
|
||||
} finally {
|
||||
double.restore();
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
* posted to the USER'S OWN outbox with the user's token, a withdrawal an
|
||||
* `as:Leave` on the same path (decision_2026-10-29_participation-privacy-mechanism).
|
||||
*
|
||||
* THE ACTIVITY. Its `object` is the meeting point itself, and it carries no
|
||||
* `to`/`cc`: not public, not addressed to the meeting point's creator. A
|
||||
* non-public activity is readable only by its author and the app (the app reads
|
||||
* the outbox through `apods:ReadOutbox`), so the target stays hidden from
|
||||
* everyone else. The Festipod backend handles it on emission — it records the
|
||||
* author's member URN in the meeting point's attendee list and the meeting
|
||||
* point in the author's participations collection.
|
||||
* THE ACTIVITY. Its `object` is the meeting point itself, and its only
|
||||
* addressee is the Festipod app actor (`to`): not public, not addressed to the
|
||||
* meeting point's creator. A non-public activity is readable only by its author
|
||||
* and its addressees, so the target stays hidden from everyone else. The user's
|
||||
* pod delivers it to the app's inbox, where the Festipod backend handles it —
|
||||
* it records the author's member URN in the meeting point's attendee list and
|
||||
* the meeting point in the author's participations collection
|
||||
* (decision_2026-11-03_participation-delivered-to-the-app).
|
||||
*
|
||||
* THE FLOW. The outbox URI is read off the user's own actor (WebID) document
|
||||
* (`outbox` predicate) at post time — the reference integration resolves it
|
||||
@@ -37,20 +38,23 @@ export interface OutboxActivityInput {
|
||||
meetingPointUri: string;
|
||||
/** The authenticated session — whose outbox receives the activity. */
|
||||
session: CurrentSession;
|
||||
/** URI of the Festipod app actor, the activity's only addressee (the Solid-OIDC client id). */
|
||||
appActorUri: string;
|
||||
}
|
||||
|
||||
async function postActivity(type: 'Join' | 'Leave', input: OutboxActivityInput): Promise<void> {
|
||||
const { meetingPointUri, session } = input;
|
||||
const { meetingPointUri, session, appActorUri } = input;
|
||||
const actor = await fetchActorDocument(session.webId, session.token);
|
||||
if (!actor.outbox) {
|
||||
throw new Error(`actor document for ${session.webId} carries no outbox — cannot post ${type}`);
|
||||
}
|
||||
// No addressee: the activity stays readable by its author and the app only.
|
||||
// The app actor alone: the activity stays readable by its author and the app only.
|
||||
const activity: Record<string, unknown> = {
|
||||
'@context': ACTIVITY_CONTEXT,
|
||||
type,
|
||||
actor: session.webId,
|
||||
object: meetingPointUri,
|
||||
to: appActorUri,
|
||||
};
|
||||
|
||||
const response = await fetch(actor.outbox, {
|
||||
|
||||
@@ -435,77 +435,77 @@ export const stepDefinitions: StepDefinitionInfo[] = [
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"le pod provider local répond sur son endpoint OpenID\", async function (this: FestipodWorld) {\n const { podProviderUrl } = apodsStackConfig();\n const status = await urlStatus(`${podProviderUrl}/.well-known/openid-configuration`);\n expect(status, `GET ${podProviderUrl}/.well-known/openid-configuration`).to.equal(200);\n});",
|
||||
"lineNumber": 95
|
||||
"lineNumber": 100
|
||||
},
|
||||
{
|
||||
"pattern": "le backend Festipod sert la description de son application",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"le backend Festipod sert la description de son application\", async function (this: FestipodWorld) {\n const { backendUrl } = apodsStackConfig();\n const description = await readResource(undefined, `${backendUrl}/app`);\n // The description IS the OIDC client the app registers with: it must carry\n // the interop access-need group the Pod grants, not merely answer 200.\n expect(JSON.stringify(description.type)).to.contain('Application');\n expect(linkId(description['interop:hasAccessNeedGroup']), 'access-need group').to.be.a('string');\n});",
|
||||
"lineNumber": 101
|
||||
"lineNumber": 106
|
||||
},
|
||||
{
|
||||
"pattern": "l'application Festipod est chargée depuis la pile locale",
|
||||
"keyword": "When",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "When(\"l'application Festipod est chargée depuis la pile locale\", async function (this: FestipodWorld) {\n const appUrl = requires(this, this.apods.appUrl, 'app URL (set by the @browser Before hook)');\n expect(this.page, 'a Playwright page (set by the @browser Before hook)').to.not.be.null;\n await this.page!.goto(appUrl, { waitUntil: 'domcontentloaded' });",
|
||||
"lineNumber": 112
|
||||
"lineNumber": 117
|
||||
},
|
||||
{
|
||||
"pattern": "l'entrée de connexion au Pod est proposée",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"l'entrée de connexion au Pod est proposée\", async function (this: FestipodWorld) {\n const { podProviderUrl } = apodsStackConfig();\n const host = new URL(podProviderUrl).host;\n await this.page!.waitForSelector(`text=Se connecter avec ${host}`, { timeout: 30000 });",
|
||||
"lineNumber": 120
|
||||
"lineNumber": 125
|
||||
},
|
||||
{
|
||||
"pattern": "aucune erreur runtime n'a été émise au chargement",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then(\"aucune erreur runtime n'a été émise au chargement\", function (this: FestipodWorld) {\n expect(\n this.pageErrors,\n `Des erreurs runtime ont été émises au chargement de l'app :\\n` +\n this.pageErrors.map((e, i) => ` [${i + 1}] ${e}`).join('\\n'),\n ).to.be.empty;\n});",
|
||||
"lineNumber": 126
|
||||
"lineNumber": 131
|
||||
},
|
||||
{
|
||||
"pattern": "l'application Festipod est installée sur le Pod de la session",
|
||||
"keyword": "Given",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Given(\"l'application Festipod est installée sur le Pod de la session\", async function (this: FestipodWorld) {\n const pod = session(this);\n const config = apodsStackConfig();\n // The registered agent IS the Solid-OIDC client_id: the app actor URI the\n // backend serves as both the actor and its Client ID Document. Registering\n // against `/app` (the alias) is refused — the agent must be the actor URI.\n const description = await readResource(undefined, `${config.backendUrl}/app`);\n const appUri = requires(this, recordUri(description), 'app actor URI (the id of GET /app)');\n const groupUri = requires(this, linkId(description['interop:hasAccessNeedGroup']), 'access-need group URI');\n const group = await readResource(undefined, groupUri);\n const acceptedAccessNeeds = arrayOf(group['interop:hasAccessNeed']);\n const acceptedSpecialRights = arrayOf(group['apods:hasSpecialRights']);\n\n const response = await fetch(`${config.podProviderUrl}/.auth-agent/app-registrations/register`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${pod.token}` },\n body: JSON.stringify({ appUri, acceptedAccessNeeds, acceptedSpecialRights }),\n });",
|
||||
"lineNumber": 143
|
||||
"lineNumber": 148
|
||||
},
|
||||
{
|
||||
"pattern": "la session rejoint le point de rencontre",
|
||||
"keyword": "When",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "When('la session rejoint le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n // The app's own path (src/shared/data/outbox.ts): as:Join to the user's own\n // outbox, `object` = the meeting point URI and `to` = the HOST (the record's\n // dc:creator). Without the addressee the backend silently drops the activity;\n // and an activity carrying no routing target never reaches the host.\n const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId;\n await postOutboxActivity(pod, 'Join', mpUri, host);\n});",
|
||||
"lineNumber": 267
|
||||
"sourceCode": "When('la session rejoint le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n // The app's own sequence (refineData.tsx, src/shared/data/outbox.ts —\n // decision_2026-10-29_participation-privacy-mechanism): the participation\n // secret on my profile first (the backend refuses a Join whose author's\n // profile carries none), then an as:Join naming the meeting point, posted to\n // my OWN outbox and addressed to the app actor alone — not public, not\n // addressed to the creator (decision_2026-11-03_participation-delivered-to-the-app).\n await ensureParticipationSecret(pod);\n await postOutboxActivity(pod, 'Join', mpUri, appActorUri(apodsStackConfig()));\n});",
|
||||
"lineNumber": 276
|
||||
},
|
||||
{
|
||||
"pattern": "la session quitte le point de rencontre",
|
||||
"keyword": "When",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "When('la session quitte le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId;\n await postOutboxActivity(pod, 'Leave', mpUri, host);\n});",
|
||||
"lineNumber": 278
|
||||
"sourceCode": "When('la session quitte le point de rencontre', async function (this: FestipodWorld) {\n const pod = session(this);\n const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');\n await postOutboxActivity(pod, 'Leave', mpUri, appActorUri(apodsStackConfig()));\n});",
|
||||
"lineNumber": 289
|
||||
},
|
||||
{
|
||||
"pattern": "la collection apods:attendees contient la session",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));\n expect(members.map(linkId)).to.include(pod.webId);\n});",
|
||||
"lineNumber": 285
|
||||
"sourceCode": "Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const urn = await ownMemberUrn(this);\n const members = (await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(urn))).map(linkId);\n expect(members, 'the member URN derived from my secret is listed').to.include(urn);\n expect(members, 'the list never holds a WebID').to.not.include(pod.webId);\n});",
|
||||
"lineNumber": 308
|
||||
},
|
||||
{
|
||||
"pattern": "le compteur du point de rencontre compte cette inscription",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then('le compteur du point de rencontre compte cette inscription', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));\n const collection = await readResource(pod, collectionUri);\n // The count is the collection total — kept as-is, never the number of\n // RESOLVED profiles, so it stays total for a reader who resolves nobody.\n const raw = collection.totalItems ?? collection['as:totalItems'];\n const total = raw === undefined ? members.length : Number(raw);\n expect(total, 'the count reflects the joined session').to.be.greaterThan(0);\n expect(members.map(linkId), 'the counted session is the one listed').to.include(pod.webId);\n});",
|
||||
"lineNumber": 304
|
||||
"sourceCode": "Then('le compteur du point de rencontre compte cette inscription', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const urn = await ownMemberUrn(this);\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(urn));\n const collection = await readResource(pod, collectionUri);\n // The count is the collection total — kept as-is, never the number of\n // RESOLVED profiles, so it stays total for a reader who resolves nobody.\n const raw = collection.totalItems ?? collection['as:totalItems'];\n const total = raw === undefined ? members.length : Number(raw);\n expect(total, 'the count reflects the joined session').to.be.greaterThan(0);\n expect(members.map(linkId), 'the counted session is the one listed (its member URN)').to.include(urn);\n});",
|
||||
"lineNumber": 331
|
||||
},
|
||||
{
|
||||
"pattern": "la liste des participants rend ce participant comme anonyme",
|
||||
"keyword": "Then",
|
||||
"file": "activitypods.steps.ts",
|
||||
"sourceCode": "Then('la liste des participants rend ce participant comme anonyme', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));\n const joined = members.find(member => linkId(member) === pod.webId);\n expect(joined, 'the joined session is listed').to.not.be.undefined;\n // \"Anonymous\" is the read-side fact: the member travels as a REFERENCE with no\n // profile the reader can resolve — the list has nothing but the URI to render,\n // which is exactly what the app shows for a participant it cannot dereference.\n const profileFields = ['name', 'preferredUsername', 'as:name', 'nickname'];\n const hasProfile =\n typeof joined === 'object' && joined !== null && profileFields.some(field => (joined as Record<string, unknown>)[field] !== undefined);\n expect(hasProfile, 'a listed participant carries no resolved profile (rendered anonymous)').to.be.false;\n});",
|
||||
"lineNumber": 317
|
||||
"sourceCode": "Then('la liste des participants rend ce participant comme anonyme', async function (this: FestipodWorld) {\n const pod = session(this);\n const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');\n const urn = await ownMemberUrn(this);\n const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(urn));\n const joined = members.find(member => linkId(member) === urn);\n expect(joined, 'the joined session is listed').to.not.be.undefined;\n // \"Anonymous\" is the read-side fact: the member is a member URN — a value in\n // no pod, no WebID, nothing to dereference — so a reader who cannot compute\n // it from a contact's secret has nothing but the count to render.\n expect(members.map(linkId).every(id => typeof id === 'string' && id.startsWith(MEMBER_URN_PREFIX)), 'every member is a member URN').to.be.true;\n const profileFields = ['name', 'preferredUsername', 'as:name', 'nickname'];\n const hasProfile =\n typeof joined === 'object' && joined !== null && profileFields.some(field => (joined as Record<string, unknown>)[field] !== undefined);\n expect(hasProfile, 'a listed participant carries no resolved profile (rendered anonymous)').to.be.false;\n});",
|
||||
"lineNumber": 345
|
||||
},
|
||||
{
|
||||
"pattern": "le portefeuille contient des données de test",
|
||||
|
||||
@@ -46,6 +46,11 @@ export function apodsStackConfig(): ApodsStackConfig {
|
||||
};
|
||||
}
|
||||
|
||||
/** The Festipod app actor of a stack: the only addressee of a Join or a Leave. */
|
||||
export function appActorUri(config: ApodsStackConfig): string {
|
||||
return `${config.backendUrl}/as/actor/app`;
|
||||
}
|
||||
|
||||
/** An authenticated Pod session, plus the actor references the flows need. */
|
||||
export interface ApodsSession {
|
||||
webId: string;
|
||||
@@ -203,11 +208,12 @@ export const PARTICIPATIONS = 'http://festipod.org/participations';
|
||||
/**
|
||||
* Post a participation activity (`Join` / `Leave`) to the session's own outbox,
|
||||
* the shape the app posts (`src/shared/data/outbox.ts`): `object` = the meeting
|
||||
* point, NO `to`/`cc` — not public, addressed to no one, so only its author and
|
||||
* the app read it. Returns the stored activity's URI (`Location`).
|
||||
* point, `to` = the app actor alone (`appActorUri`) — not public, not addressed to
|
||||
* the creator, so only its author and the app read it; the pod delivers it to the
|
||||
* app's inbox. Returns the stored activity's URI (`Location`).
|
||||
*/
|
||||
export async function postOutboxActivity(session: ApodsSession, type: 'Join' | 'Leave', object: string): Promise<string | undefined> {
|
||||
const activity: JsonRecord = { '@context': AS_CONTEXT, type, actor: session.webId, object };
|
||||
export async function postOutboxActivity(session: ApodsSession, type: 'Join' | 'Leave', object: string, to: string): Promise<string | undefined> {
|
||||
const activity: JsonRecord = { '@context': AS_CONTEXT, type, actor: session.webId, object, to };
|
||||
const response = await request(session.outbox, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/ld+json' },
|
||||
|
||||
Reference in New Issue
Block a user