Compare commits
12 Commits
706ee132da
...
6ee95c9e68
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ee95c9e68 | |||
| 9381c206df | |||
| 88f23b7023 | |||
| d38f8a7384 | |||
| 6bcf40306b | |||
| f14cc37f4c | |||
| fbb1f6f61b | |||
| a0d2cb8f73 | |||
| d4fcb7e311 | |||
| f32e166601 | |||
| 4fc856fa7e | |||
| e21c408dc1 |
@@ -24,12 +24,16 @@ All three are built together, **per backend instance**, inside the seam's `build
|
||||
|
||||
`login({ issuer, redirect })` and `register(...)` — Festipod supplies its own Pod-provider picker. `handleCallback()` from its own callback route. `logout()`. `check()`. `getSession()` for the `webId` and the token. `registerApp()` and `getAppStatus()`, the second one on Festipod's own schedule since the built-in interval is off.
|
||||
|
||||
Of the `getAppStatus()` result Festipod reads `upgradeNeeded` (pinned by the 2026-10-12 re-baseline) and `installed`, not pinned (the reconstruction records the pod's body, `{ upgradeNeeded }` "plus whatever the pod adds"): `true` while the app is registered, `false` once the user removed it — then Festipod logs the user out to the picker, never calling `registerApp`. See constraint 9, need 3.
|
||||
|
||||
### Resources and operations
|
||||
|
||||
Four resources, one per persisted entity: **events**, **meeting points**, **participations** and **user profiles**. Each is configured with `types` naming Festipod's own class URIs, and `jsonContext` carries the context that expands them.
|
||||
|
||||
Through Refine's hooks only, never the provider directly: `useList` → `getList`, `useCreate` → `create`, `useUpdate` → `update`, `useDelete` → `deleteOne`. `getOne` is reached by Refine's cache plumbing rather than an explicit call. The app mounts Refine with `liveMode: 'auto'` and `mutationMode: 'optimistic'`.
|
||||
|
||||
`create` of an event or a meeting point carries `to: as:Public` among its variables: Festipod's declaration that the record is public, on which the Festipod backend's public read and discovery listing depend. See constraint 10.
|
||||
|
||||
### Record handling
|
||||
|
||||
A record's `id` — the resource's URI — is the **only** identifier Festipod uses: the seam's `recordKey` and `domainId` both return it. `update` merging the fields it is given means the seam declares this backend as patching, and sends only what changed.
|
||||
@@ -47,26 +51,28 @@ What must keep holding for this backend to serve the app.
|
||||
1. **Festipod's own vocabulary, not ActivityStreams.** The four entities are Festipod domain types defined by SHEX shapes this repo holds; none is an ActivityStreams type. A caller-supplied `jsonContext` and caller-supplied `types` are what make that possible, and both must stay caller-supplied.
|
||||
2. **The identifier a record is addressed by must stay the identifier other records reference.** The seam carries the two separately because they differ on the other targeted backend; here they coincide, and the app relies on that. Splitting them later would break every cross-entity reference silently.
|
||||
3. **A partial update must stay partial.** The app sends only changed fields. A move to replacing the resource would drop every field omitted, with no error.
|
||||
4. **Every participation must be readable by every user.** The participant count is the number of participations of an event, counted with no filtering. What is confidential is the **link between a participation and a person** — only a connection can read the profile a participation points at and recognise who it is — never the participation record itself. See need 1: this is the constraint the package does not currently let the app satisfy.
|
||||
4. **Every participation must be readable by every user.** The participant count is the number of participations of an event, counted with no filtering. What is confidential is the **link between a participation and a person** — only a connection can read the profile a participation points at and recognise who it is — never the participation record itself. The provider alone cannot satisfy it (need 1).
|
||||
5. **The count is never a stored, incremented field.** Reading a counter, adding one and writing it back derives each write from a read of the previous one and races itself through any data provider. This is measured, not theoretical, and it is why the app derives the count from the participation list at read time.
|
||||
6. **Changes must be pushed, not polled.** Neither the app nor its BDD suites has a polling loop, and adding one is ruled out by project doctrine. `liveMode: 'auto'` is mounted on that basis.
|
||||
7. **No process-global state that outlives a remount.** Two backends coexist in one page and the user switches between them at runtime; switching remounts Refine to discard the cache of the backend being left. Building this backend's providers must have no side effect that accumulates across switches — see friction 1, which is where this currently bites.
|
||||
8. **Each entity carries a declared visibility** — public, protected or private, per the product's confidentiality model — and it is the backend that honours it. Refine has no notion of it, so the intent is declared at the seam and enforcement is the backend's.
|
||||
9. **`getAppStatus()` must keep telling a removed registration from a current one** (`installed`). Otherwise a user who revoked the app stays in a connected view whose writes cannot land — or a current user is logged out.
|
||||
10. **`create` must keep sending the caller's variables unchanged, `to` included.** A provider dropping or rewriting `to` would make every new event and meeting point private and undiscoverable, with no error.
|
||||
|
||||
## Frictions
|
||||
|
||||
1. **`authProvider` installs a timer and a listener it never removes.** Construction registers a `setInterval` and a `visibilitychange` listener with no teardown. Festipod builds providers once per backend selection, so every switch to this backend would accumulate another pair — directly against constraint 7. Workaround carried: pass `appStatusCheckInterval: false` and call `getAppStatus()` on the app's own schedule. That works, but it means the package's own staleness handling is unusable here, and the only way to opt out is to opt out entirely. The re-baseline of 2026-10-12 pinned the `getAppStatus` shape (`GET /.well-known/app-status` → `{ upgradeNeeded }`), so the workaround is now implementable; wiring it is tracked in the handoff.
|
||||
1. **`authProvider` installs a timer and a listener it never removes.** Construction registers a `setInterval` and a `visibilitychange` listener with no teardown. Festipod builds providers once per backend selection, so every switch to this backend would accumulate another pair — directly against constraint 7. Workaround carried: pass `appStatusCheckInterval: false` and call `getAppStatus()` on the app's own schedule — on load and on focus or visibility change, at most once a minute, with its own teardown. That works, but it means the package's own staleness handling is unusable here, and the only way to opt out is to opt out entirely.
|
||||
2. **`getList` fetches the whole container on every call.** Filtering, sorting and pagination run in memory afterwards, so the cost of listing an event's participations grows with the user's total number of participations rather than with the event. No workaround; it is why a filter on a `getList` buys nothing beyond convenience.
|
||||
3. **A list subscription reports membership only.** A container channel delivers `Add`/`Remove`, not a member's own fields changing, so a list of events does not refresh when an event's title, place or time is edited. Watching each record instead would open one channel per row. The event list is exactly the screen where this is visible.
|
||||
4. **`types` takes precedence over `shapeTreeUri`, the opposite of what the configuration type documents.** The declared behaviour is that `types` is "only used as a fallback when `shapeTreeUri` is not set"; a resource setting both is in fact resolved from `types`. Establishing which one actually wins cost a read of the resolution path. Configuring exactly one avoids it, but the documentation should say which wins.
|
||||
5. **The member shape of an `apods:attendees` collection is not pinned anywhere Festipod may read at work time.** The reconstruction documents the container discovery, not what a membership record is: a bare actor URI, or an object carrying event/participant/status. The project reconnaissance records only that a join "adds the actor to the collection" and that members come back "as full objects for a reader who is allowed, and as URIs otherwise". The ActivityPods backend's participation binding is designed against that: a member maps to `{ id, userId, isConfirmed: true }` (membership IS the attendance), with `eventId` supplied by the reader — the event the collection was read from — because a member carries no event link. The first runtime read against the local platform (T04.g) must confirm or correct this mapping.
|
||||
|
||||
6. **The live provider's `subscribe` rejects with an uncaught `Forbidden` when the Pod refuses its push channel.** On this platform the channel creation (`POST /.notifications/WebSocketChannel2023`) answers 403 for the app's session, and each refusal surfaces as one unhandled rejection in the page — raised inside the provider, where the app cannot catch it; Festipod's end-to-end checks recognise it by its throw site. Reporting the refusal through the subscription's error path would let the app handle it.
|
||||
|
||||
## Needs
|
||||
|
||||
1. **A read that is not scoped to the logged-in user's own Pod.** Every container resolution — in `getList`, in `create`, and in a list subscription — runs against the session's own WebID. So `getList('events')` returns only the events the current user created, and `getList('participations')` only their own participations.
|
||||
|
||||
This blocks the product, not a detail of it. Festipod exists to let a user **discover other people's public events** and see **how many people have signed up** to a meeting point. Through this provider the app can show neither: no other user's event is reachable, and the participant count of constraint 4 cannot be computed from participations it cannot read.
|
||||
|
||||
The request is modest in shape, because the capability already sits one layer down: the package's own TypeIndex discovery takes a WebID as a parameter, and notes that the public TypeIndex is readable without any special grant. What is missing is a way to reach it **through the data provider** — a way to scope a `getList` (and the matching list subscription) to a given WebID, or to a set of them. Festipod has no interest in reimplementing container resolution beside the provider; that would put the app back in the business the provider exists to hold.
|
||||
1. **A read that is not scoped to the logged-in user's own Pod.** Every container resolution — in `getList`, in `create`, and in a list subscription — runs against the session's own WebID, so `getList('events')` returns only the current user's own events. To let a user **discover other people's public events** and see **how many people signed up**, Festipod now carries a workaround beside the provider: other users' events are found through the Festipod backend's catalog (a cross-Pod index of public records), and their records and attendee collections are read by URI with plain anonymous HTTP. Neither gets Refine's cache or live updates. The capability sits one layer down (the package's TypeIndex discovery takes a WebID; the public TypeIndex needs no grant), so the request stands: a way to scope a `getList` (and its list subscription) to a given WebID, or a set of them, through the data provider.
|
||||
|
||||
2. **Releases that can be pinned, and a statement of what changes between them.** The package carries no git tags, so the only thing this repo can pin is a commit on a moving branch, and the only way to learn what an upgrade changed is to re-read the source. Tagged releases plus a note of what moved in each — in whatever form suits the project — would let a consumer upgrade deliberately instead of re-observing from scratch every time.
|
||||
|
||||
3. **`installed` pinned in the app status.** Festipod depends on it (constraint 9); stating it as part of the status, `false` once the user removed the app, would stop an upgrade from silently taking it away.
|
||||
|
||||
@@ -9,15 +9,15 @@ Platform checkout: `/home/sylvain/projects/activitypods/activitypods` (never mod
|
||||
|
||||
## Ports
|
||||
|
||||
pod provider 3000 (OpenID + `/auth` signup/login), Festipod backend 3003, Festipod app 4000, fuseki 3030 (admin/admin), mailcatcher 1080, redis 6390, arena 4567, tripleadmin 3033. The pod provider frontend owns :5000 — never run it while testing Festipod.
|
||||
pod provider 3000 (OpenID + `/auth` signup/login), pod provider frontend 5000, Festipod backend 3003, Festipod app 4000, fuseki 3030 (admin/admin), mailcatcher 1080, redis 6390, arena 4567, tripleadmin 3033. 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). The Festipod app stays on :4000, the only registered OIDC redirect origin.
|
||||
|
||||
## Bring up
|
||||
|
||||
1. Infra: `cd pod-provider && docker compose up -d`. If host port 6379 is already taken, add an override file publishing redis on another port (6390 was used) and pass `SEMAPPS_QUEUE_SERVICE_URL=redis://localhost:6390/1` and `SEMAPPS_REDIS_OIDC_PROVIDER_URL=redis://localhost:6390/2` when starting the backend — dotenv-flow does not override the process environment.
|
||||
2. Install once: `cd backend && COREPACK_ENABLE_AUTO_PIN=0 corepack yarn install --frozen-lockfile`, same in `frontend/`. Keep `COREPACK_ENABLE_AUTO_PIN=0`: corepack auto-pin rewrites `packageManager` in package.json. Note: the pod-provider README says `yarn start` for the frontend; only the `dev` script exists — use `BROWSER=none corepack yarn dev`.
|
||||
3. Backend: `cd backend && corepack yarn start`. Wait for "ServiceBroker with ... service(s) started successfully" (~11 s).
|
||||
4. Frontend: `cd frontend && BROWSER=none corepack yarn dev`. Verify: `GET http://localhost:5000/` → 200 and `GET http://localhost:3000/.well-known/config.js` → 200.
|
||||
5. Festipod stack (this repo root): `cp backend/.env.example backend/.env` before `docker compose up`. The compose reads `backend/.env` as an optional `env_file`; on a fresh clone the file is gitignored and absent, so this copy is what feeds the backend its ActivityPods settings. With the coherent layout the app is served on :4000 and the Festipod backend on :3003 — keep the pod provider frontend (which owns :5000) stopped while testing Festipod. To serve the app manually (`bun run start` on :4000) ALSO copy the root env: `cp .env.example .env` — without `VITE_BACKEND_URL` the config route serves an empty backend URL and the app deliberately falls back to the local demo (seeded data, no login).
|
||||
4. Frontend: `cd frontend && VITE_CONFIG_URL=http://localhost:3000/.well-known/config.js BROWSER=none corepack yarn dev`. Without `VITE_CONFIG_URL` the page's `window.CONFIG` stays undefined and the consent flow dies with an opaque "Failed to fetch". Verify: `GET http://localhost:5000/` → 200 and `GET http://localhost:3000/.well-known/config.js` → 200.
|
||||
5. Festipod stack (this repo root): `cp backend/.env.example backend/.env` before `docker compose up`. The compose reads `backend/.env` as an optional `env_file`; on a fresh clone the file is gitignored and absent, so this copy is what feeds the backend its ActivityPods settings. With the coherent layout the app is served on :4000 and the Festipod backend on :3003 (the pod provider frontend keeps :5000 and must run for any browser sign-in, see Ports). To serve the app manually (`bun run start` on :4000) ALSO copy the root env: `cp .env.example .env` — without `VITE_BACKEND_URL` the config route serves an empty backend URL and the app deliberately falls back to the local demo (seeded data, no login).
|
||||
|
||||
## Account and resource flow (all VERIFIED 2026-10-09)
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The product model of confidentiality and discovery — every entity lives in the SCOPE matching who must read it; other users' events are found through a shared index, not by reading the public scope
|
||||
summary: The product model of confidentiality and discovery — every entity lives in the SCOPE matching who must read it; other users' events are found through a shared index (the Festipod backend's catalog on ActivityPods), not by reading the public scope
|
||||
---
|
||||
|
||||
# Data scopes and discovery
|
||||
|
||||
The **product** model of who sees what, and of how events are found. This is **domain**: the technical *how* is the `@ng-eventually/polyfill` data SDK's business — the app only states **the business intent**.
|
||||
The **product** model of who sees what, and of how events are found. This is **domain**: the technical *how* belongs to the data layer behind the active backend (concept `data-layer`) — the app only states **the business intent**.
|
||||
|
||||
## Three scopes per piece of data
|
||||
|
||||
@@ -21,21 +21,24 @@ Every entity is stored in the **scope** matching who must be able to read it:
|
||||
| Private profile (settings, email, preferences) | **private** | the holder alone |
|
||||
| Connection A↔B (bilateral link, + future messaging) | **dialog** | both users |
|
||||
|
||||
**Deviation on the ActivityPods backend (user decision 2026-10-12, open):** a public meeting point's attendee list — its `apods:attendees` collection, whose members are WebIDs, which embed usernames — is **world-readable**, the pod's default, instead of protected. Moving it to the protected scope is a later task; until then the end-to-end rights matrix tracks it as a KNOWN-DEVIATION line, never as a positive assertion. When the meeting point is switched private, its attendee list stops being readable anonymously with it.
|
||||
|
||||
Guiding principle: **the "public" side (meeting point, event) and the "personal" side (profile, participations, connections) coexist within a single user.** Personal information is reserved to the **network** (bilateral connections), never visible to an ordinary user.
|
||||
|
||||
- **Meeting points / events are universally public.** Any user can read them and subscribe; creating a meeting point makes you its host, declaring an event makes you its declarer (no prerequisite).
|
||||
- **The host is the sole holder of write rights** on their meeting point; the declarer has no particular right over the meeting points grafted onto their event.
|
||||
- **Bilateral connection**: `DemandeDeConnexion` (unilateral, transient) → `Connexion` (bilateral, persistent) — the latter opens access to the other person's *protected* data.
|
||||
|
||||
Festipod **places each entity in its scope**; isolation between scopes is **handled by the data SDK**, not by application code (see concept `app-security`).
|
||||
Festipod **places each entity in its scope**; isolation between scopes is **handled by the data layer** (the SDK on NextGraph; the pod's access control and the Festipod backend's public-read grants on ActivityPods), not by screen code (see concept `app-security`).
|
||||
|
||||
## Event discovery
|
||||
|
||||
Reading the `public` scope only ever returns **this session's own** public documents — there is no call that unions every user's public store (concept `data-layer`, [[contract_polyfill-surface]]). A declared event was therefore reachable by its declarer alone, which made the whole cross-user sign-up flow — the product's premise — unreachable.
|
||||
A user's own reads only ever return **their own** records — on either backend, no call unions every user's public data (concept `data-layer`). Discovering events one did not create — the product's premise — therefore goes through a **shared index**: the **primary** discovery axis. A **secondary**, relational axis stays layered on top: the connections' *protected* participations ("my friends are attending…").
|
||||
|
||||
**A user discovers events they did not create through a shared index**: an ordinary public document, indistinguishable from any other, that the app reaches by a reference it hardcodes. Anyone may deposit a reference to their event into it; only the index's owner curates those deposits into visible entries, ordered by the event's start date. This is the **primary** discovery axis, settled as [[decision_2026-08-17_discovery-through-a-shared-index]] (concept `data-layer`) — **no code consumes the index yet.** A **secondary**, relational axis stays layered on top: the connections' *protected* participations ("my friends are attending…").
|
||||
The intent is the same on both backends — every public event is findable by everyone — but the index is not:
|
||||
|
||||
Four costs come with it, accepted rather than solved: an event becomes findable only once **someone curates** the index, on no fixed schedule — curation is an operator role, not a feature that runs itself; an event declared before its document could carry the field the index reads never becomes findable through it, permanently; a withdrawn or later-corrected event **stays listed** — nothing here removes an entry, so a reader of the index must tolerate a reference that no longer resolves, or resolves to something changed; and an event's position in the list is **frozen at the moment it was curated** — correcting its date afterwards does not move it. Full mechanics and the rejected alternative: [[decision_2026-08-17_discovery-through-a-shared-index]].
|
||||
- **ActivityPods (the default backend): the Festipod backend's catalog.** An event or meeting point is public when Festipod creates it addressed to the ActivityStreams Public collection (`to: as:Public`). Such a record is readable by anyone at its URI, and the Festipod backend lists it in its catalog at once — no curation. The listing follows the record: the owner's edits update it; a deletion, or a switch to private, removes it and makes the record and its attendee list unreadable anonymously again. Only a record's owner can change its listing. Découvrir shows the user's own events plus the catalog's; opening another user's event reads that event, its meeting points and their attendee counts anonymously, by URI. Costs, accepted: a record created while the backend was not receiving the pod's notifications is never listed (no backfill), and the catalog lists meeting points too, so its own total is not an event count. Verified end to end by `bun run test:e2e-rights` (groups catalog, discovery, visibility).
|
||||
- **NextGraph (backend suspended): a curated shared-index document** — [[decision_2026-08-17_discovery-through-a-shared-index]] (concept `data-layer`), no code consumes it yet. An ordinary public document the app reaches by a reference it hardcodes; anyone may deposit a reference to their event, only the index's owner curates deposits into visible entries, ordered by start date. Its accepted costs: an event is findable only once **someone curates** the index (an operator role, no fixed schedule); an event declared before its document could carry the indexed field never becomes findable; a withdrawn or corrected event **stays listed**, so a reader must tolerate a reference that no longer resolves or resolves to something changed; an entry's position is **frozen at curation**.
|
||||
|
||||
> **Sign-up notification (product intent).** Signing up to a meeting point notifies its host: identified if the participant is one of the host's connections, **unnamed otherwise**. This "identified if known, unnamed otherwise" falls out of scope placement — the host can read the sign-up, but not the *protected* profile it points at unless they are connected. The app states the intent; it implements no filter of its own.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ status: open
|
||||
|
||||
## Where the work lives
|
||||
|
||||
Branch **`refine-backends`**, worktree `/home/sylvain/projects/festipod/festipod/.claude/worktrees/refine-backends`, pushed to origin; the tip records the T04 tree close plus the adversarial-review round — `## Current gate state` below names the states by date and tip.
|
||||
Branch **`refine-backends`**, worktree `/home/sylvain/projects/festipod/festipod/.claude/worktrees/refine-backends`. On top of the T04 tree close and its adversarial-review round sits the T05 tree (public data, discovery, the end-to-end rights matrix), commits `e21c408` → `88f23b7` plus the commit recording its close — `## T05` and `## Current gate state` below name the states by date and tip.
|
||||
|
||||
It is based on **`ng-eventually`** at `c7cda38` — the most advanced branch, and the reason this branch exists. An earlier attempt, **`festipod-refine`** (tip `352d1f1`, worktree `.claude/worktrees/festipod-64`), was built on `main`, which turned out to be far behind; the Refine work was transferred here and that branch is now only an archive. It holds the **only** worked implementation of a `@ng-org/refine-providers` backend (`src/shared/refine/providers/nextgraph.ts`, in its history up to the commit that withdrew it) — the reference for the shape of the next backend.
|
||||
|
||||
@@ -32,12 +32,13 @@ Recorded in `data-layer/brief_2026-10-09_activitypods-first-backend`; the essent
|
||||
|
||||
In `src/shared/refine/`:
|
||||
|
||||
- `variant.ts` + `variant.test.ts` — pure `resolveVariant(pathname, known)`: which provider set the URL selects. 8 tests covering the case space, including the one true today (empty registry → every URL is the default).
|
||||
- `variant.ts` + `variant.test.ts` — pure `resolveVariant(pathname, known)`: which provider set the URL selects, tested over the case space.
|
||||
- `providers/types.ts` — the contract a backend must satisfy (`RefineBackend`, `RecordBinding`), each field derived from a measured encounter rather than anticipated.
|
||||
- `providers/index.ts` — the registry. **EMPTY on purpose**: no backend is implemented yet.
|
||||
- `providers/activitypods.ts` (+ test) — the ActivityPods backend: the three providers of `@activitypods/refine-providers` and the bindings of the four entities (events, meeting points, participations, user profiles); events and meeting points are created addressed `to: as:Public`.
|
||||
- `providers/index.ts` — the registry: ActivityPods is the one registered backend, hence the default; the NextGraph+polyfill backend would be the second when it resumes.
|
||||
- `RefineProvider.tsx` — mounts `<Refine>` over the backend the URL named.
|
||||
|
||||
**None of it is wired.** Nothing imports `RefineProvider`; `FestipodDataContext` on this base is untouched and still talks to the polyfill directly. Wiring it means rewriting that context against this base's much-evolved version — that is the step everything else waits on.
|
||||
**Wired since 2026-10-09 (T04).** `FestipodDataContext` runs over Refine with the ActivityPods backend (`src/shared/context/refineData.tsx`). Beside Refine, by design: joins and leaves posted to the user's outbox (`src/shared/data/outbox.ts`); discovery through the Festipod backend catalog and anonymous reads of other users' records and attendee collections (`src/shared/data/catalog.ts`, `publicRead.ts`, `attendees.ts`); sign-in, consent and the app-status probe (`src/app/auth/`). The Festipod backend (`backend/`, Moleculer + `@activitypods/app`) acts on users' pods as the app actor: attendee collections, public-read grants, the catalog, and the allow-listed public surface.
|
||||
|
||||
In `.project/`:
|
||||
|
||||
@@ -109,27 +110,80 @@ So both platforms count deposits rather than people, and the gap sits in **neith
|
||||
- **The migration was delegated to a subagent** and verified independently afterwards: the five transferred files are byte-identical, the manifest merge preserved both pre-existing entries, and the gates were re-run by hand. Its one worry — that a missing local `node_modules` had made the baseline incomparable — was **unfounded** (pnpm had installed the full tree locally; 22 entries against the main checkout's 21). Its debt block was better than the specification it was given: it caught the `__FESTIPOD_BACKEND__` consequence and two branch-relative phrasings.
|
||||
- **Moving base falsified three claims in the transferred code**, all corrected: the inbox the count comment points at is local here; the worked provider implementation is not in this branch's ancestry; and the `MeetingPoint` SHEX shape **does** exist here, so its binding stopped being optional (a backend that cannot carry one of the four entities cannot serve the app).
|
||||
|
||||
## Current gate state (2026-10-10, adversarial-review round closed, tip = the commit recording this state)
|
||||
## Current gate state (2026-10-13, T05 closed, tip = the commit recording this state)
|
||||
|
||||
Verified by the orchestrator's own runs after the round's fixes: `bunx tsc --noEmit` exit 0 ; `bun test` 96 pass / 0 fail ; the UI cucumber layer 125 scenarios / 0 failed (54 fully implemented + 71 `Scénario non implémenté` placeholders that carry a skipped step by design) ; smoke `@e2e-activitypods` 3/3 against the live local stack (pod provider :3000, festipod backend :3003, app served on an ephemeral port) ; `bun run build.ts` ok ; `contracts.py check --integrity-only` clean.
|
||||
The gate, every command run from the worktree root, the live ones against the local stack (pod provider :3000, pod frontend :5000, Festipod backend :3003, the app served on :4000 — the only registered OIDC redirect origin; bring-up: `data-layer`, `cookbook_local-activitypods-platform`):
|
||||
|
||||
- `bunx tsc --noEmit` — exit 0, no output
|
||||
- `bun test` — 395 pass / 0 fail (33 files)
|
||||
- the UI layer, `node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json` — 125 scenarios (71 skipped, 54 passed), 0 failed (the 71 are `Scénario non implémenté` placeholders, skipped by design); restore `reports/` afterwards with `git checkout -- reports/`
|
||||
- the smoke, same command with `--config cucumber.smoke.json` — 5 scenarios (5 passed), 22 steps
|
||||
- `bun run test:e2e-flow` — the banked login flow, fresh user (picker → pod login → consent → install verified → event → meeting point) — `"ok": true` (install asserted: the user's `as/event` container answers 200)
|
||||
- `bun run test:e2e-rights` — the rights matrix: three fresh users plus anonymous, positive AND negative lines with live controls (backend public surface, public reads, refused writes, profile privacy, catalog and public intent, discovery, join/leave and counters, isolation, deletion, visibility toggles); exit 0 only when every hard line holds — 98 hard lines, 98 PASS / 0 FAIL, plus KNOWN-DEVIATION 1, KNOWN-DEFECT 1, MANUAL-CHECK 3, INFO 7 — identical on three consecutive runs with fresh users (2026-10-13)
|
||||
- `bun run test:e2e-revocation` — revoking the app on the pod logs the user out to the picker; signing in again goes through consent — exit 0 (positive, negative and recovered phases `ok: true`)
|
||||
- `bun run validate` — runs the UI layer, the smoke, and the two e2e probes above as stack-gated layers: the stack is probed first; down → both SKIPPED, loudly, never counted as passed; up → a non-zero exit fails the layer. The smoke layer never skips (an unreachable stack fails it), so a fully-down stack still fails `validate` — all four layers passed (2026-10-13); with `APP_URL` pointed at a dead port, the UI layer and the smoke pass and the two probes are reported SKIPPED, the unreachable URL named
|
||||
|
||||
The three e2e scripts share one sign-in drive, `driveLogin` in `scripts/e2e-rights-matrix-support.ts`: a state machine over observed hops (the browser must leave the app for the pod, Accept is clicked once stable and must land, the shell counts only with the bottom navigation shown and no picker); the login-flow probe and the matrix also assert the install after it.
|
||||
|
||||
Earlier state (2026-10-10, the T04 adversarial-review round), verified by the orchestrator's own runs after the round's fixes: `bunx tsc --noEmit` exit 0 ; `bun test` 96 pass / 0 fail ; the UI cucumber layer 125 scenarios / 0 failed (54 fully implemented + 71 `Scénario non implémenté` placeholders that carry a skipped step by design) ; smoke `@e2e-activitypods` 3/3 against the live local stack (pod provider :3000, festipod backend :3003, app served on an ephemeral port) ; `bun run build.ts` ok ; `contracts.py check --integrity-only` clean.
|
||||
|
||||
The round: three adversarial passes (auth & security, data seam & backend, gates & ops); every confirmed finding was fixed in the commit recording this state. Fix themes — auth: `redirect` param validated (same-origin leading-slash only), callback re-entry guarded against StrictMode double-exchange, stale `?code=` URLs degrade to the picker instead of the fatal screen, `register_app` without a session falls back to the picker (was a permanent blank screen), ActivityPods logout wired through the provider, deep links survive login, clientId trailing slash normalized. Data fidelity: the meeting point's host is read from the Pod-stamped `dc:creator` (the `organizer` term is absent from the platform context and never round-tripped, so the Join lost its addressee), `location`/`city` are written as inline named nodes (`as:location` is `@type: @id`; a free-text string resolved into a garbage IRI), record ids are URL-encoded (absolute IRIs broke the router's `/events/:id` parse), participant resolution is keyed by actor URI (profiles were stored under their document URI and never matched), one delayed re-invalidation reconciles the count after join/leave, a Join/Leave without addressee throws instead of posting to no one, anonymous placeholder rows render on the event detail screen. Backend & ops: the attendees collection gets a public `acl:Read` grant in `after.createAndAttach` (the count is readable by everyone; anonymity lives in the profiles' own WAC), Join/Leave handlers retry while the collection is still attaching, `tsx` promoted to dependencies so the `--prod` image starts, compose `env_file` made optional with fuseki/redis services added and the frontend published on :4000 (the SEMAPPS_FRONT_URL default — fixes the shipped redirect_uri mismatch; :5000 belongs to the pod provider frontend and is deliberately avoided), `test:data` removed (zero matching scenarios since the suspension — a vacuous green), `strict: true` on both cucumber configs.
|
||||
|
||||
`contracts.py check` also reports, pre-existing: `polyfill-surface: provider moved: a33fb8a214 → a8d53010c2` — the pulled copy is behind the pinned ref.
|
||||
|
||||
## T05 — public data, discovery, rights matrix (closed 2026-10-13)
|
||||
|
||||
What was missing for Festipod's premise — discover other users' public events and sign up to their meeting points — and what closed it, by commit:
|
||||
|
||||
- **Public read (events, meeting points, attendee lists)** — `a0d2cb8`: a record is world-readable only when Festipod marks it public, i.e. creates it addressed `to: as:Public` (events and meeting points both); `bto`/`bcc`/`audience` never count. The grant on the attendee collection follows. An event POSTed with `apods:attendees` pre-attached no longer crashes the backend.
|
||||
- **Discovery in the app (catalog read path)** — `e21c408`: Découvrir lists the user's own events plus the backend catalog's, de-duplicated, own first; meeting points filtered out.
|
||||
- **Opening another user's event** — `fbb1f6f`, then `6bcf403`: detail, meeting points, per-point counts (event = sum), join; foreign reads are anonymous direct reads by URI (the session token only goes to the session's own origin); a join or leave stays on screen until a read issued after it confirms it.
|
||||
- **Catalog persistence** — `f32e166`: the index survives a backend restart (backend `settings` dataset, default graph); an owner's deletion de-indexes.
|
||||
- **Catalog hardening** — `f14cc37`: only LISTED-public records are listed (`to: as:Public`; `cc`-only = unlisted: readable, never listed); only the owner's own outbox can change an entry.
|
||||
- **Revocation detection** — `4fc856f`, `d4fcb7e`: an app-status probe on load, focus and visibility, at most once a minute; `installed: false` → logout + picker notice (never `registerApp`); `upgradeNeeded: true` → consent.
|
||||
- **Backend trust surface** — `88f23b7`: an allow-list in front of every backend route (9 public endpoints, everything else 404 for every caller — `/sparql` and `/key` included); every decision fed by a pod notification is taken on the activity and object re-read from the pod, serialized per object; public read and catalog listing follow the owner's visibility changes both ways, attendee collections included.
|
||||
- **Rights matrix** — `d38f8a7`, then extended with the public-surface and visibility groups at the close: `bun run test:e2e-rights` (see `## Current gate state`).
|
||||
|
||||
**User decisions recorded during T05:**
|
||||
|
||||
- **Attendee lists stay world-readable for now** (2026-10-12) — an explicit deviation from the domain doctrine, where participation is in the protected scope (`functional-domain`, `knowledge_data-scopes-and-discovery`): anonymous readers of a public meeting point's attendee collection see its members' WebIDs, which embed usernames. The matrix tracks it as a KNOWN-DEVIATION line (printed, non-fatal, flags loudly the day it changes). Moving participation to the protected scope is a later task.
|
||||
- **A revoked app logs the user out** (2026-10-12): picker with a notice, never sent back to consent.
|
||||
- **Defensive testing only** (2026-10-13): no automated check sends a forged or spoofed activity; such checks are MANUAL-CHECK lines (below).
|
||||
- **Trusted local network** (2026-10-13): no key rotation after the exposure below; it is recorded as a risk. Until `88f23b7` the Festipod backend served, anonymously and on all interfaces (reached from the LAN), an open `/sparql` over its `settings` dataset — the app actor's private key, the pod provider's password hashes and emails (the dataset is shared with the pod provider locally), webhook URLs, catalog snapshots — and the private key again at `/key` through the LDP catch-all. Fuseki still listens on all interfaces with `admin/admin`.
|
||||
|
||||
**MANUAL checks** — kept out of the automated matrix because they need a crafted request; each is covered by backend unit tests and owes one manual run by a human: (1) a Delete posted to a user's OWN outbox that embeds a Tombstone of ANOTHER user's event must not de-index it (`catalog.service` ownership, `emitted-activity` re-read); (2) a forged pod notification (webhook) must not publish a private event, grant or revoke a read, or list/de-list anything; (3) a crafted delivery to `POST /as/actor/app/inbox` or `/.webhooks/<id>`, and any write outside them, must change nothing (`public-surface`, `emitted-activity`).
|
||||
|
||||
**Platform defects to report upstream** (pre-existing, platform-side, never patched here):
|
||||
|
||||
- Anonymous `PATCH` on an attendee collection is accepted (204) although its only public rule is `acl:Read` — an `as:items` insert LANDS (attendee forgery; arbitrary predicates are dropped). Tracked by the matrix's KNOWN-DEFECT line.
|
||||
- `pod-permissions.remove` removes nothing for a public read: it compares the full `foaf:Agent` IRI to the compacted form the pod stores, and would PUT an empty rule set the pod refuses (Festipod revokes through its own ACL rewrite instead).
|
||||
- The collections mixin crashes the backend on a record POSTed with `apods:attendees` already set (`getGroupSlugFromCollectionUri(undefined)` → `new URL(undefined)`).
|
||||
- The webhook listener does not await its handlers: any handler throw, or a webhook arriving during backend startup, kills the backend process.
|
||||
- The delete matcher compares `formerType` `as:Event` to the compacted `Event`, so `onDelete` never fires for events (it would call `onUpdate`).
|
||||
- The refine-providers live provider's `subscribe` throws an uncaught `Forbidden` when the pod refuses its push channel (`POST /.notifications/WebSocketChannel2023` → 403), one per refusal.
|
||||
|
||||
**Known limitations** (T05.i, accepted): (a) an attendance add or remove already sent when the 30 s serialization limit hits still lands, unrecorded — if a newer act of the same participant was applied meanwhile, the collection shows the older act's effect until her next act; (b) a public-read or catalog write that lands late leaves a wrong state until its single re-decision runs, and that re-decision is not itself followed up; (c) a share the owner adds between Festipod's ACL read and its PATCH is recorded as Festipod's (the pod has no conditional write); (d) a timeout between the ledger record and the PATCH leaves a record without a grant; (e) grants made before the ledger existed are never revoked.
|
||||
|
||||
**Open follow-ups:**
|
||||
|
||||
- A person who joined two meeting points of one event is counted twice in the event's total (sum semantics).
|
||||
- Webfinger and the app actor's ActivityPub collections now answer 404 (not on the allow-list): add them only if a pod or remote server turns out to need them.
|
||||
- No backfill: pre-ledger grants (development data) are never revoked, events made private before `88f23b7` keep a public attendee collection until their next update, and a record created while the backend was not receiving notifications is never listed.
|
||||
- Also noted by the children: an owner's card ignores other users' meeting points until opened; other users' joins show only after a reload; does leaving revoke the organizer's read on the participant's profile (the join grants it); provider calls have no timeout; a hanging `provider.logout` leaves the connected view until reload.
|
||||
|
||||
## Open points
|
||||
|
||||
- Adding `## Needs` to the polyfill declaration is where the per-person discriminator belongs. It is **outward-facing** (the provider reads it on its next pull), so it awaits the user's go.
|
||||
- `acl:Append` behaviour, as above.
|
||||
- The router's prefix stripping, with the second backend.
|
||||
- The four provider contributions.
|
||||
- Wiring the seam: DONE 2026-10-09 (T04 tree, tip `f8c2e3b`) — `FestipodDataContext` runs over Refine with the ActivityPods backend as the registered default; the join→collection loop is built but `@suspendu` until the app is installed on a pod (consent screen). Current state: `## Current gate state` above. The app-install blocker is CLEARED end-to-end 2026-10-12 (tips `3aa9131` → this state): the login → Solid-OIDC → consent → registration → app loop completes on the aligned stack, verified by an automated Playwright probe (fresh user: picker → login → consent → `/home` authenticated → event created → meeting point attached). The chain of causes, in the order they surfaced: (1) the pod provider's OIDC adapter resolves an unknown client_id by fetching it and requires a Solid-OIDC Client ID Document — `/app` serves it when the request carries the `JsonLdContext` header, the actor otherwise; (2) the platform stack has to run a coherent version — the app packages are pinned at npm latest (`@activitypods/app` 2.3.2, refine-providers 0.2.0) and the pod provider runs the release tag v2.3.12 (fresh pull + full data reset + the pod frontend needing `VITE_CONFIG_URL` in dev and redis remapped to :6390 per the cookbook); (3) on a cold start the actor's `interop:hasAccessNeedGroup` link races the access-need-group creation and is never attached — an idempotent heal in `backend/services/app.service.js` `started()` patches it when missing; (4) the auth provider's `registerApp` discovers the AppRegistration via a `Link: rel="interop:registeredAgent"` header the pod only returns when the requester's token identity (azp = the configured client_id) equals the registration's `registeredAgent`, which the pod stores under the app ACTOR uri — so the Solid-OIDC client_id IS the actor uri (`${backendUrl}/as/actor/app`), and `/as/actor/app` serves the actor / the Client ID Document by the same header trick through a standalone `client-id-doc` service (routing it to `app.get` deadlocked boot: the app service's own `awaitCreateComplete` polls the actor over HTTP while the service is still starting). The pinned contract carries a dated re-baseline section recording the registerApp/getAppStatus findings (authorized one-time re-read). The verify-compat shim was removed once the aligned stack made it redundant. Known remainder: a revoked app is not detected client-side (getAppStatus wiring — now implementable, `{upgradeNeeded}` shape pinned). The join→collection scenarios are re-enabled and green (5/5 smoke), and the full authenticated UI flow is banked as a durable gate: `bun run test:e2e-flow` (scripts/e2e-login-flow.ts — picker → login → consent → authenticated → event → meeting point, fresh user, benign-4xx classification, non-zero exit on any fatal ≥400). Platform fragility observed while un-suspending: hand-crafting an `apods:attendees` collection (instead of letting the backend attach it) crashed the festipod backend with an uncaught `Invalid URL` in `@activitypods/app` `pod-collections-handler.getGroupSlugFromCollectionUri(undefined)` — the scenario now reads the attached collection rather than building it; the crash path itself is platform-side.
|
||||
- Wiring the seam: DONE 2026-10-09 (T04 tree, tip `f8c2e3b`) — `FestipodDataContext` runs over Refine with the ActivityPods backend as the registered default; the join→collection loop is built but `@suspendu` until the app is installed on a pod (consent screen). Current state: `## Current gate state` above. The app-install blocker is CLEARED end-to-end 2026-10-12 (tips `3aa9131` → this state): the login → Solid-OIDC → consent → registration → app loop completes on the aligned stack, verified by an automated Playwright probe (fresh user: picker → login → consent → `/home` authenticated → event created → meeting point attached). The chain of causes, in the order they surfaced: (1) the pod provider's OIDC adapter resolves an unknown client_id by fetching it and requires a Solid-OIDC Client ID Document — `/app` serves it when the request carries the `JsonLdContext` header, the actor otherwise; (2) the platform stack has to run a coherent version — the app packages are pinned at npm latest (`@activitypods/app` 2.3.2, refine-providers 0.2.0) and the pod provider runs the release tag v2.3.12 (fresh pull + full data reset + the pod frontend needing `VITE_CONFIG_URL` in dev and redis remapped to :6390 per the cookbook); (3) on a cold start the actor's `interop:hasAccessNeedGroup` link races the access-need-group creation and is never attached — an idempotent heal in `backend/services/app.service.js` `started()` patches it when missing; (4) the auth provider's `registerApp` discovers the AppRegistration via a `Link: rel="interop:registeredAgent"` header the pod only returns when the requester's token identity (azp = the configured client_id) equals the registration's `registeredAgent`, which the pod stores under the app ACTOR uri — so the Solid-OIDC client_id IS the actor uri (`${backendUrl}/as/actor/app`), and `/as/actor/app` serves the actor / the Client ID Document by the same header trick through a standalone `client-id-doc` service (routing it to `app.get` deadlocked boot: the app service's own `awaitCreateComplete` polls the actor over HTTP while the service is still starting). The pinned contract carries a dated re-baseline section recording the registerApp/getAppStatus findings (authorized one-time re-read). The verify-compat shim was removed once the aligned stack made it redundant. A revoked app is now detected client-side (`4fc856f`, `d4fcb7e` — see `## T05`). The join→collection scenarios are re-enabled and green (5/5 smoke), and the full authenticated UI flow is banked as a durable gate: `bun run test:e2e-flow` (scripts/e2e-login-flow.ts — picker → login → consent → authenticated → event → meeting point, fresh user, benign-4xx classification, non-zero exit on any fatal ≥400). Platform fragility observed while un-suspending: hand-crafting an `apods:attendees` collection (instead of letting the backend attach it) crashed the festipod backend with an uncaught `Invalid URL` in `@activitypods/app` `pod-collections-handler.getGroupSlugFromCollectionUri(undefined)` — the scenario now reads the attached collection rather than building it; the crash path itself is platform-side.
|
||||
- Both usage declarations are named `usage_festipod.md`, so the bare stem is ambiguous as a wikilink — refer to either by folder path.
|
||||
- Driving `getAppStatus()` is parked: the pinned contract documents the method but not the shape of the `AppStatus` result, so "stale" is not decidable without guessing. Belongs in the declaration's Frictions/Needs when the outward channel opens.
|
||||
- Driving `getAppStatus()`: DONE (`4fc856f`, `d4fcb7e`). Festipod relies on its `installed` member, which the reconstruction does not pin — recorded in the activitypods-providers usage declaration (constraint 9, need 3), together with its reliance on `create` sending `to` unchanged (constraint 10).
|
||||
- The `activitypods-providers` reconstruction is stamped at the 0.1.5-era pin while the installed package is 0.2.0 — re-constructing the contract at the installed version is a pending decision (the source is read once at adoption time; a re-read is its own decision).
|
||||
- The attendees collection is publicly readable by decision (count for everyone; member URIs are not secret; anonymity lives in the profiles' WAC). Revisit if a product rule ever hides membership.
|
||||
- The attendees collection of a public meeting point is world-readable — an explicit deviation from the protected participation scope, decided 2026-10-12 (see `## T05`); moving participation to the protected scope is a later task.
|
||||
- The dedup defi (same event + same place + same time) is confirmed absent across the seam; the 2026-10-03 brief marks it an open challenge — a product call, not a regression.
|
||||
- The catalog service's discovery index is process memory: a backend restart empties it while the Pods still hold the events. Latent until the cross-pod catalog provider lands.
|
||||
- Discovery through the backend catalog: DONE — read path `e21c408`, persistence `f32e166`, hardening `f14cc37` (see `## T05`).
|
||||
- `src/modules/home/**` screens still build raw `/events/${id}` paths; `parsePath` tolerates them, but they should adopt `eventPath`/`userPath` next time they are touched.
|
||||
- The dockerized path is validated by `docker compose config` only — the backend image has never been built or started by the gates; one manual operator run is owed.
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Whether a resource lies in an actor's OWN pod — the ownership test in front of any mutation the
|
||||
// backend applies because of an activity an actor emitted (the catalog's index/update/de-index).
|
||||
//
|
||||
// THE TRUST ANCHOR is the pod provider's namespace. An ActivityPods actor's URI is its pod root
|
||||
// (`http://localhost:3000/alice`) and every resource of that pod lives under it
|
||||
// (`http://localhost:3000/alice/data/…`); the pod lets no one create or modify a resource under
|
||||
// another actor's path (observed 2026-10-12: an outbox `Create` or `Update` carrying another pod's
|
||||
// resource is refused, 403 "Remote resource(s) cannot be created/modified"). The platform's own
|
||||
// "is this resource on the actor's pod" test is the same path-prefix rule (`isLocal` in its
|
||||
// fetch-pod-or-proxy mixin).
|
||||
//
|
||||
// NOT the actor document's `pim:storage`: it does declare the storage (`…/alice/data`), but that
|
||||
// document is writable by its owner — a user can add another user's storage to her own
|
||||
// `pim:storage` (verified 2026-10-12: PATCH → 204, the profile then lists both) — so it proves
|
||||
// nothing about who owns a resource.
|
||||
//
|
||||
// `actorUri` must come from the server side — the actor whose outbox the webhook fired on — never
|
||||
// from the activity's own `actor` field, which the emitting client writes.
|
||||
|
||||
const WEB_PROTOCOLS = new Set(['http:', 'https:']);
|
||||
|
||||
/**
|
||||
* The URL when `value` is an http(s) URL written in canonical form, else undefined. Requiring the
|
||||
* canonical form means no `..` / `%2e%2e` segment, host-case, default-port or other variant can
|
||||
* read as one path to this test and as another to whoever later resolves it.
|
||||
*/
|
||||
function canonicalUrl(value) {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
let url;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return WEB_PROTOCOLS.has(url.protocol) && url.href === value ? url : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* isInActorPod(uri, actorUri) → boolean. True only when `uri` is strictly under the actor's pod
|
||||
* root: same origin, and a path that continues the actor's path with a `/` and at least one more
|
||||
* character (so `/alice2/…` is not under `/alice`). False for anything else, including a missing or
|
||||
* non-canonical argument, the actor URI itself (with or without a trailing slash), and an actor at
|
||||
* the origin root (it would own the whole host).
|
||||
*/
|
||||
function isInActorPod(uri, actorUri) {
|
||||
const resource = canonicalUrl(uri);
|
||||
const actor = canonicalUrl(actorUri);
|
||||
if (!resource || !actor) return false;
|
||||
const root = actor.pathname.replace(/\/+$/, '');
|
||||
if (root.length === 0) return false;
|
||||
const prefix = `${root}/`;
|
||||
return resource.origin === actor.origin && resource.pathname.startsWith(prefix) && resource.pathname.length > prefix.length;
|
||||
}
|
||||
|
||||
module.exports = { isInActorPod };
|
||||
@@ -0,0 +1,56 @@
|
||||
const { test, expect } = require('bun:test');
|
||||
const { isInActorPod } = require('./actor-pod');
|
||||
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const BOB = 'http://localhost:3000/bob';
|
||||
|
||||
test("a resource of the actor's own pod is in it", () => {
|
||||
expect(isInActorPod(`${ALICE}/data/0b5e7c1e-event`, ALICE)).toBe(true);
|
||||
expect(isInActorPod(`${ALICE}/data/as/event/x`, ALICE)).toBe(true);
|
||||
expect(isInActorPod(`${ALICE}/data/x#fragment`, ALICE)).toBe(true);
|
||||
// An actor URI written with a trailing slash owns the same namespace.
|
||||
expect(isInActorPod(`${ALICE}/data/x`, `${ALICE}/`)).toBe(true);
|
||||
});
|
||||
|
||||
test("another actor's resource is not — including a name that merely starts the same", () => {
|
||||
expect(isInActorPod(`${BOB}/data/x`, ALICE)).toBe(false);
|
||||
expect(isInActorPod('http://localhost:3000/alice2/data/x', ALICE)).toBe(false);
|
||||
expect(isInActorPod('http://localhost:3000/alicedata/x', ALICE)).toBe(false);
|
||||
});
|
||||
|
||||
test('another origin is not, even with the same path', () => {
|
||||
expect(isInActorPod('http://localhost:3001/alice/data/x', ALICE)).toBe(false);
|
||||
expect(isInActorPod('https://localhost:3000/alice/data/x', ALICE)).toBe(false);
|
||||
expect(isInActorPod('http://evil.example/alice/data/x', ALICE)).toBe(false);
|
||||
});
|
||||
|
||||
test('a path that only LOOKS under the actor is refused (non-canonical forms)', () => {
|
||||
for (const uri of [
|
||||
`${ALICE}/../bob/data/x`,
|
||||
`${ALICE}/./data/x`,
|
||||
`${ALICE}/%2e%2e/bob/data/x`,
|
||||
`${ALICE}/data/../../bob/data/x`,
|
||||
'HTTP://localhost:3000/alice/data/x',
|
||||
'http://LOCALHOST:3000/alice/data/x',
|
||||
'http://localhost:3000//alice/data/x/../../../bob',
|
||||
` ${ALICE}/data/x`,
|
||||
`${ALICE}/data/x `
|
||||
]) {
|
||||
expect(isInActorPod(uri, ALICE)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('the actor URI itself, its parent, and missing or malformed input are refused', () => {
|
||||
expect(isInActorPod(ALICE, ALICE)).toBe(false);
|
||||
expect(isInActorPod(`${ALICE}/`, ALICE)).toBe(false);
|
||||
expect(isInActorPod('http://localhost:3000/', ALICE)).toBe(false);
|
||||
expect(isInActorPod(undefined, ALICE)).toBe(false);
|
||||
expect(isInActorPod(`${ALICE}/data/x`, undefined)).toBe(false);
|
||||
expect(isInActorPod({ id: `${ALICE}/data/x` }, ALICE)).toBe(false);
|
||||
expect(isInActorPod('not a url', ALICE)).toBe(false);
|
||||
expect(isInActorPod('urn:x:alice/data', 'urn:x:alice')).toBe(false);
|
||||
});
|
||||
|
||||
test('an actor at the origin root owns nothing (it would own the whole host)', () => {
|
||||
expect(isInActorPod('http://localhost:3000/bob/data/x', 'http://localhost:3000/')).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// The last participation act applied for each (meeting point, participant) — what makes attendance
|
||||
// independent of the order notifications arrive in.
|
||||
//
|
||||
// WHY. A participant's Join and Leave reach the backend as separate notifications, handled
|
||||
// concurrently; the per-meeting-point lock (`keyed-serializer.js`) serializes them, but in the order
|
||||
// their handlers reach it — after verification reads of unequal duration — not in the order the
|
||||
// participant acted. "Join then Leave" could be applied as Leave (nothing to remove) then Join, and leave
|
||||
// her listed. A Join or a Leave says what to do, not what the state should be, so the decision must
|
||||
// compare it with what was already applied.
|
||||
//
|
||||
// THE RULE (`isNewerAct`). Each act is placed by the time the participant's pod stamped on it — its
|
||||
// `published` (the pod overwrites any client value when the activity is posted to the outbox) — then by
|
||||
// its URI, to break a tie. Under the lock, an act is applied only when it is newer than the last act
|
||||
// applied for that pair; an older one (a late notification, a replay) changes nothing. Whatever the
|
||||
// arrival order, the pair ends in the state of the participant's latest act: "join then leave" ends not
|
||||
// attending, "leave then join" ends attending. All acts of one participant are stamped by her own pod,
|
||||
// so they share one clock.
|
||||
//
|
||||
// WHERE IT LIVES. Like the catalog (`catalog-store.js`): in the backend's main dataset, DEFAULT graph
|
||||
// (persisted by Fuseki), so the rule holds across a backend restart. One entry per pair, under a minted
|
||||
// `urn:festipod:attendance:` subject, with three Festipod predicates: the act's URI, its stamp, its kind.
|
||||
|
||||
const { iriRef, stringLiteral } = require('./catalog-store');
|
||||
|
||||
const ACT = 'http://festipod.org/attendanceAct';
|
||||
const PUBLISHED = 'http://festipod.org/attendancePublished';
|
||||
const CHANGE = 'http://festipod.org/attendanceChange';
|
||||
|
||||
/** The subject of the (meeting point, participant) entry: both URIs percent-encoded into one URN. */
|
||||
function attendanceSubject(meetingPointUri, memberUri) {
|
||||
return `urn:festipod:attendance:${encodeURIComponent(meetingPointUri)}:${encodeURIComponent(memberUri)}`;
|
||||
}
|
||||
|
||||
/** The last act applied for the pair: one row (act, published, change) or none. */
|
||||
function lastAttendanceQuery(meetingPointUri, memberUri) {
|
||||
const s = iriRef(attendanceSubject(meetingPointUri, memberUri));
|
||||
return `SELECT ?act ?published ?change WHERE { ${s} <${ACT}> ?act ; <${PUBLISHED}> ?published ; <${CHANGE}> ?change } LIMIT 1`;
|
||||
}
|
||||
|
||||
/** Replace the pair's entry with `{ activityUri, published, change }`. One update = one transaction. */
|
||||
function recordAttendanceUpdate(meetingPointUri, memberUri, { activityUri, published, change }) {
|
||||
const s = iriRef(attendanceSubject(meetingPointUri, memberUri));
|
||||
return `
|
||||
DELETE { ${s} ?p ?o }
|
||||
INSERT { ${s} <${ACT}> ${iriRef(activityUri)} ; <${PUBLISHED}> ${stringLiteral(published)} ; <${CHANGE}> ${stringLiteral(change)} }
|
||||
WHERE { OPTIONAL { ${s} ?p ?o . FILTER(?p IN (<${ACT}>, <${PUBLISHED}>, <${CHANGE}>)) } }
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* isNewerAct(candidate, last) → boolean. `candidate` and `last` are `{ activityUri, published }`
|
||||
* (`last` undefined when nothing was applied yet). Newer = a later `published` instant, or the same
|
||||
* instant and a greater URI. A candidate without a readable stamp is never newer (it cannot be placed).
|
||||
*/
|
||||
function isNewerAct(candidate, last) {
|
||||
const at = Date.parse(candidate && candidate.published);
|
||||
if (Number.isNaN(at)) return false;
|
||||
if (!last) return true;
|
||||
const lastAt = Date.parse(last.published);
|
||||
if (Number.isNaN(lastAt) || at > lastAt) return true;
|
||||
if (at < lastAt) return false;
|
||||
return String(candidate.activityUri) > String(last.activityUri);
|
||||
}
|
||||
|
||||
const termValue = term => (term && typeof term === 'object' ? term.value : term);
|
||||
|
||||
// ── Store operations: `ctx` is a moleculer context (anything with `call`) ──
|
||||
|
||||
/** The last act applied for the pair, `{ activityUri, published, change }`, or undefined. */
|
||||
async function lastAttendance(ctx, { dataset, meetingPointUri, memberUri }) {
|
||||
const rows = await ctx.call('triplestore.query', { query: lastAttendanceQuery(meetingPointUri, memberUri), dataset, webId: 'system' });
|
||||
const row = Array.isArray(rows) ? rows[0] : undefined;
|
||||
if (!row) return undefined;
|
||||
return { activityUri: termValue(row.act), published: termValue(row.published), change: termValue(row.change) };
|
||||
}
|
||||
|
||||
async function recordAttendance(ctx, { dataset, meetingPointUri, memberUri, act }) {
|
||||
await ctx.call('triplestore.update', { query: recordAttendanceUpdate(meetingPointUri, memberUri, act), dataset, webId: 'system' });
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
attendanceSubject,
|
||||
lastAttendanceQuery,
|
||||
recordAttendanceUpdate,
|
||||
isNewerAct,
|
||||
lastAttendance,
|
||||
recordAttendance
|
||||
};
|
||||
@@ -1,15 +1,21 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
|
||||
const { PodCollectionsHandlerMixin, PodResourcesHandlerMixin } = require('@activitypods/app');
|
||||
const CONFIG = require('../config/config');
|
||||
const { neverThrowingMatcher, verifiedEmit, uriOf } = require('./emitted-activity');
|
||||
const { guardedCreateAndAttach } = require('./collection-attach');
|
||||
|
||||
// Visibility decision: the attendees collection is readable by ANY visitor, including
|
||||
// unauthenticated ones (acl:agentClass foaf:Agent). A meeting point's participant count and
|
||||
// its list of participants must be visible to non-contacts — the app shows outsiders the
|
||||
// anonymous participants plus the total counter, and that is a product feature, not a leak.
|
||||
// Member URIs are not secret; the anonymity guarantee lives in each profile's own WAC (a
|
||||
// participant whose profile is not shared simply renders as anonymous), not here. The
|
||||
// reference welcometomyplace grants this to its announces group, but Festipod has no
|
||||
// announces concept, so the grant is public.
|
||||
// Attaches an `apods:attendees` collection (and a WAC group of the same members) to every
|
||||
// `as:Event` created in a user's pod that this backend learns of through the outbox watcher —
|
||||
// plain events as well as meeting points (observed 2026-10-12). A join adds the actor to it.
|
||||
//
|
||||
// Who may read the collection: the pod creates it world-readable (`acl:Read` for `foaf:Agent`) — a
|
||||
// platform default, observed 2026-10-12, not a choice made here. Whether attendee lists of a PUBLIC
|
||||
// event SHOULD be world-readable is an open product question (the functional-domain doctrine places
|
||||
// participation in the protected scope); this service does not settle it. What it does settle: the
|
||||
// collection it creates never stays more readable than its event. Once attached, it records that
|
||||
// collection's public read as Festipod's (`public-read.recordCreated`) and asks `public-read.reconcile`
|
||||
// to align the event AND its collection with the event's current public intent — granted while the
|
||||
// event is public, revoked while it is not, in both directions on every later update.
|
||||
module.exports = {
|
||||
name: 'attendees',
|
||||
mixins: [PodCollectionsHandlerMixin],
|
||||
@@ -22,28 +28,55 @@ module.exports = {
|
||||
},
|
||||
createWacGroup: true
|
||||
},
|
||||
actions: {
|
||||
async givePublicReadPermission(ctx) {
|
||||
const { collectionUri, actorUri } = ctx.params;
|
||||
|
||||
await ctx.call('pod-permissions.add', {
|
||||
uri: collectionUri,
|
||||
agentPredicate: 'acl:agentClass',
|
||||
agentUri: 'http://xmlns.com/foaf/0.1/Agent',
|
||||
mode: 'acl:Read',
|
||||
actorUri
|
||||
});
|
||||
dependencies: ['public-read'],
|
||||
// The mixin's create handler would act on the object embedded in the (unauthenticated) webhook
|
||||
// body, attributed to the activity's self-declared `actor`. Override it: the matcher stays the
|
||||
// mixin's (made never-throwing), but the handler runs only for a Create `verifiedEmit` accepts —
|
||||
// re-read from the pod as the app, attributed to the owner of the outbox the webhook fired on,
|
||||
// the STORED object, an `as:Event` of that owner's pod — and attaches the collection there.
|
||||
activities: {
|
||||
create: {
|
||||
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.create.match),
|
||||
onEmit: verifiedEmit(
|
||||
'Create',
|
||||
async function (ctx, { object }, actorUri) {
|
||||
await this.onCreate(ctx, object, actorUri);
|
||||
},
|
||||
{ objectType: 'Event' }
|
||||
)
|
||||
}
|
||||
},
|
||||
hooks: {
|
||||
after: {
|
||||
async createAndAttach(ctx, res) {
|
||||
const { actorUri } = ctx.params;
|
||||
const collectionUri = res;
|
||||
|
||||
await this.actions.givePublicReadPermission({ collectionUri, actorUri }, { parentCtx: ctx });
|
||||
|
||||
return res;
|
||||
actions: {
|
||||
// Nothing attached → no WAC group (see collection-attach.js).
|
||||
createAndAttach: guardedCreateAndAttach
|
||||
},
|
||||
methods: {
|
||||
// Called by `createAndAttach` right after it attached a collection it CREATED, before anything
|
||||
// that may fail: Festipod created it, so the public read the pod gave it is Festipod's to take back
|
||||
// (public-read-ledger.js). A collection it did not create (nothing attached) is never recorded,
|
||||
// so its public read is never revoked. A failure here fails the attach step (logged by onCreate).
|
||||
async onCollectionAttached(ctx, { collectionUri, actorUri }) {
|
||||
await ctx.call('public-read.recordCreated', { resourceUri: collectionUri, actorUri });
|
||||
},
|
||||
// Replaces the mixin's `onCreate`. The watcher runs the handlers of one `Create` in sequence
|
||||
// and does not catch, in an order that is not fixed (attendees ran before public-read and
|
||||
// catalog in the runs observed 2026-10-12): a throw here would skip whichever handlers come
|
||||
// after it, and — the webhook call being un-awaited — end the process. So a failure is
|
||||
// logged, never thrown.
|
||||
async onCreate(ctx, resource, actorUri) {
|
||||
const resourceUri = uriOf(resource);
|
||||
try {
|
||||
await this.actions.createAndAttach({ resourceUri, actorUri }, { parentCtx: ctx });
|
||||
} catch (e) {
|
||||
this.logger.error(`Could not set up the attendees collection of ${resourceUri}: ${e.message}`);
|
||||
}
|
||||
// Whatever happened above, the collection the event now names follows the event's CURRENT
|
||||
// visibility. `reconcile` reads the event again under its lock, so this cannot race the
|
||||
// public-read handlers of the same event's later updates.
|
||||
try {
|
||||
await ctx.call('public-read.reconcile', { resourceUri, actorUri });
|
||||
} catch (e) {
|
||||
this.logger.error(`Could not align the public read of ${resourceUri} and its attendees collection: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* The catalog index, persisted in the backend's own triplestore so it survives a
|
||||
* backend restart (it used to be process memory).
|
||||
*
|
||||
* WHERE IT LIVES. In the backend's main dataset (`settings`), in the DEFAULT graph.
|
||||
* Not in a dedicated named graph: that dataset is assembled from TDB2 graphs, and only
|
||||
* the graphs its assembler declares are TDB2-backed — a triple written to any other
|
||||
* named graph is held in memory by Fuseki and lost when Fuseki restarts (verified on
|
||||
* the local stack, 2026-10-12). The default graph is the one that persists.
|
||||
*
|
||||
* ONE ENTRY PER EVENT. The entry's subject IS the event URI, with exactly two
|
||||
* Festipod predicates: the snapshot (the resource as the catalog serves it, as JSON
|
||||
* text, so the `GET /catalog` items are returned byte-for-byte as they were indexed)
|
||||
* and the time it was first indexed (the listing order). Re-indexing replaces the
|
||||
* snapshot in ONE SPARQL update, which Fuseki runs as a single write transaction, so
|
||||
* concurrent re-adds of the same event cannot leave two snapshots; the listing also
|
||||
* groups by subject, so even a stray duplicate could never list an event twice.
|
||||
*/
|
||||
|
||||
const SNAPSHOT = 'http://festipod.org/catalogSnapshot';
|
||||
const INDEXED_AT = 'http://festipod.org/catalogIndexedAt';
|
||||
|
||||
/** An IRI as a SPARQL IRIREF, refusing the characters the grammar forbids in one. */
|
||||
function iriRef(uri) {
|
||||
if (typeof uri !== 'string' || uri.length === 0 || /[\u0000- <>"{}|^`\\]/.test(uri)) {
|
||||
throw new Error(`Not an IRI the catalog can store: ${JSON.stringify(uri)}`);
|
||||
}
|
||||
return `<${uri}>`;
|
||||
}
|
||||
|
||||
const STRING_ESCAPES = { '\\': '\\\\', '"': '\\"', '\n': '\\n', '\r': '\\r', '\t': '\\t', '\b': '\\b', '\f': '\\f' };
|
||||
|
||||
/** A string as a SPARQL double-quoted literal. */
|
||||
function stringLiteral(value) {
|
||||
return `"${String(value).replace(/[\\"\n\r\t\b\f]/g, c => STRING_ESCAPES[c])}"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Index (or re-index) one event: replace its snapshot, keep the time it was first
|
||||
* indexed (or stamp it now). One update request = one write transaction.
|
||||
*/
|
||||
function upsertEntryUpdate(uri, resource) {
|
||||
const s = iriRef(uri);
|
||||
return `
|
||||
DELETE { ${s} <${SNAPSHOT}> ?old }
|
||||
INSERT { ${s} <${SNAPSHOT}> ${stringLiteral(JSON.stringify(resource))} . ${s} <${INDEXED_AT}> ?at }
|
||||
WHERE {
|
||||
OPTIONAL { ${s} <${SNAPSHOT}> ?old }
|
||||
OPTIONAL { ${s} <${INDEXED_AT}> ?first }
|
||||
BIND(COALESCE(?first, NOW()) AS ?at)
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/** Remove one event's entry — only the catalog's own predicates, nothing else about that URI. */
|
||||
function removeEntryUpdate(uri) {
|
||||
const s = iriRef(uri);
|
||||
return `
|
||||
DELETE { ${s} ?p ?o }
|
||||
WHERE { ${s} ?p ?o . FILTER(?p IN (<${SNAPSHOT}>, <${INDEXED_AT}>)) }
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove one event's entry ONLY while its snapshot is still exactly `snapshot` — the text a caller
|
||||
* read and judged. If the event was re-indexed in between, its new snapshot does not match and
|
||||
* nothing is removed, so a sweep never deletes an entry it did not see.
|
||||
*/
|
||||
function removeEntryIfSnapshotUpdate(uri, snapshot) {
|
||||
const s = iriRef(uri);
|
||||
return `
|
||||
DELETE { ${s} ?p ?o }
|
||||
WHERE {
|
||||
${s} <${SNAPSHOT}> ${stringLiteral(snapshot)} .
|
||||
${s} ?p ?o . FILTER(?p IN (<${SNAPSHOT}>, <${INDEXED_AT}>))
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
/** Every entry, one row per event, in the order events were first indexed. */
|
||||
const LIST_ENTRIES_QUERY = `
|
||||
SELECT ?uri (SAMPLE(?snap) AS ?snapshot) (MIN(?at) AS ?indexedAt)
|
||||
WHERE {
|
||||
?uri <${SNAPSHOT}> ?snap .
|
||||
OPTIONAL { ?uri <${INDEXED_AT}> ?at }
|
||||
}
|
||||
GROUP BY ?uri
|
||||
ORDER BY ASC(?indexedAt) ASC(STR(?uri))
|
||||
`;
|
||||
|
||||
/** A SELECT binding's value: the triplestore service returns RDF/JS terms. */
|
||||
function termValue(term) {
|
||||
return term && typeof term === 'object' ? term.value : term;
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries from the listing rows: each event URI, its snapshot text as stored, and the item
|
||||
* parsed from it. A snapshot that is not valid JSON is skipped and reported (`invalid`).
|
||||
*/
|
||||
function entriesFromRows(rows) {
|
||||
const entries = [];
|
||||
const invalid = [];
|
||||
for (const row of rows || []) {
|
||||
const uri = termValue(row.uri);
|
||||
const snapshot = termValue(row.snapshot);
|
||||
try {
|
||||
entries.push({ uri, snapshot, item: JSON.parse(snapshot) });
|
||||
} catch {
|
||||
invalid.push(uri);
|
||||
}
|
||||
}
|
||||
return { entries, invalid };
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog items from the listing rows. A snapshot that is not valid JSON is
|
||||
* skipped and reported, rather than failing the whole public listing.
|
||||
*/
|
||||
function itemsFromRows(rows) {
|
||||
const { entries, invalid } = entriesFromRows(rows);
|
||||
return { items: entries.map(entry => entry.item), invalid };
|
||||
}
|
||||
|
||||
// ── Store operations: `ctx` is a moleculer context (anything with `call`) ──
|
||||
|
||||
async function upsertEntry(ctx, { dataset, uri, resource }) {
|
||||
await ctx.call('triplestore.update', { query: upsertEntryUpdate(uri, resource), dataset, webId: 'system' });
|
||||
}
|
||||
|
||||
async function removeEntry(ctx, { dataset, uri }) {
|
||||
await ctx.call('triplestore.update', { query: removeEntryUpdate(uri), dataset, webId: 'system' });
|
||||
}
|
||||
|
||||
async function listEntries(ctx, { dataset }) {
|
||||
// The default `accept` (JSON) makes the service parse SELECT results into RDF/JS terms.
|
||||
const rows = await ctx.call('triplestore.query', { query: LIST_ENTRIES_QUERY, dataset, webId: 'system' });
|
||||
return itemsFromRows(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every entry whose item fails `keep(item)`, each one only if its snapshot is still the one
|
||||
* judged (see `removeEntryIfSnapshotUpdate`). An entry whose snapshot cannot be parsed is not
|
||||
* judged, so it is left in place (the listing never serves it). Resolves the removed URIs.
|
||||
*/
|
||||
async function sweepEntries(ctx, { dataset, keep }) {
|
||||
const rows = await ctx.call('triplestore.query', { query: LIST_ENTRIES_QUERY, dataset, webId: 'system' });
|
||||
const { entries } = entriesFromRows(rows);
|
||||
const removed = [];
|
||||
for (const { uri, snapshot, item } of entries) {
|
||||
if (keep(item)) continue;
|
||||
await ctx.call('triplestore.update', { query: removeEntryIfSnapshotUpdate(uri, snapshot), dataset, webId: 'system' });
|
||||
removed.push(uri);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SNAPSHOT,
|
||||
INDEXED_AT,
|
||||
LIST_ENTRIES_QUERY,
|
||||
iriRef,
|
||||
stringLiteral,
|
||||
upsertEntryUpdate,
|
||||
removeEntryUpdate,
|
||||
removeEntryIfSnapshotUpdate,
|
||||
entriesFromRows,
|
||||
itemsFromRows,
|
||||
upsertEntry,
|
||||
removeEntry,
|
||||
listEntries,
|
||||
sweepEntries
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const {
|
||||
SNAPSHOT,
|
||||
INDEXED_AT,
|
||||
LIST_ENTRIES_QUERY,
|
||||
iriRef,
|
||||
stringLiteral,
|
||||
upsertEntryUpdate,
|
||||
removeEntryUpdate,
|
||||
removeEntryIfSnapshotUpdate,
|
||||
entriesFromRows,
|
||||
itemsFromRows,
|
||||
upsertEntry,
|
||||
removeEntry,
|
||||
listEntries,
|
||||
sweepEntries
|
||||
} = require('./catalog-store');
|
||||
|
||||
const EVENT = 'http://localhost:3000/alice/data/0b5e7c1e-event';
|
||||
|
||||
/** Decode the first SPARQL double-quoted literal of a query (the grammar's ECHAR escapes). */
|
||||
function firstLiteral(query) {
|
||||
const match = /"((?:[^"\\]|\\.)*)"/.exec(query);
|
||||
if (!match) throw new Error('no literal in query');
|
||||
const map = { t: '\t', b: '\b', n: '\n', r: '\r', f: '\f', '"': '"', "'": "'", '\\': '\\' };
|
||||
return match[1].replace(/\\(.)/g, (_, c) => map[c]);
|
||||
}
|
||||
|
||||
function fakeCtx(result) {
|
||||
const calls = [];
|
||||
return { calls, call: async (action, params) => (calls.push({ action, params }), result) };
|
||||
}
|
||||
|
||||
describe('catalog store — SPARQL it sends', () => {
|
||||
test('an IRI is wrapped as an IRIREF; anything that could break out of one is refused', () => {
|
||||
expect(iriRef(EVENT)).toBe(`<${EVENT}>`);
|
||||
for (const bad of ['', 'http://x/a b', 'http://x/a>', 'http://x/<a', 'http://x/"a', 'http://x/a\\b', 'http://x/{a}', undefined, 42]) {
|
||||
expect(() => iriRef(bad)).toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
test('a string literal escapes quotes, backslashes and line breaks', () => {
|
||||
expect(stringLiteral('a"b\\c\nd\re\tf')).toBe('"a\\"b\\\\c\\nd\\re\\tf"');
|
||||
});
|
||||
|
||||
test('the indexed snapshot decodes back to exactly the resource, whatever its text holds', () => {
|
||||
const resource = {
|
||||
'@context': ['https://www.w3.org/ns/activitystreams', 'http://localhost:3003/.well-known/context.jsonld'],
|
||||
id: EVENT,
|
||||
type: 'Event',
|
||||
name: 'Concert "plein air" } ; DROP ALL ; # \\ fin',
|
||||
content: 'ligne 1\nligne 2\ttab',
|
||||
'apods:attendees': 'http://localhost:3000/alice/data/attendees'
|
||||
};
|
||||
const query = upsertEntryUpdate(EVENT, resource);
|
||||
expect(JSON.parse(firstLiteral(query))).toEqual(resource);
|
||||
expect(firstLiteral(query)).toBe(JSON.stringify(resource));
|
||||
});
|
||||
|
||||
test('re-indexing replaces the snapshot and keeps the first-indexed time, in one update', () => {
|
||||
const query = upsertEntryUpdate(EVENT, { id: EVENT });
|
||||
expect(query).toContain(`DELETE { <${EVENT}> <${SNAPSHOT}> ?old }`);
|
||||
expect(query).toContain(`<${EVENT}> <${INDEXED_AT}> ?at`);
|
||||
expect(query).toContain('COALESCE(?first, NOW())');
|
||||
// One operation: no `;` separator outside the literal.
|
||||
expect(query.replace(/"(?:[^"\\]|\\.)*"/g, '""')).not.toContain(';');
|
||||
});
|
||||
|
||||
test('removal touches only the catalog predicates of that event', () => {
|
||||
const query = removeEntryUpdate(EVENT);
|
||||
expect(query).toContain(`DELETE { <${EVENT}> ?p ?o }`);
|
||||
expect(query).toContain(`FILTER(?p IN (<${SNAPSHOT}>, <${INDEXED_AT}>))`);
|
||||
});
|
||||
|
||||
test('the listing yields one row per event, oldest first', () => {
|
||||
expect(LIST_ENTRIES_QUERY).toContain('GROUP BY ?uri');
|
||||
expect(LIST_ENTRIES_QUERY).toContain('ORDER BY ASC(?indexedAt)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog store — rows to items', () => {
|
||||
test('snapshots are parsed back to the items, in row order', () => {
|
||||
const a = { id: 'http://p/a', type: 'Event', name: 'A' };
|
||||
const b = { id: 'http://p/b', type: 'Event', 'as:context': 'http://p/a' };
|
||||
const rows = [
|
||||
{ uri: { value: a.id }, snapshot: { value: JSON.stringify(a) } },
|
||||
{ uri: { value: b.id }, snapshot: { value: JSON.stringify(b) } }
|
||||
];
|
||||
expect(itemsFromRows(rows)).toEqual({ items: [a, b], invalid: [] });
|
||||
});
|
||||
|
||||
test('an unreadable snapshot is skipped and reported, not fatal', () => {
|
||||
const rows = [
|
||||
{ uri: { value: 'http://p/bad' }, snapshot: { value: '{not json' } },
|
||||
{ uri: { value: 'http://p/ok' }, snapshot: { value: '{"id":"http://p/ok"}' } }
|
||||
];
|
||||
expect(itemsFromRows(rows)).toEqual({ items: [{ id: 'http://p/ok' }], invalid: ['http://p/bad'] });
|
||||
});
|
||||
|
||||
test('no rows, no items', () => {
|
||||
expect(itemsFromRows([])).toEqual({ items: [], invalid: [] });
|
||||
expect(itemsFromRows(undefined)).toEqual({ items: [], invalid: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog store — triplestore calls', () => {
|
||||
test('index, remove and list go to the given dataset as the system', async () => {
|
||||
const ctx = fakeCtx([]);
|
||||
await upsertEntry(ctx, { dataset: 'settings', uri: EVENT, resource: { id: EVENT } });
|
||||
await removeEntry(ctx, { dataset: 'settings', uri: EVENT });
|
||||
await listEntries(ctx, { dataset: 'settings' });
|
||||
expect(ctx.calls.map(c => c.action)).toEqual(['triplestore.update', 'triplestore.update', 'triplestore.query']);
|
||||
for (const { params } of ctx.calls) {
|
||||
expect(params.dataset).toBe('settings');
|
||||
expect(params.webId).toBe('system');
|
||||
}
|
||||
expect(ctx.calls[0].params.query).toBe(upsertEntryUpdate(EVENT, { id: EVENT }));
|
||||
expect(ctx.calls[1].params.query).toBe(removeEntryUpdate(EVENT));
|
||||
expect(ctx.calls[2].params.query).toBe(LIST_ENTRIES_QUERY);
|
||||
});
|
||||
|
||||
test('a URI that is not a storable IRI is refused before anything is sent', async () => {
|
||||
const ctx = fakeCtx([]);
|
||||
await expect(upsertEntry(ctx, { dataset: 'settings', uri: 'http://x/a> } ; DROP ALL', resource: {} })).rejects.toThrow();
|
||||
expect(ctx.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog store — sweeping entries that fail a predicate', () => {
|
||||
const listed = { id: 'http://p/listed', type: 'Event', to: 'as:Public' };
|
||||
const unlisted = { id: 'http://p/private', type: 'Event', name: 'secret "address" \\ 12 rue X\nfloor 2' };
|
||||
const rowOf = item => ({ uri: { value: item.id }, snapshot: { value: JSON.stringify(item) } });
|
||||
const keepListed = item => item.to === 'as:Public';
|
||||
|
||||
test('the conditional removal names the exact snapshot text it judged, and only the catalog predicates', () => {
|
||||
const snapshot = JSON.stringify(unlisted);
|
||||
const query = removeEntryIfSnapshotUpdate(unlisted.id, snapshot);
|
||||
expect(firstLiteral(query)).toBe(snapshot);
|
||||
expect(query).toContain(`<${unlisted.id}> <${SNAPSHOT}> "`);
|
||||
expect(query).toContain(`DELETE { <${unlisted.id}> ?p ?o }`);
|
||||
expect(query).toContain(`FILTER(?p IN (<${SNAPSHOT}>, <${INDEXED_AT}>))`);
|
||||
expect(query.replace(/"(?:[^"\\]|\\.)*"/g, '""')).not.toContain(';');
|
||||
expect(() => removeEntryIfSnapshotUpdate('http://x/a> } ; DROP ALL', snapshot)).toThrow();
|
||||
});
|
||||
|
||||
test('rows become entries carrying the URI, the stored text and the parsed item', () => {
|
||||
const rows = [rowOf(listed), { uri: { value: 'http://p/bad' }, snapshot: { value: '{oops' } }];
|
||||
expect(entriesFromRows(rows)).toEqual({
|
||||
entries: [{ uri: listed.id, snapshot: JSON.stringify(listed), item: listed }],
|
||||
invalid: ['http://p/bad']
|
||||
});
|
||||
});
|
||||
|
||||
test('only the entries failing the predicate are removed, each conditionally on what was read', async () => {
|
||||
const rows = [rowOf(listed), rowOf(unlisted), { uri: { value: 'http://p/bad' }, snapshot: { value: '{oops' } }];
|
||||
const ctx = fakeCtx(rows);
|
||||
const removed = await sweepEntries(ctx, { dataset: 'settings', keep: keepListed });
|
||||
expect(removed).toEqual([unlisted.id]);
|
||||
expect(ctx.calls.map(c => c.action)).toEqual(['triplestore.query', 'triplestore.update']);
|
||||
expect(ctx.calls[0].params.query).toBe(LIST_ENTRIES_QUERY);
|
||||
expect(ctx.calls[1].params.query).toBe(removeEntryIfSnapshotUpdate(unlisted.id, JSON.stringify(unlisted)));
|
||||
for (const { params } of ctx.calls) {
|
||||
expect(params.dataset).toBe('settings');
|
||||
expect(params.webId).toBe('system');
|
||||
}
|
||||
});
|
||||
|
||||
test('nothing to sweep, nothing written', async () => {
|
||||
const ctx = fakeCtx([rowOf(listed)]);
|
||||
expect(await sweepEntries(ctx, { dataset: 'settings', keep: keepListed })).toEqual([]);
|
||||
expect(ctx.calls.map(c => c.action)).toEqual(['triplestore.query']);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,26 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { PodResourcesHandlerMixin } = require('@activitypods/app');
|
||||
const { matchActivity, ACTIVITY_TYPES, OBJECT_TYPES } = require('@semapps/activitypub');
|
||||
const CONFIG = require('../config/config');
|
||||
const { upsertEntry, removeEntry, listEntries, sweepEntries } = require('./catalog-store');
|
||||
const { hasListedPublicIntent } = require('./public-intent');
|
||||
const { isInActorPod } = require('./actor-pod');
|
||||
const { createKeyedSerializer } = require('./keyed-serializer');
|
||||
const { neverThrowingMatcher, verifiedEmit, uriOf, hasAsType } = require('./emitted-activity');
|
||||
|
||||
function arrayOf(value) {
|
||||
return value === undefined || value === null ? [] : Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the catalog may hold and serve: an item addressed to the ActivityStreams Public collection
|
||||
* in `to` — public AND listed (`hasListedPublicIntent`, built on the public-read gate's own
|
||||
* predicate). An event with no addressing (another app's, one posted directly on the pod), one
|
||||
* addressed to chosen recipients, or a public-but-unlisted one (Public in `cc` only) is never
|
||||
* listed. Festipod's create path writes `to: as:Public` on its events and meeting points, so both
|
||||
* are listed. Judged on the object alone, as the public-read gate judges it.
|
||||
*/
|
||||
const isListable = item => hasListedPublicIntent(item);
|
||||
|
||||
module.exports = {
|
||||
name: 'catalog',
|
||||
@@ -8,34 +28,195 @@ module.exports = {
|
||||
settings: {
|
||||
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event')
|
||||
},
|
||||
created() {
|
||||
this.serialize = createKeyedSerializer();
|
||||
},
|
||||
// Moleculer does not merge a custom schema key: declaring `activities` here replaces the mixin's
|
||||
// whole map. `create` and `update` keep the mixin's matchers (made never-throwing). Every `onEmit`
|
||||
// acts only on an activity `verifiedEmit` accepts (`emitted-activity.js`): re-read from the pod
|
||||
// with the app's credentials, attributed to the owner of the outbox the webhook fired on, its
|
||||
// object an `as:Event` of that owner's pod — and the actor passed on is that owner, never the
|
||||
// activity's own `actor`. Even then the activity only POINTS at the event: `sync` reads it again,
|
||||
// under the event's lock, and that read decides (see `sync`).
|
||||
activities: {
|
||||
create: {
|
||||
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.create.match),
|
||||
onEmit: verifiedEmit(
|
||||
'Create',
|
||||
async function (ctx, { objectUri }, actorUri) {
|
||||
await this.sync(ctx, objectUri, actorUri);
|
||||
},
|
||||
{ objectType: 'Event' }
|
||||
)
|
||||
},
|
||||
update: {
|
||||
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.update.match),
|
||||
onEmit: verifiedEmit(
|
||||
'Update',
|
||||
async function (ctx, { objectUri }, actorUri) {
|
||||
await this.sync(ctx, objectUri, actorUri);
|
||||
},
|
||||
{ objectType: 'Event' }
|
||||
)
|
||||
},
|
||||
// The mixin's delete handler never fires for a deleted event: it matches the
|
||||
// Tombstone's `formerType` against the COMPACTED type (`Event`) while the
|
||||
// Tombstone the Pod serves carries `as:Event` (verified 2026-10-12), and on a
|
||||
// match it would call `onUpdate` with the Tombstone instead of `onDelete`. Same
|
||||
// pattern here — an emitted Delete of a Tombstone whose former type is this
|
||||
// service's type — with both types compared EXPANDED, routed to `onDelete`.
|
||||
// The Tombstone may be EMBEDDED in the activity (the pod accepts a Delete of any URI
|
||||
// with an embedded Tombstone, verified 2026-10-12), so the match proves nothing:
|
||||
// `verifiedEmit` de-indexes only when the pod says the object is really gone.
|
||||
delete: {
|
||||
match: neverThrowingMatcher(async function (activity, fetcher) {
|
||||
const pattern = { type: ACTIVITY_TYPES.DELETE, object: { type: OBJECT_TYPES.TOMBSTONE } };
|
||||
const { match, dereferencedActivity } = await matchActivity(pattern, activity, fetcher);
|
||||
if (!match) return { match: false, dereferencedActivity };
|
||||
const tombstone = dereferencedActivity.object;
|
||||
const formerTypes = arrayOf(tombstone.formerType);
|
||||
if (formerTypes.length === 0) return { match: false, dereferencedActivity };
|
||||
try {
|
||||
const [former, own] = await Promise.all([
|
||||
this.broker.call('jsonld.parser.expandTypes', { types: formerTypes, context: tombstone['@context'] }),
|
||||
this.broker.call('jsonld.parser.expandTypes', { types: this.settings.type })
|
||||
]);
|
||||
return { match: former.some(type => own.includes(type)), dereferencedActivity };
|
||||
} catch (e) {
|
||||
this.logger.warn(`Catalog could not read the former type of ${uriOf(tombstone)}: ${e.message}`);
|
||||
return { match: false, dereferencedActivity };
|
||||
}
|
||||
}),
|
||||
onEmit: verifiedEmit('Delete', async function (ctx, { objectUri }, actorUri) {
|
||||
await this.sync(ctx, objectUri, actorUri);
|
||||
})
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Entries the catalog would not serve are removed from its store at startup, so it does not
|
||||
* keep copies of bodies that were never public (indexed before the listing rule existed). The
|
||||
* listing filters them anyway (`discover`); this only stops holding them. Never fatal.
|
||||
*/
|
||||
async started() {
|
||||
try {
|
||||
const removed = await sweepEntries(this.broker, { dataset: CONFIG.MAIN_DATASET, keep: isListable });
|
||||
if (removed.length > 0) {
|
||||
this.logger.info(`Catalog removed ${removed.length} entries not listed to the Public collection: ${removed.join(', ')}`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(`Catalog could not sweep entries not listed to the Public collection: ${e.message}`);
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
discover: {
|
||||
async handler() {
|
||||
async handler(ctx) {
|
||||
const { items, invalid } = await listEntries(ctx, { dataset: CONFIG.MAIN_DATASET });
|
||||
if (invalid.length > 0) this.logger.warn(`Catalog skipped unreadable entries: ${invalid.join(', ')}`);
|
||||
// Filtered on every read, whatever wrote the store: only listed public items are served.
|
||||
const listed = items.filter(isListable);
|
||||
return {
|
||||
'@context': 'https://www.w3.org/ns/activitystreams',
|
||||
type: 'Collection',
|
||||
summary: 'Public Festipod events and meeting points',
|
||||
totalItems: this.index ? Object.keys(this.index).length : 0,
|
||||
items: this.index ? Object.values(this.index) : []
|
||||
totalItems: listed.length,
|
||||
items: listed
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
// Each mutation is the same composition, in this order: (1) the object must lie in the pod of
|
||||
// the actor whose outbox emitted the activity (`isInActorPod`) — no actor indexes, overwrites or
|
||||
// removes another actor's entry; (2) only then is the listing rule applied; (3) only then is the
|
||||
// store written. A handler never throws: it runs inside the watcher (see `neverThrowingMatcher`).
|
||||
methods: {
|
||||
async onCreate(ctx, resource) {
|
||||
if (!this.index) this.index = {};
|
||||
this.index[resource.id || resource['@id']] = resource;
|
||||
this.logger.info(`Catalog indexed new event ${resource.id || resource['@id']}`);
|
||||
/**
|
||||
* Where every notification about an event ends: under that event's lock (`keyed-serializer.js`),
|
||||
* read it NOW from the pod and bring its entry in line — gone (or no longer an `as:Event`) →
|
||||
* de-indexed; there → `onUpdate` (indexed if listed public, de-indexed otherwise). Notifications
|
||||
* run concurrently and in no guaranteed order; serialized with the read inside, the last one to
|
||||
* run acts on the newest state, so an older notification never overrides a newer one. A read
|
||||
* that fails (unreachable, refused) changes nothing; a sync that times out (keyed-serializer.js)
|
||||
* writes nothing once its time is out, and one whose write landed after it is followed by ONE
|
||||
* re-sync (`followUp: false`, so it never chains). Never throws.
|
||||
*/
|
||||
async sync(ctx, uri, actorUri, { followUp = true } = {}) {
|
||||
await this.guarded(`sync ${uri}`, () =>
|
||||
this.serialize(
|
||||
uri,
|
||||
async signal => {
|
||||
if (!this.isOwnedBy(uri, actorUri, 'sync')) return undefined;
|
||||
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: uri, actorUri });
|
||||
// Timed out while reading (the lock has moved on): a newer sync decides, this one writes nothing.
|
||||
if (signal.aborted) return undefined;
|
||||
if (status === 404 || status === 410 || (status === 200 && hasAsType(body, 'Tombstone'))) {
|
||||
await this.onDelete(ctx, uri, actorUri);
|
||||
} else if (status !== 200 || !body || uriOf(body) !== uri) {
|
||||
this.logger.warn(`Catalog left ${uri} as it was: the pod does not return it (HTTP ${status})`);
|
||||
return undefined;
|
||||
} else if (!hasAsType(body, 'Event')) {
|
||||
await this.onDelete(ctx, uri, actorUri);
|
||||
} else {
|
||||
await this.onUpdate(ctx, body, actorUri);
|
||||
}
|
||||
// The store write was sent right after the check above; if the time ran out before it
|
||||
// returned, it may have landed after a newer sync's.
|
||||
return { wroteLate: signal.aborted };
|
||||
},
|
||||
followUp
|
||||
? {
|
||||
// Only a sync whose write landed late is re-judged, once: the re-sync has no follow-up.
|
||||
onLateSettle: result => {
|
||||
if (result && result.wroteLate) this.sync(ctx, uri, actorUri, { followUp: false });
|
||||
}
|
||||
}
|
||||
: {}
|
||||
)
|
||||
);
|
||||
},
|
||||
async onUpdate(ctx, resource) {
|
||||
if (!this.index) this.index = {};
|
||||
this.index[resource.id || resource['@id']] = resource;
|
||||
this.logger.info(`Catalog updated event ${resource.id || resource['@id']}`);
|
||||
async onCreate(ctx, resource, actorUri) {
|
||||
const uri = uriOf(resource);
|
||||
await this.guarded(`index ${uri}`, async () => {
|
||||
if (!this.isOwnedBy(uri, actorUri, 'index')) return;
|
||||
if (!isListable(resource)) return this.logNotListed(uri);
|
||||
await upsertEntry(ctx, { dataset: CONFIG.MAIN_DATASET, uri, resource });
|
||||
this.logger.info(`Catalog indexed new event ${uri}`);
|
||||
});
|
||||
},
|
||||
async onDelete(ctx, resourceUri) {
|
||||
if (!this.index) return;
|
||||
delete this.index[resourceUri];
|
||||
this.logger.info(`Catalog removed event ${resourceUri}`);
|
||||
async onUpdate(ctx, resource, actorUri) {
|
||||
const uri = uriOf(resource);
|
||||
await this.guarded(`update ${uri}`, async () => {
|
||||
if (!this.isOwnedBy(uri, actorUri, 'update')) return;
|
||||
if (!isListable(resource)) {
|
||||
// No longer listed (or never was): it must not stay listed from an earlier version.
|
||||
await removeEntry(ctx, { dataset: CONFIG.MAIN_DATASET, uri });
|
||||
return this.logNotListed(uri);
|
||||
}
|
||||
await upsertEntry(ctx, { dataset: CONFIG.MAIN_DATASET, uri, resource });
|
||||
this.logger.info(`Catalog updated event ${uri}`);
|
||||
});
|
||||
},
|
||||
async onDelete(ctx, resourceUri, actorUri) {
|
||||
await this.guarded(`remove ${resourceUri}`, async () => {
|
||||
if (!this.isOwnedBy(resourceUri, actorUri, 'remove')) return;
|
||||
await removeEntry(ctx, { dataset: CONFIG.MAIN_DATASET, uri: resourceUri });
|
||||
this.logger.info(`Catalog removed event ${resourceUri}`);
|
||||
});
|
||||
},
|
||||
/** The ownership test of every mutation; a refusal is logged, as it may be an attempt. */
|
||||
isOwnedBy(uri, actorUri, operation) {
|
||||
if (isInActorPod(uri, actorUri)) return true;
|
||||
this.logger.warn(`Catalog refused to ${operation} ${uri}: not in the pod of ${actorUri}, whose outbox emitted it`);
|
||||
return false;
|
||||
},
|
||||
logNotListed(uri) {
|
||||
this.logger.info(`Catalog does not list ${uri}: not addressed to the Public collection in \`to\``);
|
||||
},
|
||||
async guarded(what, run) {
|
||||
try {
|
||||
await run();
|
||||
} catch (e) {
|
||||
this.logger.error(`Catalog could not ${what}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const catalog = require('./catalog.service');
|
||||
const { removeEntryUpdate, upsertEntryUpdate, LIST_ENTRIES_QUERY, removeEntryIfSnapshotUpdate } = require('./catalog-store');
|
||||
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const BOB = 'http://localhost:3000/bob';
|
||||
const AS_EVENT = 'https://www.w3.org/ns/activitystreams#Event';
|
||||
|
||||
const event = (owner, extra = {}) => ({ id: `${owner}/data/${extra.slug || 'e1'}`, type: 'Event', name: 'Concert', ...extra });
|
||||
const listedEvent = owner => event(owner, { to: 'as:Public' });
|
||||
|
||||
/** The service as moleculer would run it: its methods bound to one object with a recording logger. */
|
||||
function service({ broker } = {}) {
|
||||
const logs = { info: [], warn: [], error: [] };
|
||||
const svc = {
|
||||
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
|
||||
settings: { type: 'as:Event' },
|
||||
broker: broker || { call: async () => [] }
|
||||
};
|
||||
if (catalog.created) catalog.created.call(svc);
|
||||
for (const [name, fn] of Object.entries(catalog.methods)) svc[name] = fn.bind(svc);
|
||||
return { svc, logs };
|
||||
}
|
||||
|
||||
/** A moleculer context recording triplestore calls; `rows` answers queries; `fail` makes every call throw. */
|
||||
function store({ rows = [], fail = false } = {}) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
call: async (action, params) => {
|
||||
calls.push({ action, params });
|
||||
if (fail) throw new Error('triplestore down');
|
||||
return action === 'triplestore.query' ? rows : undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const queries = ctx => ctx.calls.map(c => c.params.query);
|
||||
|
||||
describe('catalog mutations — ownership first, then the listing rule, then the store', () => {
|
||||
test("the owner's listed public event is indexed (the normal case)", async () => {
|
||||
const { svc, logs } = service();
|
||||
const ctx = store();
|
||||
const e = listedEvent(ALICE);
|
||||
await svc.onCreate(ctx, e, ALICE);
|
||||
expect(queries(ctx)).toEqual([upsertEntryUpdate(e.id, e)]);
|
||||
expect(logs.info).toContain(`Catalog indexed new event ${e.id}`);
|
||||
});
|
||||
|
||||
test("the owner's event without listed public intent is never indexed", async () => {
|
||||
for (const e of [
|
||||
event(ALICE), // no addressing: another app's event, or one posted directly on the pod
|
||||
event(ALICE, { to: BOB }), // addressed to a friend
|
||||
event(ALICE, { to: BOB, cc: 'as:Public' }), // public but unlisted
|
||||
event(ALICE, { bto: 'as:Public' })
|
||||
]) {
|
||||
const { svc, logs } = service();
|
||||
const ctx = store();
|
||||
await svc.onCreate(ctx, e, ALICE);
|
||||
expect(ctx.calls).toEqual([]);
|
||||
expect(logs.info.some(m => m.includes('does not list'))).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("an actor cannot index an object of another actor's pod, listed or not", async () => {
|
||||
const { svc, logs } = service();
|
||||
const ctx = store();
|
||||
await svc.onCreate(ctx, listedEvent(BOB), ALICE);
|
||||
expect(ctx.calls).toEqual([]);
|
||||
expect(logs.warn[0]).toContain(`Catalog refused to index ${BOB}/data/e1: not in the pod of ${ALICE}`);
|
||||
});
|
||||
|
||||
test("an update re-indexes the owner's listed event, and de-indexes it once it is no longer listed", async () => {
|
||||
const { svc } = service();
|
||||
const listed = listedEvent(ALICE);
|
||||
const ctx = store();
|
||||
await svc.onUpdate(ctx, listed, ALICE);
|
||||
expect(queries(ctx)).toEqual([upsertEntryUpdate(listed.id, listed)]);
|
||||
|
||||
const unlisted = event(ALICE, { to: BOB, cc: 'as:Public' });
|
||||
const ctx2 = store();
|
||||
await svc.onUpdate(ctx2, unlisted, ALICE);
|
||||
expect(queries(ctx2)).toEqual([removeEntryUpdate(unlisted.id)]);
|
||||
});
|
||||
|
||||
test("an update emitted by another actor writes nothing — neither an upsert nor a de-index", async () => {
|
||||
for (const e of [listedEvent(BOB), event(BOB)]) {
|
||||
const { svc, logs } = service();
|
||||
const ctx = store();
|
||||
await svc.onUpdate(ctx, e, ALICE);
|
||||
expect(ctx.calls).toEqual([]);
|
||||
expect(logs.warn[0]).toContain('Catalog refused to update');
|
||||
}
|
||||
});
|
||||
|
||||
test("the owner's deletion de-indexes; another actor's deletion is refused", async () => {
|
||||
const own = store();
|
||||
await service().svc.onDelete(own, `${ALICE}/data/e1`, ALICE);
|
||||
expect(queries(own)).toEqual([removeEntryUpdate(`${ALICE}/data/e1`)]);
|
||||
|
||||
const { svc, logs } = service();
|
||||
const foreign = store();
|
||||
await svc.onDelete(foreign, `${BOB}/data/e1`, ALICE);
|
||||
expect(foreign.calls).toEqual([]);
|
||||
expect(logs.warn[0]).toContain(`Catalog refused to remove ${BOB}/data/e1: not in the pod of ${ALICE}`);
|
||||
});
|
||||
|
||||
test("a URI that only looks like it is in the actor's pod is refused", async () => {
|
||||
for (const uri of [`${ALICE}/../bob/data/e1`, `${ALICE}/%2e%2e/bob/data/e1`, 'http://localhost:3000/alice2/data/e1']) {
|
||||
const ctx = store();
|
||||
await service().svc.onDelete(ctx, uri, ALICE);
|
||||
await service().svc.onCreate(ctx, { id: uri, type: 'Event', to: 'as:Public' }, ALICE);
|
||||
expect(ctx.calls).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('without a known emitting actor, nothing is written', async () => {
|
||||
const ctx = store();
|
||||
await service().svc.onCreate(ctx, listedEvent(ALICE), undefined);
|
||||
await service().svc.onUpdate(ctx, listedEvent(ALICE), undefined);
|
||||
await service().svc.onDelete(ctx, `${ALICE}/data/e1`, undefined);
|
||||
expect(ctx.calls).toEqual([]);
|
||||
});
|
||||
|
||||
test('a store failure is logged, never thrown into the webhook path', async () => {
|
||||
const { svc, logs } = service();
|
||||
const ctx = store({ fail: true });
|
||||
await expect(svc.onCreate(ctx, listedEvent(ALICE), ALICE)).resolves.toBeUndefined();
|
||||
await expect(svc.onUpdate(ctx, listedEvent(ALICE), ALICE)).resolves.toBeUndefined();
|
||||
await expect(svc.onUpdate(ctx, event(ALICE), ALICE)).resolves.toBeUndefined();
|
||||
await expect(svc.onDelete(ctx, `${ALICE}/data/e1`, ALICE)).resolves.toBeUndefined();
|
||||
expect(logs.error).toHaveLength(4);
|
||||
expect(logs.error[0]).toContain('triplestore down');
|
||||
});
|
||||
|
||||
test('a malformed resource is refused or logged, never thrown', async () => {
|
||||
const { svc } = service();
|
||||
const ctx = store();
|
||||
await expect(svc.onCreate(ctx, undefined, ALICE)).resolves.toBeUndefined();
|
||||
await expect(svc.onUpdate(ctx, { type: 'Event', to: 'as:Public' }, ALICE)).resolves.toBeUndefined();
|
||||
expect(ctx.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog activity handlers — re-read from the pod, acting for the outbox owner', () => {
|
||||
// A ctx that answers `pod-resources.get` from a map of uri → { status, body } (the pod re-read),
|
||||
// and records `triplestore.query`/`triplestore.update` (the store writes), answering queries []`.
|
||||
function emitCtx(pod = {}) {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
call: async (action, params) => {
|
||||
calls.push({ action, params });
|
||||
if (action === 'pod-resources.get') return pod[params.resourceUri] || { status: 404 };
|
||||
return action === 'triplestore.query' ? [] : undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
const writes = ctx => ctx.calls.filter(c => c.action === 'triplestore.update').map(c => c.params.query);
|
||||
// A Create/Update/Delete activity stored under Alice's outbox, naming an object in her pod.
|
||||
const act = (type, objectUri) => ({ id: `${ALICE}/outbox/${type}`, type, actor: ALICE, object: objectUri });
|
||||
|
||||
test("a forged activity on Alice's outbox naming Bob's event is refused: nothing is written", async () => {
|
||||
const { svc, logs } = service();
|
||||
const bobs = listedEvent(BOB);
|
||||
// Webhook body: a Create/Update/Delete whose EMBEDDED object is Bob's event, claimed public.
|
||||
// The watcher resolved actorUri = Alice (her outbox). No valid activity in Alice's pod names it.
|
||||
const ctx = emitCtx();
|
||||
await catalog.activities.create.onEmit.call(svc, ctx, { type: 'Create', actor: ALICE, object: bobs }, ALICE);
|
||||
await catalog.activities.update.onEmit.call(svc, ctx, { type: 'Update', actor: ALICE, object: bobs }, ALICE);
|
||||
await catalog.activities.delete.onEmit.call(svc, ctx, { type: 'Delete', actor: ALICE, object: { id: bobs.id, type: 'Tombstone' } }, ALICE);
|
||||
expect(writes(ctx)).toEqual([]);
|
||||
expect(logs.warn).toHaveLength(3); // each ignored with a reason
|
||||
});
|
||||
|
||||
test("an activity that the pod does not actually store (webhook-only) is refused", async () => {
|
||||
const { svc, logs } = service();
|
||||
const e = listedEvent(ALICE);
|
||||
const ctx = emitCtx(); // pod returns 404 for the activity
|
||||
await catalog.activities.create.onEmit.call(svc, ctx, act('Create', e.id), ALICE);
|
||||
expect(writes(ctx)).toEqual([]);
|
||||
expect(logs.warn[0]).toContain('ignored a Create');
|
||||
});
|
||||
|
||||
test("the owner's own, really-stored activities go through with the STORED object", async () => {
|
||||
const { svc } = service();
|
||||
const e = listedEvent(ALICE);
|
||||
// The webhook claims a bogus name; the pod holds the real one — the stored object must win.
|
||||
const stored = { ...e, name: 'the real stored name' };
|
||||
const create = act('Create', e.id);
|
||||
const del = act('Delete', e.id);
|
||||
const ctx = emitCtx({
|
||||
[create.id]: { status: 200, body: create },
|
||||
[del.id]: { status: 200, body: del },
|
||||
[e.id]: { status: 200, body: stored } // for the Create re-read; the Delete re-read also hits it
|
||||
});
|
||||
await catalog.activities.create.onEmit.call(svc, ctx, { ...create, object: { ...e, name: 'bogus' } }, ALICE);
|
||||
// The object is still live → the Delete is not honoured (the deletion is not true).
|
||||
await catalog.activities.delete.onEmit.call(svc, ctx, del, ALICE);
|
||||
expect(writes(ctx)).toEqual([upsertEntryUpdate(e.id, stored)]);
|
||||
|
||||
// Now the pod says the object is gone → the Delete de-indexes.
|
||||
const ctx2 = emitCtx({ [del.id]: { status: 200, body: del }, [e.id]: { status: 404 } });
|
||||
await catalog.activities.delete.onEmit.call(svc, ctx2, del, ALICE);
|
||||
expect(writes(ctx2)).toEqual([removeEntryUpdate(e.id)]);
|
||||
});
|
||||
|
||||
test('an embedded Tombstone of an event matches the delete handler (it is then judged by ownership)', async () => {
|
||||
const expand = t => (t === 'as:Event' || t === 'Event' ? AS_EVENT : t);
|
||||
const broker = { call: async (action, { types }) => [].concat(types).map(expand) };
|
||||
const { svc } = service({ broker });
|
||||
const activity = { type: 'Delete', object: { id: `${BOB}/data/e1`, type: 'Tombstone', formerType: 'as:Event' } };
|
||||
const result = await catalog.activities.delete.match.call(svc, activity, async () => false);
|
||||
expect(result.match).toBe(true);
|
||||
});
|
||||
|
||||
test('a matcher that fails reports no match instead of throwing', async () => {
|
||||
const { svc, logs } = service();
|
||||
const failingFetcher = async () => {
|
||||
throw new Error('proxy unreachable');
|
||||
};
|
||||
for (const key of ['create', 'update', 'delete']) {
|
||||
const activity = { type: key[0].toUpperCase() + key.slice(1), object: `${ALICE}/data/e1` };
|
||||
const result = await catalog.activities[key].match.call(svc, activity, failingFetcher);
|
||||
expect(result.match).toBe(false);
|
||||
}
|
||||
expect(logs.error).toHaveLength(3);
|
||||
expect(logs.error[0]).toContain('proxy unreachable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog — the decision is taken on the event read when acting, one notification at a time', () => {
|
||||
const act = (type, objectUri, n = '') => ({ id: `${ALICE}/outbox/${type}${n}`, type, actor: ALICE, object: objectUri });
|
||||
|
||||
test('CONCURRENT updates: an older "public" notification never re-lists an event made private', async () => {
|
||||
const e = listedEvent(ALICE);
|
||||
let current = e; // what the pod holds
|
||||
const entries = new Map(); // the catalog store
|
||||
const upd1 = act('Update', e.id, 1);
|
||||
const upd2 = act('Update', e.id, 2);
|
||||
const ctx = {
|
||||
call: async (action, params) => {
|
||||
if (action === 'pod-resources.get') {
|
||||
if (params.resourceUri === upd1.id) return { status: 200, body: upd1 };
|
||||
if (params.resourceUri === upd2.id) return { status: 200, body: upd2 };
|
||||
if (params.resourceUri === e.id) return { status: 200, body: current };
|
||||
return { status: 404 };
|
||||
}
|
||||
if (action === 'triplestore.update') {
|
||||
if (params.query === upsertEntryUpdate(e.id, e)) {
|
||||
await new Promise(resolve => setTimeout(resolve, 30)); // a slow store write
|
||||
entries.set(e.id, e);
|
||||
} else if (params.query === removeEntryUpdate(e.id)) {
|
||||
entries.delete(e.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const { svc } = service();
|
||||
const first = catalog.activities.update.onEmit.call(svc, ctx, upd1, ALICE); // notified while listed
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
current = event(ALICE, { name: 'now private' }); // the owner unlists it; a second notification follows
|
||||
const second = catalog.activities.update.onEmit.call(svc, ctx, upd2, ALICE);
|
||||
await Promise.all([first, second]);
|
||||
expect(entries.has(e.id)).toBe(false);
|
||||
});
|
||||
|
||||
test('a store write that lands after its timeout is re-judged: the newer de-listing wins', async () => {
|
||||
const { createKeyedSerializer } = require('./keyed-serializer');
|
||||
const e = listedEvent(ALICE);
|
||||
let current = e;
|
||||
const entries = new Map();
|
||||
let upserts = 0;
|
||||
const upd1 = act('Update', e.id, 1);
|
||||
const upd2 = act('Update', e.id, 2);
|
||||
const ctx = {
|
||||
call: async (action, params) => {
|
||||
if (action === 'pod-resources.get') {
|
||||
if (params.resourceUri === upd1.id) return { status: 200, body: upd1 };
|
||||
if (params.resourceUri === upd2.id) return { status: 200, body: upd2 };
|
||||
if (params.resourceUri === e.id) return { status: 200, body: current };
|
||||
return { status: 404 };
|
||||
}
|
||||
if (action === 'triplestore.update') {
|
||||
if (params.query === upsertEntryUpdate(e.id, e)) {
|
||||
if (upserts++ === 0) await new Promise(resolve => setTimeout(resolve, 120)); // sent in time, lands late
|
||||
entries.set(e.id, e);
|
||||
} else if (params.query === removeEntryUpdate(e.id)) {
|
||||
entries.delete(e.id);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const { svc } = service();
|
||||
svc.serialize = createKeyedSerializer({ timeoutMs: 50 });
|
||||
const first = catalog.activities.update.onEmit.call(svc, ctx, upd1, ALICE);
|
||||
await new Promise(resolve => setTimeout(resolve, 60));
|
||||
current = event(ALICE, { name: 'now private' });
|
||||
const second = catalog.activities.update.onEmit.call(svc, ctx, upd2, ALICE);
|
||||
await Promise.all([first, second]);
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
expect(entries.has(e.id)).toBe(false);
|
||||
});
|
||||
|
||||
test('a slow pod and NO late write: a timed-out sync schedules nothing (no self-re-queuing loop)', async () => {
|
||||
const { createKeyedSerializer } = require('./keyed-serializer');
|
||||
const e = listedEvent(ALICE);
|
||||
const upd = act('Update', e.id, 'loop');
|
||||
let eventReads = 0;
|
||||
let writes = 0;
|
||||
const ctx = {
|
||||
call: async (action, params) => {
|
||||
if (action === 'pod-resources.get') {
|
||||
if (params.resourceUri === upd.id) return { status: 200, body: upd };
|
||||
if (params.resourceUri === e.id) {
|
||||
eventReads++;
|
||||
await new Promise(resolve => setTimeout(resolve, 120)); // every read slower than the limit
|
||||
return { status: 200, body: e };
|
||||
}
|
||||
return { status: 404 };
|
||||
}
|
||||
if (action === 'triplestore.update') writes++;
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const { svc, logs } = service();
|
||||
svc.serialize = createKeyedSerializer({ timeoutMs: 50 });
|
||||
await catalog.activities.update.onEmit.call(svc, ctx, upd, ALICE);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
expect(eventReads).toBe(2); // the verification read and the one sync read — nothing re-queued
|
||||
expect(writes).toBe(0);
|
||||
expect(logs.error).toHaveLength(1); // the one timeout
|
||||
});
|
||||
|
||||
test('a public Note stored at the URI is never indexed (the stored type is re-checked)', async () => {
|
||||
const { svc, logs } = service();
|
||||
const note = { id: `${ALICE}/data/n1`, type: 'Note', to: 'as:Public', content: 'not an event' };
|
||||
const create = act('Create', note.id);
|
||||
const calls = [];
|
||||
const ctx = {
|
||||
call: async (action, params) => {
|
||||
calls.push(action);
|
||||
if (action === 'pod-resources.get') {
|
||||
if (params.resourceUri === create.id) return { status: 200, body: create };
|
||||
if (params.resourceUri === note.id) return { status: 200, body: note };
|
||||
return { status: 404 };
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
await catalog.activities.create.onEmit.call(svc, ctx, create, ALICE);
|
||||
expect(calls.filter(a => a.startsWith('triplestore'))).toEqual([]);
|
||||
expect(logs.warn[0]).toContain('is not a Event');
|
||||
});
|
||||
|
||||
test('an unreadable event (pod error) is left as it was: nothing written', async () => {
|
||||
const e = listedEvent(ALICE);
|
||||
const update = act('Update', e.id);
|
||||
let objectReads = 0;
|
||||
const calls = [];
|
||||
const ctx = {
|
||||
call: async (action, params) => {
|
||||
calls.push(action);
|
||||
if (action !== 'pod-resources.get') return [];
|
||||
if (params.resourceUri === update.id) return { status: 200, body: update };
|
||||
objectReads += 1;
|
||||
// The verification read sees the event; the read taken when acting fails.
|
||||
return objectReads === 1 ? { status: 200, body: e } : { status: 502 };
|
||||
}
|
||||
};
|
||||
const { svc, logs } = service();
|
||||
await catalog.activities.update.onEmit.call(svc, ctx, update, ALICE);
|
||||
expect(calls.filter(a => a.startsWith('triplestore'))).toEqual([]);
|
||||
expect(logs.warn[0]).toContain('HTTP 502');
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog listing — only listed public items are served, shape unchanged', () => {
|
||||
const rowOf = item => ({ uri: { value: item.id }, snapshot: { value: JSON.stringify(item) } });
|
||||
|
||||
test('entries already stored that are not listed public are filtered out at read', async () => {
|
||||
const listed = listedEvent(ALICE);
|
||||
const meetingPoint = { ...listedEvent(ALICE), id: `${ALICE}/data/mp`, 'as:context': listed.id };
|
||||
const stored = [
|
||||
rowOf(event(BOB, { slug: 'private', content: 'secret address 12 rue X' })),
|
||||
rowOf(listed),
|
||||
rowOf(event(BOB, { slug: 'friend', to: ALICE, content: 'only for my friend' })),
|
||||
rowOf(event(BOB, { slug: 'unlisted', cc: 'as:Public' })),
|
||||
rowOf(meetingPoint)
|
||||
];
|
||||
const { svc } = service();
|
||||
const result = await catalog.actions.discover.handler.call(svc, store({ rows: stored }));
|
||||
expect(Object.keys(result).sort()).toEqual(['@context', 'items', 'summary', 'totalItems', 'type']);
|
||||
expect(result['@context']).toBe('https://www.w3.org/ns/activitystreams');
|
||||
expect(result.type).toBe('Collection');
|
||||
expect(result.items).toEqual([listed, meetingPoint]);
|
||||
expect(result.totalItems).toBe(2);
|
||||
expect(JSON.stringify(result)).not.toContain('secret address');
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog startup — entries it would not serve are removed from its store', () => {
|
||||
const rowOf = item => ({ uri: { value: item.id }, snapshot: { value: JSON.stringify(item) } });
|
||||
|
||||
test('the sweep removes exactly the unlisted entries, conditionally on what it read', async () => {
|
||||
const listed = listedEvent(ALICE);
|
||||
const secret = event(BOB, { slug: 'private', content: 'secret address' });
|
||||
const unlisted = event(BOB, { slug: 'unlisted', cc: 'as:Public' });
|
||||
const broker = store({ rows: [rowOf(listed), rowOf(secret), rowOf(unlisted)] });
|
||||
const { svc, logs } = service({ broker });
|
||||
await catalog.started.call(svc);
|
||||
expect(queries(broker)).toEqual([
|
||||
LIST_ENTRIES_QUERY,
|
||||
removeEntryIfSnapshotUpdate(secret.id, JSON.stringify(secret)),
|
||||
removeEntryIfSnapshotUpdate(unlisted.id, JSON.stringify(unlisted))
|
||||
]);
|
||||
expect(logs.info[0]).toContain('Catalog removed 2 entries not listed to the Public collection');
|
||||
});
|
||||
|
||||
test('a failing sweep is logged and never prevents the start', async () => {
|
||||
const { svc, logs } = service({ broker: store({ fail: true }) });
|
||||
await expect(catalog.started.call(svc)).resolves.toBeUndefined();
|
||||
expect(logs.error[0]).toContain('Catalog could not sweep');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// The `createAndAttach` action every Festipod collection handler uses (attendees, contacts), in place
|
||||
// of the one `PodCollectionsHandlerMixin` (@activitypods/app 2.3.2) gives them.
|
||||
//
|
||||
// The mixin's version calls `pod-collections.createAndAttach`, then derives a WAC group slug from the
|
||||
// collection URI it returns. That call returns undefined when it attaches nothing — the resource
|
||||
// already carries a value for the predicate (written by its creator or by another app), or the pod
|
||||
// refused to create the collection — and the mixin then runs `new URL(undefined)`, which throws. The
|
||||
// webhook call that runs the watcher is not awaited by the platform, so that throw ended the whole
|
||||
// backend process (observed 2026-10-12, an event POSTed with `apods:attendees` already set). Here,
|
||||
// nothing attached means no group to create.
|
||||
|
||||
/** The action definition; runs bound to a service with the mixin's settings and `getGroupSlugFromCollectionUri`. */
|
||||
const guardedCreateAndAttach = {
|
||||
async handler(ctx) {
|
||||
const { resourceUri, actorUri } = ctx.params;
|
||||
const { attachPredicate, collectionOptions, createWacGroup } = this.settings;
|
||||
|
||||
const collectionUri = await ctx.call('pod-collections.createAndAttach', {
|
||||
resourceUri,
|
||||
attachPredicate,
|
||||
collectionOptions,
|
||||
actorUri
|
||||
});
|
||||
if (!collectionUri) {
|
||||
this.logger.info(`No ${attachPredicate} collection attached to ${resourceUri} (already carries one, or creation refused)`);
|
||||
return undefined;
|
||||
}
|
||||
// The service's own follow-up for a collection it has just created (attendees: record it as
|
||||
// Festipod's, see public-read-ledger.js) — right after the attach, so a failure of what follows
|
||||
// (the WAC group) cannot skip it.
|
||||
if (typeof this.onCollectionAttached === 'function') {
|
||||
await this.onCollectionAttached(ctx, { resourceUri, collectionUri, actorUri });
|
||||
}
|
||||
|
||||
if (createWacGroup) {
|
||||
await ctx.call('pod-wac-groups.create', {
|
||||
groupSlug: this.getGroupSlugFromCollectionUri(collectionUri),
|
||||
actorUri
|
||||
});
|
||||
}
|
||||
return collectionUri;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { guardedCreateAndAttach };
|
||||
@@ -0,0 +1,127 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
|
||||
const attendees = require('./attendees.service');
|
||||
const contacts = require('./contacts.service');
|
||||
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const MALLORY = 'http://localhost:3000/mallory';
|
||||
const E = `${ALICE}/data/events/e1`;
|
||||
const C = `${ALICE}/data/e1-attendees`;
|
||||
const PROFILE = `${ALICE}/data/profiles/me`;
|
||||
const CREATE = `${ALICE}/data/activities/create-1`;
|
||||
|
||||
/** A schema bound like moleculer binds it: mixin methods first, the service's own over them. */
|
||||
function bind(schema, pod, { attached, groupFails = false } = {}) {
|
||||
const logs = { info: [], warn: [], error: [] };
|
||||
const calls = [];
|
||||
const ctx = {
|
||||
call: async (action, params) => {
|
||||
calls.push({ action, params });
|
||||
if (action === 'pod-resources.get') {
|
||||
const body = pod[params.resourceUri];
|
||||
return body ? { ok: true, status: 200, body } : { ok: false, status: 404 };
|
||||
}
|
||||
if (action === 'pod-collections.createAndAttach') return attached;
|
||||
if (action === 'pod-wac-groups.create' && groupFails) throw new Error('group creation failed');
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const svc = {
|
||||
name: schema.name,
|
||||
settings: { ...PodCollectionsHandlerMixin.settings, ...schema.settings },
|
||||
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
|
||||
actions: {}
|
||||
};
|
||||
for (const [name, fn] of Object.entries({ ...PodCollectionsHandlerMixin.methods, ...(schema.methods || {}) })) svc[name] = fn.bind(svc);
|
||||
const actions = { ...PodCollectionsHandlerMixin.actions, ...schema.actions };
|
||||
for (const [name, def] of Object.entries(actions)) {
|
||||
svc.actions[name] = (params, opts) => def.handler.call(svc, { ...opts, params, call: ctx.call });
|
||||
}
|
||||
const emit = (key, hint, actorUri) => schema.activities[key].onEmit.call(svc, ctx, hint, actorUri);
|
||||
return { svc, ctx, calls, logs, emit };
|
||||
}
|
||||
|
||||
const created = objectUri => ({ id: CREATE, type: 'Create', actor: ALICE, object: objectUri });
|
||||
const actions = calls => calls.map(c => c.action);
|
||||
|
||||
describe('attendees — a verified Create attaches the collection, then aligns its public read with the event', () => {
|
||||
test('a public event: collection attached, group created, then public-read.reconcile on the event', async () => {
|
||||
const { calls, emit } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', to: 'as:Public' } }, { attached: C });
|
||||
await emit('create', created(E), ALICE);
|
||||
expect(actions(calls).filter(a => a !== 'pod-resources.get')).toEqual([
|
||||
'pod-collections.createAndAttach',
|
||||
'public-read.recordCreated',
|
||||
'pod-wac-groups.create',
|
||||
'public-read.reconcile'
|
||||
]);
|
||||
expect(calls.find(c => c.action === 'public-read.recordCreated').params).toEqual({ resourceUri: C, actorUri: ALICE });
|
||||
expect(calls.find(c => c.action === 'public-read.reconcile').params).toEqual({ resourceUri: E, actorUri: ALICE });
|
||||
});
|
||||
|
||||
test('the collection is recorded as Festipod\'s even when the WAC group creation after the attach fails', async () => {
|
||||
const { calls, emit, logs } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', name: 'private' } }, { attached: C, groupFails: true });
|
||||
await emit('create', created(E), ALICE);
|
||||
const order = actions(calls).filter(a => a !== 'pod-resources.get');
|
||||
expect(order).toEqual(['pod-collections.createAndAttach', 'public-read.recordCreated', 'pod-wac-groups.create', 'public-read.reconcile']);
|
||||
expect(logs.error.some(m => m.includes('group creation failed'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a PRIVATE event: its collection is reconciled too (the pod made it world-readable; it must not stay so)', async () => {
|
||||
const { calls, emit } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', name: 'private' } }, { attached: C });
|
||||
await emit('create', created(E), ALICE);
|
||||
expect(calls.some(c => c.action === 'public-read.reconcile' && c.params.resourceUri === E)).toBe(true);
|
||||
expect(calls.some(c => c.action === 'public-read.grant')).toBe(false);
|
||||
});
|
||||
|
||||
test('nothing attached (already carries a collection): no WAC group, no throw, still reconciled', async () => {
|
||||
const { calls, emit, logs } = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Event', 'apods:attendees': C } }, { attached: undefined });
|
||||
await emit('create', created(E), ALICE);
|
||||
expect(actions(calls)).not.toContain('pod-wac-groups.create');
|
||||
expect(actions(calls)).not.toContain('public-read.recordCreated'); // not Festipod's collection: never revoked
|
||||
expect(actions(calls)).toContain('public-read.reconcile');
|
||||
expect(logs.error).toEqual([]);
|
||||
});
|
||||
|
||||
test("a forged Create naming another actor's event, or a stored Note, touches nothing", async () => {
|
||||
const malloryEvent = `${MALLORY}/data/events/e`;
|
||||
let w = bind(attendees, {}, { attached: C });
|
||||
await w.emit('create', { type: 'Create', actor: ALICE, object: { id: malloryEvent, type: 'Event' } }, ALICE);
|
||||
expect(actions(w.calls).filter(a => a !== 'pod-resources.get')).toEqual([]);
|
||||
w = bind(attendees, { [CREATE]: created(E), [E]: { id: E, type: 'Note', to: 'as:Public' } }, { attached: C });
|
||||
await w.emit('create', created(E), ALICE);
|
||||
expect(actions(w.calls).filter(a => a !== 'pod-resources.get')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contacts — the webhook is a hint, and nothing attached creates no group', () => {
|
||||
const profile = { id: PROFILE, type: ['as:Profile', 'vcard:Individual'] };
|
||||
|
||||
test('the normal case: the collection is attached to the STORED profile, for the outbox owner', async () => {
|
||||
const { calls, emit } = bind(contacts, { [CREATE]: created(PROFILE), [PROFILE]: profile }, { attached: `${ALICE}/data/contacts` });
|
||||
// The body claims Mallory created it: the outbox owner (Alice), re-read from the pod, is who counts.
|
||||
await emit('create', { ...created(PROFILE), actor: MALLORY }, ALICE);
|
||||
const attach = calls.find(c => c.action === 'pod-collections.createAndAttach');
|
||||
expect(attach.params).toMatchObject({ resourceUri: PROFILE, actorUri: ALICE });
|
||||
expect(actions(calls)).toContain('pod-wac-groups.create');
|
||||
});
|
||||
|
||||
test('a Create the pod does not store attaches nothing', async () => {
|
||||
const { calls, emit } = bind(contacts, { [PROFILE]: profile }, { attached: `${ALICE}/data/contacts` });
|
||||
await emit('create', { type: 'Create', actor: ALICE, object: profile }, ALICE);
|
||||
expect(actions(calls)).not.toContain('pod-collections.createAndAttach');
|
||||
});
|
||||
|
||||
test('a stored object that is not a Profile attaches nothing', async () => {
|
||||
const { calls, emit } = bind(contacts, { [CREATE]: created(PROFILE), [PROFILE]: { id: PROFILE, type: 'Event' } }, { attached: `${ALICE}/data/contacts` });
|
||||
await emit('create', created(PROFILE), ALICE);
|
||||
expect(actions(calls)).not.toContain('pod-collections.createAndAttach');
|
||||
});
|
||||
|
||||
test('nothing attached (the profile already carries contacts): no WAC group, and nothing thrown', async () => {
|
||||
const { calls, emit, logs } = bind(contacts, { [CREATE]: created(PROFILE), [PROFILE]: profile }, { attached: undefined });
|
||||
await expect(emit('create', created(PROFILE), ALICE)).resolves.toBeUndefined();
|
||||
expect(actions(calls)).toContain('pod-collections.createAndAttach');
|
||||
expect(actions(calls)).not.toContain('pod-wac-groups.create');
|
||||
expect(logs.error).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,17 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { PodCollectionsHandlerMixin } = require('@activitypods/app');
|
||||
const { PodCollectionsHandlerMixin, PodResourcesHandlerMixin } = require('@activitypods/app');
|
||||
const CONFIG = require('../config/config');
|
||||
const { neverThrowingMatcher, verifiedEmit } = require('./emitted-activity');
|
||||
const { guardedCreateAndAttach } = require('./collection-attach');
|
||||
|
||||
// Attaches an `apods:contacts` collection (and a WAC group of the same members) to the `as:Profile`
|
||||
// a user creates, as the platform's collection handler does — with two differences that keep the
|
||||
// webhook a hint (see emitted-activity.js):
|
||||
// - the handler runs only for a Create `verifiedEmit` accepts: re-read from the pod as the app,
|
||||
// attributed to the owner of the outbox the webhook fired on, its object an `as:Profile` of that
|
||||
// owner's pod, re-read too. The mixin's handler would act on the object embedded in the
|
||||
// (unauthenticated) webhook body, attributed to the activity's self-declared `actor`;
|
||||
// - `createAndAttach` creates no WAC group when nothing was attached (collection-attach.js).
|
||||
module.exports = {
|
||||
name: 'contacts',
|
||||
mixins: [PodCollectionsHandlerMixin],
|
||||
@@ -13,5 +23,23 @@ module.exports = {
|
||||
summary: 'Festipod contacts'
|
||||
},
|
||||
createWacGroup: true
|
||||
},
|
||||
// Replaces the mixin's whole map (moleculer does not merge a custom schema key). The mixin's
|
||||
// update and delete handlers are left out on purpose: they never matched here (they require an
|
||||
// `onUpdate` this service does not have).
|
||||
activities: {
|
||||
create: {
|
||||
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.create.match),
|
||||
onEmit: verifiedEmit(
|
||||
'Create',
|
||||
async function (ctx, { object }, actorUri) {
|
||||
await this.onCreate(ctx, object, actorUri);
|
||||
},
|
||||
{ objectType: 'Profile' }
|
||||
)
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
createAndAttach: guardedCreateAndAttach
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@ const path = require('path');
|
||||
const { CoreService } = require('@semapps/core');
|
||||
const { apods, notify, interop, oidc, skos } = require('@semapps/ontologies');
|
||||
const CONFIG = require('../../config/config');
|
||||
const { publicSurfaceGate } = require('../public-surface');
|
||||
|
||||
module.exports = {
|
||||
mixins: [CoreService],
|
||||
@@ -20,6 +21,11 @@ module.exports = {
|
||||
},
|
||||
api: {
|
||||
port: CONFIG.PORT,
|
||||
// In front of EVERY route — these below and the ones CoreService's services add (the LDP
|
||||
// catch-all among them, which with `webacl: false` would serve the whole `settings` dataset,
|
||||
// the app's private key included): only the allow-listed public endpoints pass, anything else
|
||||
// is a 404 for every caller. See public-surface.js.
|
||||
use: [publicSurfaceGate],
|
||||
routes: [
|
||||
{
|
||||
path: '/app',
|
||||
@@ -72,6 +78,11 @@ module.exports = {
|
||||
]
|
||||
},
|
||||
webacl: false,
|
||||
void: false
|
||||
void: false,
|
||||
// No public SPARQL endpoint. CoreService mounts one at `/sparql` unless this is `false`, and
|
||||
// with `webacl: false` it answers anonymous queries over the whole `settings` dataset — the
|
||||
// app actor's key, the webhook listener URLs, the catalog store and, locally, the pod
|
||||
// provider's accounts (observed 2026-10-12). Nothing in Festipod queries it.
|
||||
sparqlEndpoint: false
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
// 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).
|
||||
//
|
||||
// THE WEBHOOK IS A HINT, NOT A SOURCE. The webhook route (`/.webhooks/:uuid`) is unauthenticated: the
|
||||
// listener only matches the uuid, the watcher derives the box owner from the body's `target`, and an
|
||||
// activity or object EMBEDDED in the body is used as written, never re-read (platform behaviour,
|
||||
// @activitypods/app 2.3.2 `pod-activities-watcher.processWebhook`, @semapps/solid listener
|
||||
// `transfer`). Nothing in the body is trusted here except as a pointer: the activity is re-read by
|
||||
// its id from the pod, the object by its id, both through the box owner's access, and the handlers
|
||||
// act only on what the pod returns.
|
||||
//
|
||||
// WHO EMITTED IT. A resource lies in an actor's pod only if that actor (or an app it authorised)
|
||||
// wrote it: the pod refuses to create or modify a resource under another actor's path (see
|
||||
// `actor-pod.js`). So:
|
||||
// - an OUTBOX notification names its emitter: the owner of that outbox (`verifiedEmit`). The
|
||||
// activity must lie in that owner's pod and the stored activity must name that owner as `actor`;
|
||||
// - an INBOX notification does not (`verifiedReceive`): the emitter is the stored activity's own
|
||||
// `actor`, believed only because the activity lies in THAT actor's pod — someone else's claim
|
||||
// about an actor cannot be stored there.
|
||||
//
|
||||
// An activity is accepted only when all of this holds:
|
||||
// 1. it is identified by a URI (an embedded activity without an id was never stored);
|
||||
// 2. for an outbox notification, that URI is strictly inside the emitter's pod;
|
||||
// 3. the pod returns it at that URI, as a resource with that id;
|
||||
// 4. its stored type is the one the handler expects, and it names exactly one `actor`: the emitter
|
||||
// (outbox), or an actor whose pod holds the activity (inbox);
|
||||
// 5. it names exactly one object, by URI, lying where the handler requires (the emitter's pod for
|
||||
// a resource the emitter creates, updates or deletes; the inbox owner's pod for an activity
|
||||
// about the inbox owner's object; anywhere when the handler checks the location itself);
|
||||
// 6. for a Delete, the pod says the object is gone (404/410) or serves a Tombstone — a live object
|
||||
// means the deletion is not true; otherwise the pod returns the object at that URI, as a
|
||||
// resource with that id, declaring the ActivityStreams type the handler acts on (a Note is not
|
||||
// an Event, whatever the notification says).
|
||||
//
|
||||
// WHAT IT DOES NOT DO: order notifications. Handlers run concurrently, one per webhook; this check
|
||||
// reads the pod BEFORE the handler acts, so on its own an older notification's action can still land
|
||||
// after a newer one's. A handler whose decision depends on the object's current state therefore takes
|
||||
// its own read INSIDE `keyed-serializer.js`, keyed by the object — that, not this check, is what makes
|
||||
// a replayed or reordered notification harmless.
|
||||
|
||||
const { isInActorPod } = require('./actor-pod');
|
||||
|
||||
const AS = 'https://www.w3.org/ns/activitystreams#';
|
||||
|
||||
const listOf = value => (value === undefined || value === null ? [] : Array.isArray(value) ? value : [value]);
|
||||
|
||||
/** The URI a value names: a string, or a node's `id`/`@id`; undefined otherwise. */
|
||||
function uriOf(value) {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value && typeof value === 'object') {
|
||||
const id = value.id || value['@id'];
|
||||
return typeof id === 'string' ? id : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The single URI a property holds (one value, a URI or a node with an id); undefined otherwise. */
|
||||
function singleUriOf(value) {
|
||||
const values = listOf(value);
|
||||
return values.length === 1 ? uriOf(values[0]) : undefined;
|
||||
}
|
||||
|
||||
/** The ActivityStreams types a node declares, as full IRIs (`Create`, `as:Create` and the IRI agree). */
|
||||
function asTypesOf(node) {
|
||||
if (!node || typeof node !== 'object') return [];
|
||||
return listOf(node.type || node['@type']).map(type => {
|
||||
if (typeof type !== 'string') return undefined;
|
||||
if (type.startsWith(AS)) return type;
|
||||
if (type.startsWith('as:')) return AS + type.slice('as:'.length);
|
||||
return type.includes(':') ? type : AS + type;
|
||||
});
|
||||
}
|
||||
|
||||
const hasAsType = (node, name) => asTypesOf(node).includes(AS + name);
|
||||
|
||||
const ACTIVITY_TYPES = new Set(['Create', 'Update', 'Delete', 'Join', 'Leave']);
|
||||
|
||||
/** `objectOwnerUri` value for a handler that checks where the object lies itself. */
|
||||
const ANY_POD = Symbol('any pod');
|
||||
|
||||
/**
|
||||
* verifyActivity({ activity, type, emitterUri, objectOwnerUri, objectType, read }) →
|
||||
* { ok: true, activity, emitterUri, objectUri, object } (object: the stored object; undefined for a Delete)
|
||||
* { ok: false, reason }
|
||||
*
|
||||
* `activity` is what the watcher handed over (a URI or a node — only its id is used). `type` is the
|
||||
* activity type the handler expects. `emitterUri` is the owner of the outbox the notification names,
|
||||
* or `null` for an inbox notification (the emitter is then the stored activity's actor). The object
|
||||
* must lie in the pod of `objectOwnerUri`, or anywhere with `ANY_POD`. `objectType` is the
|
||||
* ActivityStreams type the stored object must declare (`Event`, `Profile`; not checked for a Delete).
|
||||
* `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.
|
||||
*/
|
||||
async function verifyActivity({ activity, type, emitterUri, objectOwnerUri, objectType, read }) {
|
||||
if (!ACTIVITY_TYPES.has(type)) throw new Error(`Unsupported activity type ${type}`);
|
||||
if (objectOwnerUri === undefined) throw new Error('objectOwnerUri is required (a URI, or ANY_POD)');
|
||||
if (type !== 'Delete' && !objectType) throw new Error(`objectType is required for a ${type}`);
|
||||
const refuse = reason => ({ ok: false, reason });
|
||||
|
||||
const activityUri = uriOf(activity);
|
||||
if (!activityUri) return refuse('the activity has no id');
|
||||
if (emitterUri !== null && !isInActorPod(activityUri, emitterUri)) {
|
||||
return refuse(`the activity ${activityUri} is not in the pod of ${emitterUri}`);
|
||||
}
|
||||
|
||||
const storedActivity = await read(activityUri);
|
||||
if (storedActivity.status !== 200 || !storedActivity.body) {
|
||||
return refuse(`the pod does not return the activity ${activityUri} (HTTP ${storedActivity.status})`);
|
||||
}
|
||||
const stored = storedActivity.body;
|
||||
if (uriOf(stored) !== activityUri) return refuse(`the pod returned another resource for ${activityUri}`);
|
||||
if (!hasAsType(stored, type)) return refuse(`the stored activity ${activityUri} is not a ${type}`);
|
||||
const storedActor = singleUriOf(stored.actor);
|
||||
let emitter = emitterUri;
|
||||
if (emitter === null) {
|
||||
if (!storedActor || !isInActorPod(activityUri, storedActor)) {
|
||||
return refuse(`the stored activity ${activityUri} is not in the pod of the actor it names`);
|
||||
}
|
||||
emitter = storedActor;
|
||||
} else if (storedActor !== emitter) {
|
||||
return refuse(`the stored activity ${activityUri} is not attributed to ${emitter}`);
|
||||
}
|
||||
|
||||
const objectUri = singleUriOf(stored.object);
|
||||
if (!objectUri) return refuse(`the stored activity ${activityUri} has no single object id`);
|
||||
if (objectOwnerUri !== ANY_POD && !isInActorPod(objectUri, objectOwnerUri)) {
|
||||
return refuse(`the object ${objectUri} is not in the pod of ${objectOwnerUri}`);
|
||||
}
|
||||
|
||||
const storedObject = await read(objectUri);
|
||||
if (type === 'Delete') {
|
||||
const gone = storedObject.status === 404 || storedObject.status === 410;
|
||||
const tombstone = storedObject.status === 200 && hasAsType(storedObject.body, 'Tombstone');
|
||||
if (!gone && !tombstone) return refuse(`the object ${objectUri} is not deleted (HTTP ${storedObject.status})`);
|
||||
return { ok: true, activity: stored, emitterUri: emitter, objectUri, object: undefined };
|
||||
}
|
||||
if (storedObject.status !== 200 || !storedObject.body) {
|
||||
return refuse(`the pod does not return the object ${objectUri} (HTTP ${storedObject.status})`);
|
||||
}
|
||||
if (uriOf(storedObject.body) !== objectUri) return refuse(`the pod returned another resource for ${objectUri}`);
|
||||
if (!hasAsType(storedObject.body, objectType)) return refuse(`the stored object ${objectUri} is not a ${objectType}`);
|
||||
return { ok: true, activity: stored, emitterUri: emitter, objectUri, object: storedObject.body };
|
||||
}
|
||||
|
||||
/**
|
||||
* The outbox case: an activity `actorUri` emitted about an object in its own pod (a resource it
|
||||
* created, updated or deleted).
|
||||
*/
|
||||
function verifyEmittedActivity({ activity, actorUri, type, objectType, read }) {
|
||||
return verifyActivity({ activity, type, emitterUri: actorUri, objectOwnerUri: actorUri, objectType, read });
|
||||
}
|
||||
|
||||
/** `read` for the checks: a GET through the platform's `pod-resources.get`, as the box owner. */
|
||||
function podReader(ctx, actorUri) {
|
||||
return async uri => {
|
||||
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: uri, actorUri });
|
||||
return { status: typeof status === 'number' ? status : 0, body };
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A matcher that never throws: on error it logs and reports no match. The watcher runs every
|
||||
* service's matchers for an activity in sequence without catching, inside a webhook call the
|
||||
* platform does not await — a throw would skip the handlers after this one and end the process.
|
||||
* The match itself runs on the webhook's (untrusted) data; it only routes, the verified handler decides.
|
||||
*/
|
||||
function neverThrowingMatcher(match) {
|
||||
return async function (activity, fetcher) {
|
||||
try {
|
||||
return await match.call(this, activity, fetcher);
|
||||
} catch (e) {
|
||||
this.logger.error(`${this.name} could not match activity ${uriOf(activity)}: ${e.message}`);
|
||||
return { match: false, dereferencedActivity: activity };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs `handle` (bound to the service) on an accepted verdict; logs a refusal or a failure, never throws. */
|
||||
function verifiedHandler(box, type, handle, verify) {
|
||||
return async function (ctx, activity, actorUri) {
|
||||
try {
|
||||
const verdict = await verify(activity, actorUri, podReader(ctx, actorUri));
|
||||
if (!verdict.ok) {
|
||||
this.logger.warn(`${this.name} ignored a ${type} reported in the ${box} of ${actorUri}: ${verdict.reason}`);
|
||||
return;
|
||||
}
|
||||
await handle.call(this, ctx, verdict, actorUri);
|
||||
} catch (e) {
|
||||
this.logger.error(`${this.name} could not process a ${type} reported in the ${box} of ${actorUri}: ${e.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* An `onEmit` (outbox handler) that runs `handle(ctx, verdict, actorUri)` only for an activity
|
||||
* `verifyActivity` accepts as emitted by `actorUri`, the outbox owner. `objectType` is the type the
|
||||
* stored object must declare; `objectIn` is `'emitter'` (default: the object is in the emitter's pod)
|
||||
* or `'any'` (the handler checks where the object lies, e.g. the event a Join names).
|
||||
*/
|
||||
function verifiedEmit(type, handle, { objectType, objectIn = 'emitter' } = {}) {
|
||||
if (objectIn !== 'emitter' && objectIn !== 'any') throw new Error(`Unknown objectIn ${objectIn}`);
|
||||
return verifiedHandler('outbox', type, handle, (activity, actorUri, read) =>
|
||||
verifyActivity({
|
||||
activity,
|
||||
type,
|
||||
emitterUri: actorUri,
|
||||
objectOwnerUri: objectIn === 'any' ? ANY_POD : actorUri,
|
||||
objectType,
|
||||
read
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An `onReceive` (inbox handler) that runs `handle(ctx, verdict, actorUri)` only for an activity
|
||||
* `verifyActivity` accepts as emitted by the actor it names, about an object in the pod of
|
||||
* `actorUri`, the inbox owner. `verdict.emitterUri` is that emitter.
|
||||
*/
|
||||
function verifiedReceive(type, handle, { objectType } = {}) {
|
||||
return verifiedHandler('inbox', type, handle, (activity, actorUri, read) =>
|
||||
verifyActivity({ activity, type, emitterUri: null, objectOwnerUri: actorUri, objectType, read })
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
verifyActivity,
|
||||
verifyEmittedActivity,
|
||||
podReader,
|
||||
neverThrowingMatcher,
|
||||
verifiedEmit,
|
||||
verifiedReceive,
|
||||
uriOf,
|
||||
singleUriOf,
|
||||
hasAsType,
|
||||
ANY_POD
|
||||
};
|
||||
@@ -0,0 +1,266 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const { verifyEmittedActivity, verifyActivity, verifiedEmit, verifiedReceive, neverThrowingMatcher, ANY_POD } = require('./emitted-activity');
|
||||
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const BOB = 'http://localhost:3000/bob';
|
||||
|
||||
// The activity and object URIs a genuine Festipod creation produces: both under the actor's pod.
|
||||
const ACT = `${ALICE}/outbox/act-1`;
|
||||
const OBJ = `${ALICE}/data/events/e1`;
|
||||
|
||||
/** A `read(uri)` built from a map of uri → { status, body }; a missing uri answers 404. */
|
||||
const reader = pod => async uri => pod[uri] || { status: 404, body: undefined };
|
||||
|
||||
const createActivity = { id: ACT, type: 'Create', actor: ALICE, object: OBJ };
|
||||
const event = (extra = {}) => ({ id: OBJ, type: 'Event', name: 'Concert', ...extra });
|
||||
|
||||
describe('verifyEmittedActivity — the webhook is a hint; the pod is the truth', () => {
|
||||
test('the normal case: a Create emitted by the actor, re-read, is accepted with the STORED object', async () => {
|
||||
const stored = event({ to: 'as:Public', name: 'the real, stored name' });
|
||||
const read = reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: stored } });
|
||||
// The webhook copy carries a DIFFERENT, attacker-chosen object body — it must be ignored.
|
||||
const webhookActivity = { id: ACT, type: 'Create', actor: ALICE, object: { id: OBJ, type: 'Event', to: 'as:Public', name: 'forged' } };
|
||||
const r = await verifyEmittedActivity({ activity: webhookActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.objectUri).toBe(OBJ);
|
||||
expect(r.object.name).toBe('the real, stored name');
|
||||
});
|
||||
|
||||
test('the activity is re-read even when the webhook embeds a full activity node', async () => {
|
||||
const calls = [];
|
||||
const read = async uri => {
|
||||
calls.push(uri);
|
||||
return uri === ACT ? { status: 200, body: createActivity } : { status: 200, body: event() };
|
||||
};
|
||||
const embedded = { id: ACT, type: 'Create', actor: ALICE, object: event() };
|
||||
const r = await verifyEmittedActivity({ activity: embedded, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(calls).toEqual([ACT, OBJ]); // both re-read from the pod, nothing taken from the body
|
||||
});
|
||||
|
||||
test('FORGERY: a victim\'s private event named through the attacker\'s outbox is refused', async () => {
|
||||
// The attacker (Alice) owns her webhook URL; she forges a Create naming her own outbox as
|
||||
// target and a VICTIM\'s private event as the object, addressed to Public. The watcher resolves
|
||||
// actorUri = Alice (her outbox). The embedded object claims BOB\'s event is public.
|
||||
const victimEvent = `${BOB}/data/events/private`;
|
||||
const forged = { id: `${ALICE}/outbox/forged`, type: 'Create', actor: ALICE, object: { id: victimEvent, type: 'Event', to: 'as:Public' } };
|
||||
// Even if the pod happens to return the forged activity (it is in Alice\'s pod), its object is
|
||||
// in BOB\'s pod → refused at the object-ownership check, before any read of BOB\'s event.
|
||||
const read = reader({ [`${ALICE}/outbox/forged`]: { status: 200, body: forged } });
|
||||
const r = await verifyEmittedActivity({ activity: forged, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('not in the pod of');
|
||||
});
|
||||
|
||||
test('a Create the pod does not actually store (only in the webhook body) is refused', async () => {
|
||||
const read = reader({}); // the pod returns 404 for the activity
|
||||
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('does not return the activity');
|
||||
});
|
||||
|
||||
test('a stored activity attributed to someone else is refused', async () => {
|
||||
const read = reader({ [ACT]: { status: 200, body: { id: ACT, type: 'Create', actor: BOB, object: OBJ } } });
|
||||
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('not attributed to');
|
||||
});
|
||||
|
||||
test('a stored activity of the wrong type is refused', async () => {
|
||||
const read = reader({ [ACT]: { status: 200, body: { id: ACT, type: 'Announce', actor: ALICE, object: OBJ } } });
|
||||
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('is not a Create');
|
||||
});
|
||||
|
||||
test('an activity with no id (never stored) is refused before any read', async () => {
|
||||
let reads = 0;
|
||||
const read = async () => { reads++; return { status: 404 }; };
|
||||
const r = await verifyEmittedActivity({ activity: { type: 'Create', actor: ALICE, object: event() }, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('no id');
|
||||
expect(reads).toBe(0);
|
||||
});
|
||||
|
||||
test('a Delete is accepted only when the pod says the object is gone or a Tombstone', async () => {
|
||||
const del = { id: `${ALICE}/outbox/del`, type: 'Delete', actor: ALICE, object: OBJ };
|
||||
const act = { [`${ALICE}/outbox/del`]: { status: 200, body: del } };
|
||||
// Object gone → accepted.
|
||||
let r = await verifyEmittedActivity({ activity: del, actorUri: ALICE, type: 'Delete', objectType: 'Event', read: reader({ ...act, [OBJ]: { status: 404 } }) });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.objectUri).toBe(OBJ);
|
||||
// Tombstone → accepted.
|
||||
r = await verifyEmittedActivity({ activity: del, actorUri: ALICE, type: 'Delete', objectType: 'Event', read: reader({ ...act, [OBJ]: { status: 200, body: { id: OBJ, type: 'Tombstone' } } }) });
|
||||
expect(r.ok).toBe(true);
|
||||
// Object still live → refused (the deletion is not true).
|
||||
r = await verifyEmittedActivity({ activity: del, actorUri: ALICE, type: 'Delete', objectType: 'Event', read: reader({ ...act, [OBJ]: { status: 200, body: event() } }) });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('not deleted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifiedEmit — runs the handler only on an accepted activity, never throws', () => {
|
||||
function svc(pod) {
|
||||
const logs = { info: [], warn: [], error: [] };
|
||||
const ran = [];
|
||||
const service = {
|
||||
name: 'test',
|
||||
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
|
||||
};
|
||||
const ctx = { call: async (action, { resourceUri }) => (pod[resourceUri] || { status: 404 }) };
|
||||
const onEmit = verifiedEmit('Create', async function (_ctx, verdict, actorUri) {
|
||||
ran.push({ objectUri: verdict.objectUri, actorUri });
|
||||
}, { objectType: 'Event' }).bind(service);
|
||||
return { onEmit, ctx, logs, ran };
|
||||
}
|
||||
|
||||
test('an accepted Create runs the handler with the stored object and the watcher\'s actor', async () => {
|
||||
const { onEmit, ctx, ran } = svc({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: event() } });
|
||||
await onEmit(ctx, createActivity, ALICE);
|
||||
expect(ran).toEqual([{ objectUri: OBJ, actorUri: ALICE }]);
|
||||
});
|
||||
|
||||
test('a refused Create logs a warning and does NOT run the handler', async () => {
|
||||
const { onEmit, ctx, logs, ran } = svc({}); // pod returns 404 for the activity
|
||||
await onEmit(ctx, createActivity, ALICE);
|
||||
expect(ran).toEqual([]);
|
||||
expect(logs.warn).toHaveLength(1);
|
||||
expect(logs.warn[0]).toContain('ignored a Create');
|
||||
});
|
||||
|
||||
test('a throwing read is caught and logged, never propagated', async () => {
|
||||
const service = { name: 'test', logger: { info() {}, warn() {}, error(m) { this._e = m; } } };
|
||||
const onEmit = verifiedEmit('Create', async () => {}, { objectType: 'Event' }).bind(service);
|
||||
const ctx = { call: async () => { throw new Error('proxy down'); } };
|
||||
await expect(onEmit(ctx, createActivity, ALICE)).resolves.toBeUndefined();
|
||||
expect(service.logger._e).toContain('proxy down');
|
||||
});
|
||||
});
|
||||
|
||||
describe('neverThrowingMatcher', () => {
|
||||
test('a throwing matcher reports no match and logs', async () => {
|
||||
const service = { name: 'test', logger: { error(m) { this._e = m; } } };
|
||||
const wrapped = neverThrowingMatcher(async () => { throw new Error('boom'); }).bind(service);
|
||||
const r = await wrapped({ id: ACT }, async () => false);
|
||||
expect(r.match).toBe(false);
|
||||
expect(service.logger._e).toContain('boom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the stored object must be of the type the handler acts on', () => {
|
||||
test('a public Note re-read at the object URI is refused for an Event handler; an Event is accepted', async () => {
|
||||
const note = { id: OBJ, type: 'Note', to: 'as:Public', content: 'not an event' };
|
||||
let r = await verifyEmittedActivity({
|
||||
activity: createActivity,
|
||||
actorUri: ALICE,
|
||||
type: 'Create',
|
||||
objectType: 'Event',
|
||||
read: reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: note } })
|
||||
});
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('is not a Event');
|
||||
for (const type of ['Event', 'as:Event', 'https://www.w3.org/ns/activitystreams#Event', ['Event', 'apods:MeetingPoint']]) {
|
||||
r = await verifyEmittedActivity({
|
||||
activity: createActivity,
|
||||
actorUri: ALICE,
|
||||
type: 'Create',
|
||||
objectType: 'Event',
|
||||
read: reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body: { id: OBJ, type } } })
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('a Profile handler refuses an Event and accepts a Profile', async () => {
|
||||
const pod = body => reader({ [ACT]: { status: 200, body: createActivity }, [OBJ]: { status: 200, body } });
|
||||
const asProfile = { activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Profile' };
|
||||
expect((await verifyEmittedActivity({ ...asProfile, read: pod(event()) })).ok).toBe(false);
|
||||
expect((await verifyEmittedActivity({ ...asProfile, read: pod({ id: OBJ, type: ['as:Profile', 'vcard:Individual'] }) })).ok).toBe(true);
|
||||
});
|
||||
|
||||
test('a type is required for anything but a Delete (a forgotten check is a programming error)', async () => {
|
||||
await expect(verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', read: reader({}) })).rejects.toThrow('objectType is required');
|
||||
});
|
||||
|
||||
test('the pod answering with another resource than the activity asked for is refused', async () => {
|
||||
const read = reader({ [ACT]: { status: 200, body: { ...createActivity, id: `${ALICE}/outbox/other` } }, [OBJ]: { status: 200, body: event() } });
|
||||
const r = await verifyEmittedActivity({ activity: createActivity, actorUri: ALICE, type: 'Create', objectType: 'Event', read });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('another resource');
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyActivity — an activity RECEIVED in an inbox (emitter = the actor whose pod holds it)', () => {
|
||||
// Bob joins Alice's meeting point: the Join lies in Bob's pod, its object in Alice's pod; the
|
||||
// notification comes from ALICE's inbox (Alice is the box owner, `actorUri`).
|
||||
const MP = `${ALICE}/data/events/mp1`;
|
||||
const JOIN = `${BOB}/data/activities/join-1`;
|
||||
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP, to: ALICE };
|
||||
const meetingPoint = { id: MP, type: 'Event', 'apods:attendees': `${ALICE}/data/collections/mp1-attendees` };
|
||||
const received = read => verifyActivity({ activity: JOIN, type: 'Join', emitterUri: null, objectOwnerUri: ALICE, objectType: 'Event', read });
|
||||
|
||||
test('the normal case: a Join stored in the joiner\'s pod, about the inbox owner\'s meeting point', async () => {
|
||||
const r = await received(reader({ [JOIN]: { status: 200, body: join }, [MP]: { status: 200, body: meetingPoint } }));
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.emitterUri).toBe(BOB);
|
||||
expect(r.object).toEqual(meetingPoint);
|
||||
});
|
||||
|
||||
test('an activity naming an actor whose pod does not hold it is refused (Mallory cannot join as Bob)', async () => {
|
||||
const MALLORY = 'http://localhost:3000/mallory';
|
||||
const forgedUri = `${MALLORY}/data/activities/join-as-bob`;
|
||||
const read = reader({ [forgedUri]: { status: 200, body: { ...join, id: forgedUri, actor: BOB } }, [MP]: { status: 200, body: meetingPoint } });
|
||||
const r = await verifyActivity({ activity: forgedUri, type: 'Join', emitterUri: null, objectOwnerUri: ALICE, objectType: 'Event', read });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('not in the pod of the actor it names');
|
||||
});
|
||||
|
||||
test('a Join about an object outside the inbox owner\'s pod is refused (an embedded attendees URI never reaches a write)', async () => {
|
||||
const elsewhere = `${BOB}/data/events/bobs-mp`;
|
||||
const read = reader({ [JOIN]: { status: 200, body: { ...join, object: { id: elsewhere, type: 'Event', 'apods:attendees': 'http://localhost:3000/carol/data/c' } } } });
|
||||
const r = await received(read);
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain(`not in the pod of ${ALICE}`);
|
||||
});
|
||||
|
||||
test('an activity with several actors, or a Leave where a Join is expected, is refused', async () => {
|
||||
let r = await received(reader({ [JOIN]: { status: 200, body: { ...join, actor: [BOB, ALICE] } }, [MP]: { status: 200, body: meetingPoint } }));
|
||||
expect(r.ok).toBe(false);
|
||||
r = await received(reader({ [JOIN]: { status: 200, body: { ...join, type: 'Leave' } }, [MP]: { status: 200, body: meetingPoint } }));
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
|
||||
test('a location rule must be stated: ANY_POD explicitly, never by omission', async () => {
|
||||
await expect(verifyActivity({ activity: JOIN, type: 'Join', emitterUri: BOB, objectType: 'Event', read: reader({}) })).rejects.toThrow('objectOwnerUri is required');
|
||||
const read = reader({ [JOIN]: { status: 200, body: join }, [MP]: { status: 200, body: meetingPoint } });
|
||||
const r = await verifyActivity({ activity: JOIN, type: 'Join', emitterUri: BOB, objectOwnerUri: ANY_POD, objectType: 'Event', read });
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifiedReceive / verifiedEmit with objectIn "any"', () => {
|
||||
const MP = `${ALICE}/data/events/mp1`;
|
||||
const JOIN = `${BOB}/data/activities/join-1`;
|
||||
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP };
|
||||
const pod = { [JOIN]: { status: 200, body: join }, [MP]: { status: 200, body: { id: MP, type: 'Event' } } };
|
||||
const service = () => ({ name: 'test', logger: { info() {}, warn(m) { this.w = m; }, error(m) { this.e = m; } } });
|
||||
const ctx = { call: async (action, { resourceUri }) => pod[resourceUri] || { status: 404 } };
|
||||
|
||||
test('an inbox Join runs the handler with the emitter read from the pod, not from the webhook body', async () => {
|
||||
const ran = [];
|
||||
const onReceive = verifiedReceive('Join', async (_ctx, verdict, actorUri) => ran.push([verdict.emitterUri, verdict.objectUri, actorUri]), { objectType: 'Event' }).bind(service());
|
||||
// The webhook body claims Carol joined; the stored activity says Bob.
|
||||
await onReceive(ctx, { id: JOIN, type: 'Join', actor: 'http://localhost:3000/carol', object: MP }, ALICE);
|
||||
expect(ran).toEqual([[BOB, MP, ALICE]]);
|
||||
});
|
||||
|
||||
test('an outbox Join about an event in another pod passes with objectIn "any", and is refused by default', async () => {
|
||||
const ran = [];
|
||||
const handle = async (_ctx, verdict) => ran.push(verdict.objectUri);
|
||||
await verifiedEmit('Join', handle, { objectType: 'Event', objectIn: 'any' }).call(service(), ctx, JOIN, BOB);
|
||||
const svc = service();
|
||||
await verifiedEmit('Join', handle, { objectType: 'Event' }).call(svc, ctx, JOIN, BOB);
|
||||
expect(ran).toEqual([MP]);
|
||||
expect(svc.logger.w).toContain(`not in the pod of ${BOB}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// One-at-a-time execution PER KEY, with a time limit — the ordering primitive behind every decision
|
||||
// the backend takes about one pod object (grant or revoke its public read, index or de-index it in the
|
||||
// catalog, add or remove an attendee).
|
||||
//
|
||||
// WHY. The platform runs the handlers of every webhook as soon as it arrives, without awaiting the
|
||||
// previous one. Two notifications about the same object therefore run CONCURRENTLY: each reads, then
|
||||
// acts — and one's action can land between the other's read and action.
|
||||
//
|
||||
// WHAT IT GUARANTEES — and what it does not:
|
||||
// - works for one key never overlap; each starts once the previous one for that key has settled
|
||||
// (finished, failed, or timed out), in the order they ENTERED `serialize` — which is the order the
|
||||
// handlers got there (after their own verification reads), NOT the order the user acted or the
|
||||
// pod emitted the activities;
|
||||
// - so it makes a decision order-proof only when the work derives that decision from state it reads
|
||||
// INSIDE: every pod change is notified after it happened, so whichever work runs last started
|
||||
// after the last change and acts on a state at least that recent (public-read `reconcile`, catalog
|
||||
// `sync`);
|
||||
// - a decision taken from a notification's own content (a Join versus a Leave) gains no ordering
|
||||
// from this; it must compare that content with recorded state under the lock
|
||||
// (`attendance-ledger.js`).
|
||||
//
|
||||
// BOUNDED. A work that has not settled after `timeoutMs` (default `DEFAULT_TIMEOUT_MS`) is abandoned:
|
||||
// its caller's promise rejects with a `SerializedWorkTimeout`, its `signal` is aborted, and the key is
|
||||
// released so the next work — the next notification — re-decides from a fresh read. A pod request that
|
||||
// never answers can then block one object for at most that long, never until restart. The abandoned
|
||||
// work cannot be stopped, so each work checks `signal.aborted` immediately before every write and
|
||||
// gives up once it is set. A write ALREADY SENT when the time ran out can still land after the next
|
||||
// work has read the state. So when an abandoned work finally settles, `onLateSettle(value)` (if given)
|
||||
// is called with what the work resolved (undefined if it rejected): the CALLER decides from it whether
|
||||
// a write actually landed late and, only then, queues one fresh decision for the key — a re-decision
|
||||
// that must not itself schedule another (no chain while the pod stays slow). A work that never
|
||||
// settles never wrote late.
|
||||
//
|
||||
// Keys are independent (different objects run concurrently); an idle key holds no memory.
|
||||
|
||||
// 30 s: a serialized decision makes a handful of pod round-trips — read the object, read up to two
|
||||
// ACLs, write up to two (public-read); read, then one store write (catalog); a wait of at most 1.5 s
|
||||
// for a fresh collection, a read and one write (attendance) — each answering in tens of milliseconds
|
||||
// on the local stack, a few seconds at worst on a loaded one. 30 s is over ten times the slowest
|
||||
// legitimate run, and short enough that a stalled pod holds one object's decisions for half a minute
|
||||
// rather than until the backend restarts.
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
|
||||
class SerializedWorkTimeout extends Error {
|
||||
constructor(key, timeoutMs) {
|
||||
super(`serialized work for ${key} timed out after ${timeoutMs} ms: released, the next notification re-decides`);
|
||||
this.name = 'SerializedWorkTimeout';
|
||||
this.key = key;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* createKeyedSerializer({ timeoutMs }) → serialize(key, work, { onLateSettle })
|
||||
*
|
||||
* `serialize(key, work)` runs `work(signal)` once every work previously queued for `key` has settled,
|
||||
* and resolves or rejects as `work` does — or rejects with a `SerializedWorkTimeout` (and aborts
|
||||
* `signal`) when `work` has not settled within `timeoutMs`; `onLateSettle(value)` is then called if
|
||||
* and when that abandoned work settles, with its resolved value (undefined when it rejected). `serialize.pending()` is the number of keys with queued work.
|
||||
*/
|
||||
function createKeyedSerializer({ timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
|
||||
const tails = new Map();
|
||||
|
||||
function bounded(key, work, onLateSettle) {
|
||||
const controller = new AbortController();
|
||||
let timer;
|
||||
const expiry = new Promise((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new SerializedWorkTimeout(key, timeoutMs));
|
||||
}, timeoutMs);
|
||||
if (typeof timer.unref === 'function') timer.unref();
|
||||
});
|
||||
const run = Promise.resolve().then(() => work(controller.signal));
|
||||
if (onLateSettle) {
|
||||
const late = value => {
|
||||
if (!controller.signal.aborted) return;
|
||||
try {
|
||||
onLateSettle(value);
|
||||
} catch {
|
||||
// a re-decision that cannot even be queued changes nothing; the next notification decides
|
||||
}
|
||||
};
|
||||
run.then(late, () => late(undefined));
|
||||
}
|
||||
// `race` handles `run` too, so a late rejection of an abandoned work is never unhandled.
|
||||
return Promise.race([run, expiry]).finally(() => clearTimeout(timer));
|
||||
}
|
||||
|
||||
function serialize(key, work, { onLateSettle } = {}) {
|
||||
const previous = tails.get(key) || Promise.resolve();
|
||||
const result = previous.then(() => bounded(key, work, onLateSettle));
|
||||
// The chain continues whatever `work` does; the tail never rejects.
|
||||
const tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined
|
||||
);
|
||||
tails.set(key, tail);
|
||||
tail.then(() => {
|
||||
if (tails.get(key) === tail) tails.delete(key);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
serialize.pending = () => tails.size;
|
||||
return serialize;
|
||||
}
|
||||
|
||||
module.exports = { createKeyedSerializer, SerializedWorkTimeout, DEFAULT_TIMEOUT_MS };
|
||||
@@ -0,0 +1,156 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const { createKeyedSerializer, SerializedWorkTimeout, DEFAULT_TIMEOUT_MS } = require('./keyed-serializer');
|
||||
|
||||
const tick = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
describe('createKeyedSerializer — one work at a time per key', () => {
|
||||
test('works for one key never overlap and run in submission order, even when the first is slower', async () => {
|
||||
const serialize = createKeyedSerializer();
|
||||
const trace = [];
|
||||
const work = (name, ms) => async () => {
|
||||
trace.push(`start ${name}`);
|
||||
await tick(ms);
|
||||
trace.push(`end ${name}`);
|
||||
return name;
|
||||
};
|
||||
const results = await Promise.all([serialize('E', work('first', 30)), serialize('E', work('second', 1))]);
|
||||
expect(results).toEqual(['first', 'second']);
|
||||
expect(trace).toEqual(['start first', 'end first', 'start second', 'end second']);
|
||||
});
|
||||
|
||||
test('different keys run concurrently', async () => {
|
||||
const serialize = createKeyedSerializer();
|
||||
const trace = [];
|
||||
const work = (name, ms) => async () => {
|
||||
trace.push(`start ${name}`);
|
||||
await tick(ms);
|
||||
trace.push(`end ${name}`);
|
||||
};
|
||||
await Promise.all([serialize('E1', work('a', 20)), serialize('E2', work('b', 1))]);
|
||||
expect(trace.slice(0, 2).sort()).toEqual(['start a', 'start b']);
|
||||
expect(trace.indexOf('end b')).toBeLessThan(trace.indexOf('end a'));
|
||||
});
|
||||
|
||||
test('a failing work rejects its own caller but does not block the next one for its key', async () => {
|
||||
const serialize = createKeyedSerializer();
|
||||
const first = serialize('E', async () => {
|
||||
throw new Error('pod down');
|
||||
});
|
||||
const second = serialize('E', async () => 'ran');
|
||||
await expect(first).rejects.toThrow('pod down');
|
||||
await expect(second).resolves.toBe('ran');
|
||||
});
|
||||
|
||||
test('an idle key holds no memory', async () => {
|
||||
const serialize = createKeyedSerializer();
|
||||
await Promise.all([serialize('E1', async () => {}), serialize('E2', async () => {})]);
|
||||
await tick(0);
|
||||
expect(serialize.pending()).toBe(0);
|
||||
});
|
||||
|
||||
test('THE RACE IT CLOSES: the decision is taken on a read made when acting, so the newest state wins', async () => {
|
||||
// Two notifications about one event: it was public, then made private. Each handler reads the
|
||||
// event then acts. Unserialized, the first handler's read (public) can precede the second's while
|
||||
// its action lands last: grant-after-revoke. Serialized with the read INSIDE, the last action
|
||||
// follows a read of the newest state.
|
||||
const scenario = async runFor => {
|
||||
let podState = 'public';
|
||||
const acl = [];
|
||||
const handler = actDelay => async () => {
|
||||
const seen = podState; // the read, taken when acting
|
||||
await tick(actDelay); // the pod's latency before the action lands
|
||||
acl.push(seen === 'public' ? 'grant' : 'revoke');
|
||||
};
|
||||
const first = runFor(handler(30)); // slow: its action lands late
|
||||
await tick(5); // the first handler has read "public" and is acting
|
||||
podState = 'private'; // the owner switches the event; its notification arrives
|
||||
const second = runFor(handler(1));
|
||||
await Promise.all([first, second]);
|
||||
return acl[acl.length - 1];
|
||||
};
|
||||
// Unserialized (the baseline): the stale grant lands last.
|
||||
expect(await scenario(work => work())).toBe('grant');
|
||||
// Serialized per object: the second handler reads after the first has acted.
|
||||
const serialize = createKeyedSerializer();
|
||||
expect(await scenario(work => serialize('E', work))).toBe('revoke');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createKeyedSerializer — bounded: a stalled work never holds its key forever', () => {
|
||||
test('a work that never settles is abandoned after the timeout: its caller is told, the next work runs', async () => {
|
||||
const serialize = createKeyedSerializer({ timeoutMs: 50 });
|
||||
const stalled = serialize('E', () => new Promise(() => {})); // a pod request that never answers
|
||||
let ran = false;
|
||||
const next = serialize('E', async () => {
|
||||
ran = true;
|
||||
});
|
||||
await expect(stalled).rejects.toBeInstanceOf(SerializedWorkTimeout);
|
||||
await next;
|
||||
expect(ran).toBe(true);
|
||||
await tick(0);
|
||||
expect(serialize.pending()).toBe(0);
|
||||
});
|
||||
|
||||
test('the abandoned work sees its signal aborted, so it can give up before writing', async () => {
|
||||
const serialize = createKeyedSerializer({ timeoutMs: 20 });
|
||||
let wrote = false;
|
||||
const slow = serialize('E', async signal => {
|
||||
await tick(60); // the read that took too long
|
||||
if (signal.aborted) return;
|
||||
wrote = true;
|
||||
});
|
||||
await expect(slow).rejects.toBeInstanceOf(SerializedWorkTimeout);
|
||||
await tick(80);
|
||||
expect(wrote).toBe(false);
|
||||
});
|
||||
|
||||
test('a work within the limit is untouched, and the default limit is 30 s', async () => {
|
||||
const serialize = createKeyedSerializer({ timeoutMs: 100 });
|
||||
await expect(serialize('E', async signal => (signal.aborted ? 'aborted' : 'done'))).resolves.toBe('done');
|
||||
expect(DEFAULT_TIMEOUT_MS).toBe(30000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createKeyedSerializer — a write that lands after its timeout is re-decided', () => {
|
||||
test('onLateSettle runs once the abandoned work settles, and its re-decision runs after the newer work', async () => {
|
||||
const serialize = createKeyedSerializer({ timeoutMs: 20 });
|
||||
const order = [];
|
||||
const redecided = new Promise(resolve => {
|
||||
const slow = serialize(
|
||||
'E',
|
||||
async () => {
|
||||
await tick(60); // a write sent before the timeout, landing after it
|
||||
order.push('late write landed');
|
||||
return { wroteLate: true };
|
||||
},
|
||||
{ onLateSettle: value => value.wroteLate && serialize('E', async () => resolve(order.push('re-decision'))) }
|
||||
);
|
||||
slow.catch(() => order.push('timed out'));
|
||||
serialize('E', async () => order.push('newer work'));
|
||||
});
|
||||
await redecided;
|
||||
expect(order).toEqual(['timed out', 'newer work', 'late write landed', 're-decision']);
|
||||
});
|
||||
|
||||
test('onLateSettle is not called for a work that finished in time', async () => {
|
||||
const serialize = createKeyedSerializer({ timeoutMs: 100 });
|
||||
let called = false;
|
||||
await serialize('E', async () => {}, { onLateSettle: () => (called = true) });
|
||||
await tick(5);
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createKeyedSerializer — onLateSettle receives what the late work resolved', () => {
|
||||
test('the value of a late work is handed over (undefined when it rejected), so the caller can tell a late write', async () => {
|
||||
const serialize = createKeyedSerializer({ timeoutMs: 20 });
|
||||
const seen = [];
|
||||
const done = new Promise(resolve => {
|
||||
serialize('A', async () => (await tick(50), { wroteLate: false }), { onLateSettle: v => seen.push(['A', v]) }).catch(() => {});
|
||||
serialize('B', async () => { await tick(50); throw new Error('pod down'); }, { onLateSettle: v => { seen.push(['B', v]); resolve(); } }).catch(() => {});
|
||||
});
|
||||
await done;
|
||||
await tick(10);
|
||||
expect(seen).toEqual([['A', { wroteLate: false }], ['B', undefined]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
// Festipod's PUBLIC intent, as the backend reads it on a created resource.
|
||||
//
|
||||
// The signal is ActivityStreams public addressing: the ActivityStreams Public collection in
|
||||
// `to` or `cc`. Festipod's create path writes it on every event and meeting point it creates
|
||||
// (`src/shared/refine/providers/activitypods.ts`, the `toValues` of those two bindings), and
|
||||
// the pod stores it on the object as written (observed 2026-10-12: stored as `"to": "as:Public"`;
|
||||
// the outbox `Create` the pod generates for a container POST carries no addressing at all).
|
||||
//
|
||||
// What it is NOT: provenance. The decision does not depend on which client created the
|
||||
// resource, only on whether the resource says it is public. An as:Event without public
|
||||
// addressing — created by another app, posted directly on the pod, addressed to chosen
|
||||
// recipients only, or a future Festipod record in a protected or private scope — never matches.
|
||||
|
||||
const AS_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
|
||||
|
||||
// The three equivalent spellings of the Public collection: the full IRI and the two compacted
|
||||
// forms JSON-LD compaction against the ActivityStreams context can produce.
|
||||
const PUBLIC_FORMS = new Set([AS_PUBLIC, 'as:Public', 'Public']);
|
||||
|
||||
// `to` and `cc`, under the compacted names and their prefixed and full-IRI spellings (a
|
||||
// representation compacted against a context that does not define the short terms).
|
||||
const ADDRESSING_KEYS = [
|
||||
'to',
|
||||
'cc',
|
||||
'as:to',
|
||||
'as:cc',
|
||||
'https://www.w3.org/ns/activitystreams#to',
|
||||
'https://www.w3.org/ns/activitystreams#cc'
|
||||
];
|
||||
|
||||
const listOf = value => (value === undefined || value === null ? [] : Array.isArray(value) ? value : [value]);
|
||||
|
||||
/** The address a `to`/`cc` entry names: a bare IRI string, or a node carrying `id`/`@id`. */
|
||||
const addressOf = entry => {
|
||||
if (typeof entry === 'string') return entry;
|
||||
if (entry && typeof entry === 'object') {
|
||||
const id = entry.id || entry['@id'];
|
||||
return typeof id === 'string' ? id : undefined;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* True when this ONE JSON-LD resource (an object or an activity) is addressed to the
|
||||
* ActivityStreams Public collection in `to` or `cc`. Anything else is false: no addressing,
|
||||
* addressing to actors or collections only, Public in `bto`/`bcc`/`audience`, a non-object input.
|
||||
*/
|
||||
const isAddressedToPublic = resource => {
|
||||
if (!resource || typeof resource !== 'object') return false;
|
||||
return ADDRESSING_KEYS.some(key => listOf(resource[key]).some(entry => PUBLIC_FORMS.has(addressOf(entry))));
|
||||
};
|
||||
|
||||
/**
|
||||
* hasPublicIntent(object, activity?) → boolean.
|
||||
*
|
||||
* `object` is the created resource as stored on the pod (what the app's activity watcher hands
|
||||
* to `onCreate`: the dereferenced `object` of the `Create`). `activity` is optional: the `Create`
|
||||
* itself, when the caller holds it. True when either one is addressed to the Public collection
|
||||
* (see `isAddressedToPublic`); false when neither is — a missing argument counts as not addressed.
|
||||
*
|
||||
* This is the ONLY gate in front of making a resource world-readable or listing it publicly:
|
||||
* a caller that grants or publishes on a creation must ask this first.
|
||||
*/
|
||||
const hasPublicIntent = (object, activity) => isAddressedToPublic(object) || isAddressedToPublic(activity);
|
||||
|
||||
// ── LISTED public: what a public index may list ───────────────────────────────────────────────
|
||||
//
|
||||
// ActivityStreams addressing distinguishes two public audiences by WHERE the Public collection is
|
||||
// named, the convention fediverse servers follow: in `to`, the resource is public AND listed — meant
|
||||
// to show up in public timelines and indexes; in `cc` only, it is public but UNLISTED — anyone who
|
||||
// has its link may read it, but nothing should advertise it. The public-read grant serves both
|
||||
// (`hasPublicIntent`); a public index — Festipod's catalog — may list only the first.
|
||||
|
||||
// `to` only, under the same three spellings as above.
|
||||
const TO_KEYS = ['to', 'as:to', 'https://www.w3.org/ns/activitystreams#to'];
|
||||
|
||||
/**
|
||||
* True when this ONE JSON-LD resource names the ActivityStreams Public collection in `to` (any of
|
||||
* its spellings, as a bare IRI or a node). Public in `cc` only — unlisted — is false, as is
|
||||
* everything `isAddressedToPublic` refuses.
|
||||
*/
|
||||
const isListedToPublic = resource => {
|
||||
if (!resource || typeof resource !== 'object') return false;
|
||||
return TO_KEYS.some(key => listOf(resource[key]).some(entry => PUBLIC_FORMS.has(addressOf(entry))));
|
||||
};
|
||||
|
||||
/**
|
||||
* hasListedPublicIntent(object, activity?) → boolean.
|
||||
*
|
||||
* Public intent (`hasPublicIntent`, same arguments) AND listed: the Public collection in `to` of
|
||||
* the object or of the activity. Built on `hasPublicIntent`, so it can never be true where that one
|
||||
* is false: nothing is listed that the public-read gate would not have accepted.
|
||||
*/
|
||||
const hasListedPublicIntent = (object, activity) =>
|
||||
hasPublicIntent(object, activity) && (isListedToPublic(object) || isListedToPublic(activity));
|
||||
|
||||
module.exports = { AS_PUBLIC, isAddressedToPublic, hasPublicIntent, isListedToPublic, hasListedPublicIntent };
|
||||
@@ -0,0 +1,128 @@
|
||||
const { test, expect } = require('bun:test');
|
||||
const { AS_PUBLIC, isAddressedToPublic, hasPublicIntent } = require('./public-intent');
|
||||
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const FRIEND = 'http://localhost:3000/bob';
|
||||
|
||||
test('an object addressed to the Public collection matches, in each of its three spellings', () => {
|
||||
// The pod's own representation (observed): `"to": "as:Public"`.
|
||||
expect(hasPublicIntent({ type: 'Event', to: 'as:Public' })).toBe(true);
|
||||
expect(hasPublicIntent({ type: 'Event', to: AS_PUBLIC })).toBe(true);
|
||||
expect(hasPublicIntent({ type: 'Event', to: 'Public' })).toBe(true);
|
||||
});
|
||||
|
||||
test('public addressing matches in cc, in a list, and as a node', () => {
|
||||
expect(hasPublicIntent({ to: [FRIEND], cc: 'as:Public' })).toBe(true);
|
||||
expect(hasPublicIntent({ to: [FRIEND, AS_PUBLIC] })).toBe(true);
|
||||
expect(hasPublicIntent({ to: { id: AS_PUBLIC } })).toBe(true);
|
||||
expect(hasPublicIntent({ to: [{ '@id': 'as:Public' }] })).toBe(true);
|
||||
expect(hasPublicIntent({ 'as:to': 'as:Public' })).toBe(true);
|
||||
expect(hasPublicIntent({ 'https://www.w3.org/ns/activitystreams#cc': { '@id': AS_PUBLIC } })).toBe(true);
|
||||
});
|
||||
|
||||
test('an object with no addressing never matches — whoever created it', () => {
|
||||
// A private event posted directly on the pod, or created by another app: no `to`/`cc`.
|
||||
expect(hasPublicIntent({ id: `${ALICE}/data/1`, type: 'Event', name: 'Dîner privé' })).toBe(false);
|
||||
// A meeting point without addressing is not public either: the signal, not the shape, decides.
|
||||
expect(hasPublicIntent({ type: 'Event', 'as:context': `${ALICE}/data/0` })).toBe(false);
|
||||
});
|
||||
|
||||
test('addressing to chosen recipients only never matches', () => {
|
||||
expect(hasPublicIntent({ type: 'Event', to: [FRIEND] })).toBe(false);
|
||||
expect(hasPublicIntent({ type: 'Event', to: FRIEND, cc: `${ALICE}/followers` })).toBe(false);
|
||||
// A protected-scope record addressed to the creator's contacts collection.
|
||||
expect(hasPublicIntent({ type: 'Event', to: `${ALICE}/contacts` })).toBe(false);
|
||||
});
|
||||
|
||||
test('Public anywhere but to/cc does not match', () => {
|
||||
expect(hasPublicIntent({ bto: AS_PUBLIC })).toBe(false);
|
||||
expect(hasPublicIntent({ bcc: 'as:Public' })).toBe(false);
|
||||
expect(hasPublicIntent({ audience: AS_PUBLIC })).toBe(false);
|
||||
// The IRI as a plain value of another predicate is not addressing.
|
||||
expect(hasPublicIntent({ name: AS_PUBLIC, content: 'as:Public' })).toBe(false);
|
||||
});
|
||||
|
||||
test('look-alike addresses do not match', () => {
|
||||
expect(hasPublicIntent({ to: 'https://www.w3.org/ns/activitystreams#PublicX' })).toBe(false);
|
||||
expect(hasPublicIntent({ to: 'public' })).toBe(false);
|
||||
expect(hasPublicIntent({ to: `${ALICE}/Public` })).toBe(false);
|
||||
expect(hasPublicIntent({ to: { name: 'as:Public' } })).toBe(false);
|
||||
});
|
||||
|
||||
test('the creating activity counts when the caller holds it', () => {
|
||||
const privateObject = { type: 'Event', to: [FRIEND] };
|
||||
expect(hasPublicIntent(privateObject, { type: 'Create', to: AS_PUBLIC })).toBe(true);
|
||||
expect(hasPublicIntent(privateObject, { type: 'Create', to: [FRIEND] })).toBe(false);
|
||||
// The Create the pod generates for a container POST carries no addressing (observed).
|
||||
expect(hasPublicIntent({ type: 'Event' }, { type: 'Create', actor: ALICE, object: `${ALICE}/data/1` })).toBe(false);
|
||||
});
|
||||
|
||||
test('missing or non-object input is not public', () => {
|
||||
expect(hasPublicIntent(undefined)).toBe(false);
|
||||
expect(hasPublicIntent(null, undefined)).toBe(false);
|
||||
expect(hasPublicIntent(`${ALICE}/data/1`)).toBe(false);
|
||||
expect(isAddressedToPublic({ to: null, cc: [] })).toBe(false);
|
||||
});
|
||||
|
||||
// ── Listed public: what the catalog may list ─────────────────────────────────────────────────
|
||||
|
||||
const { isListedToPublic, hasListedPublicIntent } = require('./public-intent');
|
||||
|
||||
test('Public in `to` is listed, in each spelling and shape', () => {
|
||||
// The pod's own representation of a Festipod event (observed): `"to": "as:Public"`.
|
||||
expect(hasListedPublicIntent({ type: 'Event', to: 'as:Public' })).toBe(true);
|
||||
expect(hasListedPublicIntent({ type: 'Event', to: AS_PUBLIC })).toBe(true);
|
||||
expect(hasListedPublicIntent({ type: 'Event', to: 'Public' })).toBe(true);
|
||||
expect(hasListedPublicIntent({ to: [FRIEND, AS_PUBLIC], cc: `${ALICE}/followers` })).toBe(true);
|
||||
expect(hasListedPublicIntent({ to: { id: AS_PUBLIC } })).toBe(true);
|
||||
expect(hasListedPublicIntent({ 'as:to': [{ '@id': 'as:Public' }] })).toBe(true);
|
||||
expect(hasListedPublicIntent({ 'https://www.w3.org/ns/activitystreams#to': AS_PUBLIC })).toBe(true);
|
||||
});
|
||||
|
||||
test('Public in `cc` only is public but UNLISTED: readable, never listed', () => {
|
||||
const unlisted = { type: 'Event', to: [FRIEND], cc: 'as:Public' };
|
||||
expect(hasPublicIntent(unlisted)).toBe(true);
|
||||
expect(hasListedPublicIntent(unlisted)).toBe(false);
|
||||
expect(hasListedPublicIntent({ cc: AS_PUBLIC })).toBe(false);
|
||||
expect(hasListedPublicIntent({ 'as:cc': { '@id': 'as:Public' } })).toBe(false);
|
||||
});
|
||||
|
||||
test('no addressing, chosen recipients, bto/bcc/audience and look-alikes are never listed', () => {
|
||||
for (const resource of [
|
||||
{ id: `${ALICE}/data/1`, type: 'Event', name: 'Dîner privé' },
|
||||
{ type: 'Event', to: [FRIEND] },
|
||||
{ type: 'Event', to: `${ALICE}/contacts` },
|
||||
{ bto: AS_PUBLIC },
|
||||
{ bcc: 'as:Public' },
|
||||
{ audience: AS_PUBLIC },
|
||||
{ name: AS_PUBLIC, content: 'as:Public' },
|
||||
{ to: 'https://www.w3.org/ns/activitystreams#PublicX' },
|
||||
{ to: 'public' },
|
||||
{ to: `${ALICE}/Public` },
|
||||
{ to: { name: 'as:Public' } },
|
||||
undefined,
|
||||
null,
|
||||
`${ALICE}/data/1`
|
||||
]) {
|
||||
expect(hasListedPublicIntent(resource)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
test('the creating activity counts the same way: `to` lists, `cc` does not', () => {
|
||||
const noAddressing = { type: 'Event' };
|
||||
expect(hasListedPublicIntent(noAddressing, { type: 'Create', to: AS_PUBLIC })).toBe(true);
|
||||
expect(hasListedPublicIntent(noAddressing, { type: 'Create', cc: AS_PUBLIC })).toBe(false);
|
||||
expect(hasListedPublicIntent(noAddressing, { type: 'Create', to: [FRIEND] })).toBe(false);
|
||||
});
|
||||
|
||||
test('listed implies public: never true where hasPublicIntent is false', () => {
|
||||
const addresses = [undefined, AS_PUBLIC, 'as:Public', 'Public', FRIEND, [FRIEND, 'as:Public'], { id: AS_PUBLIC }, 'public'];
|
||||
const shapes = [];
|
||||
for (const to of addresses) for (const cc of addresses) shapes.push({ to, cc });
|
||||
for (const object of shapes) {
|
||||
for (const activity of [undefined, ...shapes.slice(0, 8)]) {
|
||||
if (hasListedPublicIntent(object, activity)) expect(hasPublicIntent(object, activity)).toBe(true);
|
||||
}
|
||||
expect(isListedToPublic(object)).toBe(hasListedPublicIntent(object));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
// The one WAC shape Festipod adds to make a pod resource world-readable — `acl:Read` for
|
||||
// `acl:agentClass foaf:Agent` — plus how to read whether it is present and how to take exactly
|
||||
// that shape away again.
|
||||
//
|
||||
// How the pod's ACL API behaves (@semapps/webacl 1.2.7, read in setRights.ts + utils.ts, confirmed
|
||||
// live 2026-10-12): `PATCH {aclUri}` only ADDS triples; `PUT {aclUri}` REPLACES the resource's own
|
||||
// rights, computed as a diff over AGENT triples (`acl:agent`, `acl:agentClass`, `acl:agentGroup`,
|
||||
// `acl:origin`) keyed by the resource's own `#Read`/`#Write`/`#Append`/`#Control` authorizations.
|
||||
// Inherited `#Default*` rules come from the container, are not the resource's own, and are left
|
||||
// untouched by a PUT. An EMPTY own rule set is refused (400 "PUT rights cannot be empty").
|
||||
//
|
||||
// A Festipod event's only OWN rule is `#Read` with `acl:agentClass foaf:Agent` (the grant this
|
||||
// backend adds); the owner reads through the container's `#Default` rules, not an own rule. So
|
||||
// removing the foaf:Agent triple alone would leave the own set empty and the PUT would be refused.
|
||||
// `revocationGraph` therefore keeps the owner as `acl:agent` of `#Read` when nothing else remains —
|
||||
// the owner already reads via the default, so no right is gained or lost — and preserves any other
|
||||
// own agent triples (e.g. a future per-contact grant) unchanged.
|
||||
//
|
||||
// NARROW: only the public READ goes. The pod keys own rules by mode (`#Read`, `#Write`, `#Append`,
|
||||
// `#Control`); the `foaf:Agent` triple is removed from the `#Read` rule alone, so a `foaf:Agent` on
|
||||
// another own rule (a world-appendable collection, say) is left exactly as it was.
|
||||
//
|
||||
// INHERITED public read cannot be taken away here: a `#Default*` rule of the container is not the
|
||||
// resource's own, and a PUT on the resource's ACL leaves it in place. `hasOwnPublicRead` and
|
||||
// `hasInheritedPublicRead` tell the two apart so the caller can say which one it met.
|
||||
//
|
||||
// The platform's own `pod-permissions.remove` cannot do this: it matches the full foaf IRI against
|
||||
// the compacted `foaf:Agent` the pod returns (removes nothing), and it drops any authorization
|
||||
// whose removed predicate becomes empty even when other agent predicates remain (would empty the
|
||||
// set → 400). This module PUTs a correct graph instead.
|
||||
|
||||
const { arrayOf } = require('@semapps/ldp');
|
||||
|
||||
const ACL = 'http://www.w3.org/ns/auth/acl#';
|
||||
const FOAF = 'http://xmlns.com/foaf/0.1/';
|
||||
const FOAF_AGENT = FOAF + 'Agent';
|
||||
const OWN_SUFFIXES = ['Read', 'Write', 'Append', 'Control'];
|
||||
const AGENT_PREDICATES = ['agent', 'agentClass', 'agentGroup', 'origin'];
|
||||
|
||||
/** Expands `acl:`/`foaf:` CURIEs and `{ '@id' }` / `{ id }` nodes to full IRIs; passes others through. */
|
||||
const expand = value => {
|
||||
const id = typeof value === 'object' && value !== null ? value['@id'] || value.id : value;
|
||||
if (typeof id !== 'string') return id;
|
||||
if (id.startsWith('acl:')) return ACL + id.slice('acl:'.length);
|
||||
if (id.startsWith('foaf:')) return FOAF + id.slice('foaf:'.length);
|
||||
return id;
|
||||
};
|
||||
|
||||
/** The id an authorization node carries (`@id` or `id`). */
|
||||
const authId = authorization =>
|
||||
(authorization && (authorization['@id'] || authorization.id)) || '';
|
||||
|
||||
/** The values of `acl:<name>` on an authorization, under its compacted or full-IRI key (raw, not expanded). */
|
||||
const rawValuesOf = (authorization, name) => [
|
||||
...arrayOf(authorization['acl:' + name]),
|
||||
...arrayOf(authorization[ACL + name])
|
||||
];
|
||||
|
||||
const expandedValuesOf = (authorization, name) => rawValuesOf(authorization, name).map(expand);
|
||||
|
||||
/** True when the authorization is a Read rule that lets any agent (foaf:Agent) read. */
|
||||
const isPublicRead = authorization =>
|
||||
expandedValuesOf(authorization, 'mode').includes(ACL + 'Read') &&
|
||||
expandedValuesOf(authorization, 'agentClass').includes(FOAF_AGENT);
|
||||
|
||||
/** True when one of the ACL's authorizations already lets any agent (foaf:Agent) read. */
|
||||
const grantsPublicRead = authorizations => arrayOf(authorizations).some(isPublicRead);
|
||||
|
||||
/** The fragment of an authorization's id (`Read` for `…#Read`, `DefaultRead` for an inherited rule). */
|
||||
const suffixOf = authorization => String(authId(authorization)).split('#')[1];
|
||||
|
||||
/** An authorization is the resource's OWN (not an inherited `#Default*`) when its id ends `#<suffix>`. */
|
||||
const isOwnAuthorization = authorization => OWN_SUFFIXES.includes(suffixOf(authorization));
|
||||
|
||||
/** True when the resource's OWN `#Read` rule lets any agent (foaf:Agent) read — what `revocationGraph` removes. */
|
||||
const hasOwnPublicRead = authorizations =>
|
||||
arrayOf(authorizations).some(authorization => suffixOf(authorization) === 'Read' && isPublicRead(authorization));
|
||||
|
||||
/** True when a rule that is NOT the resource's own (an inherited `#Default*`) lets any agent read. */
|
||||
const hasInheritedPublicRead = authorizations =>
|
||||
arrayOf(authorizations).some(authorization => !isOwnAuthorization(authorization) && isPublicRead(authorization));
|
||||
|
||||
/**
|
||||
* aclContext(aclUri) — the JSON-LD context to PUT with a revocation graph: the `acl:`/`foaf:`
|
||||
* prefixes and the id-typed agent/accessTo/mode terms, based at `aclUri` so `#Read` resolves to the
|
||||
* authorization fragment. Mirrors the pod's own `getAclContext` so the PUT round-trips identically.
|
||||
*/
|
||||
const aclContext = aclUri => ({
|
||||
'@base': aclUri,
|
||||
acl: ACL,
|
||||
foaf: FOAF,
|
||||
'acl:agent': { '@type': '@id' },
|
||||
'acl:agentClass': { '@type': '@id' },
|
||||
'acl:agentGroup': { '@type': '@id' },
|
||||
'acl:origin': { '@type': '@id' },
|
||||
'acl:mode': { '@type': '@id' },
|
||||
'acl:accessTo': { '@type': '@id' }
|
||||
});
|
||||
|
||||
/**
|
||||
* revocationGraph(authorizations, resourceUri, ownerUri) → a JSON-LD document `{ '@context', '@graph' }`
|
||||
* to PUT on the resource's ACL so it keeps every one of its OWN agent triples EXCEPT
|
||||
* `acl:agentClass foaf:Agent` on its `#Read` authorization (a `foaf:Agent` on any other own rule stays). Inherited `#Default*` rules are not emitted
|
||||
* (a PUT does not touch them). When removing the public-read triple would leave the own set empty,
|
||||
* the owner is emitted as `acl:agent` of `#Read` so the PUT is accepted without changing any right.
|
||||
* `aclUri` is where the document will be PUT (used only for the context `@base`).
|
||||
*/
|
||||
const revocationGraph = (authorizations, resourceUri, ownerUri, aclUri) => {
|
||||
const graph = [];
|
||||
for (const authorization of arrayOf(authorizations)) {
|
||||
if (!isOwnAuthorization(authorization)) continue;
|
||||
const id = authId(authorization);
|
||||
const isReadRule = suffixOf(authorization) === 'Read';
|
||||
const node = { '@id': id, '@type': 'acl:Authorization', 'acl:accessTo': resourceUri };
|
||||
for (const mode of rawValuesOf(authorization, 'mode')) {
|
||||
node['acl:mode'] = node['acl:mode'] ? [].concat(node['acl:mode'], mode) : mode;
|
||||
}
|
||||
let kept = 0;
|
||||
for (const name of AGENT_PREDICATES) {
|
||||
const values = rawValuesOf(authorization, name).filter(
|
||||
value => !(isReadRule && name === 'agentClass' && expand(value) === FOAF_AGENT)
|
||||
);
|
||||
if (values.length > 0) {
|
||||
node['acl:' + name] = values.length === 1 ? values[0] : values;
|
||||
kept += values.length;
|
||||
}
|
||||
}
|
||||
if (kept > 0) graph.push(node);
|
||||
}
|
||||
// The pod refuses an empty own rule set; keep the owner as a reader (already true via the default).
|
||||
if (graph.length === 0) {
|
||||
graph.push({ '@id': '#Read', '@type': 'acl:Authorization', 'acl:accessTo': resourceUri, 'acl:mode': 'acl:Read', 'acl:agent': ownerUri });
|
||||
}
|
||||
return { '@context': aclContext(aclUri), '@graph': graph };
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
grantsPublicRead,
|
||||
hasOwnPublicRead,
|
||||
hasInheritedPublicRead,
|
||||
revocationGraph,
|
||||
isPublicRead,
|
||||
expand,
|
||||
FOAF_AGENT,
|
||||
ACL
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const { grantsPublicRead, hasOwnPublicRead, hasInheritedPublicRead, revocationGraph, isPublicRead, FOAF_AGENT } = require('./public-read-acl');
|
||||
|
||||
const ACL = 'http://www.w3.org/ns/auth/acl#';
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const E = `${ALICE}/data/events/e1`;
|
||||
const ACL_URI = `http://localhost:3000/_acl/alice/data/events/e1`;
|
||||
|
||||
const defaultRead = { '@id': '#DefaultRead', 'acl:mode': 'acl:Read', 'acl:agent': ALICE };
|
||||
const publicRead = (agentClass = 'foaf:Agent') => ({ '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': agentClass });
|
||||
|
||||
// Flatten the PUT graph's agent triples for assertions.
|
||||
const triples = doc => {
|
||||
const out = [];
|
||||
for (const n of doc['@graph']) for (const p of ['acl:agent', 'acl:agentClass', 'acl:agentGroup', 'acl:origin'])
|
||||
for (const o of [].concat(n[p] ?? [])) out.push({ auth: n['@id'], p, o });
|
||||
return out;
|
||||
};
|
||||
|
||||
describe('grantsPublicRead / isPublicRead', () => {
|
||||
test('detects the foaf:Agent read rule in both spellings', () => {
|
||||
expect(grantsPublicRead([defaultRead, publicRead('foaf:Agent')])).toBe(true);
|
||||
expect(grantsPublicRead([defaultRead, publicRead(FOAF_AGENT)])).toBe(true);
|
||||
expect(grantsPublicRead([defaultRead, publicRead({ '@id': FOAF_AGENT })])).toBe(true);
|
||||
});
|
||||
test('a non-public ACL is not public-read', () => {
|
||||
expect(grantsPublicRead([defaultRead])).toBe(false);
|
||||
expect(isPublicRead({ '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agent': ALICE })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revocationGraph — a PUT body that drops foaf:Agent, keeps the rest, never empty', () => {
|
||||
test('typical event (only own rule is #Read foaf:Agent): keeps owner, drops public, omits #Default', () => {
|
||||
const doc = revocationGraph([defaultRead, publicRead('foaf:Agent')], E, ALICE, ACL_URI);
|
||||
const ts = triples(doc);
|
||||
expect(ts.some(t => t.o === 'foaf:Agent' || t.o === FOAF_AGENT)).toBe(false);
|
||||
expect(ts.some(t => t.auth.includes('#Default'))).toBe(false); // PUT must not touch inherited defaults
|
||||
expect(ts.some(t => t.p === 'acl:agent' && t.o === ALICE && t.auth.includes('#Read'))).toBe(true);
|
||||
expect(doc['@context']['@base']).toBe(ACL_URI);
|
||||
});
|
||||
|
||||
test('full foaf IRI is dropped too', () => {
|
||||
const ts = triples(revocationGraph([publicRead(FOAF_AGENT)], E, ALICE, ACL_URI));
|
||||
expect(ts.some(t => t.o === FOAF_AGENT || t.o === 'foaf:Agent')).toBe(false);
|
||||
expect(ts.some(t => t.p === 'acl:agent' && t.o === ALICE)).toBe(true); // owner fallback, not empty
|
||||
});
|
||||
|
||||
test('a co-existing per-contact grant on #Read is preserved', () => {
|
||||
const carol = 'http://localhost:3000/carol';
|
||||
const node = { '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent', 'acl:agent': carol };
|
||||
const ts = triples(revocationGraph([node], E, ALICE, ACL_URI));
|
||||
expect(ts.some(t => t.p === 'acl:agent' && t.o === carol)).toBe(true);
|
||||
expect(ts.some(t => t.o === 'foaf:Agent' || t.o === FOAF_AGENT)).toBe(false);
|
||||
// The owner fallback is NOT added when another agent already keeps the set non-empty.
|
||||
expect(ts.some(t => t.p === 'acl:agent' && t.o === ALICE)).toBe(false);
|
||||
});
|
||||
|
||||
test('a separate own #Control rule is retained (only the public Read triple goes)', () => {
|
||||
const control = { '@id': '#Control', 'acl:mode': 'acl:Control', 'acl:agent': ALICE };
|
||||
const ts = triples(revocationGraph([control, publicRead('foaf:Agent')], E, ALICE, ACL_URI));
|
||||
expect(ts.some(t => t.auth.includes('#Control') && t.o === ALICE)).toBe(true);
|
||||
expect(ts.some(t => t.o === 'foaf:Agent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revocationGraph is NARROW — only the public READ goes', () => {
|
||||
test('a foaf:Agent on the own #Append rule (a world-appendable collection) is kept; only #Read loses it', () => {
|
||||
const append = { '@id': '#Append', 'acl:mode': 'acl:Append', 'acl:agentClass': 'foaf:Agent' };
|
||||
const ts = triples(revocationGraph([publicRead('foaf:Agent'), append], E, ALICE, ACL_URI));
|
||||
expect(ts.some(t => t.auth === '#Append' && t.p === 'acl:agentClass' && t.o === 'foaf:Agent')).toBe(true);
|
||||
expect(ts.some(t => t.auth === '#Read' && (t.o === 'foaf:Agent' || t.o === FOAF_AGENT))).toBe(false);
|
||||
});
|
||||
|
||||
test('a foaf:Agent on the own #Write rule is kept too, under either spelling', () => {
|
||||
const write = { '@id': '#Write', 'acl:mode': 'acl:Write', 'acl:agentClass': FOAF_AGENT };
|
||||
const ts = triples(revocationGraph([publicRead(FOAF_AGENT), write], E, ALICE, ACL_URI));
|
||||
expect(ts.some(t => t.auth === '#Write' && t.o === FOAF_AGENT)).toBe(true);
|
||||
expect(ts.some(t => t.auth === '#Read' && (t.o === 'foaf:Agent' || t.o === FOAF_AGENT))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('own versus inherited public read', () => {
|
||||
const inheritedPublic = { '@id': '#DefaultRead', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' };
|
||||
test('the own #Read rule is what a revocation can remove', () => {
|
||||
expect(hasOwnPublicRead([defaultRead, publicRead()])).toBe(true);
|
||||
expect(hasInheritedPublicRead([defaultRead, publicRead()])).toBe(false);
|
||||
});
|
||||
test('a public read inherited from the container is told apart (a PUT cannot remove it)', () => {
|
||||
expect(hasOwnPublicRead([inheritedPublic])).toBe(false);
|
||||
expect(hasInheritedPublicRead([inheritedPublic])).toBe(true);
|
||||
expect(grantsPublicRead([inheritedPublic])).toBe(true);
|
||||
});
|
||||
test('a foaf:Agent on another own rule is not a public read', () => {
|
||||
expect(hasOwnPublicRead([{ '@id': '#Append', 'acl:mode': 'acl:Append', 'acl:agentClass': 'foaf:Agent' }])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// Which anonymous-read grants are FESTIPOD's — the record that lets public-read revoke only what it
|
||||
// added.
|
||||
//
|
||||
// WHY. A pod resource can be world-readable for reasons that are not Festipod's: its owner shared it
|
||||
// publicly, another app did, or the pod made it so. When an event stops being public, Festipod must
|
||||
// take back the public read IT gave and leave any other one alone. The ACL cannot tell them apart (the
|
||||
// rule is the same `acl:Read` for `foaf:Agent`), so Festipod records each grant it makes, here, and
|
||||
// `revoke` acts only on a recorded one.
|
||||
//
|
||||
// WHAT COUNTS AS FESTIPOD'S:
|
||||
// - a grant `public-read.grant` added (the resource was not already world-readable);
|
||||
// - the public read the pod gives a collection Festipod itself created (an event's attendees
|
||||
// collection: the pod makes it world-readable at creation — observed 2026-10-12/13).
|
||||
// A resource already world-readable before Festipod touched it is never recorded, so never revoked.
|
||||
//
|
||||
// WHERE IT LIVES. Like the catalog (`catalog-store.js`): in the backend's main dataset, DEFAULT graph
|
||||
// (the one Fuseki persists), one triple per resource — subject the resource URI, one Festipod predicate,
|
||||
// the time of the grant. It survives a backend restart. Grants made before this record existed
|
||||
// (development data) are not in it, so they are not revoked.
|
||||
|
||||
const { iriRef } = require('./catalog-store');
|
||||
|
||||
const GRANTED_AT = 'http://festipod.org/publicReadGrantedAt';
|
||||
|
||||
/** Record (or re-record) that Festipod made `uri` world-readable. One update = one transaction. */
|
||||
function recordGrantUpdate(uri) {
|
||||
const s = iriRef(uri);
|
||||
return `
|
||||
DELETE { ${s} <${GRANTED_AT}> ?old }
|
||||
INSERT { ${s} <${GRANTED_AT}> ?now }
|
||||
WHERE { OPTIONAL { ${s} <${GRANTED_AT}> ?old } BIND(NOW() AS ?now) }
|
||||
`;
|
||||
}
|
||||
|
||||
/** Forget Festipod's grant on `uri` — only the ledger's predicate, nothing else about that URI. */
|
||||
function forgetGrantUpdate(uri) {
|
||||
return `DELETE WHERE { ${iriRef(uri)} <${GRANTED_AT}> ?at }`;
|
||||
}
|
||||
|
||||
/** Whether Festipod's grant on `uri` is recorded. */
|
||||
function grantedQuery(uri) {
|
||||
return `ASK { ${iriRef(uri)} <${GRANTED_AT}> ?at }`;
|
||||
}
|
||||
|
||||
// ── Store operations: `ctx` is a moleculer context (anything with `call`) ──
|
||||
|
||||
async function recordGrant(ctx, { dataset, uri }) {
|
||||
await ctx.call('triplestore.update', { query: recordGrantUpdate(uri), dataset, webId: 'system' });
|
||||
}
|
||||
|
||||
async function forgetGrant(ctx, { dataset, uri }) {
|
||||
await ctx.call('triplestore.update', { query: forgetGrantUpdate(uri), dataset, webId: 'system' });
|
||||
}
|
||||
|
||||
/** Resolves true only when the store answers the ASK with `true`. */
|
||||
async function isGrantedByFestipod(ctx, { dataset, uri }) {
|
||||
return (await ctx.call('triplestore.query', { query: grantedQuery(uri), dataset, webId: 'system' })) === true;
|
||||
}
|
||||
|
||||
module.exports = { GRANTED_AT, recordGrantUpdate, forgetGrantUpdate, grantedQuery, recordGrant, forgetGrant, isGrantedByFestipod };
|
||||
@@ -0,0 +1,349 @@
|
||||
const urlJoin = require('url-join');
|
||||
const { PodResourcesHandlerMixin } = require('@activitypods/app');
|
||||
const CONFIG = require('../config/config');
|
||||
const { hasPublicIntent } = require('./public-intent');
|
||||
const { isInActorPod } = require('./actor-pod');
|
||||
const { createKeyedSerializer } = require('./keyed-serializer');
|
||||
const { neverThrowingMatcher, verifiedEmit, uriOf, singleUriOf, hasAsType } = require('./emitted-activity');
|
||||
const { grantsPublicRead, hasOwnPublicRead, hasInheritedPublicRead, revocationGraph, FOAF_AGENT } = require('./public-read-acl');
|
||||
const { recordGrant, forgetGrant, isGrantedByFestipod } = require('./public-read-ledger');
|
||||
|
||||
/** The ACL document URI of a pod resource: `{origin}/_acl{path}` (the pod's `getAclUriFromResourceUri`). */
|
||||
const aclUriOf = resourceUri => {
|
||||
const { origin, pathname } = new URL(resourceUri);
|
||||
return `${origin}/_acl${pathname}`;
|
||||
};
|
||||
|
||||
const APODS_ATTENDEES = 'http://activitypods.org/ns/core#attendees';
|
||||
|
||||
/** The attendees collection an event names (compacted or full-IRI key), when it names exactly one. */
|
||||
const attendeesCollectionOf = event => singleUriOf(event['apods:attendees'] ?? event[APODS_ATTENDEES]);
|
||||
|
||||
// Festipod creates its events and meeting points public: their bindings declare visibility
|
||||
// 'public', and its create path addresses each one to the ActivityStreams Public collection
|
||||
// (`src/shared/refine/providers/activitypods.ts`). This service owns the one WAC shape that makes
|
||||
// a pod resource world-readable — `acl:Read` for `acl:agentClass foaf:Agent`, nothing more — and
|
||||
// keeps it in step with the object's CURRENT public intent (`public-intent.js`), acting as the app
|
||||
// actor: an `as:Event` addressed to the Public collection is world-readable, one that is not is not.
|
||||
// Its attendees collection follows it both ways (the pod creates that collection world-readable).
|
||||
// It takes back ONLY the public reads Festipod gave — recorded in `public-read-ledger.js` — and leaves
|
||||
// any other one (the owner's, another app's) untouched.
|
||||
//
|
||||
// It learns of changes through the app's watcher on each user's outbox — the `Create` and `Update`
|
||||
// of any `as:Event`, whichever client made it and whatever its audience — and every notification
|
||||
// leads to the same `reconcile`: under the event's lock (`keyed-serializer.js`), read the event NOW,
|
||||
// then grant or revoke on the event and its collection. The notification itself only names the
|
||||
// object; whatever order notifications arrive or run in, the last action follows the newest read.
|
||||
module.exports = {
|
||||
name: 'public-read',
|
||||
mixins: [PodResourcesHandlerMixin],
|
||||
settings: {
|
||||
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event')
|
||||
},
|
||||
dependencies: ['pod-permissions'],
|
||||
created() {
|
||||
this.serialize = createKeyedSerializer();
|
||||
},
|
||||
// The mixin's default handlers would act on the activity's self-declared `actor` and on the
|
||||
// object embedded in the (unauthenticated) webhook body. Override the whole map: keep the
|
||||
// mixin's create/update matchers (made never-throwing), but every `onEmit` acts only on an
|
||||
// activity `verifiedEmit` accepts — re-read from the pod as the app, attributed to the owner of
|
||||
// the outbox the webhook fired on, its object an `as:Event` in that owner's pod — and even then
|
||||
// only as a pointer to the event `reconcile` reads again. No delete handler: when an event is
|
||||
// deleted its ACL goes with it.
|
||||
activities: {
|
||||
create: {
|
||||
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.create.match),
|
||||
onEmit: verifiedEmit(
|
||||
'Create',
|
||||
async function (ctx, { objectUri }, actorUri) {
|
||||
await this.onCreate(ctx, objectUri, actorUri);
|
||||
},
|
||||
{ objectType: 'Event' }
|
||||
)
|
||||
},
|
||||
update: {
|
||||
match: neverThrowingMatcher(PodResourcesHandlerMixin.activities.update.match),
|
||||
onEmit: verifiedEmit(
|
||||
'Update',
|
||||
async function (ctx, { objectUri }, actorUri) {
|
||||
await this.onUpdate(ctx, objectUri, actorUri);
|
||||
},
|
||||
{ objectType: 'Event' }
|
||||
)
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
/**
|
||||
* Brings the public read of an event of `actorUri`'s pod — and of its attendees collection — in
|
||||
* line with the event's current public intent. Serialized per event: the event is read when the
|
||||
* previous reconcile of the same event has finished, and that read decides. Resolves
|
||||
* `{ isPublic, targets }`, or undefined when the event could not be judged (gone, unreadable,
|
||||
* not an `as:Event`, not in the actor's pod). A failed grant or revoke is logged, never thrown.
|
||||
* A reconcile that timed out and then WROTE (a write in flight at the timeout that landed after
|
||||
* it) is followed by ONE fresh reconcile of the same event, which judges that late write against
|
||||
* the state as it then is; that re-decision schedules nothing more, and a timed-out reconcile that
|
||||
* wrote nothing late schedules nothing at all (see `keyed-serializer.js` `onLateSettle`).
|
||||
*/
|
||||
reconcile: {
|
||||
params: {
|
||||
resourceUri: { type: 'string' },
|
||||
actorUri: { type: 'string' }
|
||||
},
|
||||
async handler(ctx) {
|
||||
const { resourceUri, actorUri } = ctx.params;
|
||||
return this.serialize(resourceUri, signal => this.reconcileNow(ctx, resourceUri, actorUri, signal), {
|
||||
onLateSettle: result => {
|
||||
if (result && result.wroteLate) this.redecideOnce(ctx, resourceUri, actorUri);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
/** `grantNow` outside any lock (see there). */
|
||||
grant: {
|
||||
params: {
|
||||
resourceUri: { type: 'string' },
|
||||
actorUri: { type: 'string' }
|
||||
},
|
||||
async handler(ctx) {
|
||||
return this.grantNow(ctx, ctx.params.resourceUri, ctx.params.actorUri);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Records that Festipod created `resourceUri` (an event's attendees collection, right after it is
|
||||
* attached): whatever public read it has is Festipod's doing — the pod makes such collections
|
||||
* world-readable — so `revoke` may take it back.
|
||||
*/
|
||||
recordCreated: {
|
||||
params: {
|
||||
resourceUri: { type: 'string' },
|
||||
actorUri: { type: 'string' }
|
||||
},
|
||||
async handler(ctx) {
|
||||
const { resourceUri } = ctx.params;
|
||||
await recordGrant(ctx, { dataset: CONFIG.MAIN_DATASET, uri: resourceUri });
|
||||
this.logger.info(`Recorded ${resourceUri}, created by Festipod, as Festipod's to make public or not`);
|
||||
return true;
|
||||
}
|
||||
},
|
||||
/** `revokeNow` outside any lock (see there). */
|
||||
revoke: {
|
||||
params: {
|
||||
resourceUri: { type: 'string' },
|
||||
actorUri: { type: 'string' }
|
||||
},
|
||||
async handler(ctx) {
|
||||
return this.revokeNow(ctx, ctx.params.resourceUri, ctx.params.actorUri);
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// The mixin's matchers fire only when these exist. Both take the object (or its URI) as a
|
||||
// POINTER: the decision is `reconcile`'s, on a read taken when acting. They never throw: the
|
||||
// watcher runs every matching handler of an activity in sequence without catching, inside a
|
||||
// webhook call the platform does not await.
|
||||
async onCreate(ctx, resource, actorUri) {
|
||||
await this.reconcileLogged(ctx, uriOf(resource), actorUri);
|
||||
},
|
||||
async onUpdate(ctx, resource, actorUri) {
|
||||
await this.reconcileLogged(ctx, uriOf(resource), actorUri);
|
||||
},
|
||||
async reconcileLogged(ctx, resourceUri, actorUri) {
|
||||
try {
|
||||
if (!resourceUri || !actorUri) throw new Error('no event or no actor to reconcile');
|
||||
await this.actions.reconcile({ resourceUri, actorUri }, { parentCtx: ctx });
|
||||
} catch (e) {
|
||||
this.logger.error(`Could not reconcile public read on ${resourceUri}: ${e.message}`);
|
||||
}
|
||||
},
|
||||
// The one re-decision after a late write: a plain serialized reconcile WITHOUT `onLateSettle`, so
|
||||
// whatever happens to it (even a timeout with a late write of its own) it schedules nothing more.
|
||||
async redecideOnce(ctx, resourceUri, actorUri) {
|
||||
try {
|
||||
await this.serialize(resourceUri, signal => this.reconcileNow(ctx, resourceUri, actorUri, signal));
|
||||
} catch (e) {
|
||||
this.logger.error(`Could not re-decide public read on ${resourceUri} after a late write: ${e.message}`);
|
||||
}
|
||||
},
|
||||
/**
|
||||
* The resource's ACL as the pod serves it to the app: `{ status, authorizations }`, the
|
||||
* authorizations only when the pod answered 200. Unlike `pod-permissions.get`, which answers
|
||||
* `false` for ANY failure — indistinguishable from "no public read" — a failure here is reported,
|
||||
* so a caller can tell "not public" from "unknown". Read where `revoke` writes (`aclUriOf`).
|
||||
*/
|
||||
async readAcl(ctx, resourceUri, actorUri) {
|
||||
const { status, body } =
|
||||
(await ctx.call('pod-permissions.fetch', {
|
||||
url: aclUriOf(resourceUri),
|
||||
headers: { Accept: 'application/ld+json' },
|
||||
actorUri
|
||||
})) || {};
|
||||
if (status !== 200 || !body || typeof body !== 'object') return { status: typeof status === 'number' ? status : 0 };
|
||||
const graph = body['@graph'] !== undefined ? body['@graph'] : body['@id'] || body.id ? [body] : [];
|
||||
return { status, authorizations: Array.isArray(graph) ? graph : [graph] };
|
||||
},
|
||||
/**
|
||||
* Makes `resourceUri` world-readable and records it as FESTIPOD's grant — only when its ACL was
|
||||
* read and shows no public read: a resource already world-readable (the owner's share, another
|
||||
* app's) is left as it is and never recorded, and when the ACL cannot be read nothing is recorded
|
||||
* or written (the next notification retries). The record is written BEFORE the grant, so a
|
||||
* failure in between never leaves an unrecorded Festipod grant behind (which `revoke` would then
|
||||
* never take back). `signal` (from the event's lock) is checked immediately before each write;
|
||||
* the one write past it is re-recording a grant that LANDED late (a fact, see the code).
|
||||
* Resolves `true` when it added the grant, `false` otherwise; throws on a refusal or an unreadable ACL.
|
||||
*/
|
||||
async grantNow(ctx, resourceUri, actorUri, signal, late = {}) {
|
||||
const aborted = () => !!(signal && signal.aborted);
|
||||
// A write that RETURNS once the time has run out may have landed after a newer decision read.
|
||||
const wrote = () => {
|
||||
if (aborted()) late.wrote = true;
|
||||
};
|
||||
const acl = await this.readAcl(ctx, resourceUri, actorUri);
|
||||
if (!acl.authorizations) throw new Error(`the ACL of ${resourceUri} could not be read (HTTP ${acl.status}): nothing granted or recorded`);
|
||||
if (grantsPublicRead(acl.authorizations)) {
|
||||
this.logger.info(`${resourceUri} is already publicly readable`);
|
||||
return false;
|
||||
}
|
||||
const dataset = CONFIG.MAIN_DATASET;
|
||||
if (aborted()) return false;
|
||||
await recordGrant(ctx, { dataset, uri: resourceUri });
|
||||
wrote();
|
||||
if (aborted()) return false;
|
||||
const added = await ctx.call('pod-permissions.add', {
|
||||
uri: resourceUri,
|
||||
agentPredicate: 'acl:agentClass',
|
||||
agentUri: FOAF_AGENT,
|
||||
mode: 'acl:Read',
|
||||
actorUri
|
||||
});
|
||||
if (!added) {
|
||||
if (!aborted()) await forgetGrant(ctx, { dataset, uri: resourceUri }).catch(() => {});
|
||||
throw new Error(`The pod refused the public-read grant on ${resourceUri}`);
|
||||
}
|
||||
if (aborted()) {
|
||||
late.wrote = true;
|
||||
// The grant landed after the time ran out, and a newer decision may have run meanwhile and
|
||||
// forgotten the record (it saw no public read yet). A grant Festipod made must stay on record —
|
||||
// this writes a FACT, not a decision — so the re-decision that follows (`onLateSettle`) can
|
||||
// judge it against the current state and take it back if the event is no longer public.
|
||||
await recordGrant(ctx, { dataset, uri: resourceUri });
|
||||
this.logger.warn(`Public read on ${resourceUri} landed after its time ran out: re-recorded, to be re-decided`);
|
||||
return true;
|
||||
}
|
||||
this.logger.info(`Granted public read on ${resourceUri}`);
|
||||
return true;
|
||||
},
|
||||
/**
|
||||
* The reverse of `grantNow`, for a grant FESTIPOD made (recorded in the ledger) and only for it:
|
||||
* a public read Festipod did not add — the owner's, another app's, the pod's — is left untouched
|
||||
* and logged. Removes ONLY the `acl:agentClass foaf:Agent` triple of the resource's own `#Read`
|
||||
* rule, leaving every other OWN authorization (a `foaf:Agent` on another mode included) and the
|
||||
* inherited owner defaults in place, then forgets the record. When the ACL cannot be read, the
|
||||
* state is unknown: nothing changes and the record is KEPT, so a later notification retries.
|
||||
* Idempotent: with the own public-read rule already gone, it only forgets the record. A public
|
||||
* read INHERITED from the container cannot be removed this way: it is reported as a warning. Not
|
||||
* usable via `pod-permissions.remove` (see `public-read-acl.js`): it PUTs a reconstructed
|
||||
* own-rights graph, acting as the app actor (the same actor `grant` writes as, which holds
|
||||
* `acl:Control` on the resource). `signal` is checked immediately before each write. Resolves
|
||||
* `true` when it removed the rule, `false` otherwise; throws when the pod refuses the change.
|
||||
*/
|
||||
async revokeNow(ctx, resourceUri, actorUri, signal, late = {}) {
|
||||
const aborted = () => !!(signal && signal.aborted);
|
||||
const wrote = () => {
|
||||
if (aborted()) late.wrote = true;
|
||||
};
|
||||
const dataset = CONFIG.MAIN_DATASET;
|
||||
const acl = await this.readAcl(ctx, resourceUri, actorUri);
|
||||
if (!(await isGrantedByFestipod(ctx, { dataset, uri: resourceUri }))) {
|
||||
if (acl.authorizations && grantsPublicRead(acl.authorizations)) {
|
||||
this.logger.info(`${resourceUri} is publicly readable, but not through a grant Festipod made: left untouched`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!acl.authorizations) {
|
||||
this.logger.warn(`The ACL of ${resourceUri} could not be read (HTTP ${acl.status}): Festipod's grant kept, a later notification retries`);
|
||||
return false;
|
||||
}
|
||||
const inherited = hasInheritedPublicRead(acl.authorizations);
|
||||
if (!hasOwnPublicRead(acl.authorizations)) {
|
||||
if (aborted()) return false;
|
||||
await forgetGrant(ctx, { dataset, uri: resourceUri });
|
||||
wrote();
|
||||
if (inherited) {
|
||||
this.logger.warn(`${resourceUri} is publicly readable through a rule inherited from its container: not revocable here`);
|
||||
} else {
|
||||
this.logger.info(`${resourceUri} is not publicly readable: nothing to revoke`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const aclUri = aclUriOf(resourceUri);
|
||||
const document = revocationGraph(acl.authorizations, resourceUri, actorUri, aclUri);
|
||||
const app = await ctx.call('app.get');
|
||||
if (aborted()) return false;
|
||||
const { status } = await ctx.call('signature.proxy.query', {
|
||||
url: aclUri,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/ld+json' },
|
||||
body: JSON.stringify(document),
|
||||
actorUri: app.id || app['@id']
|
||||
});
|
||||
if (status !== 204 && status !== 200) {
|
||||
throw new Error(`The pod refused to revoke the public-read grant on ${resourceUri} (HTTP ${status})`);
|
||||
}
|
||||
if (aborted()) {
|
||||
late.wrote = true; // the PUT landed after the time ran out: to be re-decided
|
||||
return true;
|
||||
}
|
||||
await forgetGrant(ctx, { dataset, uri: resourceUri });
|
||||
wrote();
|
||||
|
||||
this.logger.info(`Revoked public read on ${resourceUri}`);
|
||||
if (inherited) {
|
||||
this.logger.warn(`${resourceUri} stays publicly readable through a rule inherited from its container`);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
// Runs under the event's lock (see `reconcile`). The read here is the one that decides. Once
|
||||
// `signal` is aborted (the work timed out and the lock moved on) it writes nothing more; it
|
||||
// resolves `wroteLate: true` when a write it had sent returned after that (see `reconcile`).
|
||||
async reconcileNow(ctx, resourceUri, actorUri, signal) {
|
||||
if (!isInActorPod(resourceUri, actorUri)) {
|
||||
this.logger.warn(`Public read not reconciled on ${resourceUri}: not in the pod of ${actorUri}`);
|
||||
return undefined;
|
||||
}
|
||||
const { status, body } = await ctx.call('pod-resources.get', { resourceUri, actorUri });
|
||||
if (status === 404 || status === 410 || (status === 200 && hasAsType(body, 'Tombstone'))) {
|
||||
this.logger.info(`${resourceUri} is gone: its ACL went with it`);
|
||||
return undefined;
|
||||
}
|
||||
if (status !== 200 || !body || uriOf(body) !== resourceUri) {
|
||||
this.logger.warn(`Public read not reconciled on ${resourceUri}: the pod does not return it (HTTP ${status})`);
|
||||
return undefined;
|
||||
}
|
||||
if (!hasAsType(body, 'Event')) {
|
||||
this.logger.warn(`Public read not reconciled on ${resourceUri}: not an as:Event`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const isPublic = hasPublicIntent(body);
|
||||
const targets = [resourceUri];
|
||||
const collectionUri = attendeesCollectionOf(body);
|
||||
if (collectionUri && isInActorPod(collectionUri, actorUri)) targets.push(collectionUri);
|
||||
else if (collectionUri) this.logger.warn(`${resourceUri} names an attendees collection outside its pod: left alone`);
|
||||
if (!isPublic) this.logger.info(`${resourceUri} is not addressed to the Public collection: no public read`);
|
||||
|
||||
const late = { wrote: false };
|
||||
for (const uri of targets) {
|
||||
if (signal && signal.aborted) break;
|
||||
try {
|
||||
if (isPublic) await this.grantNow(ctx, uri, actorUri, signal, late);
|
||||
else await this.revokeNow(ctx, uri, actorUri, signal, late);
|
||||
} catch (e) {
|
||||
this.logger.error(`Could not ${isPublic ? 'grant' : 'revoke'} public read on ${uri}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return { isPublic, targets, wroteLate: late.wrote };
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,613 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const publicRead = require('./public-read.service');
|
||||
const { createKeyedSerializer } = require('./keyed-serializer');
|
||||
const { GRANTED_AT, grantedQuery, recordGrantUpdate, forgetGrantUpdate } = require('./public-read-ledger');
|
||||
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const BOB = 'http://localhost:3000/bob';
|
||||
const E = `${ALICE}/data/events/e1`;
|
||||
const C = `${ALICE}/data/collections/e1-attendees`;
|
||||
|
||||
const FOAF_AGENT = 'http://xmlns.com/foaf/0.1/Agent';
|
||||
const APP = 'http://localhost:3003/as/actor/app';
|
||||
// The ACL document shape `pod-permissions.get` returns (compacted with the acl:/foaf: prefixes).
|
||||
// Note: for an event, the owner's rights come from the container's inherited #Default rules — the
|
||||
// event's only OWN rule is #Read foaf:Agent (confirmed live 2026-10-12).
|
||||
const PUBLIC_READ_AUTH = { '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' };
|
||||
const DEFAULT_READ = { '@id': '#DefaultRead', 'acl:mode': 'acl:Read', 'acl:agent': ALICE };
|
||||
// A revoke PUT returns 204; capture its body so tests can assert the reconstructed graph.
|
||||
const okProxy = captured => params => {
|
||||
captured.put = params;
|
||||
return { status: 204 };
|
||||
};
|
||||
|
||||
const publicEvent = (extra = {}) => ({ id: E, type: 'Event', to: 'as:Public', ...extra });
|
||||
const privateEvent = (extra = {}) => ({ id: E, type: 'Event', name: 'now private', ...extra });
|
||||
|
||||
/** `pod-resources.get` answering from a map of uri → stored body (404 for anything else). */
|
||||
const stored = (...bodies) => ({ resourceUri }) => {
|
||||
const body = bodies.find(b => b.id === resourceUri);
|
||||
return body ? { ok: true, status: 200, body } : { ok: false, status: 404 };
|
||||
};
|
||||
|
||||
/**
|
||||
* The backend's grant ledger, answering exactly the queries `public-read-ledger.js` builds.
|
||||
* `granted` lists the resources Festipod is recorded as having made public.
|
||||
*/
|
||||
function ledgerCall(granted, action, params) {
|
||||
const match = /<([^>]+)> <http:\/\/festipod\.org\/publicReadGrantedAt>/.exec(params.query);
|
||||
if (!match) throw new Error(`unexpected store call ${params.query}`);
|
||||
const uri = match[1];
|
||||
if (action === 'triplestore.query' && params.query === grantedQuery(uri)) return granted.has(uri);
|
||||
if (action === 'triplestore.update' && params.query === recordGrantUpdate(uri)) return void granted.add(uri);
|
||||
if (action === 'triplestore.update' && params.query === forgetGrantUpdate(uri)) return void granted.delete(uri);
|
||||
throw new Error(`unexpected store call ${params.query}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The service with its actions+methods bound to a recording logger, over a programmable pod and a
|
||||
* grant ledger (by default: Festipod made both the event and its collection public).
|
||||
*/
|
||||
function service(pod, { granted = [E, C], timeoutMs } = {}) {
|
||||
const logs = { info: [], warn: [], error: [] };
|
||||
const calls = [];
|
||||
const ledger = new Set(granted);
|
||||
const svc = {
|
||||
logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) },
|
||||
actions: {}
|
||||
};
|
||||
if (publicRead.created) publicRead.created.call(svc);
|
||||
if (timeoutMs) svc.serialize = createKeyedSerializer({ timeoutMs });
|
||||
const ctx = {
|
||||
call: async (action, params) => {
|
||||
calls.push({ action, params });
|
||||
if (action.startsWith('triplestore.')) return ledgerCall(ledger, action, params);
|
||||
if (action === 'pod-permissions.fetch' && !pod['pod-permissions.fetch']) {
|
||||
// The ACL read, served from a test's `pod-permissions.get` (authorizations, or `false` = a failed read).
|
||||
const uri = params.url.replace(/^(https?:\/\/[^/]+)\/_acl/, '$1');
|
||||
const authorizations = pod['pod-permissions.get'] ? await pod['pod-permissions.get']({ uri }) : undefined;
|
||||
return authorizations === false || authorizations === undefined ? { status: 500 } : { status: 200, body: { '@graph': authorizations } };
|
||||
}
|
||||
return pod[action] ? pod[action](params) : undefined;
|
||||
}
|
||||
};
|
||||
// Bind actions so `this.actions.reconcile|grant|revoke(...)` work from the methods.
|
||||
for (const [name, def] of Object.entries(publicRead.actions)) {
|
||||
svc.actions[name] = (params, opts) => def.handler.call(svc, { ...opts, params, call: ctx.call });
|
||||
}
|
||||
for (const [name, fn] of Object.entries(publicRead.methods)) svc[name] = fn.bind(svc);
|
||||
return { svc, ctx, logs, calls, ledger };
|
||||
}
|
||||
|
||||
// The agent triples a PUT revocation graph carries, flattened to {auth, p, o} for assertions.
|
||||
function putTriples(putParams) {
|
||||
const graph = JSON.parse(putParams.body)['@graph'];
|
||||
const out = [];
|
||||
for (const node of graph) {
|
||||
for (const p of ['acl:agent', 'acl:agentClass', 'acl:agentGroup', 'acl:origin']) {
|
||||
for (const o of [].concat(node[p] ?? [])) out.push({ auth: node['@id'], p, o });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe('public-read.onUpdate — the grant and the ACL stay consistent with visibility', () => {
|
||||
test('private → public: grants acl:Read to foaf:Agent (and is idempotent)', async () => {
|
||||
const pod = { 'pod-resources.get': stored(publicEvent()), 'pod-permissions.get': () => [DEFAULT_READ], 'pod-permissions.add': () => true };
|
||||
const { svc, ctx, calls } = service(pod);
|
||||
await svc.onUpdate(ctx, publicEvent(), ALICE);
|
||||
const add = calls.find(c => c.action === 'pod-permissions.add');
|
||||
expect(add).toBeDefined();
|
||||
expect(add.params).toMatchObject({ uri: E, agentPredicate: 'acl:agentClass', agentUri: FOAF_AGENT, mode: 'acl:Read' });
|
||||
expect(calls.some(c => c.action === 'signature.proxy.query')).toBe(false);
|
||||
});
|
||||
|
||||
test('already public twin: grant finds the rule present and adds nothing', async () => {
|
||||
const pod = {
|
||||
'pod-resources.get': stored(publicEvent()),
|
||||
'pod-permissions.get': () => [DEFAULT_READ, PUBLIC_READ_AUTH],
|
||||
'pod-permissions.add': () => {
|
||||
throw new Error('must not add');
|
||||
}
|
||||
};
|
||||
const { svc, ctx, calls } = service(pod);
|
||||
await svc.onUpdate(ctx, publicEvent(), ALICE);
|
||||
expect(calls.some(c => c.action === 'pod-permissions.add')).toBe(false);
|
||||
});
|
||||
|
||||
test('public → private: PUTs a graph that drops foaf:Agent and keeps the owner (set not empty)', async () => {
|
||||
const captured = {};
|
||||
const pod = {
|
||||
'pod-resources.get': stored(privateEvent()),
|
||||
'pod-permissions.get': () => [DEFAULT_READ, PUBLIC_READ_AUTH],
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': okProxy(captured)
|
||||
};
|
||||
const { svc, ctx, calls } = service(pod);
|
||||
await svc.onUpdate(ctx, privateEvent(), ALICE);
|
||||
const put = calls.find(c => c.action === 'signature.proxy.query');
|
||||
expect(put).toBeDefined();
|
||||
expect(put.params).toMatchObject({ method: 'PUT', url: `http://localhost:3000/_acl/alice/data/events/e1`, actorUri: APP });
|
||||
const triples = putTriples(captured.put);
|
||||
// No foaf:Agent anywhere in the PUT; the #Default rule is NOT re-emitted (a PUT leaves it be).
|
||||
expect(triples.some(t => t.p === 'acl:agentClass' && (t.o === 'foaf:Agent' || t.o === FOAF_AGENT))).toBe(false);
|
||||
expect(triples.some(t => t.auth.includes('#Default'))).toBe(false);
|
||||
// The own set is non-empty: the owner is kept as acl:agent of #Read.
|
||||
expect(triples.some(t => t.p === 'acl:agent' && t.o === ALICE && t.auth.includes('#Read'))).toBe(true);
|
||||
});
|
||||
|
||||
test('public → private when the ACL stored the FULL foaf IRI: still dropped', async () => {
|
||||
const captured = {};
|
||||
const fullIriAuth = { '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': FOAF_AGENT };
|
||||
const pod = {
|
||||
'pod-resources.get': stored(privateEvent()),
|
||||
'pod-permissions.get': () => [DEFAULT_READ, fullIriAuth],
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': okProxy(captured)
|
||||
};
|
||||
const { svc, ctx } = service(pod);
|
||||
await svc.onUpdate(ctx, privateEvent(), ALICE);
|
||||
expect(putTriples(captured.put).some(t => t.o === FOAF_AGENT || t.o === 'foaf:Agent')).toBe(false);
|
||||
});
|
||||
|
||||
test('a per-contact own grant is preserved when the public rule is removed', async () => {
|
||||
const captured = {};
|
||||
const friend = 'http://localhost:3000/carol';
|
||||
const readWithFriend = { '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent', 'acl:agent': friend };
|
||||
const pod = {
|
||||
'pod-resources.get': stored(privateEvent()),
|
||||
'pod-permissions.get': () => [DEFAULT_READ, readWithFriend],
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': okProxy(captured)
|
||||
};
|
||||
const { svc, ctx } = service(pod);
|
||||
await svc.onUpdate(ctx, privateEvent(), ALICE);
|
||||
const triples = putTriples(captured.put);
|
||||
expect(triples.some(t => t.p === 'acl:agent' && t.o === friend)).toBe(true); // friend kept
|
||||
expect(triples.some(t => t.o === 'foaf:Agent' || t.o === FOAF_AGENT)).toBe(false); // public dropped
|
||||
});
|
||||
|
||||
test('update to private on an already-private event: nothing to revoke, no PUT', async () => {
|
||||
const pod = {
|
||||
'pod-resources.get': stored(privateEvent({ name: 'still private' })),
|
||||
'pod-permissions.get': () => [DEFAULT_READ],
|
||||
'signature.proxy.query': () => {
|
||||
throw new Error('must not PUT');
|
||||
}
|
||||
};
|
||||
const { svc, ctx, calls } = service(pod);
|
||||
await svc.onUpdate(ctx, privateEvent({ name: 'still private' }), ALICE);
|
||||
expect(calls.some(c => c.action === 'signature.proxy.query')).toBe(false);
|
||||
});
|
||||
|
||||
test('a pod refusal of the revoke PUT is logged, never thrown', async () => {
|
||||
const pod = {
|
||||
'pod-resources.get': stored(privateEvent()),
|
||||
'pod-permissions.get': () => [DEFAULT_READ, PUBLIC_READ_AUTH],
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': () => ({ status: 400 })
|
||||
};
|
||||
const { svc, ctx, logs } = service(pod);
|
||||
await expect(svc.onUpdate(ctx, privateEvent(), ALICE)).resolves.toBeUndefined();
|
||||
expect(logs.error.some(m => m.includes('HTTP 400'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a pod failure is logged, never thrown (the watcher is not awaited)', async () => {
|
||||
const pod = {
|
||||
'pod-resources.get': stored(publicEvent()),
|
||||
'pod-permissions.get': () => {
|
||||
throw new Error('pod down');
|
||||
}
|
||||
};
|
||||
const { svc, ctx, logs } = service(pod);
|
||||
await expect(svc.onUpdate(ctx, publicEvent(), ALICE)).resolves.toBeUndefined();
|
||||
expect(logs.error[0]).toContain('pod down');
|
||||
});
|
||||
|
||||
test('a failing read of the event itself is logged, never thrown', async () => {
|
||||
const pod = {
|
||||
'pod-resources.get': () => {
|
||||
throw new Error('proxy unreachable');
|
||||
}
|
||||
};
|
||||
const { svc, ctx, logs } = service(pod);
|
||||
await expect(svc.onUpdate(ctx, publicEvent(), ALICE)).resolves.toBeUndefined();
|
||||
expect(logs.error[0]).toContain('proxy unreachable');
|
||||
});
|
||||
});
|
||||
|
||||
describe('public-read.onCreate — grants only on public intent', () => {
|
||||
test('a created public event is granted', async () => {
|
||||
const pod = { 'pod-resources.get': stored(publicEvent()), 'pod-permissions.get': () => [DEFAULT_READ], 'pod-permissions.add': () => true };
|
||||
const { svc, ctx, calls } = service(pod);
|
||||
await svc.onCreate(ctx, publicEvent(), ALICE);
|
||||
expect(calls.some(c => c.action === 'pod-permissions.add')).toBe(true);
|
||||
});
|
||||
|
||||
test('a created private event is not granted', async () => {
|
||||
const pod = { 'pod-resources.get': stored(privateEvent({ name: 'private' })), 'pod-permissions.get': () => [DEFAULT_READ] };
|
||||
const { svc, ctx, calls, logs } = service(pod);
|
||||
await svc.onCreate(ctx, privateEvent({ name: 'private' }), ALICE);
|
||||
expect(calls.some(c => c.action === 'pod-permissions.add')).toBe(false);
|
||||
expect(calls.some(c => c.action === 'signature.proxy.query')).toBe(false);
|
||||
expect(logs.info.some(m => m.includes('not addressed to the Public collection'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public-read.reconcile — decides on the STORED event, read when acting', () => {
|
||||
test('the notification\'s copy is only a pointer: a "public" copy of an event the pod holds private grants nothing', async () => {
|
||||
const pod = { 'pod-resources.get': stored(privateEvent()), 'pod-permissions.get': () => [DEFAULT_READ], 'pod-permissions.add': () => true };
|
||||
const { svc, ctx, calls } = service(pod);
|
||||
await svc.onUpdate(ctx, publicEvent(), ALICE);
|
||||
expect(calls.some(c => c.action === 'pod-permissions.add')).toBe(false);
|
||||
});
|
||||
|
||||
test('a public Note stored at the URI is not an event: nothing granted (the type is re-checked)', async () => {
|
||||
const note = { id: E, type: 'Note', to: 'as:Public' };
|
||||
const pod = { 'pod-resources.get': stored(note), 'pod-permissions.get': () => [DEFAULT_READ], 'pod-permissions.add': () => true };
|
||||
const { svc, ctx, calls, logs } = service(pod);
|
||||
await svc.onCreate(ctx, E, ALICE);
|
||||
expect(calls.some(c => c.action.startsWith('pod-permissions'))).toBe(false);
|
||||
expect(logs.warn[0]).toContain('not an as:Event');
|
||||
});
|
||||
|
||||
test("an event outside the actor's pod is neither read nor touched", async () => {
|
||||
const bobs = `${BOB}/data/events/e1`;
|
||||
const { svc, ctx, calls, logs } = service({});
|
||||
await svc.onUpdate(ctx, bobs, ALICE);
|
||||
expect(calls).toEqual([]);
|
||||
expect(logs.warn[0]).toContain(`not in the pod of ${ALICE}`);
|
||||
});
|
||||
|
||||
test('a deleted event is left alone (its ACL went with it)', async () => {
|
||||
const { svc, ctx, calls } = service({ 'pod-resources.get': () => ({ status: 404 }) });
|
||||
await svc.onUpdate(ctx, E, ALICE);
|
||||
expect(calls.map(c => c.action)).toEqual(['pod-resources.get']);
|
||||
});
|
||||
|
||||
test('CONCURRENT notifications: the newest state wins (no grant lands after the revoke)', async () => {
|
||||
// A stateful pod: the event's addressing, and whether its ACL holds the public-read rule.
|
||||
// The grant is slow (the pod's latency); the event is made private while the first handler acts.
|
||||
let current = publicEvent();
|
||||
let aclIsPublic = false;
|
||||
const pod = {
|
||||
'pod-resources.get': ({ resourceUri }) => (resourceUri === E ? { status: 200, body: current } : { status: 404 }),
|
||||
'pod-permissions.get': () => (aclIsPublic ? [DEFAULT_READ, PUBLIC_READ_AUTH] : [DEFAULT_READ]),
|
||||
'pod-permissions.add': async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 30));
|
||||
aclIsPublic = true;
|
||||
return true;
|
||||
},
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': ({ body }) => {
|
||||
aclIsPublic = putTriples({ body }).some(t => t.o === 'foaf:Agent' || t.o === FOAF_AGENT);
|
||||
return { status: 204 };
|
||||
}
|
||||
};
|
||||
const { svc, ctx } = service(pod);
|
||||
const first = svc.onUpdate(ctx, publicEvent(), ALICE); // notified while public
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
current = privateEvent(); // the owner makes it private; a second notification follows
|
||||
const second = svc.onUpdate(ctx, privateEvent(), ALICE);
|
||||
await Promise.all([first, second]);
|
||||
expect(aclIsPublic).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("public-read.reconcile — the event's attendees collection follows its visibility, both ways", () => {
|
||||
const COLLECTION_DEFAULT = { '@id': '#Read', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' };
|
||||
|
||||
test('public: the event AND its collection are granted (the collection already is: nothing added)', async () => {
|
||||
const granted = [];
|
||||
const pod = {
|
||||
'pod-resources.get': stored(publicEvent({ 'apods:attendees': C })),
|
||||
'pod-permissions.get': ({ uri }) => (uri === C ? [COLLECTION_DEFAULT] : [DEFAULT_READ]),
|
||||
'pod-permissions.add': ({ uri }) => granted.push(uri)
|
||||
};
|
||||
const { svc, ctx, calls } = service(pod);
|
||||
await svc.onUpdate(ctx, E, ALICE);
|
||||
expect(calls.filter(c => c.action === 'pod-permissions.fetch').map(c => c.params.url)).toEqual([
|
||||
'http://localhost:3000/_acl/alice/data/events/e1',
|
||||
'http://localhost:3000/_acl/alice/data/collections/e1-attendees'
|
||||
]);
|
||||
expect(granted).toEqual([E]);
|
||||
});
|
||||
|
||||
test('private: the public read of the event AND of its collection are revoked', async () => {
|
||||
const puts = [];
|
||||
const pod = {
|
||||
'pod-resources.get': stored(privateEvent({ 'apods:attendees': C })),
|
||||
'pod-permissions.get': ({ uri }) => (uri === C ? [COLLECTION_DEFAULT] : [DEFAULT_READ, PUBLIC_READ_AUTH]),
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': params => {
|
||||
puts.push(params.url);
|
||||
return { status: 204 };
|
||||
}
|
||||
};
|
||||
const { svc, ctx } = service(pod);
|
||||
await svc.onUpdate(ctx, E, ALICE);
|
||||
expect(puts).toEqual(['http://localhost:3000/_acl/alice/data/events/e1', 'http://localhost:3000/_acl/alice/data/collections/e1-attendees']);
|
||||
});
|
||||
|
||||
test('private → public: the collection is granted back', async () => {
|
||||
const granted = [];
|
||||
const pod = {
|
||||
'pod-resources.get': stored(publicEvent({ 'http://activitypods.org/ns/core#attendees': { id: C } })),
|
||||
'pod-permissions.get': () => [DEFAULT_READ],
|
||||
'pod-permissions.add': ({ uri }) => granted.push(uri)
|
||||
};
|
||||
const { svc, ctx } = service(pod);
|
||||
await svc.onUpdate(ctx, E, ALICE);
|
||||
expect(granted).toEqual([E, C]);
|
||||
});
|
||||
|
||||
test('a collection named outside the event\'s pod is left alone', async () => {
|
||||
const foreign = `${BOB}/data/collections/x`;
|
||||
const pod = { 'pod-resources.get': stored(privateEvent({ 'apods:attendees': foreign })), 'pod-permissions.get': () => [DEFAULT_READ] };
|
||||
const { svc, ctx, calls, logs } = service(pod);
|
||||
await svc.onUpdate(ctx, E, ALICE);
|
||||
expect(calls.some(c => c.params && c.params.uri === foreign)).toBe(false);
|
||||
expect(logs.warn.some(m => m.includes('outside its pod'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a public read INHERITED from the container cannot be revoked: reported, no PUT', async () => {
|
||||
const inherited = { '@id': '#DefaultRead', 'acl:mode': 'acl:Read', 'acl:agentClass': 'foaf:Agent' };
|
||||
const pod = {
|
||||
'pod-resources.get': stored(privateEvent({ 'apods:attendees': C })),
|
||||
'pod-permissions.get': ({ uri }) => (uri === C ? [inherited] : [DEFAULT_READ]),
|
||||
'signature.proxy.query': () => {
|
||||
throw new Error('must not PUT');
|
||||
}
|
||||
};
|
||||
const { svc, ctx, logs } = service(pod);
|
||||
await svc.onUpdate(ctx, E, ALICE);
|
||||
expect(logs.warn.some(m => m.includes(`${C} is publicly readable through a rule inherited`))).toBe(true);
|
||||
expect(logs.error).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public-read — revokes ONLY a public read Festipod granted (the persisted grant ledger)', () => {
|
||||
test('a public read Festipod did not add (owner, another app) is left untouched and logged', async () => {
|
||||
const pod = {
|
||||
'pod-resources.get': stored(privateEvent()),
|
||||
'pod-permissions.get': () => [DEFAULT_READ, PUBLIC_READ_AUTH],
|
||||
'signature.proxy.query': () => {
|
||||
throw new Error('must not PUT');
|
||||
}
|
||||
};
|
||||
const { svc, ctx, calls, logs } = service(pod, { granted: [] });
|
||||
await svc.onUpdate(ctx, privateEvent(), ALICE);
|
||||
expect(calls.some(c => c.action === 'signature.proxy.query')).toBe(false);
|
||||
expect(logs.info.some(m => m.includes('not through a grant Festipod made: left untouched'))).toBe(true);
|
||||
expect(logs.error).toEqual([]);
|
||||
});
|
||||
|
||||
test('a grant is recorded BEFORE it is made, and a revocation forgets it', async () => {
|
||||
let aclIsPublic = false;
|
||||
let recordedWhenAdded;
|
||||
const pod = {
|
||||
'pod-resources.get': stored(publicEvent()),
|
||||
'pod-permissions.get': () => (aclIsPublic ? [DEFAULT_READ, PUBLIC_READ_AUTH] : [DEFAULT_READ]),
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': () => {
|
||||
aclIsPublic = false;
|
||||
return { status: 204 };
|
||||
}
|
||||
};
|
||||
const w = service(pod, { granted: [] });
|
||||
pod['pod-permissions.add'] = () => {
|
||||
recordedWhenAdded = w.ledger.has(E);
|
||||
aclIsPublic = true;
|
||||
return true;
|
||||
};
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE);
|
||||
expect(recordedWhenAdded).toBe(true);
|
||||
expect(w.ledger.has(E)).toBe(true);
|
||||
pod['pod-resources.get'] = stored(privateEvent());
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE);
|
||||
expect(aclIsPublic).toBe(false);
|
||||
expect(w.ledger.has(E)).toBe(false);
|
||||
});
|
||||
|
||||
test('already world-readable before Festipod: nothing granted, nothing recorded', async () => {
|
||||
const pod = { 'pod-resources.get': stored(publicEvent()), 'pod-permissions.get': () => [DEFAULT_READ, PUBLIC_READ_AUTH] };
|
||||
const w = service(pod, { granted: [] });
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE);
|
||||
expect(w.ledger.has(E)).toBe(false);
|
||||
});
|
||||
|
||||
test('a grant the pod refuses is not left in the record', async () => {
|
||||
const pod = { 'pod-resources.get': stored(publicEvent()), 'pod-permissions.get': () => [DEFAULT_READ], 'pod-permissions.add': () => false };
|
||||
const w = service(pod, { granted: [] });
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE);
|
||||
expect(w.ledger.has(E)).toBe(false);
|
||||
expect(w.logs.error.some(m => m.includes('refused the public-read grant'))).toBe(true);
|
||||
});
|
||||
|
||||
test('recordCreated records a collection Festipod created, without depending on an ACL read', async () => {
|
||||
const w = service({}, { granted: [] }); // no ACL reachable at all
|
||||
expect(await w.svc.actions.recordCreated({ resourceUri: C, actorUri: ALICE })).toBe(true);
|
||||
expect([...w.ledger]).toEqual([C]);
|
||||
});
|
||||
|
||||
test('the ledger predicate is Festipod\'s own', () => {
|
||||
expect(GRANTED_AT).toBe('http://festipod.org/publicReadGrantedAt');
|
||||
expect(grantedQuery(E)).toBe(`ASK { <${E}> <${GRANTED_AT}> ?at }`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public-read — an unreadable ACL is UNKNOWN, never "not public" (second review)', () => {
|
||||
const OWNER_SHARE = [DEFAULT_READ, PUBLIC_READ_AUTH];
|
||||
|
||||
test('revoke on a transient ACL failure keeps Festipod\'s record; the next notification revokes', async () => {
|
||||
let gets = 0;
|
||||
let puts = 0;
|
||||
const pod = {
|
||||
'pod-resources.get': stored(privateEvent()),
|
||||
'pod-permissions.get': () => (gets++ === 0 ? false : OWNER_SHARE), // one non-200, then the real ACL
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': () => {
|
||||
puts++;
|
||||
return { status: 204 };
|
||||
}
|
||||
};
|
||||
const w = service(pod, { granted: [E] });
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE);
|
||||
expect(puts).toBe(0);
|
||||
expect(w.ledger.has(E)).toBe(true); // kept: the state was unknown
|
||||
expect(w.logs.warn.some(m => m.includes('could not be read'))).toBe(true);
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE);
|
||||
expect(puts).toBe(1);
|
||||
expect(w.ledger.has(E)).toBe(false);
|
||||
});
|
||||
|
||||
test("grant on a transient ACL failure records nothing, so the owner's own share is never taken for Festipod's", async () => {
|
||||
let gets = 0;
|
||||
let puts = 0;
|
||||
let current = publicEvent();
|
||||
const pod = {
|
||||
'pod-resources.get': () => ({ status: 200, body: current }),
|
||||
'pod-permissions.get': () => (gets++ === 0 ? false : OWNER_SHARE), // the owner already shared it publicly
|
||||
'pod-permissions.add': () => true,
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': () => {
|
||||
puts++;
|
||||
return { status: 204 };
|
||||
}
|
||||
};
|
||||
const w = service(pod, { granted: [] });
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE); // ACL unreadable: no grant, no record
|
||||
expect(w.ledger.has(E)).toBe(false);
|
||||
current = privateEvent();
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE);
|
||||
expect(puts).toBe(0); // the owner's public share is left untouched
|
||||
});
|
||||
});
|
||||
|
||||
describe('public-read — a timed-out reconcile writes nothing more (second review)', () => {
|
||||
test('a stale "public" reconcile stalled in its ACL read neither records nor grants after a newer "private" one', async () => {
|
||||
let reads = 0;
|
||||
let aclGets = 0;
|
||||
let aclPublic = false;
|
||||
const pod = {
|
||||
'pod-resources.get': () => ({ status: 200, body: reads++ === 0 ? publicEvent() : privateEvent() }),
|
||||
'pod-permissions.get': async () => {
|
||||
if (aclGets++ === 0) await new Promise(resolve => setTimeout(resolve, 120)); // stalls past the timeout
|
||||
return aclPublic ? [DEFAULT_READ, PUBLIC_READ_AUTH] : [DEFAULT_READ];
|
||||
},
|
||||
'pod-permissions.add': () => {
|
||||
aclPublic = true;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
const w = service(pod, { granted: [], timeoutMs: 50 });
|
||||
const stale = w.svc.reconcileLogged(w.ctx, E, ALICE); // reads: public
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
const newer = w.svc.reconcileLogged(w.ctx, E, ALICE); // reads: private (the owner's newer edit)
|
||||
await Promise.all([stale, newer]);
|
||||
await new Promise(resolve => setTimeout(resolve, 250)); // the stale one resumes; its re-decision runs
|
||||
expect(aclPublic).toBe(false);
|
||||
expect(w.ledger.has(E)).toBe(false);
|
||||
});
|
||||
|
||||
test('a stale "private" reconcile stalled in its ACL read does not revoke after a newer "public" one', async () => {
|
||||
let reads = 0;
|
||||
let aclGets = 0;
|
||||
let aclPublic = true;
|
||||
const pod = {
|
||||
'pod-resources.get': () => ({ status: 200, body: reads++ === 0 ? privateEvent() : publicEvent() }),
|
||||
'pod-permissions.get': async () => {
|
||||
if (aclGets++ === 0) await new Promise(resolve => setTimeout(resolve, 120));
|
||||
return aclPublic ? [DEFAULT_READ, PUBLIC_READ_AUTH] : [DEFAULT_READ];
|
||||
},
|
||||
'pod-permissions.add': () => {
|
||||
aclPublic = true;
|
||||
return true;
|
||||
},
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': () => {
|
||||
aclPublic = false;
|
||||
return { status: 204 };
|
||||
}
|
||||
};
|
||||
const w = service(pod, { granted: [E], timeoutMs: 50 });
|
||||
const stale = w.svc.reconcileLogged(w.ctx, E, ALICE); // reads: private
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
const newer = w.svc.reconcileLogged(w.ctx, E, ALICE); // reads: public
|
||||
await Promise.all([stale, newer]);
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
expect(aclPublic).toBe(true);
|
||||
});
|
||||
|
||||
test('a grant that lands AFTER its timeout is undone by the re-decision that follows', async () => {
|
||||
let current = publicEvent();
|
||||
let aclPublic = false;
|
||||
let adds = 0;
|
||||
const pod = {
|
||||
'pod-resources.get': () => ({ status: 200, body: current }),
|
||||
'pod-permissions.get': () => (aclPublic ? [DEFAULT_READ, PUBLIC_READ_AUTH] : [DEFAULT_READ]),
|
||||
'pod-permissions.add': async () => {
|
||||
if (adds++ === 0) await new Promise(resolve => setTimeout(resolve, 120)); // sent in time, lands late
|
||||
aclPublic = true;
|
||||
return true;
|
||||
},
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': () => {
|
||||
aclPublic = false;
|
||||
return { status: 204 };
|
||||
}
|
||||
};
|
||||
const w = service(pod, { granted: [], timeoutMs: 50 });
|
||||
const stale = w.svc.reconcileLogged(w.ctx, E, ALICE);
|
||||
await new Promise(resolve => setTimeout(resolve, 60)); // timed out with its PATCH in flight
|
||||
current = privateEvent();
|
||||
const newer = w.svc.reconcileLogged(w.ctx, E, ALICE); // runs before the late PATCH lands
|
||||
await Promise.all([stale, newer]);
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
expect(aclPublic).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public-read — late-settle handling only after a late WRITE, and never chained (round-4 review)', () => {
|
||||
test('a slow pod and NO late write: a timed-out reconcile schedules nothing', async () => {
|
||||
let reads = 0;
|
||||
const pod = {
|
||||
'pod-resources.get': async () => {
|
||||
reads++;
|
||||
await new Promise(resolve => setTimeout(resolve, 120));
|
||||
return { status: 200, body: publicEvent() };
|
||||
},
|
||||
'pod-permissions.get': () => [DEFAULT_READ, PUBLIC_READ_AUTH]
|
||||
};
|
||||
const w = service(pod, { granted: [], timeoutMs: 50 });
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
expect(reads).toBe(1);
|
||||
});
|
||||
|
||||
test('a re-decision after a late write schedules no other, even when it writes late itself', async () => {
|
||||
let reads = 0;
|
||||
let aclPublic = false;
|
||||
const slow = () => new Promise(resolve => setTimeout(resolve, 120));
|
||||
const pod = {
|
||||
// The owner keeps flipping the event: every read sees the other visibility.
|
||||
'pod-resources.get': () => ({ status: 200, body: reads++ % 2 === 0 ? publicEvent() : privateEvent() }),
|
||||
'pod-permissions.get': () => (aclPublic ? [DEFAULT_READ, PUBLIC_READ_AUTH] : [DEFAULT_READ]),
|
||||
'pod-permissions.add': async () => {
|
||||
await slow(); // every write lands after the limit
|
||||
aclPublic = true;
|
||||
return true;
|
||||
},
|
||||
'app.get': () => ({ id: APP }),
|
||||
'signature.proxy.query': async () => {
|
||||
await slow();
|
||||
aclPublic = false;
|
||||
return { status: 204 };
|
||||
}
|
||||
};
|
||||
const w = service(pod, { granted: [], timeoutMs: 50 });
|
||||
await w.svc.onUpdate(w.ctx, E, ALICE);
|
||||
await new Promise(resolve => setTimeout(resolve, 1200));
|
||||
expect(reads).toBe(2); // the reconcile and its ONE re-decision — no chain
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
// The backend's whole HTTP surface, as an allow-list checked in front of every route.
|
||||
//
|
||||
// WHY. The backend runs SemApps' CoreService with `webacl: false`, so nothing checks rights on the
|
||||
// resources it stores, and CoreService mounts routes Festipod never asked for — above all the LDP
|
||||
// catch-all (`@semapps/ldp` `getCatchAllRoute`: `GET|POST|PUT|PATCH|DELETE /<any path>`,
|
||||
// `authorization: false`), which served the backend's `settings` dataset to anyone: the app actor's
|
||||
// PRIVATE key (`GET /key`, observed 2026-10-13), the user registrations and grants the app holds
|
||||
// (`/interop/…`), the webhook listeners. A signed request is no better: any pod user can sign one.
|
||||
// Rather than chase each route (deny-list), the gateway serves ONLY what a caller legitimately needs
|
||||
// and refuses everything else with a 404 — for every caller, anonymous or signed, since none needs
|
||||
// more over HTTP: the backend's own services reach its data through internal calls, never through
|
||||
// these routes.
|
||||
//
|
||||
// WHAT IS PUBLIC, and who calls it (backend request log, 2026-10-09 → 2026-10-13, all e2e runs):
|
||||
// GET /app the app description — the Festipod front end and the pod (install)
|
||||
// GET /as/actor/app the app actor and its Solid-OIDC Client ID Document — the pod
|
||||
// GET /catalog the public catalog — the Festipod front end
|
||||
// GET /public-key/<id> the app actor's PUBLIC key — every pod verifying an app signature
|
||||
// GET /interop/access-need-group/<id>, /interop/access-need/<id>
|
||||
// what the app asks for — the pod, at install and consent
|
||||
// GET /.well-known/context.jsonld the JSON-LD context the app's documents reference — the pod
|
||||
// GET /.well-known/nodeinfo, /nodeinfo/2.1
|
||||
// server discovery (links the app actor) — the pod
|
||||
// POST /as/actor/app/inbox activities pods deliver to the app (HTTP-signature checked by the platform)
|
||||
// POST /.webhooks/<id> the pods' notifications to the app's watcher (see emitted-activity.js)
|
||||
// HEAD follows GET; a CORS preflight (OPTIONS) is judged on the method it announces.
|
||||
//
|
||||
// Everything else is refused, notably: `/key` (private keys), `/` and every other container listing,
|
||||
// `/interop/*-registration|*-grant`, `/sparql` (also unmounted, `core.service.js`), `/proxy` (the
|
||||
// signing proxy — the app uses it internally only), `/.well-known/webfinger` (unused), the app
|
||||
// actor's other collections, and every write through the LDP routes.
|
||||
|
||||
// moleculer-web turns a thrown error's `code` into the HTTP status (it is not a direct dependency).
|
||||
const { MoleculerClientError } = require('moleculer').Errors;
|
||||
|
||||
// One path segment as the backend mints them (UUIDs): no dot, so no `.`/`..` segment; no `%`, so no
|
||||
// encoded separator or traversal; no empty segment.
|
||||
const ID = '[A-Za-z0-9][A-Za-z0-9_-]*';
|
||||
|
||||
const READ = ['GET', 'HEAD'];
|
||||
|
||||
const PUBLIC_ENDPOINTS = [
|
||||
{ methods: READ, path: /^\/app$/ },
|
||||
{ methods: READ, path: /^\/as\/actor\/app$/ },
|
||||
{ methods: READ, path: /^\/catalog$/ },
|
||||
{ methods: READ, path: new RegExp(`^/public-key/${ID}$`) },
|
||||
{ methods: READ, path: new RegExp(`^/interop/access-need-group/${ID}$`) },
|
||||
{ methods: READ, path: new RegExp(`^/interop/access-need/${ID}$`) },
|
||||
{ methods: READ, path: /^\/\.well-known\/context\.jsonld$/ },
|
||||
{ methods: READ, path: /^\/\.well-known\/nodeinfo$/ },
|
||||
{ methods: READ, path: /^\/nodeinfo\/2\.1$/ },
|
||||
{ methods: ['POST'], path: /^\/as\/actor\/app\/inbox$/ },
|
||||
{ methods: ['POST'], path: new RegExp(`^/\\.webhooks/${ID}$`) }
|
||||
];
|
||||
|
||||
/**
|
||||
* isPublicRequest(method, path) → boolean. True only when `method` (upper case) on `path` (the
|
||||
* request path without its query string, as the gateway routes it) is in `PUBLIC_ENDPOINTS`.
|
||||
*/
|
||||
function isPublicRequest(method, path) {
|
||||
if (typeof method !== 'string' || typeof path !== 'string') return false;
|
||||
const verb = method.toUpperCase();
|
||||
return PUBLIC_ENDPOINTS.some(endpoint => endpoint.methods.includes(verb) && endpoint.path.test(path));
|
||||
}
|
||||
|
||||
/** The path the gateway routes on: moleculer-web's `parsedUrl` (query stripped, trailing `/` trimmed). */
|
||||
function routedPathOf(req) {
|
||||
if (typeof req.parsedUrl === 'string') return req.parsedUrl;
|
||||
const raw = String(req.originalUrl || req.url || '').split('?')[0];
|
||||
return raw.length > 1 && raw.endsWith('/') ? raw.slice(0, -1) : raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* publicSurfaceGate(req, res, next) — a moleculer-web global middleware (`api.use`, which the gateway
|
||||
* puts in front of every route, including the ones services add at runtime). Passes a request in
|
||||
* `PUBLIC_ENDPOINTS` through; answers any other with 404 before authentication or any action runs.
|
||||
*/
|
||||
function publicSurfaceGate(req, res, next) {
|
||||
const method = req.method === 'OPTIONS' ? req.headers && req.headers['access-control-request-method'] : req.method;
|
||||
if (isPublicRequest(method, routedPathOf(req))) return next();
|
||||
return next(new MoleculerClientError('Not found', 404, 'NOT_FOUND'));
|
||||
}
|
||||
|
||||
module.exports = { isPublicRequest, publicSurfaceGate, PUBLIC_ENDPOINTS };
|
||||
@@ -0,0 +1,176 @@
|
||||
const path = require('path');
|
||||
const { test, expect, describe, afterEach } = require('bun:test');
|
||||
const { ServiceBroker } = require('moleculer');
|
||||
const { isPublicRequest, publicSurfaceGate } = require('./public-surface');
|
||||
|
||||
const UUID = 'd9b5f8ca-b2e2-41eb-af83-64e5ef1d4b20';
|
||||
|
||||
describe('the core service mounts the gate in front of every route', () => {
|
||||
test('core.service.js wires publicSurfaceGate as a global gateway middleware, and no SPARQL endpoint', () => {
|
||||
const core = require('./core/core.service.js');
|
||||
expect(core.settings.api.use).toContain(publicSurfaceGate);
|
||||
expect(core.settings.sparqlEndpoint).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPublicRequest — the allow-list', () => {
|
||||
test('every endpoint a pod or the Festipod front end calls is public', () => {
|
||||
for (const [method, p] of [
|
||||
['GET', '/app'],
|
||||
['GET', '/as/actor/app'],
|
||||
['GET', '/catalog'],
|
||||
['GET', `/public-key/${UUID}`],
|
||||
['HEAD', `/public-key/${UUID}`],
|
||||
['GET', `/interop/access-need-group/${UUID}`],
|
||||
['GET', `/interop/access-need/${UUID}`],
|
||||
['GET', '/.well-known/context.jsonld'],
|
||||
['GET', '/.well-known/nodeinfo'],
|
||||
['GET', '/nodeinfo/2.1'],
|
||||
['POST', '/as/actor/app/inbox'],
|
||||
['POST', `/.webhooks/${UUID}`]
|
||||
]) {
|
||||
expect(`${method} ${p} ${isPublicRequest(method, p)}`).toBe(`${method} ${p} true`);
|
||||
}
|
||||
});
|
||||
|
||||
test("the app's private keys, its registrations and grants, every listing and the SPARQL endpoint are refused", () => {
|
||||
for (const p of [
|
||||
'/key',
|
||||
`/key/${UUID}`,
|
||||
'/', // the root container: lists every container of the settings dataset
|
||||
'/public-key', // container listing
|
||||
'/interop',
|
||||
`/interop/application-registration/${UUID}`,
|
||||
`/interop/access-grant/${UUID}`,
|
||||
`/interop/data-grant/${UUID}`,
|
||||
'/interop/access-need',
|
||||
'/sparql',
|
||||
'/as/actor/app/outbox',
|
||||
'/as/actor/app/inbox', // GET: nobody reads the app's inbox
|
||||
'/as/actor/app/followers',
|
||||
'/as/actor',
|
||||
'/.well-known/webfinger',
|
||||
`/.webhooks/${UUID}`, // GET
|
||||
'/proxy'
|
||||
]) {
|
||||
expect(`GET ${p} ${isPublicRequest('GET', p)}`).toBe(`GET ${p} false`);
|
||||
}
|
||||
});
|
||||
|
||||
test('every write outside the two delivery endpoints is refused', () => {
|
||||
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
|
||||
for (const p of ['/app', '/catalog', '/as/actor/app', `/public-key/${UUID}`, `/interop/access-need/${UUID}`, '/key', '/', '/sparql', '/proxy', '/as/actor/app/outbox']) {
|
||||
expect(`${method} ${p} ${isPublicRequest(method, p)}`).toBe(`${method} ${p} false`);
|
||||
}
|
||||
}
|
||||
expect(isPublicRequest('PUT', `/.webhooks/${UUID}`)).toBe(false);
|
||||
expect(isPublicRequest('DELETE', '/as/actor/app/inbox')).toBe(false);
|
||||
});
|
||||
|
||||
test('a path that only resembles a public one is refused (traversal, encoding, extra segments, case)', () => {
|
||||
for (const p of [
|
||||
`/public-key/../key/${UUID}`,
|
||||
'/public-key/..',
|
||||
`/public-key/%2e%2e/key/${UUID}`,
|
||||
`/public-key/${UUID}%2F..%2Fkey`,
|
||||
`/public-key/${UUID}/extra`,
|
||||
`//public-key/${UUID}`,
|
||||
`/Public-Key/${UUID}`,
|
||||
'/app/',
|
||||
'/apps',
|
||||
'/catalog/x',
|
||||
`/interop/access-need/${UUID}/../../key`,
|
||||
'/.well-known/context.jsonldx'
|
||||
]) {
|
||||
expect(`${p} ${isPublicRequest('GET', p)}`).toBe(`${p} false`);
|
||||
}
|
||||
expect(isPublicRequest(undefined, '/app')).toBe(false);
|
||||
expect(isPublicRequest('GET', undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publicSurfaceGate — the middleware', () => {
|
||||
const run = req => {
|
||||
let passed;
|
||||
publicSurfaceGate(req, {}, err => {
|
||||
passed = err === undefined ? true : err;
|
||||
});
|
||||
return passed;
|
||||
};
|
||||
|
||||
test('passes a public request, answers 404 to any other (before authentication runs)', () => {
|
||||
expect(run({ method: 'GET', parsedUrl: '/catalog', headers: {} })).toBe(true);
|
||||
const refusal = run({ method: 'GET', parsedUrl: '/key', headers: {} });
|
||||
expect(refusal).toBeInstanceOf(Error);
|
||||
expect(refusal.code).toBe(404);
|
||||
});
|
||||
|
||||
test('a CORS preflight is judged on the method it announces', () => {
|
||||
expect(run({ method: 'OPTIONS', parsedUrl: '/catalog', headers: { 'access-control-request-method': 'GET' } })).toBe(true);
|
||||
expect(run({ method: 'OPTIONS', parsedUrl: '/catalog', headers: { 'access-control-request-method': 'DELETE' } }).code).toBe(404);
|
||||
expect(run({ method: 'OPTIONS', parsedUrl: '/catalog', headers: {} }).code).toBe(404);
|
||||
});
|
||||
|
||||
test('without the gateway-parsed path, the raw URL is used without its query string', () => {
|
||||
expect(run({ method: 'GET', originalUrl: '/catalog?x=1', headers: {} })).toBe(true);
|
||||
expect(run({ method: 'GET', originalUrl: '/key?x=/catalog', headers: {} }).code).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// The mechanism, end to end: a real moleculer-web gateway (the version CoreService uses), with the
|
||||
// REAL @semapps/ldp catch-all route added at runtime as the LDP service adds it, in front of a stand-in
|
||||
// `ldp.api` whose every action answers as if it served the settings dataset. Status codes only.
|
||||
describe('the gate covers routes added at runtime — the LDP catch-all that served the private key', () => {
|
||||
const semappsCore = path.dirname(require.resolve('@semapps/core'));
|
||||
const ApiGateway = require(require.resolve('moleculer-web', { paths: [semappsCore] }));
|
||||
const ldpDir = path.dirname(require.resolve('@semapps/ldp/package.json', { paths: [semappsCore] }));
|
||||
const { default: getCatchAllRoute } = require(path.join(ldpDir, 'dist/routes/getCatchAllRoute.js'));
|
||||
|
||||
let broker;
|
||||
afterEach(async () => {
|
||||
if (broker) await broker.stop();
|
||||
broker = undefined;
|
||||
});
|
||||
|
||||
async function startGateway(use) {
|
||||
broker = new ServiceBroker({ logger: false });
|
||||
const answer = () => 'settings dataset content';
|
||||
broker.createService({
|
||||
name: 'ldp.api',
|
||||
actions: { get: answer, head: answer, post: answer, put: answer, patch: answer, delete: answer }
|
||||
});
|
||||
const gateway = broker.createService({
|
||||
mixins: [ApiGateway],
|
||||
settings: { port: 0, ip: '127.0.0.1', routes: [], ...(use ? { use } : {}) },
|
||||
methods: {
|
||||
// CoreService's: no credentials → anonymous.
|
||||
authenticate(ctx) {
|
||||
ctx.meta.webId = 'anon';
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
await broker.start();
|
||||
await broker.call('api.addRoute', { route: getCatchAllRoute('/', false) });
|
||||
return `http://127.0.0.1:${gateway.server.address().port}`;
|
||||
}
|
||||
|
||||
const status = async (base, method, p) => (await fetch(base + p, { method })).status;
|
||||
|
||||
test('BASELINE (no gate): the catch-all serves the key container to an anonymous caller', async () => {
|
||||
const base = await startGateway(undefined);
|
||||
expect(await status(base, 'GET', '/key')).toBe(200);
|
||||
});
|
||||
|
||||
test('with the gate: the key container, the root and any write are 404; a public key is served', async () => {
|
||||
const base = await startGateway([publicSurfaceGate]);
|
||||
expect(await status(base, 'GET', '/key')).toBe(404);
|
||||
expect(await status(base, 'GET', `/key/${UUID}`)).toBe(404);
|
||||
expect(await status(base, 'GET', '/')).toBe(404);
|
||||
expect(await status(base, 'GET', `/interop/data-grant/${UUID}`)).toBe(404);
|
||||
expect(await status(base, 'PUT', `/public-key/${UUID}`)).toBe(404);
|
||||
expect(await status(base, 'DELETE', `/interop/access-need/${UUID}`)).toBe(404);
|
||||
expect(await status(base, 'GET', `/public-key/${UUID}`)).toBe(200);
|
||||
expect(await status(base, 'GET', `/interop/access-need/${UUID}`)).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -1,120 +1,179 @@
|
||||
const { ACTIVITY_TYPES, OBJECT_TYPES } = require('@semapps/activitypub');
|
||||
const { ACTIVITY_TYPES, OBJECT_TYPES, matchActivity } = require('@semapps/activitypub');
|
||||
const { PodActivitiesHandlerMixin } = require('@activitypods/app');
|
||||
const { arrayOf } = require('@semapps/ldp');
|
||||
const CONFIG = require('../config/config');
|
||||
const { isInActorPod } = require('./actor-pod');
|
||||
const { createKeyedSerializer } = require('./keyed-serializer');
|
||||
const { isNewerAct, lastAttendance, recordAttendance } = require('./attendance-ledger');
|
||||
const { neverThrowingMatcher, verifiedEmit, verifiedReceive, singleUriOf, uriOf } = require('./emitted-activity');
|
||||
|
||||
const APODS_ATTENDEES = 'http://activitypods.org/ns/core#attendees';
|
||||
const APODS_CONTACTS = 'http://activitypods.org/ns/core#contacts';
|
||||
const DC_CREATOR = 'http://purl.org/dc/terms/creator';
|
||||
|
||||
const JOIN_PATTERN = { type: ACTIVITY_TYPES.JOIN, object: { type: OBJECT_TYPES.EVENT } };
|
||||
const LEAVE_PATTERN = { type: ACTIVITY_TYPES.LEAVE, object: { type: OBJECT_TYPES.EVENT } };
|
||||
|
||||
const AS_PUBLISHED = 'https://www.w3.org/ns/activitystreams#published';
|
||||
|
||||
/** The time the pod stamped on a stored activity (`published`, under any of its spellings). */
|
||||
const publishedOf = activity => {
|
||||
if (!activity || typeof activity !== 'object') return undefined;
|
||||
const value = activity.published ?? activity['as:published'] ?? activity[AS_PUBLISHED];
|
||||
return value && typeof value === 'object' ? value['@value'] : value;
|
||||
};
|
||||
|
||||
/** The single URI an event names under a compacted or full-IRI key. */
|
||||
const linkOf = (node, compacted, full) => singleUriOf(node[compacted] ?? node[full]);
|
||||
|
||||
// Participation: a user joins (or leaves) a meeting point with a Join (Leave) posted to her outbox,
|
||||
// addressed to its organizer. Two effects, each from one side's box:
|
||||
// - EMITTED (the joiner's outbox): the organizer joins the joiner's contacts group, so the
|
||||
// organizer may read her profile;
|
||||
// - RECEIVED (the organizer's inbox): the joiner is added to (removed from) the meeting point's
|
||||
// attendees collection.
|
||||
// The webhook that reports either is a hint (emitted-activity.js): each handler acts only on the
|
||||
// activity and the meeting point as the pod stores them, and only for the people they really name:
|
||||
// - the joiner is the actor whose pod holds the activity, never a value of the webhook body;
|
||||
// - the organizer is the meeting point's `dc:creator`, accepted only when the meeting point lies in
|
||||
// that creator's pod — the pod stamps the creator, and nobody can store a resource in someone
|
||||
// else's pod;
|
||||
// - the collection and the group written to are the ones the STORED meeting point and the
|
||||
// joiner's STORED actor document name, and must lie in the pod of whoever owns them.
|
||||
// Joins and leaves of one meeting point are processed one at a time (keyed-serializer.js), and the
|
||||
// order they reach that lock does not decide: under the lock, an act is applied only when it is newer —
|
||||
// by the time the participant's pod stamped on it — than the last act applied for that participant and
|
||||
// meeting point (`attendance-ledger.js`, persisted). Whatever order the notifications run in, the
|
||||
// participant ends in the state of her latest act. The collection is read when acting.
|
||||
module.exports = {
|
||||
name: 'registration',
|
||||
mixins: [PodActivitiesHandlerMixin],
|
||||
created() {
|
||||
this.serialize = createKeyedSerializer();
|
||||
},
|
||||
async started() {
|
||||
this.logger.info('Registration watcher subscribed');
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* The Event's `apods:attendees` collection is attached asynchronously by
|
||||
* PodCollectionsHandlerMixin on create, so a Join/Leave processed in that window sees no
|
||||
* collection. Returning immediately would drop the activity forever (the client overlay
|
||||
* shows joined, nothing reconciles). Re-fetch the Event a bounded number of times until the
|
||||
* collection appears; only then give up.
|
||||
* The attendees collection of a meeting point of `actorUri`'s pod, from the STORED meeting point.
|
||||
* It is attached asynchronously when the meeting point is created (attendees service), so a
|
||||
* Join/Leave processed in that window sees none: the meeting point is read again a bounded number
|
||||
* of times until it names one; only then give up. The collection must lie in the same pod.
|
||||
*/
|
||||
async fetchAttendeesCollectionUri(ctx, event, actorUri) {
|
||||
if (event['apods:attendees'] || !event.id) return event['apods:attendees'];
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
async attendeesCollectionOf(ctx, meetingPoint, meetingPointUri, actorUri) {
|
||||
let collectionUri = linkOf(meetingPoint, 'apods:attendees', APODS_ATTENDEES);
|
||||
for (let attempt = 0; !collectionUri && attempt < 3; attempt++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
try {
|
||||
const { ok, body } = await ctx.call('pod-resources.get', { resourceUri: event.id, actorUri });
|
||||
if (ok && body && body['apods:attendees']) return body['apods:attendees'];
|
||||
const { status, body } = await ctx.call('pod-resources.get', { resourceUri: meetingPointUri, actorUri });
|
||||
if (status === 200 && body) collectionUri = linkOf(body, 'apods:attendees', APODS_ATTENDEES);
|
||||
} catch (e) {
|
||||
this.logger.warn(`Could not re-fetch ${event.id} while waiting for its attendees collection: ${e.message}`);
|
||||
this.logger.warn(`Could not re-read ${meetingPointUri} while waiting for its attendees collection: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
if (collectionUri && !isInActorPod(collectionUri, actorUri)) {
|
||||
this.logger.warn(`${meetingPointUri} names an attendees collection outside its pod: ignored`);
|
||||
return undefined;
|
||||
}
|
||||
return collectionUri;
|
||||
},
|
||||
/**
|
||||
* Applies one participant act (`join` / `leave`, the stored activity `activity`) to the meeting
|
||||
* point's collection — under the meeting point's lock, and only when the act is newer than the last
|
||||
* one applied for that participant there (see the header). A stale act changes nothing; an act
|
||||
* without a pod stamp cannot be placed and changes nothing. The decision is recorded once the
|
||||
* collection matches it. Gives up before any write once its time is out (`signal`).
|
||||
*
|
||||
* KNOWN LIMIT — a write in flight at the timeout: an add or remove ALREADY SENT when the time ran
|
||||
* out still lands, and is not recorded. If a newer act of the same participant was applied in the
|
||||
* meantime (on a read taken before the late write landed), the collection shows the older act's
|
||||
* effect until the participant acts again. It is deliberately NOT corrected from the ledger
|
||||
* afterwards: when nothing superseded the late act (a rejoin, say), the late write is right and it
|
||||
* is the ledger that is behind — re-applying the ledger would undo it, and the two cases cannot be
|
||||
* told apart here. Needs a pod slower than the 30 s limit.
|
||||
*/
|
||||
async updateAttendance(ctx, { change, memberUri, meetingPointUri, meetingPoint, activity, actorUri }) {
|
||||
const act = { activityUri: uriOf(activity), published: publishedOf(activity), change };
|
||||
if (Number.isNaN(Date.parse(act.published))) {
|
||||
this.logger.warn(`${change} ${act.activityUri} of ${memberUri} carries no pod stamp (published): it cannot be ordered, unchanged`);
|
||||
return;
|
||||
}
|
||||
await this.serialize(meetingPointUri, async signal => {
|
||||
const dataset = CONFIG.MAIN_DATASET;
|
||||
const last = await lastAttendance(ctx, { dataset, meetingPointUri, memberUri });
|
||||
if (!isNewerAct(act, last)) {
|
||||
this.logger.info(`${change} ${act.activityUri} of ${memberUri} on ${meetingPointUri} is not newer than the last act applied (${last.activityUri}, possibly the same act notified again): unchanged`);
|
||||
return;
|
||||
}
|
||||
const collectionUri = await this.attendeesCollectionOf(ctx, meetingPoint, meetingPointUri, actorUri);
|
||||
if (!collectionUri) {
|
||||
this.logger.warn(`No attendees collection on ${meetingPointUri}, skipping ${change}`);
|
||||
return;
|
||||
}
|
||||
if (signal.aborted) return;
|
||||
const attending = arrayOf(await ctx.call('pod-collections.getItems', { collectionUri, actorUri })).includes(memberUri);
|
||||
if (signal.aborted) return;
|
||||
if (change === 'join' && !attending) {
|
||||
await ctx.call('attendees.add', { collectionUri, itemUri: memberUri, actorUri });
|
||||
} else if (change === 'leave' && attending) {
|
||||
await ctx.call('attendees.remove', { collectionUri, itemUri: memberUri, actorUri });
|
||||
} else {
|
||||
this.logger.info(`${memberUri} already ${change === 'join' ? 'attends' : 'does not attend'} ${meetingPointUri}`);
|
||||
}
|
||||
if (signal.aborted) return;
|
||||
await recordAttendance(ctx, { dataset, meetingPointUri, memberUri, act });
|
||||
});
|
||||
}
|
||||
},
|
||||
activities: {
|
||||
join: {
|
||||
match: {
|
||||
type: ACTIVITY_TYPES.JOIN,
|
||||
object: {
|
||||
type: OBJECT_TYPES.EVENT
|
||||
}
|
||||
},
|
||||
async onEmit(ctx, activity, actorUri) {
|
||||
const event = activity.object;
|
||||
const organizerUri = event['dc:creator'];
|
||||
if (!organizerUri) return;
|
||||
match: neverThrowingMatcher(function (activity, fetcher) {
|
||||
return matchActivity(JOIN_PATTERN, activity, fetcher);
|
||||
}),
|
||||
// The joiner's outbox: `actorUri` is the joiner. The meeting point lies in the organizer's pod.
|
||||
onEmit: verifiedEmit(
|
||||
'Join',
|
||||
async function (ctx, { object: meetingPoint, objectUri: meetingPointUri }, actorUri) {
|
||||
const organizerUri = linkOf(meetingPoint, 'dc:creator', DC_CREATOR);
|
||||
if (!organizerUri || !isInActorPod(meetingPointUri, organizerUri)) {
|
||||
this.logger.warn(`${meetingPointUri} is not in the pod of the creator it names: no contact added`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { body: actor } = await ctx.call('pod-resources.get', { resourceUri: actorUri, actorUri });
|
||||
if (!actor || !actor['apods:contacts']) return;
|
||||
const { body: actor } = await ctx.call('pod-resources.get', { resourceUri: actorUri, actorUri });
|
||||
const contactsUri = actor && linkOf(actor, 'apods:contacts', APODS_CONTACTS);
|
||||
if (!contactsUri) return;
|
||||
if (!isInActorPod(contactsUri, actorUri)) {
|
||||
this.logger.warn(`${actorUri} names a contacts collection outside its pod: no contact added`);
|
||||
return;
|
||||
}
|
||||
|
||||
const contactsGroupUri = await ctx.call('pod-wac-groups.getUriFromCollectionUri', {
|
||||
collectionUri: actor['apods:contacts']
|
||||
});
|
||||
|
||||
await ctx.call('pod-wac-groups.addMember', {
|
||||
groupUri: contactsGroupUri,
|
||||
memberUri: organizerUri,
|
||||
actorUri
|
||||
});
|
||||
},
|
||||
async onReceive(ctx, activity, actorUri) {
|
||||
const event = activity.object;
|
||||
const attendeesCollectionUri = await this.fetchAttendeesCollectionUri(ctx, event, actorUri);
|
||||
|
||||
if (!attendeesCollectionUri) {
|
||||
this.logger.warn(`No attendees collection on ${event.id}, skipping Join`);
|
||||
return;
|
||||
}
|
||||
|
||||
const attendees = await ctx.call('pod-collections.getItems', {
|
||||
collectionUri: attendeesCollectionUri,
|
||||
actorUri
|
||||
});
|
||||
|
||||
if (arrayOf(attendees).includes(activity.actor)) {
|
||||
this.logger.info(`${activity.actor} is already attending ${event.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.call('attendees.add', {
|
||||
collectionUri: attendeesCollectionUri,
|
||||
itemUri: activity.actor,
|
||||
actorUri
|
||||
});
|
||||
}
|
||||
const contactsGroupUri = await ctx.call('pod-wac-groups.getUriFromCollectionUri', { collectionUri: contactsUri });
|
||||
await ctx.call('pod-wac-groups.addMember', { groupUri: contactsGroupUri, memberUri: organizerUri, actorUri });
|
||||
},
|
||||
{ objectType: 'Event', objectIn: 'any' }
|
||||
),
|
||||
// The organizer's inbox: `actorUri` is the organizer; the meeting point must lie in her pod.
|
||||
onReceive: verifiedReceive(
|
||||
'Join',
|
||||
async function (ctx, { activity, emitterUri, object, objectUri }, actorUri) {
|
||||
await this.updateAttendance(ctx, { change: 'join', memberUri: emitterUri, meetingPointUri: objectUri, meetingPoint: object, activity, actorUri });
|
||||
},
|
||||
{ objectType: 'Event' }
|
||||
)
|
||||
},
|
||||
leave: {
|
||||
match: {
|
||||
type: ACTIVITY_TYPES.LEAVE,
|
||||
object: {
|
||||
type: OBJECT_TYPES.EVENT
|
||||
}
|
||||
},
|
||||
async onReceive(ctx, activity, actorUri) {
|
||||
const event = activity.object;
|
||||
const attendeesCollectionUri = await this.fetchAttendeesCollectionUri(ctx, event, actorUri);
|
||||
|
||||
if (!attendeesCollectionUri) {
|
||||
this.logger.warn(`No attendees collection on ${event.id}, skipping Leave`);
|
||||
return;
|
||||
}
|
||||
|
||||
const attendees = await ctx.call('pod-collections.getItems', {
|
||||
collectionUri: attendeesCollectionUri,
|
||||
actorUri
|
||||
});
|
||||
|
||||
if (!arrayOf(attendees).includes(activity.actor)) {
|
||||
this.logger.info(`${activity.actor} is not attending ${event.id}, skipping Leave`);
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.call('attendees.remove', {
|
||||
collectionUri: attendeesCollectionUri,
|
||||
itemUri: activity.actor,
|
||||
actorUri
|
||||
});
|
||||
}
|
||||
match: neverThrowingMatcher(function (activity, fetcher) {
|
||||
return matchActivity(LEAVE_PATTERN, activity, fetcher);
|
||||
}),
|
||||
onReceive: verifiedReceive(
|
||||
'Leave',
|
||||
async function (ctx, { activity, emitterUri, object, objectUri }, actorUri) {
|
||||
await this.updateAttendance(ctx, { change: 'leave', memberUri: emitterUri, meetingPointUri: objectUri, meetingPoint: object, activity, actorUri });
|
||||
},
|
||||
{ objectType: 'Event' }
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
const { test, expect, describe } = require('bun:test');
|
||||
const registration = require('./registration.service');
|
||||
const { createKeyedSerializer } = require('./keyed-serializer');
|
||||
const { lastAttendanceQuery, recordAttendanceUpdate, isNewerAct, attendanceSubject } = require('./attendance-ledger');
|
||||
|
||||
const ALICE = 'http://localhost:3000/alice'; // the organizer
|
||||
const BOB = 'http://localhost:3000/bob'; // the joiner
|
||||
const MALLORY = 'http://localhost:3000/mallory';
|
||||
|
||||
const MP = `${ALICE}/data/events/mp1`;
|
||||
const C = `${ALICE}/data/collections/mp1-attendees`;
|
||||
const JOIN = `${BOB}/data/activities/join-1`;
|
||||
const LEAVE = `${BOB}/data/activities/leave-1`;
|
||||
const BOB_CONTACTS = `${BOB}/data/collections/contacts`;
|
||||
|
||||
const meetingPoint = (extra = {}) => ({ id: MP, type: 'Event', name: 'Entrée nord', 'dc:creator': ALICE, 'apods:attendees': C, to: 'as:Public', ...extra });
|
||||
// The pod stamps `published` on every activity posted to an outbox.
|
||||
const join = { id: JOIN, type: 'Join', actor: BOB, object: MP, to: ALICE, published: '2026-10-13T10:00:00.000Z' };
|
||||
const leave = { id: LEAVE, type: 'Leave', actor: BOB, object: MP, to: ALICE, published: '2026-10-13T10:00:05.000Z' };
|
||||
|
||||
/**
|
||||
* A pod (uri → stored body, 404 otherwise) plus the collection's state and the backend's attendance
|
||||
* ledger (answering exactly the queries `attendance-ledger.js` builds); records every call.
|
||||
* `addDelay` makes `attendees.add` slow; `readDelays` (uri → ms) makes some pod reads slow, to expose
|
||||
* interleavings. `ledger` seeds the last act applied per member.
|
||||
*/
|
||||
function world({ stored = {}, items = [], addDelay = 0, readDelays = {}, ledger = {} } = {}) {
|
||||
const calls = [];
|
||||
const state = { items: [...items], ledger: { ...ledger } };
|
||||
const members = [BOB, MALLORY, ALICE];
|
||||
// Every act the store could be asked to record: the stored Join/Leave activities.
|
||||
const acts = Object.values(stored)
|
||||
.filter(body => body.type === 'Join' || body.type === 'Leave')
|
||||
.map(body => ({ activityUri: body.id, published: body.published, change: body.type.toLowerCase() }));
|
||||
const ctx = {
|
||||
call: async (action, params) => {
|
||||
calls.push({ action, params });
|
||||
switch (action) {
|
||||
case 'pod-resources.get': {
|
||||
if (readDelays[params.resourceUri]) await new Promise(resolve => setTimeout(resolve, readDelays[params.resourceUri]));
|
||||
const body = stored[params.resourceUri];
|
||||
return body ? { ok: true, status: 200, body } : { ok: false, status: 404 };
|
||||
}
|
||||
case 'triplestore.query': {
|
||||
const member = members.find(m => params.query === lastAttendanceQuery(MP, m));
|
||||
if (!member) throw new Error(`unexpected query ${params.query}`);
|
||||
const last = state.ledger[member];
|
||||
return last ? [{ act: { value: last.activityUri }, published: { value: last.published }, change: { value: last.change } }] : [];
|
||||
}
|
||||
case 'triplestore.update': {
|
||||
for (const m of members) {
|
||||
const act = acts.find(a => params.query === recordAttendanceUpdate(MP, m, a));
|
||||
if (act) {
|
||||
state.ledger[m] = act;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
throw new Error(`unexpected update ${params.query}`);
|
||||
}
|
||||
case 'pod-collections.getItems':
|
||||
return [...state.items];
|
||||
case 'attendees.add':
|
||||
if (addDelay) await new Promise(resolve => setTimeout(resolve, addDelay));
|
||||
state.items.push(params.itemUri);
|
||||
return undefined;
|
||||
case 'attendees.remove':
|
||||
state.items = state.items.filter(item => item !== params.itemUri);
|
||||
return undefined;
|
||||
case 'pod-wac-groups.getUriFromCollectionUri':
|
||||
return `${params.collectionUri}-group`;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
return { ctx, calls, state };
|
||||
}
|
||||
|
||||
function service({ timeoutMs } = {}) {
|
||||
const logs = { info: [], warn: [], error: [] };
|
||||
const svc = { name: 'registration', logger: { info: m => logs.info.push(m), warn: m => logs.warn.push(m), error: m => logs.error.push(m) } };
|
||||
if (registration.created) registration.created.call(svc);
|
||||
if (timeoutMs) svc.serialize = createKeyedSerializer({ timeoutMs });
|
||||
for (const [name, fn] of Object.entries(registration.methods)) svc[name] = fn.bind(svc);
|
||||
return { svc, logs };
|
||||
}
|
||||
|
||||
const receiveJoin = (svc, ctx, hint) => registration.activities.join.onReceive.call(svc, ctx, hint, ALICE);
|
||||
const receiveLeave = (svc, ctx, hint) => registration.activities.leave.onReceive.call(svc, ctx, hint, ALICE);
|
||||
const emitJoin = (svc, ctx, hint) => registration.activities.join.onEmit.call(svc, ctx, hint, BOB);
|
||||
const adds = calls => calls.filter(c => c.action === 'attendees.add').map(c => c.params);
|
||||
|
||||
describe("registration — a Join received in the organizer's inbox adds the joiner the pod names", () => {
|
||||
test('the normal case: the stored joiner is added to the STORED meeting point\'s collection', async () => {
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint() } });
|
||||
await receiveJoin(service().svc, ctx, join);
|
||||
expect(adds(calls)).toEqual([{ collectionUri: C, itemUri: BOB, actorUri: ALICE }]);
|
||||
});
|
||||
|
||||
test('the webhook body is ignored: neither its actor nor its embedded attendees collection is used', async () => {
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint() } });
|
||||
const body = { id: JOIN, type: 'Join', actor: MALLORY, object: { id: MP, type: 'Event', 'apods:attendees': `${MALLORY}/data/c` } };
|
||||
await receiveJoin(service().svc, ctx, body);
|
||||
expect(adds(calls)).toEqual([{ collectionUri: C, itemUri: BOB, actorUri: ALICE }]);
|
||||
});
|
||||
|
||||
test('a Join the pod does not store (webhook only) adds no one', async () => {
|
||||
const { ctx, calls } = world({ stored: { [MP]: meetingPoint() } });
|
||||
const { svc, logs } = service();
|
||||
await receiveJoin(svc, ctx, { ...join, object: meetingPoint() });
|
||||
expect(adds(calls)).toEqual([]);
|
||||
expect(logs.warn[0]).toContain('ignored a Join');
|
||||
});
|
||||
|
||||
test('a Join about a meeting point outside the organizer\'s pod adds no one', async () => {
|
||||
const bobsMp = `${BOB}/data/events/mp`;
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: { ...join, object: bobsMp }, [bobsMp]: { ...meetingPoint(), id: bobsMp } } });
|
||||
await receiveJoin(service().svc, ctx, join);
|
||||
expect(adds(calls)).toEqual([]);
|
||||
});
|
||||
|
||||
test('a stored meeting point naming a collection outside the organizer\'s pod: nothing written', async () => {
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint({ 'apods:attendees': `${MALLORY}/data/c` }) } });
|
||||
const { svc, logs } = service();
|
||||
await receiveJoin(svc, ctx, join);
|
||||
expect(adds(calls)).toEqual([]);
|
||||
expect(logs.warn.some(m => m.includes('outside its pod'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a stored object that is not an event (a Note) adds no one', async () => {
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: { id: MP, type: 'Note', 'apods:attendees': C } } });
|
||||
await receiveJoin(service().svc, ctx, join);
|
||||
expect(adds(calls)).toEqual([]);
|
||||
});
|
||||
|
||||
test('an already attending joiner is not added twice', async () => {
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint() }, items: [BOB] });
|
||||
await receiveJoin(service().svc, ctx, join);
|
||||
expect(adds(calls)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("registration — a Leave received in the organizer's inbox removes only its stored emitter", () => {
|
||||
test('the stored leaver is removed; a body claiming another actor changes nothing to who is removed', async () => {
|
||||
const { ctx, state } = world({ stored: { [LEAVE]: leave, [MP]: meetingPoint() }, items: [BOB, MALLORY] });
|
||||
await receiveLeave(service().svc, ctx, { ...leave, actor: MALLORY });
|
||||
expect(state.items).toEqual([MALLORY]);
|
||||
});
|
||||
|
||||
test('a Leave the pod does not store removes no one', async () => {
|
||||
const { ctx, state } = world({ stored: { [MP]: meetingPoint() }, items: [BOB] });
|
||||
await receiveLeave(service().svc, ctx, leave);
|
||||
expect(state.items).toEqual([BOB]);
|
||||
});
|
||||
|
||||
test('a Join and a Leave of one meeting point never interleave (the join cannot land after the leave)', async () => {
|
||||
const { ctx, state } = world({ stored: { [JOIN]: join, [LEAVE]: leave, [MP]: meetingPoint() }, addDelay: 30 });
|
||||
const { svc } = service();
|
||||
await Promise.all([receiveJoin(svc, ctx, join), receiveLeave(svc, ctx, leave)]);
|
||||
expect(state.items).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registration — attendance follows the participant\'s LATEST act, whatever order notifications run in', () => {
|
||||
// The reviewer's probe: the Join's verification read is slower, so the Leave reaches the lock first.
|
||||
test('join then leave, the Leave handled first: ends NOT attending', async () => {
|
||||
const { ctx, state } = world({ stored: { [JOIN]: join, [LEAVE]: leave, [MP]: meetingPoint() }, readDelays: { [JOIN]: 40 } });
|
||||
const { svc } = service();
|
||||
const j = receiveJoin(svc, ctx, { id: JOIN }); // the Join webhook first
|
||||
await new Promise(resolve => setTimeout(resolve, 5)); // the Leave webhook 5 ms later
|
||||
const l = receiveLeave(svc, ctx, { id: LEAVE });
|
||||
await Promise.all([j, l]);
|
||||
expect(state.items).toEqual([]);
|
||||
});
|
||||
|
||||
test('leave then join, the Join handled first: ends attending', async () => {
|
||||
const earlyLeave = { ...leave, published: '2026-10-13T09:00:00.000Z' }; // she left, then joined again
|
||||
const { ctx, state } = world({ stored: { [JOIN]: join, [LEAVE]: earlyLeave, [MP]: meetingPoint() }, items: [BOB], readDelays: { [LEAVE]: 40 } });
|
||||
const { svc } = service();
|
||||
const l = receiveLeave(svc, ctx, { id: LEAVE });
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
const j = receiveJoin(svc, ctx, { id: JOIN });
|
||||
await Promise.all([l, j]);
|
||||
expect(state.items).toEqual([BOB]);
|
||||
});
|
||||
|
||||
test('a replayed older Join after a later Leave changes nothing (the ledger says the Leave is newer)', async () => {
|
||||
const { ctx, state, calls } = world({
|
||||
stored: { [JOIN]: join, [LEAVE]: leave, [MP]: meetingPoint() },
|
||||
ledger: { [BOB]: { activityUri: LEAVE, published: leave.published, change: 'leave' } }
|
||||
});
|
||||
const { svc, logs } = service();
|
||||
await receiveJoin(svc, ctx, { id: JOIN });
|
||||
expect(state.items).toEqual([]);
|
||||
expect(adds(calls)).toEqual([]);
|
||||
expect(logs.info.some(m => m.includes('not newer than the last act applied'))).toBe(true);
|
||||
});
|
||||
|
||||
test('every applied act is recorded, even when the collection already matched it', async () => {
|
||||
const { ctx, state } = world({ stored: { [JOIN]: join, [MP]: meetingPoint() }, items: [BOB] });
|
||||
await receiveJoin(service().svc, ctx, { id: JOIN });
|
||||
expect(state.ledger[BOB]).toEqual({ activityUri: JOIN, published: join.published, change: 'join' });
|
||||
});
|
||||
|
||||
test('an act without a pod stamp cannot be ordered: nothing written', async () => {
|
||||
const unstamped = { ...join, published: undefined };
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: unstamped, [MP]: meetingPoint() } });
|
||||
const { svc, logs } = service();
|
||||
await receiveJoin(svc, ctx, { id: JOIN });
|
||||
expect(adds(calls)).toEqual([]);
|
||||
expect(calls.some(c => c.action === 'triplestore.update')).toBe(false);
|
||||
expect(logs.warn.some(m => m.includes('no pod stamp'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registration — a write in flight at the timeout (no re-alignment: decided limit)', () => {
|
||||
// Bob joined then left (Leave L0 recorded). He acts once more; that act's write is sent before the
|
||||
// 50 ms timeout and lands at 120 ms; nothing follows. The late write is the latest act: it must stand.
|
||||
const L0 = { id: `${BOB}/data/activities/leave-0`, type: 'Leave', actor: BOB, object: MP, to: ALICE, published: '2026-10-13T09:00:00.000Z' };
|
||||
const J0 = { id: `${BOB}/data/activities/join-0`, type: 'Join', actor: BOB, object: MP, to: ALICE, published: '2026-10-13T09:00:00.000Z' };
|
||||
|
||||
test('a late, unrecorded REJOIN is not undone by the older recorded Leave: ends attending', async () => {
|
||||
const { ctx, state } = world({
|
||||
stored: { [JOIN]: join, [MP]: meetingPoint(), [L0.id]: L0 },
|
||||
addDelay: 120,
|
||||
ledger: { [BOB]: { activityUri: L0.id, published: L0.published, change: 'leave' } }
|
||||
});
|
||||
const { svc } = service({ timeoutMs: 50 });
|
||||
await receiveJoin(svc, ctx, join);
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
expect(state.items).toEqual([BOB]);
|
||||
});
|
||||
|
||||
test('the mirror case: a late, unrecorded LEAVE is not undone by the older recorded Join: ends NOT attending', async () => {
|
||||
const slowRemove = world({
|
||||
stored: { [LEAVE]: leave, [MP]: meetingPoint(), [J0.id]: J0 },
|
||||
items: [BOB],
|
||||
ledger: { [BOB]: { activityUri: J0.id, published: J0.published, change: 'join' } }
|
||||
});
|
||||
const { ctx, state } = slowRemove;
|
||||
const call = ctx.call;
|
||||
ctx.call = async (action, params) => {
|
||||
if (action === 'attendees.remove') await new Promise(resolve => setTimeout(resolve, 120)); // lands late
|
||||
return call(action, params);
|
||||
};
|
||||
const { svc } = service({ timeoutMs: 50 });
|
||||
await receiveLeave(svc, ctx, leave);
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
expect(state.items).toEqual([]);
|
||||
});
|
||||
|
||||
test('KNOWN LIMIT (documented in updateAttendance): an older Join whose add lands after a newer Leave stays applied until the next act', async () => {
|
||||
const { ctx, state } = world({ stored: { [JOIN]: join, [LEAVE]: leave, [MP]: meetingPoint() }, addDelay: 120 });
|
||||
const { svc } = service({ timeoutMs: 50 });
|
||||
const j = receiveJoin(svc, ctx, join);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
const l = receiveLeave(svc, ctx, leave);
|
||||
await Promise.all([j, l]);
|
||||
await new Promise(resolve => setTimeout(resolve, 250));
|
||||
expect(state.ledger[BOB].change).toBe('leave'); // the newer act is the recorded one
|
||||
expect(state.items).toEqual([BOB]); // the late add of the older Join stands — no re-alignment
|
||||
});
|
||||
});
|
||||
|
||||
describe("registration — a Join emitted from the joiner's outbox adds the meeting point's real organizer to her contacts", () => {
|
||||
const bobActor = { id: BOB, type: 'Person', 'apods:contacts': BOB_CONTACTS };
|
||||
const added = calls => calls.filter(c => c.action === 'pod-wac-groups.addMember').map(c => c.params);
|
||||
|
||||
test('the normal case: the stored creator of a meeting point in her own pod', async () => {
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint(), [BOB]: bobActor } });
|
||||
await emitJoin(service().svc, ctx, join);
|
||||
expect(added(calls)).toEqual([{ groupUri: `${BOB_CONTACTS}-group`, memberUri: ALICE, actorUri: BOB }]);
|
||||
});
|
||||
|
||||
test('a creator named in the webhook body is ignored (Mallory cannot get into Bob\'s contacts)', async () => {
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint(), [BOB]: bobActor } });
|
||||
await emitJoin(service().svc, ctx, { ...join, object: { id: MP, type: 'Event', 'dc:creator': MALLORY } });
|
||||
expect(added(calls).map(p => p.memberUri)).toEqual([ALICE]);
|
||||
});
|
||||
|
||||
test('a stored creator who does not own the pod holding the meeting point is refused', async () => {
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint({ 'dc:creator': MALLORY }), [BOB]: bobActor } });
|
||||
await emitJoin(service().svc, ctx, join);
|
||||
expect(added(calls)).toEqual([]);
|
||||
});
|
||||
|
||||
test('a Join stored in another actor\'s pod is not Bob\'s', async () => {
|
||||
const forged = `${MALLORY}/data/activities/j`;
|
||||
const { ctx, calls } = world({ stored: { [forged]: { ...join, id: forged }, [MP]: meetingPoint(), [BOB]: bobActor } });
|
||||
await emitJoin(service().svc, ctx, forged);
|
||||
expect(added(calls)).toEqual([]);
|
||||
});
|
||||
|
||||
test("a contacts collection outside the joiner's pod is not written", async () => {
|
||||
const { ctx, calls } = world({ stored: { [JOIN]: join, [MP]: meetingPoint(), [BOB]: { ...bobActor, 'apods:contacts': `${MALLORY}/data/c` } } });
|
||||
await emitJoin(service().svc, ctx, join);
|
||||
expect(added(calls)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('registration — matchers never throw', () => {
|
||||
test('a fetch failure while matching reports no match', async () => {
|
||||
const { svc, logs } = service();
|
||||
const failing = async () => {
|
||||
throw new Error('proxy unreachable');
|
||||
};
|
||||
for (const key of ['join', 'leave']) {
|
||||
const result = await registration.activities[key].match.call(svc, { type: key === 'join' ? 'Join' : 'Leave', object: MP }, failing);
|
||||
expect(result.match).toBe(false);
|
||||
}
|
||||
expect(logs.error).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attendance-ledger — the order of acts', () => {
|
||||
const at = (published, activityUri = JOIN) => ({ activityUri, published });
|
||||
test('a later stamp is newer; an earlier one is not; a tie is broken by the activity URI', () => {
|
||||
expect(isNewerAct(at('2026-10-13T10:00:01Z'), at('2026-10-13T10:00:00Z'))).toBe(true);
|
||||
expect(isNewerAct(at('2026-10-13T09:59:59Z'), at('2026-10-13T10:00:00Z'))).toBe(false);
|
||||
expect(isNewerAct(at('2026-10-13T10:00:00Z', `${BOB}/b`), at('2026-10-13T10:00:00Z', `${BOB}/a`))).toBe(true);
|
||||
expect(isNewerAct(at('2026-10-13T10:00:00Z', `${BOB}/a`), at('2026-10-13T10:00:00Z', `${BOB}/a`))).toBe(false); // the same act twice
|
||||
});
|
||||
test('the first act is always newer; an unstamped act never is', () => {
|
||||
expect(isNewerAct(at('2026-10-13T10:00:00Z'), undefined)).toBe(true);
|
||||
expect(isNewerAct(at(undefined), undefined)).toBe(false);
|
||||
expect(isNewerAct(at('not a date'), undefined)).toBe(false);
|
||||
});
|
||||
test('one entry per (meeting point, participant), under a URN the store accepts', () => {
|
||||
expect(attendanceSubject(MP, BOB)).not.toBe(attendanceSubject(MP, MALLORY));
|
||||
expect(attendanceSubject(MP, BOB)).toMatch(/^urn:festipod:attendance:[^<>"{}|^`\\ ]+$/);
|
||||
expect(() => lastAttendanceQuery(MP, BOB)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@
|
||||
"cucumber:run": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json",
|
||||
"test:auth-setup": "bun scripts/setup-test-auth.ts",
|
||||
"test:e2e-flow": "bun scripts/e2e-login-flow.ts",
|
||||
"test:e2e-revocation": "bun scripts/e2e-revocation.ts",
|
||||
"test:e2e-rights": "bun scripts/e2e-rights-matrix.ts",
|
||||
"cucumber:report": "bun scripts/parse-test-results.ts",
|
||||
"features:parse": "bun scripts/parse-features.ts",
|
||||
"steps:extract": "bun scripts/extract-step-definitions.ts",
|
||||
|
||||
+24
-50
@@ -13,12 +13,22 @@
|
||||
* only (the app's FRONT_URL), so the app MUST be served at APP_URL — do not
|
||||
* serve a private instance on an ephemeral port: the pod rejects the redirect.
|
||||
*
|
||||
* ONE LOGIN DRIVE. The sign-in is `driveLogin` from `e2e-rights-matrix-support.ts`,
|
||||
* shared with the rights matrix: a state machine over OBSERVED hops (it must
|
||||
* leave the app for the pod, the consent Accept is clicked once stable and the
|
||||
* page must then leave the consent screen, and the shell counts only with the
|
||||
* bottom navigation shown and no picker). The previous local loop could report
|
||||
* "shell reached" while still on `:4000/` — the user never consented, the app
|
||||
* was never installed. `authenticated` also requires the install to be visible
|
||||
* on the pod (the user's registered Event container answers 200).
|
||||
*
|
||||
* The two benign ≥400 classes (the pod notification WebSocket channel, and an
|
||||
* anonymous WAC profile read) are classified, never fatal. Anything else fails.
|
||||
*/
|
||||
|
||||
import { chromium, type Page } from 'playwright';
|
||||
import { podLogin, readResource, type ApodsStackConfig } from '../src/shared/support/activitypodsStack';
|
||||
import type { ApodsStackConfig } from '../src/shared/support/activitypodsStack';
|
||||
import { driveLogin, installedContainer, loginWithRetry, type DriveContext } from './e2e-rights-matrix-support';
|
||||
|
||||
const strip = (u: string): string => u.replace(/\/+$/, '');
|
||||
const BACKEND: string = strip(process.env.BACKEND_URL || 'http://localhost:3003');
|
||||
@@ -27,6 +37,7 @@ const config: ApodsStackConfig = {
|
||||
podProviderUrl: strip(process.env.POD_PROVIDER_URL || 'http://localhost:3000'),
|
||||
backendUrl: BACKEND,
|
||||
};
|
||||
const POD_FRONT: string = strip(process.env.POD_FRONTEND_URL || 'http://localhost:5000');
|
||||
|
||||
const USER = `flow${Date.now()}`;
|
||||
const PASS = 'Test1test';
|
||||
@@ -37,9 +48,9 @@ interface HttpError { url: string; status: number; method: string; body: string
|
||||
const redirects: string[] = [];
|
||||
const errors4xx: HttpError[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
const hops: string[] = [];
|
||||
/** The shared login drive's context: it writes the observed hops; HTTP and page errors are collected by `wire` below. */
|
||||
const drive: DriveContext = { app: APP, podFront: POD_FRONT, hops: [], errors: [], consoleErrors: [], pageErrors: [], deadline: DEADLINE };
|
||||
|
||||
const clean = (s: string): string => s.replace('http://localhost:', ':').replace('http://127.0.0.1:', ':127.');
|
||||
const bodyText = (page: Page): Promise<string> =>
|
||||
page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim());
|
||||
const snap = (page: Page, name: string): Promise<void> =>
|
||||
@@ -65,46 +76,6 @@ function wire(page: Page): void {
|
||||
page.on('pageerror', e => consoleErrors.push('pageerror: ' + String(e.message).slice(0, 300)));
|
||||
}
|
||||
|
||||
/** Drive picker → pod login → consent until the app shell is reached. */
|
||||
async function driveLogin(page: Page): Promise<boolean> {
|
||||
let authorizeSeen = 0;
|
||||
for (let i = 0; i < 20 && Date.now() < DEADLINE; i++) {
|
||||
const url = page.url();
|
||||
if (hops[hops.length - 1] !== clean(url)) hops.push(clean(url));
|
||||
|
||||
if (url.includes(':5000') && (await page.locator('input[name="username"]').count().catch(() => 0))) {
|
||||
hops.push(' [login] submitting credentials');
|
||||
await page.locator('input[name="username"]').fill(USER).catch(() => {});
|
||||
await page.locator('input[name="password"]').fill(PASS).catch(() => {});
|
||||
const btn = page.getByRole('button', { name: /SE CONNECTER|Se connecter|Sign in|Connexion/i });
|
||||
if (await btn.count()) await btn.first().click().catch(() => {});
|
||||
else await page.locator('button[type=submit]').first().click().catch(() => {});
|
||||
await page.waitForTimeout(3500);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (url.includes(':5000/authorize')) {
|
||||
authorizeSeen++;
|
||||
const acc = page.getByRole('button', { name: /^ACCEPTER$|^Accepter$|^Accept$/i });
|
||||
let visible = await acc.first().isVisible({ timeout: 5000 }).catch(() => false);
|
||||
hops.push(` [authorize #${authorizeSeen}] accept visible=${visible}`);
|
||||
await snap(page, 'login-flow-consent');
|
||||
if (visible) { await acc.first().click().catch(() => {}); hops.push(' [authorize] clicked accept'); }
|
||||
if (authorizeSeen >= 3) { hops.push(' [LOOP] authorize revisited 3x — consent loop'); return false; }
|
||||
await page.waitForTimeout(5000);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (url.startsWith(APP)) {
|
||||
if (url.includes('/login')) { await page.waitForTimeout(5000); continue; }
|
||||
const picker = await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 0);
|
||||
if (!picker) { hops.push(' [app] shell reached'); return true; }
|
||||
}
|
||||
await page.waitForTimeout(1500);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function createEvent(page: Page): Promise<boolean> {
|
||||
await page.goto(`${APP}/events/new`, { waitUntil: 'domcontentloaded' }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
@@ -142,19 +113,20 @@ function classify(e: HttpError): string | null {
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// Sign in (signup fallback) via the shared helper — the UI login then uses the same creds.
|
||||
await podLogin(config, USER, PASS);
|
||||
// Sign in (signup fallback, retried: a fresh signup can outlive the helper's
|
||||
// timeout while succeeding server-side) — the UI login then uses the same creds.
|
||||
const session = await loginWithRetry(config, USER, PASS);
|
||||
|
||||
const browser = await chromium.launch({ headless: true, args: ['--allow-insecure-localhost', '--disable-web-security'] });
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
wire(page);
|
||||
|
||||
await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForTimeout(1500);
|
||||
await page.getByRole('button', { name: /Se connecter avec localhost:3000/i }).click();
|
||||
const authenticated = await driveLogin(page);
|
||||
// The shared drive opens the app, clicks the pod's sign-in button and follows the observed hops.
|
||||
const login = await driveLogin(page, 'flow', USER, PASS, drive);
|
||||
await snap(page, 'login-flow-after-login');
|
||||
const install = login.ok ? await installedContainer(session) : { ok: false, actual: 'not checked: the sign-in drive failed' };
|
||||
const authenticated = login.ok && install.ok;
|
||||
const eventCreated = authenticated ? await createEvent(page) : false;
|
||||
await snap(page, 'login-flow-after-event');
|
||||
const meetingPointCreated = eventCreated ? await createMeetingPoint(page) : false;
|
||||
@@ -169,10 +141,12 @@ function classify(e: HttpError): string | null {
|
||||
const report = {
|
||||
user: USER,
|
||||
authenticated,
|
||||
login: login.detail,
|
||||
installed: install.actual,
|
||||
eventCreated,
|
||||
meetingPointCreated,
|
||||
redirects,
|
||||
hops,
|
||||
hops: drive.hops,
|
||||
benign4xx: benign,
|
||||
fatal4xx: fatal,
|
||||
consoleErrors,
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* Revocation probe: drives the REAL local ActivityPods stack through the browser
|
||||
* and asserts that Festipod notices when its registration on the user's pod is
|
||||
* revoked, instead of staying in a "connected" view whose writes cannot land.
|
||||
*
|
||||
* THREE PHASES, one fresh user:
|
||||
*
|
||||
* 1. CURRENT — picker → pod login → consent → app. The app-status probe runs on
|
||||
* load (the GET of the pod's `/.well-known/app-status` is observed on the
|
||||
* wire, with its body, and the app logs `[AppStatus] probe (load) → current`);
|
||||
* a focus + visibilitychange right after does NOT probe at once (one-minute
|
||||
* throttle); an event is created and read back from the pod; a
|
||||
* visibilitychange inside the minute is deferred, not dropped — its probe
|
||||
* lands at the end of the minute and reads current.
|
||||
* 2. REVOKED — the registration is removed on the pod
|
||||
* (`POST {pod}/.auth-agent/app-registrations/remove`, the user's own token).
|
||||
* Without any reload, a window focus inside the minute — the user coming
|
||||
* back from the pod's tab — must, by the end of that minute, read the
|
||||
* revocation (`installed:false`) and LOG THE USER OUT: the picker with the
|
||||
* notice that the app's authorization was removed, NO redirect to the
|
||||
* consent screen (a deliberate revocation is not sent back to consent), no
|
||||
* session left (Back and a fresh load stay on the picker; the event-creation
|
||||
* drive that worked in phase 1 lands on the picker, never on success, and
|
||||
* nothing with that title reaches the pod).
|
||||
* 3. RECOVERED — signing in again from the picker goes through
|
||||
* `registerApp` → the consent screen; accepting it brings the user back to a
|
||||
* connected app whose probe reads current again, and a write lands. The
|
||||
* pod's own session survived the app's logout, so this sign-in shows no pod
|
||||
* login form: the sign-in click leads straight to consent.
|
||||
*
|
||||
* Both sign-ins are the shared drive `driveLogin` (`e2e-rights-matrix-support.ts`).
|
||||
*
|
||||
* Nothing is stubbed: the probe result is whatever the pod answers. The probe
|
||||
* is triggered only through the app's real paths (page load, window focus,
|
||||
* visibilitychange); its one-minute throttle is untouched.
|
||||
*
|
||||
* ORIGIN. As for `e2e-login-flow.ts`: the OIDC redirect_uri is registered for
|
||||
* `http://localhost:4000/login` only, so the app MUST be served at APP_URL.
|
||||
*
|
||||
* Exit code 0 only when every phase holds; the JSON report says which did not.
|
||||
*/
|
||||
|
||||
import { chromium, type Page } from 'playwright';
|
||||
import {
|
||||
linkId,
|
||||
podLogin,
|
||||
readResource,
|
||||
recordUri,
|
||||
type ApodsSession,
|
||||
type ApodsStackConfig,
|
||||
} from '../src/shared/support/activitypodsStack';
|
||||
import { driveLogin, type DriveContext } from './e2e-rights-matrix-support';
|
||||
|
||||
const strip = (u: string): string => u.replace(/\/+$/, '');
|
||||
const BACKEND: string = strip(process.env.BACKEND_URL || 'http://localhost:3003');
|
||||
const APP: string = strip(process.env.APP_URL || 'http://localhost:4000');
|
||||
const config: ApodsStackConfig = {
|
||||
podProviderUrl: strip(process.env.POD_PROVIDER_URL || 'http://localhost:3000'),
|
||||
backendUrl: BACKEND,
|
||||
};
|
||||
const POD_FRONT = strip(process.env.POD_FRONTEND_URL || 'http://localhost:5000');
|
||||
|
||||
const USER = `revoke${Date.now()}`;
|
||||
const PASS = 'Test1test';
|
||||
const DEADLINE = Date.now() + 10 * 60 * 1000;
|
||||
|
||||
type Phase = 'current' | 'revoked' | 'recovered';
|
||||
let phase: Phase = 'current';
|
||||
|
||||
interface StatusSeen { phase: Phase; status: number; body: unknown }
|
||||
interface HttpError { phase: Phase; url: string; status: number; method: string; body: string }
|
||||
|
||||
const statusSeen: StatusSeen[] = [];
|
||||
const probeLogs: Array<{ phase: Phase; at: number; text: string }> = [];
|
||||
const errors4xx: HttpError[] = [];
|
||||
const writes: Array<{ phase: Phase; method: string; url: string; status: number }> = [];
|
||||
const consoleErrors: string[] = [];
|
||||
const redirects: string[] = [];
|
||||
/** The shared sign-in drive's context (`driveLogin`): it writes the observed hops, tagged by phase; errors are collected by `wire` below. */
|
||||
const drive: DriveContext = { app: APP, podFront: POD_FRONT, hops: [], errors: [], consoleErrors: [], pageErrors: [], deadline: DEADLINE };
|
||||
|
||||
const clean = (s: string): string => s.replace('http://localhost:', ':').replace('http://127.0.0.1:', ':127.');
|
||||
const bodyText = (page: Page): Promise<string> =>
|
||||
page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim());
|
||||
const snap = (page: Page, name: string): Promise<void> =>
|
||||
page.screenshot({ path: `/tmp/${name}.png`, fullPage: true }).then(() => undefined).catch(() => undefined);
|
||||
const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
function wire(page: Page): void {
|
||||
let last = '';
|
||||
page.on('framenavigated', f => {
|
||||
if (f === page.mainFrame() && f.url() !== last) {
|
||||
redirects.push(`[${phase}] ${clean(last || '(start)')} -> ${clean(f.url())}`);
|
||||
last = f.url();
|
||||
}
|
||||
});
|
||||
page.on('response', r => {
|
||||
const method = r.request()?.method?.() ?? '?';
|
||||
const url = r.url();
|
||||
if (method === 'GET' && url.endsWith('/.well-known/app-status')) {
|
||||
const at = phase;
|
||||
r.json()
|
||||
.then(body => statusSeen.push({ phase: at, status: r.status(), body }))
|
||||
.catch(() => statusSeen.push({ phase: at, status: r.status(), body: '(unreadable)' }));
|
||||
}
|
||||
if ((method === 'POST' || method === 'PUT' || method === 'PATCH') && url.startsWith(config.podProviderUrl)) {
|
||||
writes.push({ phase, method, url: clean(url), status: r.status() });
|
||||
}
|
||||
if (r.status() >= 400) {
|
||||
const at = phase;
|
||||
r.text()
|
||||
.then(b => errors4xx.push({ phase: at, url, status: r.status(), method, body: b.slice(0, 300) }))
|
||||
.catch(() => errors4xx.push({ phase: at, url, status: r.status(), method, body: '(unreadable)' }));
|
||||
}
|
||||
});
|
||||
page.on('console', m => {
|
||||
const text = m.text();
|
||||
if (text.includes('[AppStatus]')) probeLogs.push({ phase, at: Date.now(), text: text.slice(0, 400) });
|
||||
if (m.type() === 'error') consoleErrors.push(`[${phase}] ${text.slice(0, 300)}`);
|
||||
});
|
||||
page.on('pageerror', e => consoleErrors.push(`[${phase}] pageerror: ${String(e.message).slice(0, 300)}`));
|
||||
}
|
||||
|
||||
const onApp = (page: Page): boolean => page.url().startsWith(APP);
|
||||
const pickerShown = async (page: Page): Promise<boolean> =>
|
||||
(await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 0)) > 0;
|
||||
const whereIs = async (page: Page): Promise<string> =>
|
||||
!onApp(page)
|
||||
? (page.url().startsWith(`${POD_FRONT}/authorize`) ? 'consent' : `elsewhere:${clean(page.url())}`)
|
||||
: (await pickerShown(page)) ? 'picker' : 'still-connected';
|
||||
|
||||
/** The event-creation drive from `e2e-login-flow.ts`, reporting what the app showed. */
|
||||
async function createEvent(page: Page, title: string): Promise<{ eventUri?: string; successToast: boolean; finalUrl: string }> {
|
||||
let successToast = false;
|
||||
const toastWatch = setInterval(() => {
|
||||
void page.locator('text=Événement relayé').count().then(n => { if (n) successToast = true; }).catch(() => {});
|
||||
}, 250);
|
||||
try {
|
||||
await page.goto(`${APP}/events/new`, { waitUntil: 'domcontentloaded' }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
await page.locator('input[placeholder="Donnez un nom à votre événement"]').fill(title, { timeout: 3000 }).catch(() => {});
|
||||
const dates = page.locator('input[type="date"]');
|
||||
if (await dates.count().catch(() => 0)) await dates.first().fill('2026-12-20', { timeout: 3000 }).catch(() => {});
|
||||
await page.getByRole('button', { name: /Suivant/ }).click({ timeout: 3000 }).catch(() => {});
|
||||
await page.waitForTimeout(1200);
|
||||
const next = page.getByRole('button', { name: /Suivant/ });
|
||||
if (await next.count().catch(() => 0)) await next.click({ timeout: 3000 }).catch(() => {});
|
||||
await page.waitForTimeout(1200);
|
||||
await page.getByRole('button', { name: /Relayer l'événement/ }).click({ timeout: 3000 }).catch(() => {});
|
||||
await page.waitForTimeout(5000);
|
||||
} finally {
|
||||
clearInterval(toastWatch);
|
||||
}
|
||||
const finalUrl = page.url();
|
||||
const m = finalUrl.startsWith(`${APP}/events/`) ? /\/events\/([^/?#]+)/.exec(finalUrl.slice(APP.length)) : null;
|
||||
const segment = m?.[1];
|
||||
const eventUri = segment && segment !== 'new' ? decodeURIComponent(segment) : undefined;
|
||||
return { eventUri, successToast, finalUrl };
|
||||
}
|
||||
|
||||
/** Does the user's pod hold an event with this title? Read with the user's own token. */
|
||||
async function podHoldsEventTitled(session: ApodsSession, title: string): Promise<boolean> {
|
||||
const container = await readResource(session, `${session.storage}/as/event`).catch(() => undefined);
|
||||
if (!container) return false;
|
||||
if (JSON.stringify(container).includes(title)) return true;
|
||||
const raw = container['ldp:contains'] ?? container['contains'];
|
||||
const members = (Array.isArray(raw) ? raw : raw ? [raw] : []).map(linkId).filter((u): u is string => !!u);
|
||||
for (const uri of members) {
|
||||
const rec = await readResource(session, uri).catch(() => undefined);
|
||||
if (rec && JSON.stringify(rec).includes(title)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Wait until a predicate holds or the bound elapses. */
|
||||
async function waitFor(pred: () => boolean | Promise<boolean>, ms: number): Promise<boolean> {
|
||||
const end = Date.now() + ms;
|
||||
while (Date.now() < end) {
|
||||
if (await pred()) return true;
|
||||
await sleep(250);
|
||||
}
|
||||
return pred();
|
||||
}
|
||||
|
||||
/** Fire the real DOM event a probe listener is attached to. */
|
||||
const fire = (page: Page, trigger: 'focus' | 'visibility'): Promise<void> =>
|
||||
page.evaluate((which) => {
|
||||
if (which === 'focus') window.dispatchEvent(new Event('focus'));
|
||||
else document.dispatchEvent(new Event('visibilitychange'));
|
||||
}, trigger).catch(() => {});
|
||||
|
||||
const lastProbeAt = (): number =>
|
||||
Math.max(0, ...probeLogs.filter(l => l.text.includes('probe (')).map(l => l.at));
|
||||
|
||||
/**
|
||||
* The probe's own schedule, exercised live and never shortened: fire `trigger`
|
||||
* less than a minute after the app's last probe, check that NO probe starts at
|
||||
* once (throttle), then wait for the deferred probe of that trigger to land
|
||||
* with the expected verdict at the end of the minute (not dropped).
|
||||
*/
|
||||
async function deferredProbe(page: Page, trigger: 'focus' | 'visibility', verdict: RegExp): Promise<{
|
||||
firedAfterLastProbeMs: number; immediate: boolean; probed: boolean; gapFromLastProbeMs?: number;
|
||||
}> {
|
||||
const last = lastProbeAt();
|
||||
const firedAfterLastProbeMs = Date.now() - last;
|
||||
const before = probeLogs.length;
|
||||
await fire(page, trigger);
|
||||
await page.waitForTimeout(3000);
|
||||
const immediate = probeLogs.slice(before).some(l => l.text.includes('probe ('));
|
||||
const pattern = new RegExp(`probe \\(${trigger}\\) → ${verdict.source}`);
|
||||
const probed = await waitFor(() => probeLogs.slice(before).some(l => pattern.test(l.text)), 75_000);
|
||||
const hit = probeLogs.slice(before).find(l => pattern.test(l.text));
|
||||
return { firedAfterLastProbeMs, immediate, probed, gapFromLastProbeMs: hit ? hit.at - last : undefined };
|
||||
}
|
||||
|
||||
const isCurrent = (b: unknown): boolean =>
|
||||
typeof b === 'object' && b !== null && (b as Record<string, unknown>)['installed'] === true && (b as Record<string, unknown>)['upgradeNeeded'] === false;
|
||||
|
||||
/** Benign ≥400 by design (same classes as e2e-login-flow.ts). */
|
||||
function benign(e: HttpError): boolean {
|
||||
if (e.url.includes('/.notifications/WebSocketChannel2023')) return true;
|
||||
if (e.status === 403 && e.method === 'GET' && /\/data\//.test(e.url)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const user = await podLogin(config, USER, PASS);
|
||||
const appDescription = await readResource(undefined, `${BACKEND}/app`);
|
||||
const appUri = recordUri(appDescription);
|
||||
if (!appUri) throw new Error('GET /app carries no id (the app actor URI)');
|
||||
|
||||
const browser = await chromium.launch({ headless: true, args: ['--allow-insecure-localhost', '--disable-web-security'] });
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
wire(page);
|
||||
|
||||
// ── Phase 1: CURRENT ────────────────────────────────────────────────────
|
||||
// The shared drive opens the app, clicks the pod's sign-in button and follows
|
||||
// the observed hops (pod login → consent → shell, bottom navigation shown).
|
||||
const connected = (await driveLogin(page, 'current', USER, PASS, drive)).ok;
|
||||
const probeRanCurrent = connected && await waitFor(
|
||||
() => statusSeen.some(s => s.phase === 'current' && s.status === 200 && isCurrent(s.body))
|
||||
&& probeLogs.some(l => l.phase === 'current' && /probe \(load\) → current/.test(l.text)),
|
||||
15000,
|
||||
);
|
||||
// Throttle, observed through the real listeners: focus + visibilitychange
|
||||
// seconds after the load probe must not issue another status request.
|
||||
const beforeNudge = statusSeen.filter(s => s.phase === 'current').length;
|
||||
await page.evaluate(() => {
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
}).catch(() => {});
|
||||
await page.waitForTimeout(3000);
|
||||
const afterNudge = statusSeen.filter(s => s.phase === 'current').length;
|
||||
const throttled = beforeNudge >= 1 && afterNudge === beforeNudge;
|
||||
const titleA = `Concert courant ${USER}`;
|
||||
const writeA = connected ? await createEvent(page, titleA) : { successToast: false, finalUrl: page.url() } as { eventUri?: string; successToast: boolean; finalUrl: string };
|
||||
const writeAOnPod = writeA.eventUri ? await podHoldsEventTitled(user, titleA) : false;
|
||||
await snap(page, 'revocation-current-after-write');
|
||||
// A visibilitychange right after the write's reload: deferred, then current.
|
||||
const visibilityProbe = connected ? await deferredProbe(page, 'visibility', /current/) : undefined;
|
||||
|
||||
// ── Phase 2: REVOKED ────────────────────────────────────────────────────
|
||||
phase = 'revoked';
|
||||
const revoke = await fetch(`${config.podProviderUrl}/.auth-agent/app-registrations/remove`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${user.token}` },
|
||||
body: JSON.stringify({ appUri }),
|
||||
});
|
||||
const revokeResult = { status: revoke.status, body: (await revoke.text()).slice(0, 300) };
|
||||
// Removal is processed by the pod and the app backend; give it a moment to settle.
|
||||
await sleep(3000);
|
||||
|
||||
// (a) FOCUS, no reload — the user revokes in the pod's tab and comes back
|
||||
// within the minute of the last probe: the deferred focus probe must read the
|
||||
// revocation and take the app off the connected view.
|
||||
const focusProbe = await deferredProbe(page, 'focus', /stale \(not-installed\)/);
|
||||
const leftByFocus = await waitFor(async () => !onApp(page) || (await pickerShown(page)), 15000);
|
||||
const leftByFocusTo = await whereIs(page);
|
||||
await snap(page, 'revocation-revoked-left-by-focus');
|
||||
|
||||
const notice = page.locator('[data-testid="auth-picker-notice"]');
|
||||
const noticeShown = onApp(page) && (await notice.count().catch(() => 0)) > 0;
|
||||
const noticeText = noticeShown ? ((await notice.first().innerText().catch(() => '')) as string).replace(/\s+/g, ' ').trim() : '';
|
||||
|
||||
// (b) No session left: Back must not return to the connected view, a fresh
|
||||
// load must stay on the picker, and the same event-creation drive that worked
|
||||
// in phase 1 must not reach success (it lands on the picker).
|
||||
await page.goBack({ waitUntil: 'domcontentloaded' }).catch(() => {});
|
||||
await page.waitForTimeout(2000);
|
||||
const stillPickerAfterBack = (await whereIs(page)) === 'picker';
|
||||
await page.goto(`${APP}/`, { waitUntil: 'domcontentloaded' }).catch(() => {});
|
||||
await page.waitForTimeout(2500);
|
||||
const stillPickerAfterReload = (await whereIs(page)) === 'picker';
|
||||
const titleB = `Concert revoque ${USER}`;
|
||||
const writeB = await createEvent(page, titleB);
|
||||
await snap(page, 'revocation-revoked-after-write-attempt');
|
||||
const leftAfterReload = await waitFor(async () => !onApp(page) || (await pickerShown(page)), 15000);
|
||||
const leftAfterReloadTo = await whereIs(page);
|
||||
const writeBOnPod = await podHoldsEventTitled(user, titleB);
|
||||
const consentRedirectInRevoked = redirects.some(r => r.startsWith('[revoked]') && r.includes(`${clean(POD_FRONT)}/authorize`));
|
||||
const pageWritesInRevoked = writes.filter(w => w.phase === 'revoked');
|
||||
await snap(page, 'revocation-revoked-left');
|
||||
|
||||
// ── Phase 3: RECOVERED ──────────────────────────────────────────────────
|
||||
phase = 'recovered';
|
||||
// Sign in again from the picker: login → registerApp → consent → app.
|
||||
const signInAgain = page.getByRole('button', { name: /Se connecter avec localhost:3000/i });
|
||||
let reconnected = false;
|
||||
if (leftAfterReloadTo === 'picker' && (await signInAgain.count().catch(() => 0))) {
|
||||
// The same drive: from the app's root (the picker, no session left), sign-in click → pod login → consent → shell.
|
||||
reconnected = (await driveLogin(page, 'recovered', USER, PASS, drive)).ok;
|
||||
}
|
||||
const consentReachedOnSignIn = redirects.some(r => r.startsWith('[recovered]') && r.includes(`${clean(POD_FRONT)}/authorize`));
|
||||
const probeReadsCurrentAgain = reconnected && await waitFor(
|
||||
() => statusSeen.some(s => s.phase === 'recovered' && s.status === 200 && isCurrent(s.body)),
|
||||
15000,
|
||||
);
|
||||
const titleC = `Concert retabli ${USER}`;
|
||||
const writeC = reconnected ? await createEvent(page, titleC) : { successToast: false, finalUrl: page.url() } as { eventUri?: string; successToast: boolean; finalUrl: string };
|
||||
const writeCOnPod = writeC.eventUri ? await podHoldsEventTitled(user, titleC) : false;
|
||||
await snap(page, 'revocation-recovered-after-write');
|
||||
|
||||
await ctx.close();
|
||||
await browser.close();
|
||||
await sleep(1000);
|
||||
|
||||
// The deferred probe lands at the end of the minute: never before (throttle),
|
||||
// and not much after (it was deferred, not dropped). Measured between the
|
||||
// app's own log lines, so a few hundred ms of request latency either way.
|
||||
const deferredOnTime = (p?: { immediate: boolean; probed: boolean; gapFromLastProbeMs?: number }): boolean =>
|
||||
!!p && !p.immediate && p.probed && p.gapFromLastProbeMs !== undefined
|
||||
&& p.gapFromLastProbeMs >= 58_000 && p.gapFromLastProbeMs <= 75_000;
|
||||
const positive = {
|
||||
connected,
|
||||
probeRanCurrent,
|
||||
throttled,
|
||||
statusRequestsAfterNudge: afterNudge,
|
||||
write: { ...writeA, onPod: writeAOnPod },
|
||||
visibilityProbe,
|
||||
ok: connected && probeRanCurrent && throttled && !!writeA.eventUri && writeA.successToast && writeAOnPod
|
||||
&& deferredOnTime(visibilityProbe),
|
||||
};
|
||||
const negative = {
|
||||
revoke: revokeResult,
|
||||
focusProbe,
|
||||
leftByFocus,
|
||||
leftByFocusTo,
|
||||
noticeShown,
|
||||
noticeText,
|
||||
consentRedirectInRevoked,
|
||||
pageWritesInRevoked,
|
||||
stillPickerAfterBack,
|
||||
stillPickerAfterReload,
|
||||
leftAfterReload,
|
||||
leftAfterReloadTo,
|
||||
writeAttempt: { ...writeB, onPod: writeBOnPod },
|
||||
ok: (revoke.status === 200 || revoke.status === 204)
|
||||
&& deferredOnTime(focusProbe) && leftByFocus && leftByFocusTo === 'picker'
|
||||
&& noticeShown && noticeText.includes('retirée')
|
||||
&& !consentRedirectInRevoked && !pageWritesInRevoked.length
|
||||
&& stillPickerAfterBack && stillPickerAfterReload && leftAfterReloadTo === 'picker'
|
||||
&& !writeB.eventUri && !writeB.successToast && !writeBOnPod,
|
||||
};
|
||||
const recovered = {
|
||||
reconnected,
|
||||
consentReachedOnSignIn,
|
||||
probeReadsCurrentAgain,
|
||||
write: { ...writeC, onPod: writeCOnPod },
|
||||
ok: reconnected && consentReachedOnSignIn && probeReadsCurrentAgain && !!writeC.eventUri && writeC.successToast && writeCOnPod,
|
||||
};
|
||||
// Phase 2's ≥400s are the revocation's own consequences; the other phases must be clean.
|
||||
const fatal = errors4xx.filter(e => e.phase !== 'revoked' && !benign(e));
|
||||
const report = {
|
||||
user: USER,
|
||||
appUri,
|
||||
positive,
|
||||
negative,
|
||||
recovered,
|
||||
statusBodies: statusSeen,
|
||||
probeLogs,
|
||||
writes,
|
||||
revokedPhase4xx: errors4xx.filter(e => e.phase === 'revoked').map(e => `${e.status} ${e.method} ${clean(e.url)}`),
|
||||
fatal4xx: fatal,
|
||||
redirects,
|
||||
hops: drive.hops,
|
||||
consoleErrors,
|
||||
ok: positive.ok && negative.ok && recovered.ok && fatal.length === 0,
|
||||
};
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
process.exit(report.ok ? 0 : 1);
|
||||
})().catch(err => {
|
||||
console.log(JSON.stringify({ ok: false, fatal: String(err).slice(0, 400), phase, statusSeen, probeLogs, redirects, errors4xx, consoleErrors }, null, 2));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* Support for `e2e-rights-matrix.ts`: the per-line report, raw HTTP with a
|
||||
* user's token (or none), account bootstrap with programmatic app install, and
|
||||
* the browser drives (pod login + consent, event and meeting-point creation).
|
||||
*
|
||||
* WHY NOT IMPORT THE BANKED SCRIPTS. `e2e-login-flow.ts` and `e2e-revocation.ts`
|
||||
* run their whole probe at import time (top-level IIFE) and their drives read
|
||||
* module-level user constants, so their helpers cannot be imported. The drives
|
||||
* below are the same sequences, parameterised by the user who drives. The other
|
||||
* direction works: `e2e-login-flow.ts` and `e2e-revocation.ts` import `driveLogin`
|
||||
* (the login-flow probe also the install check) from here, so the sign-in drive
|
||||
* exists once.
|
||||
*
|
||||
* NEVER A SILENT PASS. A line's check is a function returning `{ ok, actual }`;
|
||||
* a throw, a timeout or an unreachable host is recorded as FAIL with the error
|
||||
* as the actual value. A refusal is matched on an explicit status only
|
||||
* (`refused`), so a 404, a 500 or a network error (status 0) never counts as one.
|
||||
*/
|
||||
|
||||
import type { Frame, Page } from 'playwright';
|
||||
import {
|
||||
linkId,
|
||||
podLogin,
|
||||
readResource,
|
||||
recordUri,
|
||||
type ApodsSession,
|
||||
type ApodsStackConfig,
|
||||
type JsonRecord,
|
||||
} from '../src/shared/support/activitypodsStack';
|
||||
|
||||
// ── Report ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** How a line weighs on the exit code: only `hard` lines can fail the run. */
|
||||
export type LineKind = 'hard' | 'known-deviation' | 'known-defect' | 'manual' | 'info';
|
||||
export type LineStatus = 'PASS' | 'FAIL' | 'KNOWN-DEVIATION' | 'KNOWN-DEFECT' | 'MANUAL-CHECK' | 'INFO';
|
||||
|
||||
export interface CheckOutcome {
|
||||
ok: boolean;
|
||||
actual: string;
|
||||
/** Raw evidence (status codes, bodies, ids) printed under the line. */
|
||||
evidence?: string;
|
||||
}
|
||||
|
||||
export interface LineResult {
|
||||
id: string;
|
||||
group: string;
|
||||
name: string;
|
||||
kind: LineKind;
|
||||
expected: string;
|
||||
actual: string;
|
||||
status: LineStatus;
|
||||
evidence?: string;
|
||||
/** A tracked classification printed with a FAIL (it never turns it into a pass). */
|
||||
finding?: string;
|
||||
/** A loud notice: a known line whose observed behaviour changed. */
|
||||
notice?: string;
|
||||
}
|
||||
|
||||
const errorText = (e: unknown): string => (e instanceof Error ? `${e.name}: ${e.message}` : String(e)).slice(0, 300);
|
||||
|
||||
export class Matrix {
|
||||
readonly lines: LineResult[] = [];
|
||||
|
||||
/** A hard assertion: PASS only when the check returns ok; anything else is FAIL. */
|
||||
async hard(
|
||||
id: string, group: string, name: string, expected: string,
|
||||
check: () => Promise<CheckOutcome>,
|
||||
opts: { finding?: string } = {},
|
||||
): Promise<boolean> {
|
||||
let outcome: CheckOutcome;
|
||||
try {
|
||||
outcome = await check();
|
||||
} catch (e) {
|
||||
outcome = { ok: false, actual: `ERROR ${errorText(e)}` };
|
||||
}
|
||||
const status: LineStatus = outcome.ok ? 'PASS' : 'FAIL';
|
||||
this.push({
|
||||
id, group, name, kind: 'hard', expected, actual: outcome.actual, status, evidence: outcome.evidence,
|
||||
finding: status === 'FAIL' ? opts.finding : undefined,
|
||||
});
|
||||
return outcome.ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* A tracked, non-fatal line (known deviation or known defect). `classify`
|
||||
* receives the observation and says whether the tracked behaviour still holds;
|
||||
* when it does not, the line carries a loud notice (promote it).
|
||||
*/
|
||||
async known(
|
||||
kind: 'known-deviation' | 'known-defect', id: string, group: string, name: string, expected: string,
|
||||
observe: () => Promise<CheckOutcome & { notice?: string }>,
|
||||
): Promise<void> {
|
||||
let outcome: CheckOutcome & { notice?: string };
|
||||
try {
|
||||
outcome = await observe();
|
||||
} catch (e) {
|
||||
outcome = { ok: false, actual: `ERROR ${errorText(e)}`, notice: 'the observation itself failed — the tracked behaviour is unverified this run' };
|
||||
}
|
||||
this.push({
|
||||
id, group, name, kind, expected, actual: outcome.actual,
|
||||
status: kind === 'known-defect' ? 'KNOWN-DEFECT' : 'KNOWN-DEVIATION',
|
||||
evidence: outcome.evidence, notice: outcome.notice,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A MANUAL-CHECK line: an assertion this script deliberately does NOT exercise
|
||||
* live (it would send a forged or spoofed activity — user decision 2026-10-13,
|
||||
* defensive only). Printed with where it is covered; never affects the exit code.
|
||||
*/
|
||||
manual(id: string, group: string, name: string, coverage: string): void {
|
||||
this.push({ id, group, name, kind: 'manual', expected: '(not exercised live)', actual: coverage, status: 'MANUAL-CHECK' });
|
||||
}
|
||||
|
||||
/** An informational line: never affects the exit code; `notice` flags an observation that broke its stated premise. */
|
||||
info(id: string, group: string, name: string, actual: string, notice?: string): void {
|
||||
this.push({ id, group, name, kind: 'info', expected: '(informational)', actual, status: 'INFO', notice });
|
||||
}
|
||||
|
||||
private push(line: LineResult): void {
|
||||
this.lines.push(line);
|
||||
const mark = line.status === 'FAIL' ? 'FAIL' : line.status;
|
||||
console.error(`[matrix] ${mark.padEnd(15)} ${line.id} — ${line.name} :: ${line.actual}`);
|
||||
}
|
||||
|
||||
get hardFailures(): LineResult[] {
|
||||
return this.lines.filter(l => l.kind === 'hard' && l.status === 'FAIL');
|
||||
}
|
||||
|
||||
print(header: string): void {
|
||||
const out: string[] = [];
|
||||
out.push('', '═'.repeat(100), `RIGHTS MATRIX — ${header}`, '═'.repeat(100));
|
||||
let group = '';
|
||||
for (const l of this.lines) {
|
||||
if (l.group !== group) { group = l.group; out.push('', `── ${group} ${'─'.repeat(Math.max(0, 95 - group.length))}`); }
|
||||
out.push(`${l.status.padEnd(15)} ${l.id} ${l.name}`);
|
||||
out.push(`${' '.repeat(16)}expected: ${l.expected}`);
|
||||
out.push(`${' '.repeat(16)}actual: ${l.actual}`);
|
||||
if (l.evidence) out.push(`${' '.repeat(16)}evidence: ${l.evidence}`);
|
||||
if (l.finding) out.push(`${' '.repeat(16)}finding: ${l.finding}`);
|
||||
if (l.notice) out.push(`${' '.repeat(16)}!!! NOTICE: ${l.notice}`);
|
||||
}
|
||||
const count = (s: LineStatus): number => this.lines.filter(l => l.status === s).length;
|
||||
const notices = this.lines.filter(l => l.notice);
|
||||
out.push('', '═'.repeat(100));
|
||||
out.push(`hard lines: ${this.lines.filter(l => l.kind === 'hard').length} — PASS ${count('PASS')}, FAIL ${count('FAIL')}; `
|
||||
+ `KNOWN-DEVIATION ${count('KNOWN-DEVIATION')}, KNOWN-DEFECT ${count('KNOWN-DEFECT')}, MANUAL-CHECK ${count('MANUAL-CHECK')}, INFO ${count('INFO')}`);
|
||||
for (const l of this.hardFailures) out.push(`FAILED: ${l.id} — ${l.name}${l.finding ? ` [${l.finding.split(':')[0]}]` : ''}`);
|
||||
for (const l of notices) {
|
||||
out.push('', '!'.repeat(100), `!!! ${l.id} (${l.status}): ${l.notice}`, '!'.repeat(100));
|
||||
}
|
||||
out.push(`VERDICT: ${this.hardFailures.length === 0 ? 'PASS — every hard line holds' : `FAIL — ${this.hardFailures.length} hard line(s) failed`}`);
|
||||
out.push('═'.repeat(100));
|
||||
console.log(out.join('\n'));
|
||||
}
|
||||
}
|
||||
|
||||
/** Throw a named prerequisite failure (the line then FAILs, it is never skipped). */
|
||||
export function need<T>(value: T | undefined | null | false, what: string): T {
|
||||
if (value === undefined || value === null || value === false) throw new Error(`prerequisite missing: ${what}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Is `status` one of the explicit refusals `allowed`? 0 (unreachable), 404, 5xx never are. */
|
||||
export const refused = (status: number, allowed: number[]): boolean => allowed.includes(status);
|
||||
|
||||
// ── HTTP ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface HttpResult { status: number; text: string; headers: Headers; error?: string }
|
||||
|
||||
/**
|
||||
* One request, bounded. A network failure or timeout comes back as status 0
|
||||
* with the error text — never thrown, never a status a refusal could match.
|
||||
*/
|
||||
export async function http(
|
||||
method: string,
|
||||
url: string,
|
||||
opts: { token?: string; body?: string; contentType?: string; accept?: string; timeoutMs?: number } = {},
|
||||
): Promise<HttpResult> {
|
||||
const headers = new Headers({ Accept: opts.accept ?? 'application/ld+json' });
|
||||
if (opts.token) headers.set('Authorization', `Bearer ${opts.token}`);
|
||||
if (opts.contentType) headers.set('Content-Type', opts.contentType);
|
||||
try {
|
||||
const r = await fetch(url, { method, headers, body: opts.body, signal: AbortSignal.timeout(opts.timeoutMs ?? 15000) });
|
||||
return { status: r.status, text: await r.text(), headers: r.headers };
|
||||
} catch (e) {
|
||||
return { status: 0, text: '', headers: new Headers(), error: errorText(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export const brief = (r: HttpResult): string =>
|
||||
r.status === 0 ? `unreachable (${r.error})` : `HTTP ${r.status}${r.text ? ` ${r.text.replace(/\s+/g, ' ').slice(0, 160)}` : ''}`;
|
||||
|
||||
export function parseJson(text: string): JsonRecord | undefined {
|
||||
try {
|
||||
const v: unknown = JSON.parse(text);
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as JsonRecord) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const arrayOf = (value: unknown): unknown[] =>
|
||||
value === undefined || value === null ? [] : Array.isArray(value) ? value : [value];
|
||||
|
||||
/** The member URIs of an attendees collection body (`items`, no `totalItems` on this platform). */
|
||||
export function itemUris(record: JsonRecord | undefined): string[] {
|
||||
if (!record) return [];
|
||||
return arrayOf(record['items'] ?? record['as:items']).map(linkId).filter((u): u is string => !!u);
|
||||
}
|
||||
|
||||
export const sleep = (ms: number): Promise<void> => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
/** Poll `probe` until `done` holds or the bound elapses; returns the last value either way. */
|
||||
export async function poll<T>(probe: () => Promise<T>, done: (v: T) => boolean, ms: number, every = 1000): Promise<T> {
|
||||
const end = Date.now() + ms;
|
||||
let v = await probe();
|
||||
while (!done(v) && Date.now() < end) {
|
||||
await sleep(every);
|
||||
v = await probe();
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
// ── Accounts ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `podLogin` with retries: a fresh pod signup can outlive the helper's 15 s
|
||||
* timeout while still succeeding server-side, so a retry logs in.
|
||||
*/
|
||||
export async function loginWithRetry(config: ApodsStackConfig, username: string, password: string): Promise<ApodsSession> {
|
||||
let last: unknown;
|
||||
for (let attempt = 0; attempt < 4; attempt++) {
|
||||
try {
|
||||
return await podLogin(config, username, password);
|
||||
} catch (e) {
|
||||
last = e;
|
||||
await sleep(5000);
|
||||
}
|
||||
}
|
||||
throw new Error(`podLogin(${username}) failed after retries: ${errorText(last)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmatic app install (the smoke's install step): register the app ACTOR
|
||||
* URI with every access need of its group, then wait for the registered Event
|
||||
* container to exist.
|
||||
*/
|
||||
export async function installApp(config: ApodsStackConfig, session: ApodsSession): Promise<{ ok: boolean; actual: string }> {
|
||||
const description = await readResource(undefined, `${config.backendUrl}/app`);
|
||||
const appUri = need(recordUri(description), 'app actor URI (id of GET /app)');
|
||||
const groupUri = need(linkId(description['interop:hasAccessNeedGroup']), 'access-need group URI');
|
||||
const group = await readResource(undefined, groupUri);
|
||||
const body = JSON.stringify({
|
||||
appUri,
|
||||
acceptedAccessNeeds: arrayOf(group['interop:hasAccessNeed']),
|
||||
acceptedSpecialRights: arrayOf(group['apods:hasSpecialRights']),
|
||||
});
|
||||
const reg = await http('POST', `${config.podProviderUrl}/.auth-agent/app-registrations/register`, {
|
||||
token: session.token, body, contentType: 'application/json', accept: 'application/json', timeoutMs: 30000,
|
||||
});
|
||||
if (reg.status < 200 || reg.status >= 300) return { ok: false, actual: `register → ${brief(reg)}` };
|
||||
const container = await poll(
|
||||
() => http('GET', `${session.storage}/as/event`, { token: session.token }),
|
||||
r => r.status === 200, 30000, 500,
|
||||
);
|
||||
return { ok: container.status === 200, actual: `register → HTTP ${reg.status}; ${session.storage}/as/event → ${brief(container).slice(0, 40)}` };
|
||||
}
|
||||
|
||||
/** The profile document an actor links (`url`), read from the actor document. */
|
||||
export async function profileUriOf(webId: string): Promise<string | undefined> {
|
||||
const r = await http('GET', webId);
|
||||
return linkId(parseJson(r.text)?.['url']);
|
||||
}
|
||||
|
||||
// ── Browser drives ────────────────────────────────────────────────────────
|
||||
|
||||
export interface UiHttpError { who: string; url: string; status: number; method: string; body: string }
|
||||
export interface UiPageError { who: string; url: string; message: string; stack: string }
|
||||
|
||||
export interface DriveContext {
|
||||
app: string;
|
||||
podFront: string;
|
||||
hops: string[];
|
||||
errors: UiHttpError[];
|
||||
consoleErrors: string[];
|
||||
/** Uncaught exceptions thrown in a page, with the page URL at the time. */
|
||||
pageErrors: UiPageError[];
|
||||
deadline: number;
|
||||
}
|
||||
|
||||
const clean = (s: string): string => s.replace('http://localhost:', ':').replace('http://127.0.0.1:', ':127.');
|
||||
|
||||
export const bodyText = (page: Page): Promise<string> =>
|
||||
page.locator('body').innerText().catch(() => '').then(t => t.replace(/\s+/g, ' ').trim());
|
||||
|
||||
/** Record every ≥400 response, console error and uncaught page error of a page, tagged by user. */
|
||||
export function wire(page: Page, who: string, ctx: DriveContext): void {
|
||||
page.on('response', r => {
|
||||
if (r.status() >= 400) {
|
||||
const method = r.request()?.method?.() ?? '?';
|
||||
r.text()
|
||||
.then(b => ctx.errors.push({ who, url: r.url(), status: r.status(), method, body: b.slice(0, 300) }))
|
||||
.catch(() => ctx.errors.push({ who, url: r.url(), status: r.status(), method, body: '(unreadable)' }));
|
||||
}
|
||||
});
|
||||
page.on('console', m => { if (m.type() === 'error') ctx.consoleErrors.push(`[${who}] ${m.text().slice(0, 300)}`); });
|
||||
page.on('pageerror', e => ctx.pageErrors.push({ who, url: page.url(), message: String(e.message).slice(0, 300), stack: String(e.stack ?? '').slice(0, 600) }));
|
||||
}
|
||||
|
||||
/** What a UI drive is EXPECTED to be refused, per user — every entry an exact URL. */
|
||||
export interface ExpectedRefusals {
|
||||
/** This run's profile documents: a profile answers only whom it grants (the Alice/Bob rule). */
|
||||
profiles: ReadonlySet<string>;
|
||||
/** Each user's OWN `pim:preferencesFile`: read by the app with its app token, which the pod does not grant (VERIFIED 2026-10-13). */
|
||||
ownPreferences: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The expected ≥400 responses of a UI drive, each an exact request: the pod's
|
||||
* push channel creation (POST …/.notifications/WebSocketChannel2023 → 403),
|
||||
* a GET 403 on one of this run's profile documents, and a GET 403 on the
|
||||
* signed-in user's OWN preferences file. Anything else — another 403 under
|
||||
* `/data/` included — is unexpected.
|
||||
*/
|
||||
export function benignUiError(e: UiHttpError, expected: ExpectedRefusals): string | null {
|
||||
if (e.method === 'POST' && e.status === 403 && e.url.endsWith('/.notifications/WebSocketChannel2023')) return 'push-channel creation 403';
|
||||
if (e.method === 'GET' && e.status === 403 && expected.profiles.has(e.url)) return 'profile-document read 403 (WAC)';
|
||||
if (e.method === 'GET' && e.status === 403 && expected.ownPreferences.get(e.who) === e.url) return 'own preferences-file read 403 (app token not granted)';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The uncaught page errors explained by an expected refusal: the live
|
||||
* provider's `subscribe` rejects with "Forbidden" when the pod refuses its
|
||||
* push channel — exactly one per refused channel POST of the same user
|
||||
* (VERIFIED 2026-10-13: Bob 2/2, Alice 4/4, Carol 4/4). The throw site is
|
||||
* identified by its two top stack frames, learnt from errors whose async stack
|
||||
* still reaches `subscribe` (half of them are cut before that frame, from the
|
||||
* very same site). Every other page error, and any surplus, is unexpected.
|
||||
*/
|
||||
export function splitPageErrors(errors: UiPageError[], http: UiHttpError[]): { expected: UiPageError[]; unexpected: UiPageError[] } {
|
||||
const channelRefusals = new Map<string, number>();
|
||||
for (const e of http) {
|
||||
if (e.method === 'POST' && e.status === 403 && e.url.endsWith('/.notifications/WebSocketChannel2023')) {
|
||||
channelRefusals.set(e.who, (channelRefusals.get(e.who) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const site = (e: UiPageError): string =>
|
||||
e.stack.split('\n').slice(1, 3).map(l => l.trim().replace(/^at async /, 'at ')).join(' < ');
|
||||
const subscribeSites = new Set(errors.filter(e => e.message === 'Forbidden' && /\bsubscribe\b/.test(e.stack)).map(site));
|
||||
const expected: UiPageError[] = [];
|
||||
const unexpected: UiPageError[] = [];
|
||||
for (const e of errors) {
|
||||
const budget = channelRefusals.get(e.who) ?? 0;
|
||||
if (e.message === 'Forbidden' && subscribeSites.has(site(e)) && budget > 0) {
|
||||
channelRefusals.set(e.who, budget - 1);
|
||||
expected.push(e);
|
||||
} else {
|
||||
unexpected.push(e);
|
||||
}
|
||||
}
|
||||
return { expected, unexpected };
|
||||
}
|
||||
|
||||
/** A user's own preferences file (`pim:preferencesFile` of the actor document, read with her token). */
|
||||
export async function preferencesFileOf(session: ApodsSession): Promise<string | undefined> {
|
||||
const r = await http('GET', session.webId, { token: session.token });
|
||||
return linkId(parseJson(r.text)?.['pim:preferencesFile']);
|
||||
}
|
||||
|
||||
export interface LoginOutcome { ok: boolean; detail: string }
|
||||
|
||||
/**
|
||||
* App picker → pod login → consent → back in the app shell, as a sequence of
|
||||
* OBSERVED facts — never a fixed delay, never the mere absence of the picker.
|
||||
*
|
||||
* The real hop sequence (observed 2026-10-13): the sign-in click leaves the
|
||||
* app for the pod's login form (:5000/login) — unless the pod's session is
|
||||
* still open (a sign-out of the app keeps it: the revocation probe's second
|
||||
* sign-in), in which case no form is shown and the hops below start at once;
|
||||
* after the credentials the
|
||||
* browser comes BACK to the app's OIDC callback (:4000/login?code…), and only
|
||||
* then — when the app is not yet registered on this pod — does the app send it
|
||||
* to the pod's consent screen (:5000/authorize), which navigates a second time
|
||||
* right after it opens. Accept leads back to the app, then to the shell.
|
||||
*
|
||||
* H1 (false "shell reached"): while the app is on its own origin between those
|
||||
* hops it renders neither the picker nor the shell. The shell therefore counts
|
||||
* only once the browser has been to the pod, the URL is past /login, and the
|
||||
* bottom navigation is visible with no picker.
|
||||
* H2 (accept never landing): Accept is clicked only once it has been attached,
|
||||
* visible and enabled for a full second (the page's second navigation is over),
|
||||
* and the click is then VERIFIED by the page leaving the consent screen; if it
|
||||
* does not, the drive fails with what the page showed — it never re-clicks.
|
||||
*/
|
||||
export async function driveLogin(page: Page, who: string, username: string, password: string, ctx: DriveContext): Promise<LoginOutcome> {
|
||||
const t0 = Date.now();
|
||||
const at = (): string => `+${((Date.now() - t0) / 1000).toFixed(1)}s`;
|
||||
const hop = (h: string): void => { ctx.hops.push(`[${who}] ${at()} ${h}`); };
|
||||
// The hop log covers this drive only: the listener is removed when the drive ends, so a page
|
||||
// that signs in twice (the revocation probe) does not log its navigations twice.
|
||||
const onNavigated = (f: Frame): void => { if (f === page.mainFrame()) hop(`→ ${clean(f.url())}`); };
|
||||
page.on('framenavigated', onNavigated);
|
||||
try {
|
||||
const fail = async (why: string): Promise<LoginOutcome> => {
|
||||
const text = (await bodyText(page)).slice(0, 240);
|
||||
hop(`FAIL ${why} | at ${clean(page.url())} | page: ${text}`);
|
||||
return { ok: false, detail: `${why} (at ${clean(page.url())}; page: ${text.slice(0, 120)})` };
|
||||
};
|
||||
const onApp = (): boolean => page.url().startsWith(ctx.app);
|
||||
const accept = page.getByRole('button', { name: /^ACCEPTER$|^Accepter$|^Accept$/i });
|
||||
const consentReady = async (): Promise<boolean> =>
|
||||
page.url().startsWith(`${ctx.podFront}/authorize`)
|
||||
&& (await accept.first().isVisible().catch(() => false))
|
||||
&& (await accept.first().isEnabled().catch(() => false));
|
||||
const shellReady = async (): Promise<boolean> =>
|
||||
onApp() && !new URL(page.url()).pathname.startsWith('/login')
|
||||
&& (await page.getByText('Découvrir', { exact: true }).first().isVisible().catch(() => false))
|
||||
&& (await page.locator('text=Connectez-vous avec votre Pod').count().catch(() => 1)) === 0;
|
||||
|
||||
await page.goto(`${ctx.app}/`, { waitUntil: 'domcontentloaded' });
|
||||
const signIn = page.getByRole('button', { name: /Se connecter avec localhost:3000/i });
|
||||
if (!(await signIn.first().waitFor({ state: 'visible', timeout: 30000 }).then(() => true, () => false))) return fail('the sign-in button never appeared');
|
||||
await signIn.first().click();
|
||||
if (!(await page.waitForURL(u => !u.href.startsWith(ctx.app), { timeout: 30000 }).then(() => true, () => false))) {
|
||||
return fail('the sign-in click never left the app for the pod');
|
||||
}
|
||||
|
||||
// At the pod: its login form when no pod session is open — or, when one still is (signing out
|
||||
// of the APP keeps the pod's session), no form at all: straight on to consent or to the shell.
|
||||
const userField = page.locator('input[name="username"]');
|
||||
const atPod = await poll(
|
||||
async () => ((await userField.isVisible().catch(() => false)) ? 'login' : (await consentReady()) ? 'consent' : (await shellReady()) ? 'shell' : 'pending'),
|
||||
v => v !== 'pending', 30000, 250,
|
||||
);
|
||||
if (atPod === 'pending') return fail('after the sign-in click, neither the pod login form, the consent screen nor the app shell appeared');
|
||||
if (atPod === 'login') {
|
||||
await userField.fill(username);
|
||||
await page.locator('input[name="password"]').fill(password);
|
||||
const submit = page.getByRole('button', { name: /SE CONNECTER|Se connecter|Sign in|Connexion/i });
|
||||
await ((await submit.count()) ? submit.first() : page.locator('button[type=submit]').first()).click();
|
||||
hop('credentials submitted');
|
||||
} else {
|
||||
hop(`no pod login asked (the pod session is still open) → ${atPod}`);
|
||||
}
|
||||
|
||||
// After login: the consent screen (app not yet registered here) or straight to the shell (already registered).
|
||||
const next = await poll(
|
||||
async () => ((await consentReady()) ? 'consent' : (await shellReady()) ? 'shell' : 'pending'),
|
||||
v => v !== 'pending', 60000, 250,
|
||||
);
|
||||
if (next === 'pending') return fail('after login, neither the consent screen nor the app shell appeared');
|
||||
if (next === 'consent') {
|
||||
// Stable for a full second (4 consecutive observations): the second navigation of the page is over.
|
||||
let stable = 0;
|
||||
const settleEnd = Date.now() + 15000;
|
||||
while (stable < 4 && Date.now() < settleEnd) {
|
||||
stable = (await consentReady()) ? stable + 1 : 0;
|
||||
await page.waitForTimeout(250);
|
||||
}
|
||||
if (stable < 4) return fail('the consent Accept button never stayed visible and enabled');
|
||||
hop('consent ready (Accept attached, visible, enabled for 1 s)');
|
||||
await accept.first().click();
|
||||
hop('accept clicked');
|
||||
if (!(await page.waitForURL(u => !u.href.startsWith(`${ctx.podFront}/authorize`), { timeout: 30000 }).then(() => true, () => false))) {
|
||||
return fail('Accept was clicked but the page never left the consent screen');
|
||||
}
|
||||
hop('left the consent screen');
|
||||
if ((await poll(shellReady, v => v, 45000, 250)) !== true) return fail('back from consent, the app shell never appeared');
|
||||
}
|
||||
hop(`shell reached (bottom navigation, no picker) via ${next === 'consent' ? 'consent' : 'an existing registration'}`);
|
||||
return { ok: true, detail: `shell reached in ${at()} via ${next === 'consent' ? 'the consent screen' : 'an existing registration (no consent asked)'}` };
|
||||
} finally {
|
||||
page.off('framenavigated', onNavigated);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the app installed on this user's pod? The registered Event container
|
||||
* exists only once the app registration went through (consent or programmatic
|
||||
* install). Polled: the registration is confirmed asynchronously.
|
||||
*/
|
||||
export async function installedContainer(session: ApodsSession, timeoutMs = 30000): Promise<CheckOutcome> {
|
||||
const uri = `${session.storage}/as/event`;
|
||||
const r = await poll(() => http('GET', uri, { token: session.token }), v => v.status === 200, timeoutMs, 1000);
|
||||
return r.status === 200
|
||||
? { ok: true, actual: `${clean(uri)} → HTTP 200` }
|
||||
: { ok: false, actual: `${clean(uri)} → ${r.status === 0 ? `unreachable (${r.error})` : `HTTP ${r.status}`} after ${timeoutMs / 1000} s — the app is NOT installed on this pod (consent never completed / registration missing)` };
|
||||
}
|
||||
|
||||
/** The banked event-creation drive; the event URI is read off the detail route it lands on. */
|
||||
export async function createEventUi(page: Page, app: string, title: string): Promise<{ eventUri?: string; finalUrl: string }> {
|
||||
await page.goto(`${app}/events/new`, { waitUntil: 'domcontentloaded' }).catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
await page.locator('input[placeholder="Donnez un nom à votre événement"]').fill(title, { timeout: 10000 }).catch(() => {});
|
||||
const dates = page.locator('input[type="date"]');
|
||||
if (await dates.count().catch(() => 0)) await dates.first().fill('2026-12-20', { timeout: 3000 }).catch(() => {});
|
||||
await page.getByRole('button', { name: /Suivant/ }).click({ timeout: 5000 }).catch(() => {});
|
||||
await page.waitForTimeout(1200);
|
||||
const next = page.getByRole('button', { name: /Suivant/ });
|
||||
if (await next.count().catch(() => 0)) await next.click({ timeout: 5000 }).catch(() => {});
|
||||
await page.waitForTimeout(1200);
|
||||
await page.getByRole('button', { name: /Relayer l'événement/ }).click({ timeout: 5000 }).catch(() => {});
|
||||
const landed = await poll(async () => page.url(), u => /\/events\/[^/]+$/.test(new URL(u).pathname) && !u.includes('/events/new'), 15000, 500);
|
||||
const m = landed.startsWith(`${app}/events/`) ? /\/events\/([^/?#]+)/.exec(landed.slice(app.length)) : null;
|
||||
const segment = m?.[1];
|
||||
return { eventUri: segment && segment !== 'new' ? decodeURIComponent(segment) : undefined, finalUrl: landed };
|
||||
}
|
||||
|
||||
/** The banked meeting-point drive, from the event detail page it starts on. */
|
||||
export async function createMeetingPointUi(page: Page, title: string): Promise<boolean> {
|
||||
const propose = page.getByRole('button', { name: /Proposer un point de rencontre/i });
|
||||
await propose.first().waitFor({ timeout: 15000 }).catch(() => {});
|
||||
if (!(await propose.count().catch(() => 0))) return false;
|
||||
await propose.first().click().catch(() => {});
|
||||
await page.waitForTimeout(1500);
|
||||
await page.locator('input[placeholder="Ex : Gouvernance coopérative — retours SCIC"]').fill(title, { timeout: 10000 }).catch(() => {});
|
||||
await page.getByRole('button', { name: /Créer le point de rencontre/ }).click({ timeout: 5000 }).catch(() => {});
|
||||
const txt = await poll(() => bodyText(page), t => t.includes(title), 15000, 500);
|
||||
return txt.includes(title) && !/pas pu/i.test(txt);
|
||||
}
|
||||
@@ -0,0 +1,991 @@
|
||||
/**
|
||||
* The rights matrix, end to end, on the REAL local ActivityPods stack: three
|
||||
* fresh users per run — Bob (organizer), Alice (another user), Carol (a third
|
||||
* user) — plus anonymous, every feature exercised with positive AND negative
|
||||
* access assertions. This script IS the acceptance gate for public data,
|
||||
* discovery and participation: a human no longer validates them one by one.
|
||||
*
|
||||
* HOW IT DRIVES. The browser where UI truth matters (Bob and Alice sign in
|
||||
* through the pod's login + consent; Bob creates the event and TWO meeting
|
||||
* points through the app; Alice finds the event in Découvrir, opens it, joins
|
||||
* and leaves both meeting points by tapping; Carol, a non-contact, reads the
|
||||
* counts her own view renders). Direct HTTP with each user's own token for the
|
||||
* access-control lines (faster, and precise about statuses). Carol installs the
|
||||
* app programmatically (the smoke's registration call), then signs in. Setup
|
||||
* asserts each user's registered Event container answers 200: a sign-in that
|
||||
* never installed the app fails AT setup, not as a 400 twenty lines later.
|
||||
*
|
||||
* DEFENSIVE ONLY (user decision 2026-10-13). No line sends a forged or spoofed
|
||||
* activity, nor writes spoofed data, to the live stack. Such checks are
|
||||
* MANUAL-CHECK lines naming where they are covered (backend unit tests).
|
||||
*
|
||||
* THE REPORT. Every line is a named assertion with expected vs actual:
|
||||
* PASS / FAIL — hard lines; any FAIL makes the exit code non-zero.
|
||||
* KNOWN-DEVIATION — a tracked, decided deviation from the domain
|
||||
* doctrine (attendee lists are world-readable).
|
||||
* KNOWN-DEFECT — a tracked platform defect (anonymous PATCH on an
|
||||
* attendees collection is accepted).
|
||||
* MANUAL-CHECK — not exercised live (see DEFENSIVE ONLY above).
|
||||
* Tracked lines never fail the run, but print a loud NOTICE the day their
|
||||
* behaviour changes, so they get promoted to hard assertions.
|
||||
*
|
||||
* NEGATIVE LINES pass only on an explicit refusal: 401 or 403 for an anonymous
|
||||
* request, 403 for an authenticated one. A 404, a 5xx or an unreachable host
|
||||
* (status 0) is never a refusal. Each negative line that could be passed by a
|
||||
* broken probe has a CONTROL line on the opposite state (Alice reads her own
|
||||
* profile; a twin event WITH public intent is readable and listed; Bob reads
|
||||
* Alice's profile only after her join; the owner's own container POST lands).
|
||||
*
|
||||
* BACKEND PUBLIC SURFACE (T05.i allow-list). Anonymous GETs only, status codes
|
||||
* only — never a body (before 88f23b7, `/key` served the app's private key):
|
||||
* every allow-listed endpoint answers 200 (the control), a fixed list of
|
||||
* non-public paths answers 404, the gate's refusal for every caller.
|
||||
*
|
||||
* VISIBILITY FOLLOWS THE OWNER (T05.i). An event V with a meeting point VM and
|
||||
* a participant (Carol joins through her own outbox, as the app does) is
|
||||
* switched private → public → private by Bob PATCHing `to as:Public` on his
|
||||
* own two resources — a legitimate owner update (no UI for it yet). At every
|
||||
* state: anonymous reads of V, VM and both attendees collections, the catalog
|
||||
* listing, and Carol's membership (only the public read moves). For a
|
||||
* collection the pod's refusal is 404 (VERIFIED 2026-10-13); it counts as one
|
||||
* only next to Bob's 200 on the same URI at the same moment.
|
||||
*
|
||||
* Lines are independent: a failure is recorded and the run continues; a line
|
||||
* whose prerequisite is missing FAILs with the name of what is missing.
|
||||
*
|
||||
* ORIGIN. As for `e2e-login-flow.ts`: the OIDC redirect_uri is registered for
|
||||
* `http://localhost:4000/login` only, so the app MUST be served at APP_URL.
|
||||
*/
|
||||
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join as joinPath } from 'node:path';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import {
|
||||
attendeesCollectionUri,
|
||||
linkId,
|
||||
postOutboxActivity,
|
||||
recordUri,
|
||||
type ApodsSession,
|
||||
type ApodsStackConfig,
|
||||
type JsonRecord,
|
||||
} from '../src/shared/support/activitypodsStack';
|
||||
import {
|
||||
Matrix,
|
||||
arrayOf,
|
||||
benignUiError,
|
||||
bodyText,
|
||||
brief,
|
||||
createEventUi,
|
||||
createMeetingPointUi,
|
||||
driveLogin,
|
||||
http,
|
||||
installApp,
|
||||
installedContainer,
|
||||
itemUris,
|
||||
loginWithRetry,
|
||||
need,
|
||||
parseJson,
|
||||
poll,
|
||||
profileUriOf,
|
||||
refused,
|
||||
sleep,
|
||||
splitPageErrors,
|
||||
preferencesFileOf,
|
||||
wire,
|
||||
type CheckOutcome,
|
||||
type DriveContext,
|
||||
} from './e2e-rights-matrix-support';
|
||||
|
||||
const strip = (u: string): string => u.replace(/\/+$/, '');
|
||||
const BACKEND = strip(process.env.BACKEND_URL || 'http://localhost:3003');
|
||||
const APP = strip(process.env.APP_URL || 'http://localhost:4000');
|
||||
const POD_FRONT = strip(process.env.POD_FRONTEND_URL || 'http://localhost:5000');
|
||||
const config: ApodsStackConfig = {
|
||||
podProviderUrl: strip(process.env.POD_PROVIDER_URL || 'http://localhost:3000'),
|
||||
backendUrl: BACKEND,
|
||||
};
|
||||
|
||||
const TS = Date.now();
|
||||
const PASS = 'Test1test';
|
||||
const USERS = { bob: `rmbob${TS}`, alice: `rmalice${TS}`, carol: `rmcarol${TS}` };
|
||||
const TITLE_E = `Matrice droits ${TS}`;
|
||||
const TITLE_M = `Rdv matrice ${TS}`;
|
||||
const TITLE_M2 = `Rdv second ${TS}`;
|
||||
const TITLE_P = `Prive matrice ${TS}`;
|
||||
const TITLE_T = `Public jumeau ${TS}`;
|
||||
const TITLE_V = `Visibilite matrice ${TS}`;
|
||||
const TITLE_VM = `Rdv visibilite ${TS}`;
|
||||
|
||||
const PLATFORM_CONTEXT = 'https://activitypods.org/context.json';
|
||||
const AS_PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
|
||||
const ANON = [401, 403];
|
||||
const AUTHN = [403];
|
||||
|
||||
const m = new Matrix();
|
||||
const ui: DriveContext = { app: APP, podFront: POD_FRONT, hops: [], errors: [], consoleErrors: [], pageErrors: [], deadline: Date.now() + 15 * 60 * 1000 };
|
||||
|
||||
/** Everything the lines share; each field is set by the line that establishes it. */
|
||||
const S: {
|
||||
bob?: ApodsSession; alice?: ApodsSession; carol?: ApodsSession;
|
||||
/** M and its attendees A are the first meeting point; M2 / A2 the second. */
|
||||
E?: string; M?: string; A?: string; M2?: string; A2?: string; P?: string; T?: string;
|
||||
/** The visibility group's event, its meeting point, and their attendees collections. */
|
||||
V?: string; VM?: string; AV?: string; AVM?: string;
|
||||
/** Set only by the line that SAW Carol's WebID land in VM's collection. */
|
||||
carolInVM?: boolean;
|
||||
bobProfile?: string; aliceProfile?: string; carolProfile?: string;
|
||||
/** Each user's own preferences file, keyed by the UI drive's tag (bob/alice/carol). */
|
||||
preferences: Map<string, string>;
|
||||
alicePage?: Page; carolPage?: Page;
|
||||
/** Set only by the line that SAW Alice's WebID land in the collection. */
|
||||
joined: { M?: boolean; M2?: boolean };
|
||||
before?: { participants: number; mp: number; mp2: number };
|
||||
snapshot?: { E: string; M: string };
|
||||
} = { joined: {}, preferences: new Map() };
|
||||
|
||||
// ── Small readers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function catalogIds(): Promise<{ ids: string[]; items: JsonRecord[]; raw: string }> {
|
||||
const r = await http('GET', `${BACKEND}/catalog`, { accept: 'application/json' });
|
||||
if (r.status !== 200) throw new Error(`GET /catalog → ${brief(r)}`);
|
||||
const body = parseJson(r.text);
|
||||
const items = arrayOf(body?.['orderedItems'] ?? body?.['items'] ?? body?.['as:orderedItems'])
|
||||
.filter((x): x is JsonRecord => typeof x === 'object' && x !== null);
|
||||
return { ids: items.map(i => recordUri(i)).filter((u): u is string => !!u), items, raw: r.text };
|
||||
}
|
||||
|
||||
async function attendees(uri: string, token?: string): Promise<{ status: number; items: string[]; brief: string }> {
|
||||
const r = await http('GET', uri, { token });
|
||||
return { status: r.status, items: r.status === 200 ? itemUris(parseJson(r.text)) : [], brief: brief(r) };
|
||||
}
|
||||
|
||||
const ctxLink = (rec: JsonRecord): string | undefined => linkId(rec['as:context'] ?? rec['context']);
|
||||
|
||||
/**
|
||||
* The card of one meeting point on the event detail page: the deepest element
|
||||
* holding both its title and a button (ancestors precede descendants in
|
||||
* document order, so `.last()` is the card itself).
|
||||
*/
|
||||
const mpCard = (page: Page, title: string) =>
|
||||
page.locator('div').filter({ has: page.getByText(title, { exact: true }) }).filter({ has: page.getByRole('button') }).last();
|
||||
|
||||
const countIn = (text: string): number | undefined => {
|
||||
const k = /👥\s*(\d+)\s+inscrits?/i.exec(text);
|
||||
return k ? Number(k[1]) : undefined;
|
||||
};
|
||||
|
||||
/** UI counts on the event detail page: header "PARTICIPANTS (n)" and each meeting point's "👥 k inscrit(s)". */
|
||||
async function detailCounts(page: Page): Promise<{ participants?: number; mp?: number; mp2?: number; text: string }> {
|
||||
const text = await bodyText(page);
|
||||
const p = /participants \((\d+)\)/i.exec(text);
|
||||
const card = async (title: string): Promise<number | undefined> =>
|
||||
countIn(await mpCard(page, title).innerText({ timeout: 2000 }).catch(() => ''));
|
||||
return { participants: p ? Number(p[1]) : undefined, mp: await card(TITLE_M), mp2: await card(TITLE_M2), text };
|
||||
}
|
||||
const showCounts = (c: { participants?: number; mp?: number; mp2?: number }): string =>
|
||||
`PARTICIPANTS (${c.participants}), M ${c.mp} inscrit, M2 ${c.mp2} inscrit`;
|
||||
|
||||
/**
|
||||
* The summed counter of E read from the data: every meeting point the catalog
|
||||
* links to E, each attendees collection read anonymously. A read that does not
|
||||
* answer 200 is an ERROR, never "0 items".
|
||||
*/
|
||||
async function summedCounter(): Promise<{ mps: string[]; per: Map<string, string[]>; sum: number }> {
|
||||
const E = need(S.E, 'E');
|
||||
const mps = (await catalogIds()).items.filter(i => ctxLink(i) === E);
|
||||
const per = new Map<string, string[]>();
|
||||
let sum = 0;
|
||||
for (const mp of mps) {
|
||||
const uri = need(recordUri(mp), 'a meeting point id in the catalog');
|
||||
const a = await attendees(need(attendeesCollectionUri(mp), `attendees of ${uri}`));
|
||||
if (a.status !== 200) throw new Error(`attendees of ${uri} → ${a.brief} (a failed read is never counted as 0)`);
|
||||
per.set(uri, a.items);
|
||||
sum += a.items.length;
|
||||
}
|
||||
return { mps: mps.map(i => recordUri(i) ?? '?'), per, sum };
|
||||
}
|
||||
|
||||
const PUT_BODY = (id: string): string =>
|
||||
JSON.stringify({ '@context': PLATFORM_CONTEXT, id, type: 'Event', name: `HIJACKED ${TS}` });
|
||||
const POST_BODY = JSON.stringify({ '@context': PLATFORM_CONTEXT, type: 'Note', content: `HIJACKED ${TS}` });
|
||||
const PATCH_BODY = (id: string): string =>
|
||||
`INSERT DATA { <${id}> <https://www.w3.org/ns/activitystreams#name> "HIJACKED ${TS}" . }`;
|
||||
|
||||
/** A write attempt that must be refused with one of `allowed`. */
|
||||
function refusedWrite(
|
||||
id: string, name: string, method: 'PUT' | 'POST' | 'PATCH' | 'DELETE', who: 'anonymous' | 'alice', target: () => string, group: string,
|
||||
opts: { body?: string } = {},
|
||||
): Promise<boolean> {
|
||||
const allowed = who === 'anonymous' ? ANON : AUTHN;
|
||||
return m.hard(id, group, name, `refused ${allowed.join('/')}`, async () => {
|
||||
const uri = target();
|
||||
const token = who === 'alice' ? need(S.alice, 'Alice session').token : undefined;
|
||||
const r = method === 'PUT' ? await http('PUT', uri, { token, body: opts.body ?? PUT_BODY(uri), contentType: 'application/ld+json' })
|
||||
: method === 'POST' ? await http('POST', uri, { token, body: opts.body ?? POST_BODY, contentType: 'application/ld+json' })
|
||||
: method === 'PATCH' ? await http('PATCH', uri, { token, body: opts.body ?? PATCH_BODY(uri), contentType: 'application/sparql-update' })
|
||||
: await http('DELETE', uri, { token });
|
||||
return { ok: refused(r.status, allowed), actual: brief(r) };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST on a NON-container resource is rejected before any rights check, for
|
||||
* every agent — the owner included (VERIFIED 2026-10-12: Bob's own POST on E →
|
||||
* 400 with JSON-LD, 500 with Turtle; E unchanged). It is not a write path, so
|
||||
* the matrix's POST-on-E/M lines are INFORMATIONAL (orchestrator decision
|
||||
* 2026-10-12): each shows its status beside the owner's own POST on E. The
|
||||
* POST requests still fire before `write.unchanged`, which fails on any byte
|
||||
* they would change. The rights intent is asserted by the strict
|
||||
* container-POST lines and the PUT/PATCH/DELETE lines.
|
||||
*/
|
||||
const POST_ON_RESOURCE_NAME = (who: string, target: string): string =>
|
||||
`${who} POST on ${target} — method-not-applicable: POST on a non-container resource → 400 for every agent, owner included; `
|
||||
+ 'the POST rights intent is asserted by write.*-POST-container (anonymous/Alice refused, owner 201) and the PUT/PATCH/DELETE lines';
|
||||
|
||||
/** Fire a POST on a non-container resource and keep its status for the informational line. */
|
||||
async function postOnResource(who: 'anonymous' | 'alice', target: () => string): Promise<string> {
|
||||
try {
|
||||
const uri = target();
|
||||
const token = who === 'alice' ? need(S.alice, 'Alice session').token : undefined;
|
||||
const r = await http('POST', uri, { token, body: POST_BODY, contentType: 'application/ld+json' });
|
||||
return String(r.status);
|
||||
} catch (e) {
|
||||
return `not observed: ${String(e).slice(0, 120)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── The matrix ────────────────────────────────────────────────────────────
|
||||
|
||||
/** A UI sign-in line: the drive's own verdict, never the absence of the picker. */
|
||||
function signIn(id: string, who: 'bob' | 'alice' | 'carol', label: string, page: Page, onOk: () => void): Promise<boolean> {
|
||||
return m.hard(id, 'setup', `${label} signs in to the app: pod login → consent (if not yet installed) → app shell`, 'left the app for the pod, came back, bottom navigation shown, no picker', async () => {
|
||||
need(S[who], `${label} account`);
|
||||
const r = await driveLogin(page, who, USERS[who], PASS, ui);
|
||||
if (r.ok) onOk();
|
||||
return { ok: r.ok, actual: r.detail };
|
||||
});
|
||||
}
|
||||
|
||||
/** The install assertion: the registered Event container answers 200 for this user. */
|
||||
function installed(id: string, who: 'bob' | 'alice' | 'carol', label: string): Promise<boolean> {
|
||||
return m.hard(id, 'setup', `${label}'s pod has the app installed`, 'her/his as/event container answers 200 (else: the sign-in never installed the app)', async (): Promise<CheckOutcome> =>
|
||||
installedContainer(need(S[who], `${label} account`)));
|
||||
}
|
||||
|
||||
/** Find a meeting point of E in Bob's container by its title; polled (the write lands asynchronously). */
|
||||
async function findMeetingPoint(title: string): Promise<string | undefined> {
|
||||
const bob = need(S.bob, 'Bob account');
|
||||
const E = need(S.E, 'event E');
|
||||
return poll(async () => {
|
||||
const c = parseJson((await http('GET', `${bob.storage}/as/event`, { token: bob.token })).text);
|
||||
for (const entry of arrayOf(c?.['ldp:contains'] ?? c?.['contains'])) {
|
||||
const uri = linkId(entry);
|
||||
if (!uri || uri === E) continue;
|
||||
const rec = parseJson((await http('GET', uri, { token: bob.token })).text);
|
||||
if (rec && ctxLink(rec) === E && JSON.stringify(rec).includes(title)) return uri;
|
||||
}
|
||||
return undefined;
|
||||
}, v => !!v, 20000);
|
||||
}
|
||||
|
||||
async function attachedCollection(M: string): Promise<string | undefined> {
|
||||
const bob = need(S.bob, 'Bob account');
|
||||
return poll(async () => attendeesCollectionUri(parseJson((await http('GET', M, { token: bob.token })).text) ?? {}), v => !!v, 20000);
|
||||
}
|
||||
|
||||
async function setup(browser: Browser): Promise<void> {
|
||||
const G = 'setup';
|
||||
await m.hard('setup.bob-account', G, `Bob (${USERS.bob}) signs up / in on the pod`, 'session with outbox + storage', async () => {
|
||||
S.bob = await loginWithRetry(config, USERS.bob, PASS);
|
||||
return { ok: true, actual: S.bob.webId };
|
||||
});
|
||||
await m.hard('setup.alice-account', G, `Alice (${USERS.alice}) signs up / in on the pod`, 'session with outbox + storage', async () => {
|
||||
S.alice = await loginWithRetry(config, USERS.alice, PASS);
|
||||
return { ok: true, actual: S.alice.webId };
|
||||
});
|
||||
await m.hard('setup.carol-account', G, `Carol (${USERS.carol}) signs up / in on the pod`, 'session with outbox + storage', async () => {
|
||||
S.carol = await loginWithRetry(config, USERS.carol, PASS);
|
||||
return { ok: true, actual: S.carol.webId };
|
||||
});
|
||||
await m.hard('setup.carol-install', G, 'Carol installs the app (programmatic registration)', 'registered; her as/event container answers 200', async () =>
|
||||
installApp(config, need(S.carol, 'Carol session')));
|
||||
|
||||
// Bob: consent through the UI, then the event and its two meeting points through the app.
|
||||
const bobCtx = await browser.newContext();
|
||||
const bobPage = await bobCtx.newPage();
|
||||
wire(bobPage, 'bob', ui);
|
||||
await signIn('setup.bob-consent', 'bob', 'Bob', bobPage, () => {});
|
||||
await installed('setup.bob-installed', 'bob', 'Bob');
|
||||
await m.hard('setup.bob-event', G, `Bob creates event E "${TITLE_E}" through the app`, 'lands on E detail; E is in Bob\'s pod with that name', async () => {
|
||||
const bob = need(S.bob, 'Bob account');
|
||||
const r = await createEventUi(bobPage, APP, TITLE_E);
|
||||
const E = need(r.eventUri, `an event route after creation (landed on ${r.finalUrl})`);
|
||||
const rec = parseJson((await http('GET', E, { token: bob.token })).text);
|
||||
const name = rec?.['name'];
|
||||
S.E = E;
|
||||
return { ok: E.startsWith(`${bob.storage}/`) && name === TITLE_E, actual: `${E} name=${JSON.stringify(name)} to=${JSON.stringify(rec?.['to'])}` };
|
||||
});
|
||||
await m.hard('setup.bob-meeting-point', G, `Bob proposes meeting point M "${TITLE_M}" on E through the app`, 'M in Bob\'s as/event container with as:context = E', async () => {
|
||||
need(S.E, 'event E');
|
||||
const shown = await createMeetingPointUi(bobPage, TITLE_M);
|
||||
S.M = await findMeetingPoint(TITLE_M);
|
||||
return { ok: shown && !!S.M, actual: `ui shows it: ${shown}; M = ${S.M ?? '(not found)'}` };
|
||||
});
|
||||
await m.hard('setup.attendees-collection', G, 'M carries its apods:attendees collection (attached by the backend)', 'collection URI on M', async () => {
|
||||
S.A = await attachedCollection(need(S.M, 'meeting point M'));
|
||||
return { ok: !!S.A, actual: S.A ?? '(none after 20 s)' };
|
||||
});
|
||||
await m.hard('setup.bob-meeting-point-2', G, `Bob proposes a SECOND meeting point M2 "${TITLE_M2}" on E through the app`, 'M2 in Bob\'s as/event container with as:context = E, distinct from M', async () => {
|
||||
need(S.E, 'event E');
|
||||
const shown = await createMeetingPointUi(bobPage, TITLE_M2);
|
||||
S.M2 = await findMeetingPoint(TITLE_M2);
|
||||
return { ok: shown && !!S.M2 && S.M2 !== S.M, actual: `ui shows it: ${shown}; M2 = ${S.M2 ?? '(not found)'}` };
|
||||
});
|
||||
await m.hard('setup.attendees-collection-2', G, 'M2 carries its own apods:attendees collection', 'collection URI on M2, distinct from M\'s', async () => {
|
||||
S.A2 = await attachedCollection(need(S.M2, 'meeting point M2'));
|
||||
return { ok: !!S.A2 && S.A2 !== S.A, actual: S.A2 ?? '(none after 20 s)' };
|
||||
});
|
||||
await bobCtx.close();
|
||||
|
||||
// Bob, direct HTTP with his token: one event WITHOUT public intent (no `to`),
|
||||
// and its control twin WITH it — same path, opposite intent.
|
||||
await m.hard('setup.bob-private-event', G, `Bob POSTs event P "${TITLE_P}" into his as/event container, NO \`to\``, '201 + Location', async () => {
|
||||
const bob = need(S.bob, 'Bob account');
|
||||
const r = await http('POST', `${bob.storage}/as/event`, {
|
||||
token: bob.token, contentType: 'application/ld+json',
|
||||
body: JSON.stringify({ '@context': PLATFORM_CONTEXT, type: 'Event', name: TITLE_P, startTime: '2026-12-21T18:00:00Z', endTime: '2026-12-21T22:00:00Z' }),
|
||||
});
|
||||
S.P = r.headers.get('Location') ?? undefined;
|
||||
return { ok: (r.status === 201 || r.status === 200) && !!S.P, actual: `HTTP ${r.status} → ${S.P ?? '(no Location)'}` };
|
||||
});
|
||||
await m.hard('setup.bob-public-twin', G, `CONTROL: Bob POSTs twin T "${TITLE_T}" the same way, WITH \`to: as:Public\``, '201 + Location', async () => {
|
||||
const bob = need(S.bob, 'Bob account');
|
||||
const r = await http('POST', `${bob.storage}/as/event`, {
|
||||
token: bob.token, contentType: 'application/ld+json',
|
||||
body: JSON.stringify({ '@context': PLATFORM_CONTEXT, type: 'Event', name: TITLE_T, startTime: '2026-12-21T18:00:00Z', endTime: '2026-12-21T22:00:00Z', to: AS_PUBLIC }),
|
||||
});
|
||||
S.T = r.headers.get('Location') ?? undefined;
|
||||
return { ok: (r.status === 201 || r.status === 200) && !!S.T, actual: `HTTP ${r.status} → ${S.T ?? '(no Location)'}` };
|
||||
});
|
||||
|
||||
// Alice: consent through the UI; her page carries discovery, join and leave.
|
||||
const alicePage = await (await browser.newContext()).newPage();
|
||||
wire(alicePage, 'alice', ui);
|
||||
await signIn('setup.alice-consent', 'alice', 'Alice', alicePage, () => { S.alicePage = alicePage; });
|
||||
await installed('setup.alice-installed', 'alice', 'Alice');
|
||||
|
||||
// Carol: already installed programmatically; she signs in to see her own (non-contact) view.
|
||||
const carolPage = await (await browser.newContext()).newPage();
|
||||
wire(carolPage, 'carol', ui);
|
||||
await signIn('setup.carol-sign-in', 'carol', 'Carol', carolPage, () => { S.carolPage = carolPage; });
|
||||
|
||||
S.bobProfile = S.bob ? await profileUriOf(S.bob.webId) : undefined;
|
||||
S.aliceProfile = S.alice ? await profileUriOf(S.alice.webId) : undefined;
|
||||
S.carolProfile = S.carol ? await profileUriOf(S.carol.webId) : undefined;
|
||||
for (const who of ['bob', 'alice', 'carol'] as const) {
|
||||
const session = S[who];
|
||||
const pref = session ? await preferencesFileOf(session) : undefined;
|
||||
if (pref) S.preferences.set(who, pref);
|
||||
}
|
||||
}
|
||||
|
||||
async function publicReads(): Promise<void> {
|
||||
const G = 'public reads (positive)';
|
||||
const read = (id: string, name: string, token: () => string | undefined, target: () => string, extra?: (rec: JsonRecord | undefined) => string) =>
|
||||
m.hard(id, G, name, '200', async () => {
|
||||
const r = await http('GET', target(), { token: token() });
|
||||
return { ok: r.status === 200, actual: `HTTP ${r.status}${extra ? ` ${extra(parseJson(r.text))}` : ''}` };
|
||||
});
|
||||
await read('read.anon-E', 'anonymous GET E', () => undefined, () => need(S.E, 'event E'));
|
||||
await read('read.anon-M', 'anonymous GET M', () => undefined, () => need(S.M, 'meeting point M'));
|
||||
await read('read.alice-E', 'Alice GET E (her token)', () => need(S.alice, 'Alice').token, () => need(S.E, 'event E'));
|
||||
await read('read.alice-M', 'Alice GET M (her token)', () => need(S.alice, 'Alice').token, () => need(S.M, 'meeting point M'));
|
||||
await m.hard('read.alice-attendees', G, "Alice GET M's attendees (her token)", '200 with an `items` key (count = items.length; no totalItems on this platform)', async () => {
|
||||
const r = await http('GET', need(S.A, 'attendees collection'), { token: need(S.alice, 'Alice').token });
|
||||
const rec = parseJson(r.text);
|
||||
const hasItemsKey = !!rec && ('items' in rec || 'as:items' in rec);
|
||||
return { ok: r.status === 200 && hasItemsKey, actual: `HTTP ${r.status} items.length=${itemUris(rec).length} (items key present: ${hasItemsKey})` };
|
||||
});
|
||||
}
|
||||
|
||||
async function writesRefused(): Promise<void> {
|
||||
const G = 'writes (negative)';
|
||||
const bobRead = async (uri: string): Promise<string> => {
|
||||
const r = await http('GET', uri, { token: need(S.bob, 'Bob').token });
|
||||
if (r.status !== 200) throw new Error(`Bob GET ${uri} → ${brief(r)}`);
|
||||
return r.text;
|
||||
};
|
||||
try {
|
||||
S.snapshot = { E: await bobRead(need(S.E, 'E')), M: await bobRead(need(S.M, 'M')) };
|
||||
} catch { /* the unchanged line below FAILs with the missing snapshot */ }
|
||||
const E = (): string => need(S.E, 'event E');
|
||||
const M = (): string => need(S.M, 'meeting point M');
|
||||
await refusedWrite('write.anon-PUT-E', 'anonymous PUT on E', 'PUT', 'anonymous', E, G);
|
||||
const postOn: Record<string, string> = {};
|
||||
postOn['anon-E'] = await postOnResource('anonymous', E);
|
||||
await refusedWrite('write.anon-PATCH-E', 'anonymous PATCH on E', 'PATCH', 'anonymous', E, G);
|
||||
await refusedWrite('write.anon-PUT-M', 'anonymous PUT on M', 'PUT', 'anonymous', M, G);
|
||||
postOn['anon-M'] = await postOnResource('anonymous', M);
|
||||
await refusedWrite('write.anon-PATCH-M', 'anonymous PATCH on M', 'PATCH', 'anonymous', M, G);
|
||||
await refusedWrite('write.alice-PUT-E', 'Alice PUT on E', 'PUT', 'alice', E, G);
|
||||
postOn['alice-E'] = await postOnResource('alice', E);
|
||||
await refusedWrite('write.alice-PATCH-E', 'Alice PATCH on E', 'PATCH', 'alice', E, G);
|
||||
await refusedWrite('write.alice-PUT-M', 'Alice PUT on M', 'PUT', 'alice', M, G);
|
||||
postOn['alice-M'] = await postOnResource('alice', M);
|
||||
await refusedWrite('write.alice-PATCH-M', 'Alice PATCH on M', 'PATCH', 'alice', M, G);
|
||||
await m.hard('write.unchanged', G, 'E and M are byte-identical after every refused write (read by Bob)', 'identical to the pre-write snapshot', async () => {
|
||||
const snap = need(S.snapshot, 'pre-write snapshot of E and M');
|
||||
const e = await bobRead(E());
|
||||
const mm = await bobRead(M());
|
||||
return { ok: e === snap.E && mm === snap.M, actual: `E ${e === snap.E ? 'unchanged' : 'CHANGED'}, M ${mm === snap.M ? 'unchanged' : 'CHANGED'}${e.includes('HIJACKED') || mm.includes('HIJACKED') ? ' (HIJACKED marker present)' : ''}` };
|
||||
});
|
||||
const container = (): string => `${need(S.bob, 'Bob').storage}/as/event`;
|
||||
const hijack = JSON.stringify({ '@context': PLATFORM_CONTEXT, type: 'Event', name: `HIJACKED ${TS}` });
|
||||
await refusedWrite('write.anon-POST-container', "anonymous POST into Bob's as/event container (owner control: setup.bob-private-event → 201)", 'POST', 'anonymous', container, G, { body: hijack });
|
||||
await refusedWrite('write.alice-POST-container', "Alice POST into Bob's as/event container (owner control: setup.bob-private-event → 201)", 'POST', 'alice', container, G, { body: hijack });
|
||||
let ownerStatus = 'not observed';
|
||||
m.info('write.owner-POST-E', G, 'evidence for the POST-on-resource lines: Bob (owner) POST on his own E', await (async () => {
|
||||
const r = await http('POST', need(S.E, 'E'), { token: need(S.bob, 'Bob').token, body: POST_BODY, contentType: 'application/ld+json' }).catch(() => undefined);
|
||||
if (r) ownerStatus = String(r.status);
|
||||
return r ? `Bob POST on E → ${brief(r)}` : 'not observed';
|
||||
})().catch(e => `not observed: ${String(e)}`));
|
||||
const posts: Array<[string, string, string, string]> = [
|
||||
['write.anon-POST-E', 'anon-E', 'anonymous', 'E'],
|
||||
['write.anon-POST-M', 'anon-M', 'anonymous', 'M'],
|
||||
['write.alice-POST-E', 'alice-E', 'Alice', 'E'],
|
||||
['write.alice-POST-M', 'alice-M', 'Alice', 'M'],
|
||||
];
|
||||
for (const [id, key, who, target] of posts) {
|
||||
const status = postOn[key] ?? 'not observed';
|
||||
const premiseHolds = status === ownerStatus && status === '400';
|
||||
m.info(id, G, POST_ON_RESOURCE_NAME(who, target), `${who} POST on ${target} → ${status}; owner control: Bob POST on E → ${ownerStatus}`,
|
||||
premiseHolds ? undefined : 'method-not-applicable no longer holds as observed (status differs from the owner\'s, or is not 400) — re-evaluate whether POST on a resource became a write path');
|
||||
}
|
||||
}
|
||||
|
||||
async function profilePrivacy(): Promise<void> {
|
||||
const G = 'profile privacy (negative, with control)';
|
||||
await m.hard('profile.alice-own', G, 'CONTROL: Alice GET her own profile document (her token)', '200 (the URI is right; refusals below are WAC)', async () => {
|
||||
const r = await http('GET', need(S.aliceProfile, "Alice's profile URI (actor `url`)"), { token: need(S.alice, 'Alice').token });
|
||||
return { ok: r.status === 200, actual: `HTTP ${r.status} ${S.aliceProfile}` };
|
||||
});
|
||||
await m.hard('profile.alice-reads-bob', G, "Alice GET Bob's profile document (her token)", 'refused 403', async () => {
|
||||
const r = await http('GET', need(S.bobProfile, "Bob's profile URI"), { token: need(S.alice, 'Alice').token });
|
||||
return { ok: refused(r.status, AUTHN), actual: `${brief(r).slice(0, 60)} ${S.bobProfile}` };
|
||||
});
|
||||
await m.hard('profile.bob-reads-alice-before-join', G, "Bob GET Alice's profile document BEFORE any join (his token)", 'refused 403 (nobody is a contact at start)', async () => {
|
||||
const r = await http('GET', need(S.aliceProfile, "Alice's profile URI"), { token: need(S.bob, 'Bob').token });
|
||||
return { ok: refused(r.status, AUTHN), actual: brief(r).slice(0, 60) };
|
||||
});
|
||||
await m.hard('profile.carol-reads-alice', G, "Carol GET Alice's profile document (her token)", 'refused 403', async () => {
|
||||
const r = await http('GET', need(S.aliceProfile, "Alice's profile URI"), { token: need(S.carol, 'Carol').token });
|
||||
return { ok: refused(r.status, AUTHN), actual: brief(r).slice(0, 60) };
|
||||
});
|
||||
await m.hard('profile.anon-reads-alice', G, "anonymous GET Alice's profile document", 'refused 401/403', async () => {
|
||||
const r = await http('GET', need(S.aliceProfile, "Alice's profile URI"));
|
||||
return { ok: refused(r.status, ANON), actual: brief(r).slice(0, 60) };
|
||||
});
|
||||
}
|
||||
|
||||
async function catalogAndIntent(): Promise<void> {
|
||||
const G = 'catalog & public intent';
|
||||
const listed = (id: string, name: string, target: () => string) => m.hard(id, G, name, 'listed in GET /catalog within 30 s', async () => {
|
||||
const uri = target();
|
||||
const c = await poll(() => catalogIds(), v => v.ids.includes(uri), 30000);
|
||||
return { ok: c.ids.includes(uri), actual: `${c.ids.includes(uri) ? 'listed' : 'NOT listed'} (catalog has ${c.ids.length} items)` };
|
||||
});
|
||||
await listed('catalog.E-listed', 'E (created through the app) is listed', () => need(S.E, 'E'));
|
||||
await listed('catalog.M-listed', 'M (created through the app) is listed', () => need(S.M, 'M'));
|
||||
await listed('catalog.M2-listed', 'M2 (created through the app) is listed', () => need(S.M2, 'M2'));
|
||||
await m.hard('catalog.M-context', G, "M's catalog item links E through as:context", 'as:context = E', async () => {
|
||||
const c = await catalogIds();
|
||||
const item = c.items.find(i => recordUri(i) === need(S.M, 'M'));
|
||||
return { ok: !!item && ctxLink(item) === need(S.E, 'E'), actual: `as:context = ${item ? ctxLink(item) : '(M not in catalog)'}` };
|
||||
});
|
||||
await listed('catalog.T-listed', 'CONTROL: twin T (direct POST, WITH public intent) is listed', () => need(S.T, 'twin T'));
|
||||
await m.hard('read.anon-T', G, 'CONTROL: anonymous GET twin T', '200', async () => {
|
||||
const r = await http('GET', need(S.T, 'twin T'));
|
||||
return { ok: r.status === 200, actual: `HTTP ${r.status}` };
|
||||
});
|
||||
await m.hard('private.bob-reads-P', G, 'CONTROL: Bob GET P (his token) — P exists', '200', async () => {
|
||||
const r = await http('GET', need(S.P, 'private P'), { token: need(S.bob, 'Bob').token });
|
||||
return { ok: r.status === 200, actual: `HTTP ${r.status}` };
|
||||
});
|
||||
await m.hard('private.anon-P', G, 'anonymous GET P (no public intent)', 'refused 401/403', async () => {
|
||||
const r = await http('GET', need(S.P, 'private P'));
|
||||
return { ok: refused(r.status, ANON), actual: brief(r).slice(0, 80) };
|
||||
});
|
||||
await m.hard('private.P-not-in-catalog', G, 'P is never listed in GET /catalog', 'absent once its later twin T is listed, and 5 s after', async () => {
|
||||
const P = need(S.P, 'private P');
|
||||
const T = need(S.T, 'twin T');
|
||||
const settled = await poll(() => catalogIds(), v => v.ids.includes(T), 30000);
|
||||
if (!settled.ids.includes(T)) throw new Error('the window never settled: twin T was not listed either');
|
||||
await sleep(5000);
|
||||
const c = await catalogIds();
|
||||
return { ok: !c.ids.includes(P) && !c.raw.includes(TITLE_P), actual: c.ids.includes(P) ? 'LISTED' : c.raw.includes(TITLE_P) ? 'title present in catalog body' : 'absent' };
|
||||
});
|
||||
}
|
||||
|
||||
async function discoveryUi(): Promise<void> {
|
||||
const G = 'discovery (Alice, UI)';
|
||||
let text = '';
|
||||
await m.hard('discover.E-listed', G, "Alice's Découvrir (/events) lists Bob's E (title match)", `"${TITLE_E}" shown`, async () => {
|
||||
const page = need(S.alicePage, "Alice's signed-in page");
|
||||
await page.goto(`${APP}/events`, { waitUntil: 'domcontentloaded' });
|
||||
text = await poll(() => bodyText(page), t => t.includes(TITLE_E) && t.includes(TITLE_T), 30000, 1000);
|
||||
return { ok: text.includes(TITLE_E), actual: text.includes(TITLE_E) ? 'shown' : `absent; page: ${text.slice(0, 160)}` };
|
||||
});
|
||||
await m.hard('discover.T-listed', G, "CONTROL: Alice's Découvrir lists the public twin T", `"${TITLE_T}" shown`, async () =>
|
||||
({ ok: text.includes(TITLE_T), actual: text.includes(TITLE_T) ? 'shown' : 'absent' }));
|
||||
await m.hard('discover.P-absent', G, "Alice's Découvrir never shows Bob's private P", `"${TITLE_P}" absent (same render that shows E and T)`, async () => {
|
||||
if (!text.includes(TITLE_E) || !text.includes(TITLE_T)) throw new Error('prerequisite missing: a Découvrir render showing E and T');
|
||||
return { ok: !text.includes(TITLE_P), actual: text.includes(TITLE_P) ? 'SHOWN' : 'absent' };
|
||||
});
|
||||
try {
|
||||
const c = await catalogIds();
|
||||
const ours = new Set([S.E, S.M, S.T].filter(Boolean));
|
||||
m.info('discover.fresh-user-empty', G, 'a brand-new user\'s Découvrir is empty of others\' events (sanity)',
|
||||
`not applicable on a shared stack: the catalog already holds ${c.ids.filter(i => !ours.has(i)).length} items from other users`);
|
||||
} catch (e) {
|
||||
m.info('discover.fresh-user-empty', G, 'a brand-new user\'s Découvrir is empty of others\' events (sanity)', `catalog unreadable: ${String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Tap a meeting point's toggle on the detail page and wait for its card to show `after`. */
|
||||
async function tapMeetingPoint(page: Page, title: string, before: RegExp, after: RegExp): Promise<CheckOutcome> {
|
||||
const card = mpCard(page, title);
|
||||
await card.getByRole('button', { name: before }).click({ timeout: 10000 });
|
||||
const ok = await card.getByRole('button', { name: after }).waitFor({ state: 'visible', timeout: 15000 }).then(() => true, () => false);
|
||||
return { ok, actual: ok ? `card shows ${after.source}` : `no ${after.source} on the card within 15 s` };
|
||||
}
|
||||
|
||||
/** Poll a collection (Alice's token) until it does / does not hold Alice; any non-200 read keeps the line failing. */
|
||||
async function aliceIn(collection: string, wanted: boolean): Promise<{ ok: boolean; status: number; items: string[] }> {
|
||||
const alice = need(S.alice, 'Alice');
|
||||
const a = await poll(() => attendees(collection, alice.token), v => v.status === 200 && v.items.includes(alice.webId) === wanted, 30000);
|
||||
return { ok: a.status === 200 && a.items.includes(alice.webId) === wanted, status: a.status, items: a.items };
|
||||
}
|
||||
|
||||
async function join(): Promise<void> {
|
||||
const G = 'join (Alice, UI)';
|
||||
await m.hard('join.open-E', G, 'Alice taps E in Découvrir → its detail renders', 'detail route of E, title shown, M and M2 listed', async () => {
|
||||
const page = need(S.alicePage, "Alice's signed-in page");
|
||||
const E = need(S.E, 'E');
|
||||
await page.getByText(TITLE_E, { exact: true }).first().click({ timeout: 10000 });
|
||||
const t = await poll(() => bodyText(page), x => x.includes(TITLE_E) && x.includes(TITLE_M) && x.includes(TITLE_M2) && /👥\s*\d+\s+inscrit/i.test(x), 30000);
|
||||
const onRoute = decodeURIComponent(new URL(page.url()).pathname).endsWith(`/events/${E}`);
|
||||
return { ok: onRoute && t.includes(TITLE_E) && t.includes(TITLE_M) && t.includes(TITLE_M2), actual: `route ok: ${onRoute}; title: ${t.includes(TITLE_E)}; M: ${t.includes(TITLE_M)}; M2: ${t.includes(TITLE_M2)}${t.includes('non trouvé') ? '; "non trouvé" shown' : ''}` };
|
||||
});
|
||||
await m.hard('join.before', G, "Before joining, Alice's view counts nobody (fresh event)", 'PARTICIPANTS (0), M 0 inscrit, M2 0 inscrit', async () => {
|
||||
const c = await poll(() => detailCounts(need(S.alicePage, "Alice's page")), v => v.participants !== undefined && v.mp !== undefined && v.mp2 !== undefined, 15000);
|
||||
if (c.participants !== undefined && c.mp !== undefined && c.mp2 !== undefined) S.before = { participants: c.participants, mp: c.mp, mp2: c.mp2 };
|
||||
return { ok: c.participants === 0 && c.mp === 0 && c.mp2 === 0, actual: showCounts(c) };
|
||||
});
|
||||
await m.hard('join.tap', G, "Alice taps S'inscrire on M", "M's card turns ✓ Inscrit", async () =>
|
||||
tapMeetingPoint(need(S.alicePage, "Alice's page"), TITLE_M, /^S'inscrire$/, /✓ Inscrit/));
|
||||
await m.hard('join.collection', G, "Alice's WebID appears in M's attendees items", 'items ∋ Alice within 30 s', async () => {
|
||||
const r = await aliceIn(need(S.A, 'attendees of M'), true);
|
||||
if (r.ok) S.joined.M = true;
|
||||
return { ok: r.ok, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` };
|
||||
});
|
||||
await m.hard('join.ui-counts', G, "Alice's view increments by 1 (event header and M; M2 unchanged)", 'PARTICIPANTS (1), M 1 inscrit, M2 0 inscrit', async () => {
|
||||
need(S.joined.M, 'the join on M landed (join.collection)');
|
||||
const page = need(S.alicePage, "Alice's page");
|
||||
const c = await poll(() => detailCounts(page), v => v.participants === 1 && v.mp === 1 && v.mp2 === 0, 20000);
|
||||
return { ok: c.participants === 1 && c.mp === 1 && c.mp2 === 0, actual: showCounts(c) };
|
||||
});
|
||||
await m.hard('join.tap-2', G, "Alice ALSO taps S'inscrire on M2 (one person in both meeting points)", "M2's card turns ✓ Inscrit", async () =>
|
||||
tapMeetingPoint(need(S.alicePage, "Alice's page"), TITLE_M2, /^S'inscrire$/, /✓ Inscrit/));
|
||||
await m.hard('join.collection-2', G, "Alice's WebID appears in M2's attendees items", 'items ∋ Alice within 30 s', async () => {
|
||||
const r = await aliceIn(need(S.A2, 'attendees of M2'), true);
|
||||
if (r.ok) S.joined.M2 = true;
|
||||
return { ok: r.ok, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` };
|
||||
});
|
||||
await m.hard('join.ui-counts-2', G, "Alice's view: the event counts her in each meeting point", 'PARTICIPANTS (2), M 1 inscrit, M2 1 inscrit', async () => {
|
||||
need(S.joined.M && S.joined.M2, 'both joins landed (join.collection, join.collection-2)');
|
||||
const page = need(S.alicePage, "Alice's page");
|
||||
const c = await poll(() => detailCounts(page), v => v.participants === 2 && v.mp === 1 && v.mp2 === 1, 20000);
|
||||
return { ok: c.participants === 2 && c.mp === 1 && c.mp2 === 1, actual: showCounts(c) };
|
||||
});
|
||||
await m.hard('join.bob-resolves-alice', G, "Bob can resolve Alice's name after her join (Bob's token on her profile)", "200 — the join grants the organizer contact-read", async () => {
|
||||
need(S.joined.M, 'the join on M landed (join.collection)');
|
||||
const bob = need(S.bob, 'Bob');
|
||||
const r = await poll(() => http('GET', need(S.aliceProfile, "Alice's profile URI"), { token: bob.token }), v => v.status === 200, 20000, 2000);
|
||||
return { ok: r.status === 200, actual: `${brief(r).slice(0, 120)} (polled 20 s)`, evidence: `GET ${S.aliceProfile} with Bob's token` };
|
||||
}, { finding: "PRODUCT-FINDING: joining a meeting point does not grant its organizer read access on the joiner's profile — the organizer cannot resolve who joined" });
|
||||
await m.hard('join.carol-cannot-resolve-alice', G, "Carol cannot resolve Alice's name after the join (Carol's token on her profile)", 'refused 403', async () => {
|
||||
need(S.joined.M, 'the join on M landed (join.collection)');
|
||||
const r = await http('GET', need(S.aliceProfile, "Alice's profile URI"), { token: need(S.carol, 'Carol').token });
|
||||
return { ok: refused(r.status, AUTHN), actual: brief(r).slice(0, 80) };
|
||||
});
|
||||
}
|
||||
|
||||
async function counterSemantics(): Promise<void> {
|
||||
const G = 'counter semantics';
|
||||
await m.hard('counter.sum', G,
|
||||
"E's counter is the SUM of its meeting points' item counts — Alice in both M and M2 counts twice (sum 2; max, first or distinct would give 1)",
|
||||
"data: M=[Alice], M2=[Alice], sum 2; Alice's view PARTICIPANTS (2)", async () => {
|
||||
need(S.joined.M && S.joined.M2, 'both joins landed (join.collection, join.collection-2)');
|
||||
const alice = need(S.alice, 'Alice').webId;
|
||||
const d = await summedCounter();
|
||||
const inM = d.per.get(need(S.M, 'M'))?.includes(alice) ?? false;
|
||||
const inM2 = d.per.get(need(S.M2, 'M2'))?.includes(alice) ?? false;
|
||||
const ui = await poll(() => detailCounts(need(S.alicePage, "Alice's page")), v => v.participants === 2, 15000);
|
||||
const parts = [...d.per].map(([u, it]) => `${u.slice(-12)}:${it.length}`).join(', ');
|
||||
return { ok: d.mps.length === 2 && inM && inM2 && d.sum === 2 && ui.participants === 2, actual: `MPs=${d.mps.length} [${parts}] Alice in M: ${inM}, in M2: ${inM2}; sum=${d.sum}; Alice's UI PARTICIPANTS (${ui.participants})` };
|
||||
});
|
||||
await m.hard('counter.non-contact-total', G,
|
||||
"A non-contact (Carol) is shown the TOTAL in her own view, not the count of profiles she can resolve",
|
||||
"Carol's view of E: PARTICIPANTS (2), M 1 inscrit, M2 1 inscrit — while Alice's profile is refused to her (resolvable: 0)", async () => {
|
||||
need(S.joined.M && S.joined.M2, 'both joins landed (join.collection, join.collection-2)');
|
||||
const page = need(S.carolPage, "Carol's signed-in page");
|
||||
await page.goto(`${APP}/events/${encodeURIComponent(need(S.E, 'E'))}`, { waitUntil: 'domcontentloaded' });
|
||||
const c = await poll(() => detailCounts(page), v => v.participants === 2 && v.mp === 1 && v.mp2 === 1, 30000);
|
||||
const p = await http('GET', need(S.aliceProfile, "Alice's profile"), { token: need(S.carol, 'Carol').token });
|
||||
const anonymousRows = (c.text.match(/Participant inconnu/g) ?? []).length;
|
||||
return { ok: c.participants === 2 && c.mp === 1 && c.mp2 === 1 && refused(p.status, AUTHN), actual: `Carol's UI: ${showCounts(c)}; anonymous rows shown: ${anonymousRows}; Alice's profile for Carol → HTTP ${p.status}` };
|
||||
});
|
||||
}
|
||||
|
||||
async function isolation(): Promise<void> {
|
||||
const G = 'isolation (negative)';
|
||||
await m.hard('isolation.alice-own-pod', G, "Alice's own as/event container (her token) does not contain Bob's E or M — cross-user visibility comes only through the catalog", '200, neither E nor M in it', async () => {
|
||||
const alice = need(S.alice, 'Alice');
|
||||
const r = await http('GET', `${alice.storage}/as/event`, { token: alice.token });
|
||||
const rec = parseJson(r.text);
|
||||
const members = arrayOf(rec?.['ldp:contains'] ?? rec?.['contains']).map(linkId).filter((u): u is string => !!u);
|
||||
const E = need(S.E, 'E');
|
||||
const M = need(S.M, 'M');
|
||||
const leaked = r.text.includes(E) || r.text.includes(M);
|
||||
return { ok: r.status === 200 && !!rec && !leaked, actual: `HTTP ${r.status}; ${members.length} member(s); E/M ${leaked ? 'PRESENT' : 'absent'}` };
|
||||
});
|
||||
}
|
||||
|
||||
async function catalogDeletion(): Promise<void> {
|
||||
const G = 'catalog deletion';
|
||||
m.manual('manual.forged-delete', G,
|
||||
"A user posting to HER outbox a Delete that embeds a Tombstone of another user's event must not de-index it",
|
||||
'NOT exercised live (defensive only, user decision 2026-10-13: no forged activity is sent). Covered by backend unit tests — '
|
||||
+ 'T05.h catalog.service ownership check (object must lie under the emitting outbox\'s pod) and T05.i verifiedEmit (activity and object re-read from the pod) — '
|
||||
+ 'plus a manual check recorded in the handoff.');
|
||||
await m.hard('catalog.owner-delete-deindexes', G, 'Bob deletes his own public twin T → T leaves /catalog', 'owner DELETE 2xx, then T absent from /catalog within 30 s', async () => {
|
||||
const T = need(S.T, 'twin T');
|
||||
const del = await http('DELETE', T, { token: need(S.bob, 'Bob').token });
|
||||
const c = await poll(() => catalogIds(), v => !v.ids.includes(T), 30000);
|
||||
return { ok: del.status >= 200 && del.status < 300 && !c.ids.includes(T), actual: `DELETE → HTTP ${del.status}; T ${c.ids.includes(T) ? 'still listed' : 'de-indexed'}` };
|
||||
});
|
||||
}
|
||||
|
||||
async function leave(): Promise<void> {
|
||||
const G = 'leave (Alice, UI)';
|
||||
await m.hard('leave.tap', G, 'Alice taps ✓ Inscrit on M (leave)', "M's card turns back to S'inscrire", async () => {
|
||||
need(S.joined.M, 'the join on M landed (join.collection) — a leave without a join proves nothing');
|
||||
return tapMeetingPoint(need(S.alicePage, "Alice's page"), TITLE_M, /✓ Inscrit/, /^S'inscrire$/);
|
||||
});
|
||||
await m.hard('leave.collection', G, "Alice's WebID disappears from M's attendees items", 'items ∌ Alice within 30 s (read 200)', async () => {
|
||||
need(S.joined.M, 'the join on M landed (join.collection)');
|
||||
const r = await aliceIn(need(S.A, 'attendees of M'), false);
|
||||
return { ok: r.ok, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` };
|
||||
});
|
||||
await m.hard('leave.ui-counts', G, "Alice's view decrements: she is now counted in M2 only", 'PARTICIPANTS (1), M 0 inscrit, M2 1 inscrit', async () => {
|
||||
need(S.joined.M && S.joined.M2, 'both joins landed');
|
||||
const page = need(S.alicePage, "Alice's page");
|
||||
const c = await poll(() => detailCounts(page), v => v.participants === 1 && v.mp === 0 && v.mp2 === 1, 20000);
|
||||
return { ok: c.participants === 1 && c.mp === 0 && c.mp2 === 1, actual: showCounts(c) };
|
||||
});
|
||||
await m.hard('leave.tap-2', G, 'Alice taps ✓ Inscrit on M2 (leave)', "M2's card turns back to S'inscrire", async () => {
|
||||
need(S.joined.M2, 'the join on M2 landed (join.collection-2) — a leave without a join proves nothing');
|
||||
return tapMeetingPoint(need(S.alicePage, "Alice's page"), TITLE_M2, /✓ Inscrit/, /^S'inscrire$/);
|
||||
});
|
||||
await m.hard('leave.collection-2', G, "Alice's WebID disappears from M2's attendees items", 'items ∌ Alice within 30 s (read 200)', async () => {
|
||||
need(S.joined.M2, 'the join on M2 landed (join.collection-2)');
|
||||
const r = await aliceIn(need(S.A2, 'attendees of M2'), false);
|
||||
return { ok: r.ok, actual: `HTTP ${r.status} items=${JSON.stringify(r.items)}` };
|
||||
});
|
||||
await m.hard('leave.ui-counts-2', G, "Alice's view is back to its pre-join counts", 'PARTICIPANTS (0), M 0 inscrit, M2 0 inscrit (= join.before)', async () => {
|
||||
need(S.joined.M && S.joined.M2, 'both joins landed');
|
||||
const before = need(S.before, 'pre-join counts');
|
||||
const page = need(S.alicePage, "Alice's page");
|
||||
const same = (v: { participants?: number; mp?: number; mp2?: number }): boolean => v.participants === before.participants && v.mp === before.mp && v.mp2 === before.mp2;
|
||||
const c = await poll(() => detailCounts(page), same, 20000);
|
||||
return { ok: same(c), actual: showCounts(c) };
|
||||
});
|
||||
await m.hard('leave.sum', G, "E's summed counter is back to its pre-join value (every collection read 200)", 'sum of M and M2 items = 0', async () => {
|
||||
need(S.joined.M && S.joined.M2, 'both joins landed');
|
||||
const d = await summedCounter();
|
||||
return { ok: d.mps.length === 2 && d.sum === need(S.before, 'pre-join counts').participants, actual: `MPs=${d.mps.length} sum=${d.sum}` };
|
||||
});
|
||||
}
|
||||
|
||||
async function deletesRefused(): Promise<void> {
|
||||
const G = 'deletes (negative)';
|
||||
await refusedWrite('delete.anon-E', 'anonymous DELETE E', 'DELETE', 'anonymous', () => need(S.E, 'E'), G);
|
||||
await refusedWrite('delete.alice-E', 'Alice DELETE E', 'DELETE', 'alice', () => need(S.E, 'E'), G);
|
||||
await refusedWrite('delete.alice-M', 'Alice DELETE M', 'DELETE', 'alice', () => need(S.M, 'M'), G);
|
||||
await refusedWrite('delete.alice-attendees', "Alice DELETE M's attendees collection", 'DELETE', 'alice', () => need(S.A, 'attendees'), G);
|
||||
await refusedWrite('delete.alice-bob-profile', "Alice DELETE Bob's profile document", 'DELETE', 'alice', () => need(S.bobProfile, "Bob's profile"), G);
|
||||
await m.hard('delete.still-there', G, "E, M, M's attendees and Bob's profile all still exist (read by Bob)", 'all 200', async () => {
|
||||
const bob = need(S.bob, 'Bob');
|
||||
const uris = [need(S.E, 'E'), need(S.M, 'M'), need(S.A, 'attendees'), need(S.bobProfile, "Bob's profile")];
|
||||
const st = await Promise.all(uris.map(u => http('GET', u, { token: bob.token }).then(r => r.status)));
|
||||
return { ok: st.every(s => s === 200), actual: st.join(' / ') };
|
||||
});
|
||||
}
|
||||
|
||||
async function tracked(): Promise<void> {
|
||||
const G = 'tracked platform behaviours (non-fatal)';
|
||||
await m.known('known-deviation', 'deviation.anon-attendees-read', G,
|
||||
"anonymous GET M's attendees (doctrine: participation is PROTECTED; platform default: world-readable)",
|
||||
'doctrine: refused; tracked as world-readable (user decision 2026-10-12)', async () => {
|
||||
const r = await http('GET', need(S.A, 'attendees'));
|
||||
const n = r.status === 200 ? itemUris(parseJson(r.text)).length : 0;
|
||||
if (r.status === 200) return { ok: true, actual: `HTTP 200 — world-readable (items.length=${n}; members are WebIDs, which embed usernames)` };
|
||||
if (refused(r.status, ANON)) return { ok: true, actual: `HTTP ${r.status} — refused`, notice: 'the attendee list is no longer world-readable: the deviation is gone — promote this line to a hard negative assertion' };
|
||||
return { ok: false, actual: brief(r), notice: 'neither world-readable nor an explicit refusal — investigate before trusting this line' };
|
||||
});
|
||||
|
||||
await m.known('known-defect', 'defect.anon-attendees-patch', G,
|
||||
"anonymous PATCH on M's attendees collection (its only foaf:Agent rule is acl:Read)",
|
||||
'should be refused 401/403; tracked platform defect: accepted (204)', async () => {
|
||||
// DEFENSIVE ONLY: the probe is a triple on a test predicate, which the
|
||||
// collection accepts and silently drops (VERIFIED 2026-10-12: 204, nothing
|
||||
// written) — no member is spoofed. The status alone is the tripwire: a WAC
|
||||
// refusal would answer 401/403. That an anonymous `as:items` insert LANDS
|
||||
// was verified once on 2026-10-12 and is not re-exercised live.
|
||||
const A = need(S.A, 'attendees');
|
||||
const bob = need(S.bob, 'Bob');
|
||||
const marker = `anon-patch-tripwire-${TS}`;
|
||||
const before = itemUris(parseJson((await http('GET', A, { token: bob.token })).text));
|
||||
const r = await http('PATCH', A, { body: `INSERT DATA { <${A}> <http://festipod.org/ns/test#anonymousPatchTripwire> "${marker}" . }`, contentType: 'application/sparql-update' });
|
||||
const afterRead = await http('GET', A, { token: bob.token });
|
||||
const after = itemUris(parseJson(afterRead.text));
|
||||
const wrote = afterRead.text.includes(marker) || after.length !== before.length;
|
||||
const actual = `HTTP ${r.status}; collection ${wrote ? 'CHANGED' : 'unchanged'} (items ${before.length} → ${after.length})`;
|
||||
if (r.status >= 200 && r.status < 300 && !wrote) return { ok: true, actual };
|
||||
if (refused(r.status, ANON)) return { ok: true, actual, notice: 'anonymous PATCH on an attendees collection is now REFUSED — the platform defect looks fixed: promote this line to a hard negative assertion (refused 401/403)' };
|
||||
return { ok: false, actual, notice: `behaviour changed (HTTP ${r.status}, collection ${wrote ? 'changed' : 'unchanged'}) — neither the tracked acceptance nor a refusal: investigate, then decide whether to promote` };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend's public surface: the allow-list of `backend/services/public-surface.js`
|
||||
* (T05.i). Anonymous GETs, STATUS CODES ONLY — no body is ever printed or kept.
|
||||
*/
|
||||
async function publicSurface(): Promise<void> {
|
||||
const G = 'backend public surface (anonymous GET, status codes only)';
|
||||
const status = async (path: string): Promise<number> => (await http('GET', `${BACKEND}${path}`)).status;
|
||||
const pathOf = (uri: string | undefined, what: string): string => {
|
||||
const u = need(uri, what);
|
||||
if (!u.startsWith(`${BACKEND}/`)) throw new Error(`${what} is not on the backend (${u})`);
|
||||
return u.slice(BACKEND.length);
|
||||
};
|
||||
const shown = (path: string): string => path.replace(/\/[A-Za-z0-9_-]{16,}$/, '/<id>');
|
||||
await m.hard('surface.public-200', G,
|
||||
'CONTROL: every allow-listed public endpoint answers an anonymous GET',
|
||||
'200 each: /app, /as/actor/app, /catalog, /public-key/<id>, /interop/access-need-group/<id>, /interop/access-need/<id>, /.well-known/context.jsonld, /.well-known/nodeinfo, /nodeinfo/2.1',
|
||||
async () => {
|
||||
const actor = parseJson((await http('GET', `${BACKEND}/as/actor/app`)).text);
|
||||
const app = parseJson((await http('GET', `${BACKEND}/app`)).text);
|
||||
const groupPath = pathOf(linkId(app?.['interop:hasAccessNeedGroup']), 'the access-need group (GET /app)');
|
||||
const group = parseJson((await http('GET', `${BACKEND}${groupPath}`)).text);
|
||||
const paths = [
|
||||
'/app', '/as/actor/app', '/catalog',
|
||||
pathOf(linkId(actor?.['publicKey']), 'the app actor\'s public key (GET /as/actor/app)'),
|
||||
groupPath,
|
||||
pathOf(linkId(arrayOf(group?.['interop:hasAccessNeed'])[0]), 'an access need (the access-need group)'),
|
||||
'/.well-known/context.jsonld', '/.well-known/nodeinfo', '/nodeinfo/2.1',
|
||||
];
|
||||
const codes = await Promise.all(paths.map(status));
|
||||
return { ok: codes.every(c => c === 200), actual: paths.map((p, i) => `${shown(p)} ${codes[i]}`).join(', ') };
|
||||
});
|
||||
const webfinger = `/.well-known/webfinger?resource=${encodeURIComponent(`acct:app@${new URL(BACKEND).host}`)}`;
|
||||
const refusedPaths = [
|
||||
'/', '/key', '/sparql', '/sparql?query=ASK%7B%7D', '/settings', '/proxy', '/_acl', '/.webhooks',
|
||||
'/interop/application-registration', '/interop/access-grant', '/interop/data-grant',
|
||||
'/as/actor/app/outbox', '/as/actor/app/followers', webfinger,
|
||||
];
|
||||
await m.hard('surface.non-public-404', G,
|
||||
'anonymous GET on non-public backend paths (private keys, SPARQL, listings, registrations and grants, the app actor\'s collections, webfinger)',
|
||||
'404 each — the gate\'s refusal for every caller (control: surface.public-200 on the same host)', async () => {
|
||||
const codes = await Promise.all(refusedPaths.map(status));
|
||||
return { ok: codes.every(c => c === 404), actual: refusedPaths.map((p, i) => `${p} ${codes[i]}`).join(', ') };
|
||||
});
|
||||
m.manual('manual.surface-writes', G,
|
||||
'writes on the backend outside its two delivery endpoints are refused (404), and a crafted delivery to POST /as/actor/app/inbox or /.webhooks/<id> is never trusted',
|
||||
'NOT exercised live (read-only sweeps only, user decision 2026-10-13). Covered by backend unit tests — '
|
||||
+ 'public-surface.test.js (every write outside the two delivery endpoints refused; the LDP catch-all behind the gate) and '
|
||||
+ 'emitted-activity.test.js (activity and object re-read from the pod before any decision).');
|
||||
}
|
||||
|
||||
/** Bob's view of a resource's attendees collection link, polled (the backend attaches it asynchronously). */
|
||||
async function collectionOf(uri: string): Promise<string | undefined> {
|
||||
const bob = need(S.bob, 'Bob account');
|
||||
return poll(async () => attendeesCollectionUri(parseJson((await http('GET', uri, { token: bob.token })).text) ?? {}), v => !!v, 30000);
|
||||
}
|
||||
|
||||
/** Bob POSTs an event the way Festipod creates one (container POST, `to: as:Public`). */
|
||||
async function postPublicEvent(name: string, extra: Record<string, unknown> = {}): Promise<{ uri?: string; status: number }> {
|
||||
const bob = need(S.bob, 'Bob account');
|
||||
const r = await http('POST', `${bob.storage}/as/event`, {
|
||||
token: bob.token, contentType: 'application/ld+json',
|
||||
body: JSON.stringify({ '@context': PLATFORM_CONTEXT, type: 'Event', name, startTime: '2026-12-22T18:00:00Z', endTime: '2026-12-22T22:00:00Z', to: AS_PUBLIC, ...extra }),
|
||||
});
|
||||
return { uri: r.headers.get('Location') ?? undefined, status: r.status };
|
||||
}
|
||||
|
||||
async function visibility(): Promise<void> {
|
||||
const G = 'visibility follows the owner (V + VM + participant)';
|
||||
await m.hard('visibility.setup-V', G, `Bob creates event V "${TITLE_V}" and its meeting point VM "${TITLE_VM}" as Festipod does (container POST, \`to: as:Public\`)`, '201 + Location for each', async () => {
|
||||
const v = await postPublicEvent(TITLE_V);
|
||||
S.V = v.uri;
|
||||
const vm = S.V ? await postPublicEvent(TITLE_VM, { 'as:context': S.V }) : { status: 0, uri: undefined };
|
||||
S.VM = vm.uri;
|
||||
return { ok: !!S.V && !!S.VM, actual: `V: HTTP ${v.status} → ${S.V ?? '(no Location)'}; VM: HTTP ${vm.status} → ${S.VM ?? '(no Location)'}` };
|
||||
});
|
||||
await m.hard('visibility.setup-collections', G, 'V and VM each carry their apods:attendees collection', 'two distinct collection URIs within 30 s', async () => {
|
||||
S.AV = await collectionOf(need(S.V, 'event V'));
|
||||
S.AVM = await collectionOf(need(S.VM, 'meeting point VM'));
|
||||
return { ok: !!S.AV && !!S.AVM && S.AV !== S.AVM, actual: `V: ${S.AV ?? '(none)'}; VM: ${S.AVM ?? '(none)'}` };
|
||||
});
|
||||
await m.hard('visibility.setup-join', G, "Carol joins VM through her own outbox (Join addressed to Bob, as the app does)", "Carol's WebID in VM's attendees items within 30 s (read by Bob)", async () => {
|
||||
const carol = need(S.carol, 'Carol account');
|
||||
const bob = need(S.bob, 'Bob account');
|
||||
const AVM = need(S.AVM, "VM's attendees collection");
|
||||
await postOutboxActivity(carol, 'Join', need(S.VM, 'meeting point VM'), bob.webId);
|
||||
const a = await poll(() => attendees(AVM, bob.token), v => v.status === 200 && v.items.includes(carol.webId), 30000);
|
||||
S.carolInVM = a.status === 200 && a.items.includes(carol.webId);
|
||||
return { ok: S.carolInVM, actual: `HTTP ${a.status} items=${JSON.stringify(a.items)}` };
|
||||
});
|
||||
|
||||
const AS_TO = 'https://www.w3.org/ns/activitystreams#to';
|
||||
const setPublic = async (uri: string, on: boolean): Promise<number> =>
|
||||
(await http('PATCH', uri, { token: need(S.bob, 'Bob account').token, contentType: 'application/sparql-update', body: `${on ? 'INSERT' : 'DELETE'} DATA { <${uri}> <${AS_TO}> <${AS_PUBLIC}> . }` })).status;
|
||||
|
||||
type Read = { label: string; uri: string; anon: number; bob?: number };
|
||||
/** One observation of the four resources: anonymous status, plus Bob's for the collections (the 404 control). */
|
||||
const observe = async (): Promise<Read[]> => {
|
||||
const bob = need(S.bob, 'Bob account');
|
||||
const targets: Array<[string, string, boolean]> = [
|
||||
['V', need(S.V, 'event V'), false], ['VM', need(S.VM, 'meeting point VM'), false],
|
||||
["VM's collection", need(S.AVM, "VM's attendees collection"), true], ["V's collection", need(S.AV, "V's attendees collection"), true],
|
||||
];
|
||||
return Promise.all(targets.map(async ([label, uri, isCollection]) => ({
|
||||
label, uri,
|
||||
anon: (await http('GET', uri)).status,
|
||||
bob: isCollection ? (await http('GET', uri, { token: bob.token })).status : undefined,
|
||||
})));
|
||||
};
|
||||
const readable = (r: Read): boolean => r.anon === 200;
|
||||
/** A refusal: 401/403; for a collection also the pod's 404, but only while Bob reads the same URI 200. */
|
||||
const closed = (r: Read): boolean => refused(r.anon, ANON) || (r.bob !== undefined && r.anon === 404 && r.bob === 200);
|
||||
const show = (rs: Read[]): string => rs.map(r => `${r.label} anon ${r.anon}${r.bob !== undefined ? ` (Bob ${r.bob})` : ''}`).join(', ');
|
||||
|
||||
const states: Array<{ id: string; label: string; on: boolean; toggle: boolean }> = [
|
||||
{ id: 'created-public', label: 'as created (public)', on: true, toggle: false },
|
||||
{ id: 'private-1', label: 'switched private', on: false, toggle: true },
|
||||
{ id: 'public-again', label: 'switched public again', on: true, toggle: true },
|
||||
{ id: 'private-2', label: 'switched private again', on: false, toggle: true },
|
||||
];
|
||||
for (const st of states) {
|
||||
if (st.toggle) {
|
||||
await m.hard(`visibility.${st.id}-update`, G, `Bob ${st.on ? 'adds' : 'removes'} \`to as:Public\` on V and on VM (PATCH, his own token)`, '2xx for both', async () => {
|
||||
const a = await setPublic(need(S.V, 'event V'), st.on);
|
||||
const b = await setPublic(need(S.VM, 'meeting point VM'), st.on);
|
||||
return { ok: a >= 200 && a < 300 && b >= 200 && b < 300, actual: `V PATCH ${a}, VM PATCH ${b}` };
|
||||
});
|
||||
}
|
||||
await m.hard(`visibility.${st.id}-reads`, G,
|
||||
`${st.label}: anonymous reads of V, VM, VM's collection, V's collection`,
|
||||
st.on ? '200 for all four within 45 s' : 'V and VM refused 401/403; each collection refused (401/403, or the pod\'s 404 while Bob reads it 200) within 45 s',
|
||||
async () => {
|
||||
const want = st.on ? readable : closed;
|
||||
const rs = await poll(observe, v => v.every(want), 45000, 1500);
|
||||
return { ok: rs.every(want), actual: show(rs) };
|
||||
});
|
||||
await m.hard(`visibility.${st.id}-catalog`, G, `${st.label}: V and VM ${st.on ? 'listed in' : 'absent from'} GET /catalog`, `${st.on ? 'both listed' : 'neither listed'} within 45 s`, async () => {
|
||||
const V = need(S.V, 'event V');
|
||||
const VM = need(S.VM, 'meeting point VM');
|
||||
const c = await poll(() => catalogIds(), v => v.ids.includes(V) === st.on && v.ids.includes(VM) === st.on, 45000, 1500);
|
||||
return { ok: c.ids.includes(V) === st.on && c.ids.includes(VM) === st.on, actual: `V ${c.ids.includes(V) ? 'listed' : 'absent'}, VM ${c.ids.includes(VM) ? 'listed' : 'absent'}` };
|
||||
});
|
||||
await m.hard(`visibility.${st.id}-member`, G, `${st.label}: Carol is still a member of VM (only the public read moved)`, "Carol's WebID in VM's items (read by Bob, 200)", async () => {
|
||||
need(S.carolInVM, 'the join on VM landed (visibility.setup-join)');
|
||||
const carol = need(S.carol, 'Carol account');
|
||||
const a = await attendees(need(S.AVM, "VM's attendees collection"), need(S.bob, 'Bob account').token);
|
||||
return { ok: a.status === 200 && a.items.includes(carol.webId), actual: `HTTP ${a.status} items=${JSON.stringify(a.items)}` };
|
||||
});
|
||||
}
|
||||
m.manual('manual.visibility-crafted', G,
|
||||
'a webhook or activity CLAIMING a visibility change (not made on the pod) never grants, revokes, lists or de-lists',
|
||||
'NOT exercised live (no forged activity, user decision 2026-10-13). Covered by backend unit tests — emitted-activity.test.js and '
|
||||
+ 'public-read.service.test.js / catalog.service.test.js (decisions taken on the object re-read from the pod, under its per-object lock).');
|
||||
}
|
||||
|
||||
async function uiHygiene(): Promise<void> {
|
||||
const G = 'UI drives';
|
||||
// Expected refusals are exact URLs: this run's profile documents, and each
|
||||
// user's OWN preferences file. Any other ≥400 is unexpected.
|
||||
const profiles = new Set([S.bobProfile, S.aliceProfile, S.carolProfile].filter((u): u is string => !!u));
|
||||
const expected = { profiles, ownPreferences: S.preferences };
|
||||
const unexpected = ui.errors.filter(e => benignUiError(e, expected) === null);
|
||||
const seen = new Map<string, number>();
|
||||
for (const e of ui.errors) {
|
||||
const label = benignUiError(e, expected);
|
||||
if (label) seen.set(label, (seen.get(label) ?? 0) + 1);
|
||||
}
|
||||
await m.hard('ui.no-unexpected-4xx', G, 'No unexpected ≥400 response during the Bob, Alice and Carol UI drives',
|
||||
'none beyond: push-channel creation 403, profile-document 403 (this run\'s profiles), own preferences-file 403', async () => {
|
||||
if (profiles.size < 3 || S.preferences.size < 3) throw new Error('prerequisite missing: the three profile and preferences-file URIs (to tell an expected 403 from any other)');
|
||||
return {
|
||||
ok: unexpected.length === 0,
|
||||
actual: unexpected.length === 0
|
||||
? `none (expected seen: ${[...seen].map(([k, n]) => `${k} ×${n}`).join(', ') || 'none'})`
|
||||
: unexpected.map(e => `[${e.who}] ${e.status} ${e.method} ${e.url} ${e.body.slice(0, 80)}`).join(' | '),
|
||||
};
|
||||
});
|
||||
const pe = splitPageErrors(ui.pageErrors, ui.errors);
|
||||
await m.hard('ui.no-page-errors', G, 'No unexpected uncaught page error during the UI drives (app and pod pages)',
|
||||
'none beyond the live provider\'s "Forbidden" from subscribe, one per refused push channel of the same user', async () => ({
|
||||
ok: pe.unexpected.length === 0,
|
||||
actual: pe.unexpected.length === 0
|
||||
? `none (expected: ${pe.expected.length} × "Forbidden" from the live provider's subscribe)`
|
||||
: pe.unexpected.map(e => `[${e.who}] at ${e.url}: ${e.message} | ${e.stack.split('\n').slice(1, 3).join(' ').trim()}`).join(' | '),
|
||||
}));
|
||||
m.info('ui.expected-page-errors', G, 'Uncaught "Forbidden" from the live provider\'s subscribe when the pod refuses its push channel (SDK side — to report upstream, never worked around here)',
|
||||
`${pe.expected.length} seen (${[...new Set(pe.expected.map(e => e.who))].join(', ') || 'none'})`);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.error(`[matrix] run ${TS}: bob=${USERS.bob} alice=${USERS.alice} carol=${USERS.carol}`);
|
||||
// No --disable-web-security: the app must work under the browser's real
|
||||
// cross-origin rules, as it does for a user.
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
await publicSurface();
|
||||
await setup(browser);
|
||||
await publicReads();
|
||||
await writesRefused();
|
||||
await profilePrivacy();
|
||||
await catalogAndIntent();
|
||||
await discoveryUi();
|
||||
await join();
|
||||
await counterSemantics();
|
||||
await isolation();
|
||||
await catalogDeletion();
|
||||
await leave();
|
||||
await deletesRefused();
|
||||
await tracked();
|
||||
await visibility();
|
||||
} finally {
|
||||
await uiHygiene();
|
||||
await browser.close().catch(() => {});
|
||||
}
|
||||
const file = joinPath(tmpdir(), `rights-matrix-${TS}.json`);
|
||||
writeFileSync(file, JSON.stringify({ run: TS, users: USERS, ids: { E: S.E, M: S.M, A: S.A, M2: S.M2, A2: S.A2, P: S.P, T: S.T, V: S.V, VM: S.VM, AV: S.AV, AVM: S.AVM }, lines: m.lines, hops: ui.hops, uiErrors: ui.errors, pageErrors: ui.pageErrors, consoleErrors: ui.consoleErrors }, null, 2));
|
||||
m.print(`run ${TS} (bob=${USERS.bob}, alice=${USERS.alice}, carol=${USERS.carol}) — JSON: ${file}`);
|
||||
process.exit(m.hardFailures.length === 0 ? 0 : 1);
|
||||
})().catch(err => {
|
||||
m.print(`ABORTED: ${String(err).slice(0, 300)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
+139
-17
@@ -2,8 +2,8 @@
|
||||
/**
|
||||
* validate.ts — the Festipod validation matrix.
|
||||
*
|
||||
* SCOPE (2026-10-09, after the ActivityPods-first switch). Two layers are in
|
||||
* the gate:
|
||||
* SCOPE (2026-10-09, after the ActivityPods-first switch; e2e probes added
|
||||
* 2026-10-13). Four layers are in the gate:
|
||||
*
|
||||
* (a) Festipod @ui — the UI layer. The `@ui` tag does not exist in this repo;
|
||||
* the layer is exactly the complement of the polyfill/broker tags, and its
|
||||
@@ -23,6 +23,25 @@
|
||||
* Requires the stack to be UP (the app server and Chromium are started
|
||||
* here); an unreachable stack fails the layer rather than skipping it.
|
||||
*
|
||||
* (c) Festipod e2e rights matrix — `bun run test:e2e-rights`: three fresh
|
||||
* users plus anonymous, every feature with positive AND negative access
|
||||
* lines (public surface, public reads, refused writes, profile privacy,
|
||||
* catalog and public intent, discovery, join/leave and counters,
|
||||
* visibility toggles). Exit 0 only when every hard line holds.
|
||||
*
|
||||
* (d) Festipod e2e revocation — `bun run test:e2e-revocation`: the app
|
||||
* notices its registration being revoked on the pod, logs the user out,
|
||||
* and recovers through consent.
|
||||
*
|
||||
* (c) and (d) drive the browser through the pod's login and consent, so
|
||||
* they need the WHOLE local stack, the app served at APP_URL included
|
||||
* (the OIDC redirect_uri is registered for that origin only). They are
|
||||
* STACK-GATED: the stack is probed first; when any part is unreachable
|
||||
* both are SKIPPED — loudly, the unreachable URL named in the matrix and
|
||||
* in the verdict, never silently — and when it is up they run, and a
|
||||
* non-zero exit fails the layer. A fully-down stack still fails
|
||||
* `validate` through (b), which never skips.
|
||||
*
|
||||
* SUSPENDED, NOT RUN HERE (documented skip, code preserved — nothing deleted):
|
||||
* - `@data`, `@e2e`, `@multibrowser`, `@shared-wallet`, `@smoke` scenarios are
|
||||
* polyfill/broker-bound: they sign the shared NextGraph wallet through the
|
||||
@@ -34,7 +53,8 @@
|
||||
* `rule_tests-validate-festipod-not-the-sdk`).
|
||||
*
|
||||
* A smoke layer that cannot reach its stack FAILS, loudly — it never silently
|
||||
* skips. Exit code is non-zero if any layer fails.
|
||||
* skips. A skipped e2e probe never counts as passed: it is reported as
|
||||
* SKIPPED with its reason. Exit code is non-zero if any layer fails.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
@@ -51,12 +71,34 @@ const APODS_TAGS = "@e2e-activitypods and not @suspendu";
|
||||
|
||||
const TIMEOUT_UI_MS = 15 * 60 * 1000; // 15 min
|
||||
const TIMEOUT_APODS_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_RIGHTS_MS = 25 * 60 * 1000; // 25 min (the matrix bounds itself at 15 min of UI drives)
|
||||
const TIMEOUT_REVOCATION_MS = 15 * 60 * 1000; // 15 min (the probe bounds itself at 10 min)
|
||||
|
||||
/** The local stack, with the same defaults and env overrides as the e2e scripts. */
|
||||
const strip = (u: string): string => u.replace(/\/+$/, "");
|
||||
const POD_PROVIDER = strip(process.env.POD_PROVIDER_URL || "http://localhost:3000");
|
||||
const POD_FRONT = strip(process.env.POD_FRONTEND_URL || "http://localhost:5000");
|
||||
const BACKEND = strip(process.env.BACKEND_URL || "http://localhost:3003");
|
||||
const APP = strip(process.env.APP_URL || "http://localhost:4000");
|
||||
const FUSEKI = strip(process.env.FUSEKI_URL || "http://localhost:3030");
|
||||
|
||||
/** What the browser-driven e2e probes need answering 200 — every piece of the local stack. */
|
||||
const E2E_STACK: Array<[string, string]> = [
|
||||
// The triplestore both servers store into: with it down they still answer their static routes
|
||||
// (observed 2026-10-13: the pod's OpenID configuration 200 while every sign-up failed).
|
||||
["triplestore (fuseki)", `${FUSEKI}/`],
|
||||
["pod provider", `${POD_PROVIDER}/.well-known/openid-configuration`],
|
||||
["pod frontend (login + consent)", `${POD_FRONT}/`],
|
||||
["Festipod backend", `${BACKEND}/app`],
|
||||
["Festipod app at APP_URL (the only registered OIDC redirect origin)", `${APP}/`],
|
||||
];
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
interface StepResult {
|
||||
label: string;
|
||||
status: "passed" | "failed" | "error";
|
||||
status: "passed" | "failed" | "error" | "skipped";
|
||||
/** Failure lines; for a skipped layer, why it was skipped. */
|
||||
failures: string[];
|
||||
exitCode: number;
|
||||
durationMs: number;
|
||||
@@ -75,14 +117,20 @@ function cucumberArgs(config: string, tags: string): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
function runStep(label: string, args: string[], timeoutMs: number): StepResult {
|
||||
function runStep(
|
||||
label: string,
|
||||
args: string[],
|
||||
timeoutMs: number,
|
||||
command = "node",
|
||||
extract: (output: string) => string[] = extractFailures,
|
||||
): StepResult {
|
||||
const t0 = Date.now();
|
||||
console.log(`\n${"═".repeat(64)}`);
|
||||
console.log(`▶ ${label}`);
|
||||
console.log(` node ${args.join(" ")}`);
|
||||
console.log(` ${command} ${args.join(" ")}`);
|
||||
console.log(`${"═".repeat(64)}`);
|
||||
|
||||
const result = spawnSync("node", args, {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: REPO_DIR,
|
||||
env: process.env,
|
||||
encoding: "utf-8",
|
||||
@@ -107,12 +155,68 @@ function runStep(label: string, args: string[], timeoutMs: number): StepResult {
|
||||
return {
|
||||
label,
|
||||
status: (result.status ?? 1) === 0 ? "passed" : "failed",
|
||||
failures: extractFailures(combined),
|
||||
failures: extract(combined),
|
||||
exitCode: result.status ?? 1,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
/** A stack-gated layer that did not run: reported, never counted as passed. */
|
||||
function skippedStep(label: string, reasons: string[]): StepResult {
|
||||
console.log(`\n${"═".repeat(64)}`);
|
||||
console.log(`⏭️ ${label} — SKIPPED: the local stack is down`);
|
||||
for (const r of reasons) console.log(` ↳ ${r}`);
|
||||
console.log(`${"═".repeat(64)}`);
|
||||
return { label, status: "skipped", failures: reasons, exitCode: 0, durationMs: 0 };
|
||||
}
|
||||
|
||||
/** Every part of the e2e stack that does not answer 200, as "name: url → status". Empty when the stack is up. */
|
||||
async function e2eStackDown(): Promise<string[]> {
|
||||
const down: string[] = [];
|
||||
for (const [name, url] of E2E_STACK) {
|
||||
let status: number | string;
|
||||
try {
|
||||
status = (await fetch(url, { signal: AbortSignal.timeout(5000) })).status;
|
||||
} catch (e) {
|
||||
status = `unreachable (${e instanceof Error ? e.message : String(e)})`;
|
||||
}
|
||||
if (status !== 200) down.push(`${name}: ${url} → ${typeof status === "number" ? `HTTP ${status}` : status}`);
|
||||
}
|
||||
return down;
|
||||
}
|
||||
|
||||
/** The rights matrix's verdict lines: every failed hard line, any notice, and the verdict. */
|
||||
function extractMatrixFailures(output: string): string[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => /^(FAILED: |VERDICT: FAIL|!!! (?!NOTICE:)\S)/.test(l) || l.includes("RIGHTS MATRIX — ABORTED"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The revocation probe's JSON report (pretty-printed: its top-level braces are
|
||||
* the only ones at column 0): the phases that did not hold, and unexpected ≥400s.
|
||||
*/
|
||||
function extractRevocationFailures(output: string): string[] {
|
||||
const start = output.lastIndexOf("\n{\n") + 1;
|
||||
const end = output.indexOf("\n}", start);
|
||||
try {
|
||||
const report = JSON.parse(output.slice(start, end + 2)) as Record<string, unknown>;
|
||||
if (report["ok"] === true) return [];
|
||||
const failures: string[] = [];
|
||||
for (const phase of ["positive", "negative", "recovered"]) {
|
||||
const p = report[phase] as { ok?: unknown } | undefined;
|
||||
if (p && p.ok !== true) failures.push(`phase ${phase}: ok=${String(p.ok)}`);
|
||||
}
|
||||
const fatal = report["fatal4xx"];
|
||||
if (Array.isArray(fatal) && fatal.length) failures.push(`${fatal.length} unexpected ≥400 response(s)`);
|
||||
if (typeof report["fatal"] === "string") failures.push(`aborted: ${report["fatal"]}`);
|
||||
return failures.length ? failures : ["ok=false"];
|
||||
} catch {
|
||||
return ["no JSON report found in the output"];
|
||||
}
|
||||
}
|
||||
|
||||
/** The failure lines worth surfacing: failed scenario names and the summary. */
|
||||
function extractFailures(output: string): string[] {
|
||||
const lines = output.split("\n");
|
||||
@@ -139,31 +243,49 @@ function printMatrix(steps: StepResult[]): void {
|
||||
console.log("╚══════════════════════════════════════════════════════════╝\n");
|
||||
const maxLabel = Math.max(...steps.map((s) => s.label.length));
|
||||
for (const step of steps) {
|
||||
const icon = step.status === "passed" ? "✅" : step.status === "failed" ? "❌" : "⚠️ ";
|
||||
console.log(` ${icon} ${step.label.padEnd(maxLabel + 2)} [${fmtDuration(step.durationMs)}]`);
|
||||
const icon = step.status === "passed" ? "✅" : step.status === "failed" ? "❌" : step.status === "skipped" ? "⏭️ " : "⚠️ ";
|
||||
const time = step.status === "skipped" ? "SKIPPED — local stack down" : fmtDuration(step.durationMs);
|
||||
console.log(` ${icon} ${step.label.padEnd(maxLabel + 2)} [${time}]`);
|
||||
for (const f of step.failures) console.log(` ↳ ${f}`);
|
||||
}
|
||||
console.log("");
|
||||
console.log(" ℹ️ @data/@e2e/@multibrowser/@shared-wallet/@smoke : suspendus (workstream NextGraph).");
|
||||
const failed = steps.filter((s) => s.status !== "passed");
|
||||
console.log(failed.length === 0
|
||||
? " 🟢 ALL LAYERS PASSED"
|
||||
: ` 🔴 ${failed.length} LAYER(S) FAILED: ${failed.map((s) => s.label).join(", ")}`);
|
||||
const failed = steps.filter((s) => s.status === "failed" || s.status === "error");
|
||||
const skipped = steps.filter((s) => s.status === "skipped");
|
||||
if (skipped.length) {
|
||||
console.log(` 🟡 ${skipped.length} LAYER(S) SKIPPED (local stack down — NOT validated): ${skipped.map((s) => s.label).join(", ")}`);
|
||||
}
|
||||
console.log(failed.length > 0
|
||||
? ` 🔴 ${failed.length} LAYER(S) FAILED: ${failed.map((s) => s.label).join(", ")}`
|
||||
: skipped.length
|
||||
? " 🟢 NO LAYER FAILED — but the skipped layers above were not run"
|
||||
: " 🟢 ALL LAYERS PASSED");
|
||||
console.log(` ⏱ Total: ${fmtDuration(steps.reduce((sum, s) => sum + s.durationMs, 0))}`);
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// ─── Main ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function main(): void {
|
||||
async function main(): Promise<void> {
|
||||
console.log(`🔍 Festipod — validation (repo: ${REPO_DIR})`);
|
||||
|
||||
const steps: StepResult[] = [];
|
||||
steps.push(runStep("festipod:@ui", cucumberArgs("cucumber.json", UI_TAGS), TIMEOUT_UI_MS));
|
||||
steps.push(runStep("festipod:@e2e-activitypods", cucumberArgs("cucumber.smoke.json", APODS_TAGS), TIMEOUT_APODS_MS));
|
||||
|
||||
// Stack-gated e2e probes: one stack probe, then both run or both are skipped (loudly).
|
||||
const down = await e2eStackDown();
|
||||
if (down.length) {
|
||||
const reasons = [...down, "start the stack: concept `data-layer`, `cookbook_local-activitypods-platform.md`"];
|
||||
steps.push(skippedStep("festipod:e2e-rights", reasons));
|
||||
steps.push(skippedStep("festipod:e2e-revocation", reasons));
|
||||
} else {
|
||||
steps.push(runStep("festipod:e2e-rights", ["run", "test:e2e-rights"], TIMEOUT_RIGHTS_MS, "bun", extractMatrixFailures));
|
||||
steps.push(runStep("festipod:e2e-revocation", ["run", "test:e2e-revocation"], TIMEOUT_REVOCATION_MS, "bun", extractRevocationFailures));
|
||||
}
|
||||
|
||||
printMatrix(steps);
|
||||
process.exit(steps.some((s) => s.status !== "passed") ? 1 : 0);
|
||||
process.exit(steps.some((s) => s.status === "failed" || s.status === "error") ? 1 : 0);
|
||||
}
|
||||
|
||||
main();
|
||||
void main();
|
||||
|
||||
+126
-3
@@ -10,6 +10,10 @@
|
||||
* `clientId` and `${origin}/login` as `redirectUri`. Every session the flow
|
||||
* establishes is published to `utils/currentSession` — the module store the
|
||||
* data provider's dispatch reads, independent of where each mounts in the tree.
|
||||
* While signed in, the app-status probe (`auth/appStatusProbe`) re-checks the
|
||||
* registration on load and on focus/visibility: a revoked or stale app re-runs
|
||||
* the provider's `registerApp` (its consent redirect is the recovery) instead of
|
||||
* staying in a "connected" view whose writes cannot land.
|
||||
*
|
||||
* 2. NEXTGRAPH (suspended at the root, kept for the URL variant). The legacy
|
||||
* `ensureIdentity()` flow: the SDK mounts its own full-screen barrier and owns
|
||||
@@ -24,7 +28,7 @@
|
||||
* remains reachable.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { ensureIdentity } from '@ng-eventually/polyfill';
|
||||
import { startNgSession } from '../shared/utils/ngSession';
|
||||
import { setCurrentPrincipal } from '../shared/utils/currentPrincipal';
|
||||
@@ -39,8 +43,19 @@ import {
|
||||
safeRedirectPath,
|
||||
} from './auth/activityPodsAuth';
|
||||
import { PodProviderPicker } from './auth/PodProviderPicker';
|
||||
import { handleStaleReading, type StaleReason } from './auth/appStatusProbe';
|
||||
import { useAppStatusProbe } from './auth/useAppStatusProbe';
|
||||
|
||||
type ActivityPodsAuthState = 'checking' | 'authenticated' | 'unauthenticated' | 'error';
|
||||
/**
|
||||
* `reauthorizing`: the app-status probe found the registration revoked or stale
|
||||
* and `registerApp` is sending the browser to the consent screen — the app view
|
||||
* is taken down rather than left looking connected.
|
||||
*/
|
||||
type ActivityPodsAuthState = 'checking' | 'authenticated' | 'unauthenticated' | 'reauthorizing' | 'error';
|
||||
|
||||
/** Shown on the picker when the app was removed from the pod and the user was logged out. */
|
||||
const REGISTRATION_LOST_NOTICE =
|
||||
'L’autorisation de Festipod a été retirée de votre Pod : vous avez été déconnecté. Vous pouvez vous reconnecter à tout moment.';
|
||||
|
||||
type CallbackResult = Awaited<ReturnType<AuthProvider['handleCallback']>>;
|
||||
|
||||
@@ -86,6 +101,22 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
// ActivityPods flow state.
|
||||
const [apState, setApState] = useState<ActivityPodsAuthState>('checking');
|
||||
const [apError, setApError] = useState<string | null>(null);
|
||||
// Why the picker is back after a session existed (registration lost).
|
||||
const [apNotice, setApNotice] = useState<string | null>(null);
|
||||
// Set when the app-status probe took the connected view down: the consent
|
||||
// redirect is under way ('reauthorizing'), or the app was removed from the
|
||||
// pod and the user logged out ('registration-lost'). Should the logout itself
|
||||
// fail the token stays stored, and without this any later route change — a
|
||||
// Back, a link — would re-derive "authenticated" from it and bring back a
|
||||
// view that can neither read nor write. Lives for the
|
||||
// page: the consent and the picker's sign-in both leave it for the pod, and a
|
||||
// reload probes afresh.
|
||||
const blockedRef = useRef<{ kind: 'reauthorizing'; reason: StaleReason } | { kind: 'registration-lost' } | null>(null);
|
||||
// One recovery at a time: the probe already serializes its own, this also
|
||||
// covers repeated clicks on the reauthorizing screen's retry (each would
|
||||
// start its own consent redirect). A `registerApp` that never settles keeps
|
||||
// it set — the same limit as a hung probe run; a reload clears it.
|
||||
const recoveringRef = useRef(false);
|
||||
|
||||
const apAuthProvider = useMemo(() => {
|
||||
if (!activityPodsEnabled) return undefined;
|
||||
@@ -188,10 +219,13 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
const session = provider.getSession();
|
||||
if (session) {
|
||||
const blocked = blockedRef.current;
|
||||
if (session && !blocked) {
|
||||
setCurrentPrincipal(session.webId);
|
||||
setCurrentSession({ webId: session.webId, token: session.token });
|
||||
if (!cancelled) setApState('authenticated');
|
||||
} else if (session && blocked?.kind === 'reauthorizing') {
|
||||
if (!cancelled) setApState('reauthorizing');
|
||||
} else {
|
||||
setCurrentSession(undefined);
|
||||
if (!cancelled) setApState('unauthenticated');
|
||||
@@ -209,6 +243,70 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
return () => { cancelled = true; };
|
||||
}, [activityPodsEnabled, apAuthProvider, demoMode, navigate, route.page]);
|
||||
|
||||
// A revoked or stale registration. Revoked (the app was removed from the
|
||||
// pod): log out through the provider and land on the picker with a notice —
|
||||
// no consent redirect. Out of date (grants to update): re-run the provider's
|
||||
// registration flow, whose consent redirect IS the recovery; if that cannot
|
||||
// run, stay connected and let the next probe retry.
|
||||
const handleStaleRegistration = useCallback(async (reason: StaleReason) => {
|
||||
const provider = apAuthProvider;
|
||||
if (!provider || recoveringRef.current) return;
|
||||
recoveringRef.current = true;
|
||||
let handled: Awaited<ReturnType<typeof handleStaleReading>>;
|
||||
try {
|
||||
handled = await handleStaleReading(reason, {
|
||||
getWebId: () => provider.getSession()?.webId,
|
||||
registerApp: (webId) => provider.registerApp(webId),
|
||||
logout: async () => { await provider.logout({}); },
|
||||
});
|
||||
} finally {
|
||||
recoveringRef.current = false;
|
||||
}
|
||||
const { resolution, detail } = handled;
|
||||
console.info(`[AppStatus] handled (${reason}) → ${detail} → ${resolution}`);
|
||||
switch (resolution) {
|
||||
case 'stay-connected': {
|
||||
// From the probe this changes nothing; from the reauthorizing screen's
|
||||
// retry (a consent given meanwhile) it brings the app back — with the
|
||||
// session published again, or the data layer would serve demo data
|
||||
// behind a connected-looking app.
|
||||
const session = provider.getSession();
|
||||
if (!session) {
|
||||
blockedRef.current = null;
|
||||
setCurrentSession(undefined);
|
||||
setApState('unauthenticated');
|
||||
return;
|
||||
}
|
||||
blockedRef.current = null;
|
||||
setCurrentPrincipal(session.webId);
|
||||
setCurrentSession({ webId: session.webId, token: session.token });
|
||||
setApState('authenticated');
|
||||
return;
|
||||
}
|
||||
case 'reauthorizing':
|
||||
blockedRef.current = { kind: 'reauthorizing', reason };
|
||||
setApState('reauthorizing');
|
||||
return;
|
||||
case 'signed-out':
|
||||
blockedRef.current = null;
|
||||
setCurrentSession(undefined);
|
||||
setApState('unauthenticated');
|
||||
return;
|
||||
case 'registration-lost':
|
||||
blockedRef.current = { kind: 'registration-lost' };
|
||||
setCurrentSession(undefined);
|
||||
setApNotice(REGISTRATION_LOST_NOTICE);
|
||||
setApState('unauthenticated');
|
||||
return;
|
||||
}
|
||||
}, [apAuthProvider]);
|
||||
|
||||
useAppStatusProbe({
|
||||
provider: apAuthProvider,
|
||||
active: activityPodsEnabled && !demoMode && apState === 'authenticated',
|
||||
onStale: handleStaleRegistration,
|
||||
});
|
||||
|
||||
// Once identified, leave the welcome screen for the app home.
|
||||
useEffect(() => {
|
||||
if (apState === 'authenticated' && route.page === 'welcome') {
|
||||
@@ -265,6 +363,30 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
if (apState === 'checking') {
|
||||
return null;
|
||||
}
|
||||
if (apState === 'reauthorizing') {
|
||||
return (
|
||||
<div id="auth-reauthorizing" role="status" className="app-card" style={{ margin: '2rem 1rem' }}>
|
||||
<h1 className="app-title">Autorisation à renouveler</h1>
|
||||
<p className="app-text">
|
||||
Festipod n’est plus autorisé sur votre Pod, ou son autorisation doit être
|
||||
mise à jour. Redirection vers votre Pod pour la renouveler…
|
||||
</p>
|
||||
{/* The redirect can be interrupted (Back, a page restored from the
|
||||
browser's cache): this screen must never be a dead end. */}
|
||||
<button
|
||||
type="button"
|
||||
className="app-btn"
|
||||
onClick={() => {
|
||||
const blocked = blockedRef.current;
|
||||
void handleStaleRegistration(blocked?.kind === 'reauthorizing' ? blocked.reason : 'not-installed');
|
||||
}}
|
||||
style={{ marginTop: 16 }}
|
||||
>
|
||||
Continuer vers mon Pod
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (apState === 'unauthenticated') {
|
||||
// Seed the post-login destination: an explicit (validated) `?redirect=`
|
||||
// wins; otherwise a deep link the user opened lands them back on that page
|
||||
@@ -280,6 +402,7 @@ export function AuthGate({ children }: { children: ReactNode }) {
|
||||
<PodProviderPicker
|
||||
authProvider={apAuthProvider!}
|
||||
redirect={loginRedirect}
|
||||
notice={apNotice ?? undefined}
|
||||
onDemo={() => {
|
||||
setDemoMode(true);
|
||||
navigate('/home');
|
||||
|
||||
@@ -11,6 +11,8 @@ interface PodProviderPickerProps {
|
||||
* so the picker never reads the raw URL itself.
|
||||
*/
|
||||
redirect: string;
|
||||
/** Why the user is back on the picker (e.g. the app's registration was revoked). */
|
||||
notice?: string;
|
||||
onDemo?: () => void;
|
||||
}
|
||||
|
||||
@@ -21,7 +23,7 @@ interface PodProviderPickerProps {
|
||||
* offered as a one-click option. The user can also type any Solid Pod provider
|
||||
* URL. A "continue offline" button keeps the local demo fallback reachable.
|
||||
*/
|
||||
export function PodProviderPicker({ authProvider, redirect, onDemo }: PodProviderPickerProps) {
|
||||
export function PodProviderPicker({ authProvider, redirect, notice, onDemo }: PodProviderPickerProps) {
|
||||
const { podProviderUrl } = resolveActivityPodsAuthConfig(window.location.origin);
|
||||
const [customUrl, setCustomUrl] = useState('');
|
||||
|
||||
@@ -68,6 +70,17 @@ export function PodProviderPicker({ authProvider, redirect, onDemo }: PodProvide
|
||||
Connectez-vous avec votre Pod pour découvrir et créer des points de rencontre.
|
||||
</Text>
|
||||
|
||||
{notice && (
|
||||
<p
|
||||
role="status"
|
||||
data-testid="auth-picker-notice"
|
||||
className="app-text"
|
||||
style={{ textAlign: 'center', fontSize: 14, marginBottom: 24, color: '#b45309' }}
|
||||
>
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{defaultProviderHost && (
|
||||
<Button
|
||||
variant="primary"
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
MIN_PROBE_INTERVAL_MS,
|
||||
classifyAppStatus,
|
||||
createAppStatusProbe,
|
||||
handleStaleReading,
|
||||
recoverRegistration,
|
||||
resolveStaleRegistration,
|
||||
type AppStatusProbeDeps,
|
||||
type StaleReason,
|
||||
} from './appStatusProbe';
|
||||
|
||||
// Bodies as the local pod (v2.3.12) returns them from /.well-known/app-status to
|
||||
// the app's session token (recorded by scripts/e2e-revocation.ts): current, and
|
||||
// after the registration was removed. UPGRADE is not observed — it is the shape
|
||||
// the contract's re-baseline names (`upgradeNeeded: true`).
|
||||
const CURRENT = { onlineBackend: true, installed: true, upgradeNeeded: false, webhookChannels: [] };
|
||||
const REVOKED = { onlineBackend: true, installed: false, upgradeNeeded: false };
|
||||
const UPGRADE = { onlineBackend: true, installed: true, upgradeNeeded: true, webhookChannels: [] };
|
||||
|
||||
/** A probe over a scripted status source, a manual clock, manual timers and a captured log. */
|
||||
function harness(statuses: Array<unknown | (() => Promise<unknown>)>, opts: { session?: () => boolean } = {}) {
|
||||
let clock = 1_000_000;
|
||||
let nextHandle = 1;
|
||||
const timers = new Map<number, { fn: () => void; due: number }>();
|
||||
const calls: number[] = [];
|
||||
const stale: StaleReason[] = [];
|
||||
const logs: string[] = [];
|
||||
const queue = [...statuses];
|
||||
const deps: AppStatusProbeDeps = {
|
||||
hasSession: opts.session ?? (() => true),
|
||||
getAppStatus: () => {
|
||||
calls.push(clock);
|
||||
const next = queue.shift();
|
||||
return typeof next === 'function' ? (next as () => Promise<unknown>)() : Promise.resolve(next);
|
||||
},
|
||||
onStale: (reason) => { stale.push(reason); },
|
||||
now: () => clock,
|
||||
log: (m) => { logs.push(m); },
|
||||
setTimer: (fn, ms) => { const h = nextHandle++; timers.set(h, { fn, due: clock + ms }); return h; },
|
||||
clearTimer: (h) => { timers.delete(h as number); },
|
||||
};
|
||||
return {
|
||||
probe: createAppStatusProbe(deps),
|
||||
calls,
|
||||
stale,
|
||||
logs,
|
||||
timers,
|
||||
/** Move the clock and fire every timer that came due, as the event loop would. */
|
||||
advance: async (ms: number) => {
|
||||
clock += ms;
|
||||
for (const [h, t] of [...timers]) {
|
||||
if (t.due <= clock) { timers.delete(h); t.fn(); }
|
||||
}
|
||||
// let the fired runs settle
|
||||
for (let i = 0; i < 5; i++) await Promise.resolve();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('classifyAppStatus', () => {
|
||||
test('installed and no upgrade is current', () => {
|
||||
expect(classifyAppStatus(CURRENT)).toEqual({ kind: 'current' });
|
||||
});
|
||||
|
||||
test('not installed (what a revocation leaves) is stale even though upgradeNeeded is false', () => {
|
||||
expect(classifyAppStatus(REVOKED)).toEqual({ kind: 'stale', reason: 'not-installed' });
|
||||
});
|
||||
|
||||
test('upgradeNeeded is stale', () => {
|
||||
expect(classifyAppStatus(UPGRADE)).toEqual({ kind: 'stale', reason: 'upgrade-needed' });
|
||||
});
|
||||
|
||||
test('not installed wins over upgradeNeeded', () => {
|
||||
expect(classifyAppStatus({ installed: false, upgradeNeeded: true })).toEqual({ kind: 'stale', reason: 'not-installed' });
|
||||
});
|
||||
|
||||
test('a body that decides nothing is undecided, not stale', () => {
|
||||
expect(classifyAppStatus({ upgradeNeeded: false }).kind).toBe('undecided');
|
||||
expect(classifyAppStatus({ installed: 'yes', upgradeNeeded: false }).kind).toBe('undecided');
|
||||
expect(classifyAppStatus(null).kind).toBe('undecided');
|
||||
expect(classifyAppStatus('ok').kind).toBe('undecided');
|
||||
expect(classifyAppStatus([CURRENT]).kind).toBe('undecided');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAppStatusProbe — throttle', () => {
|
||||
test('the interval is never below a minute', () => {
|
||||
expect(MIN_PROBE_INTERVAL_MS).toBeGreaterThanOrEqual(60_000);
|
||||
});
|
||||
|
||||
test('a trigger less than a minute after the previous run makes no request now', async () => {
|
||||
const h = harness([CURRENT, CURRENT]);
|
||||
expect(await h.probe.run('load')).toEqual({ kind: 'current' });
|
||||
await h.advance(MIN_PROBE_INTERVAL_MS - 1);
|
||||
expect(await h.probe.run('focus')).toEqual({ kind: 'skipped', why: 'throttled', deferredMs: 1 });
|
||||
expect(h.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a throttled trigger is deferred to the end of the minute, not dropped', async () => {
|
||||
// The user revokes in the pod's tab and comes back 40 s after the load probe.
|
||||
const h = harness([CURRENT, REVOKED]);
|
||||
await h.probe.run('load');
|
||||
await h.advance(40_000);
|
||||
expect(await h.probe.run('focus')).toEqual({ kind: 'skipped', why: 'throttled', deferredMs: 20_000 });
|
||||
await h.advance(19_999);
|
||||
expect(h.calls).toHaveLength(1);
|
||||
await h.advance(1);
|
||||
expect(h.calls).toHaveLength(2);
|
||||
expect(h.stale).toEqual(['not-installed']);
|
||||
expect(h.logs.some((l) => l.includes('probe (focus) → stale (not-installed)'))).toBe(true);
|
||||
});
|
||||
|
||||
test('at most one deferred run is pending, whatever the number of triggers', async () => {
|
||||
const h = harness([CURRENT, CURRENT, CURRENT]);
|
||||
await h.probe.run('load');
|
||||
await h.advance(10_000);
|
||||
await h.probe.run('focus');
|
||||
expect(await h.probe.run('visibility')).toEqual({ kind: 'skipped', why: 'throttled' });
|
||||
await h.probe.run('focus');
|
||||
expect(h.timers.size).toBe(1);
|
||||
await h.advance(MIN_PROBE_INTERVAL_MS);
|
||||
expect(h.calls).toHaveLength(2);
|
||||
expect(h.timers.size).toBe(0);
|
||||
});
|
||||
|
||||
test('two runs never start less than a minute apart, deferred ones included', async () => {
|
||||
const h = harness([CURRENT, CURRENT, CURRENT]);
|
||||
await h.probe.run('load');
|
||||
await h.advance(30_000);
|
||||
await h.probe.run('focus'); // deferred to +60 s
|
||||
await h.advance(30_000); // deferred run starts here
|
||||
await h.advance(1_000);
|
||||
await h.probe.run('visibility'); // deferred to +120 s
|
||||
await h.advance(58_999);
|
||||
expect(h.calls).toHaveLength(2);
|
||||
await h.advance(1);
|
||||
expect(h.calls).toHaveLength(3);
|
||||
const gaps = h.calls.slice(1).map((t, i) => t - h.calls[i]!);
|
||||
expect(gaps.every((g) => g >= MIN_PROBE_INTERVAL_MS)).toBe(true);
|
||||
});
|
||||
|
||||
test('cancelPending drops a deferred trigger — nothing probes once the view is gone', async () => {
|
||||
const h = harness([CURRENT, CURRENT]);
|
||||
await h.probe.run('load');
|
||||
await h.advance(5_000);
|
||||
await h.probe.run('focus');
|
||||
h.probe.cancelPending();
|
||||
expect(h.timers.size).toBe(0);
|
||||
await h.advance(MIN_PROBE_INTERVAL_MS * 2);
|
||||
expect(h.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a deferred run whose session has ended requests nothing', async () => {
|
||||
let session = true;
|
||||
const h = harness([CURRENT, CURRENT], { session: () => session });
|
||||
await h.probe.run('load');
|
||||
await h.advance(5_000);
|
||||
await h.probe.run('focus');
|
||||
session = false;
|
||||
await h.advance(MIN_PROBE_INTERVAL_MS);
|
||||
expect(h.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('a run a full minute later probes again', async () => {
|
||||
const h = harness([CURRENT, REVOKED]);
|
||||
await h.probe.run('load');
|
||||
await h.advance(MIN_PROBE_INTERVAL_MS);
|
||||
expect(await h.probe.run('focus')).toEqual({ kind: 'stale', reason: 'not-installed' });
|
||||
expect(h.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('a run while one is in flight is skipped', async () => {
|
||||
let release: (v: unknown) => void = () => {};
|
||||
const h = harness([() => new Promise((r) => { release = r; })]);
|
||||
const first = h.probe.run('load');
|
||||
await h.advance(MIN_PROBE_INTERVAL_MS * 2);
|
||||
expect(await h.probe.run('focus')).toEqual({ kind: 'skipped', why: 'in-flight' });
|
||||
release(CURRENT);
|
||||
expect(await first).toEqual({ kind: 'current' });
|
||||
expect(h.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('without a session nothing is requested and the minute is not consumed', async () => {
|
||||
const h = harness([CURRENT], { session: () => false });
|
||||
expect(await h.probe.run('load')).toEqual({ kind: 'skipped', why: 'no-session' });
|
||||
expect(h.calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAppStatusProbe — fails open', () => {
|
||||
test('a network error is logged and nothing else happens', async () => {
|
||||
const h = harness([() => Promise.reject(new TypeError('Failed to fetch'))]);
|
||||
const outcome = await h.probe.run('load');
|
||||
expect(outcome.kind).toBe('failed-open');
|
||||
expect(h.stale).toEqual([]);
|
||||
expect(h.logs.some((l) => l.includes('failing open') && l.includes('Failed to fetch'))).toBe(true);
|
||||
});
|
||||
|
||||
test('a 5xx (the provider throws with status attached) fails open', async () => {
|
||||
const err = Object.assign(new Error('Service Unavailable'), { status: 503, body: '' });
|
||||
const h = harness([() => Promise.reject(err)]);
|
||||
const outcome = await h.probe.run('focus');
|
||||
expect(outcome).toEqual({ kind: 'failed-open', detail: 'HTTP 503: Service Unavailable' });
|
||||
expect(h.stale).toEqual([]);
|
||||
});
|
||||
|
||||
test('an undecidable body does not trigger recovery', async () => {
|
||||
const h = harness([{ upgradeNeeded: false }]);
|
||||
expect((await h.probe.run('load')).kind).toBe('undecided');
|
||||
expect(h.stale).toEqual([]);
|
||||
});
|
||||
|
||||
test('a failed run still counts for the throttle — a failing pod is not hammered', async () => {
|
||||
const h = harness([() => Promise.reject(new TypeError('Failed to fetch')), CURRENT]);
|
||||
await h.probe.run('load');
|
||||
await h.advance(1_000);
|
||||
expect(await h.probe.run('focus')).toEqual({ kind: 'skipped', why: 'throttled', deferredMs: MIN_PROBE_INTERVAL_MS - 1_000 });
|
||||
expect(h.calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAppStatusProbe — stale readings', () => {
|
||||
test('a revoked registration calls onStale once with not-installed', async () => {
|
||||
const h = harness([REVOKED]);
|
||||
await h.probe.run('load');
|
||||
expect(h.stale).toEqual(['not-installed']);
|
||||
});
|
||||
|
||||
test('an upgrade-needed registration calls onStale with upgrade-needed', async () => {
|
||||
const h = harness([UPGRADE]);
|
||||
await h.probe.run('visibility');
|
||||
expect(h.stale).toEqual(['upgrade-needed']);
|
||||
});
|
||||
|
||||
test('a current registration does not call onStale', async () => {
|
||||
const h = harness([CURRENT]);
|
||||
await h.probe.run('load');
|
||||
expect(h.stale).toEqual([]);
|
||||
});
|
||||
|
||||
test('a run waits for onStale, so no second probe starts during the recovery', async () => {
|
||||
let finishRecovery: () => void = () => {};
|
||||
let clock = 0;
|
||||
const calls: number[] = [];
|
||||
const probe = createAppStatusProbe({
|
||||
hasSession: () => true,
|
||||
getAppStatus: () => { calls.push(clock); return Promise.resolve(REVOKED); },
|
||||
onStale: () => new Promise<void>((r) => { finishRecovery = r; }),
|
||||
now: () => clock,
|
||||
log: () => {},
|
||||
});
|
||||
const first = probe.run('load');
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
clock += MIN_PROBE_INTERVAL_MS * 2;
|
||||
expect(await probe.run('focus')).toEqual({ kind: 'skipped', why: 'in-flight' });
|
||||
finishRecovery();
|
||||
await first;
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recoverRegistration', () => {
|
||||
test('registerApp resolving undefined means the browser is leaving for consent', async () => {
|
||||
const outcome = await recoverRegistration({ getWebId: () => 'http://pod/alice', registerApp: async () => undefined });
|
||||
expect(outcome).toEqual({ kind: 'redirecting' });
|
||||
});
|
||||
|
||||
test('registerApp resolving a URI means the registration is current after all', async () => {
|
||||
const outcome = await recoverRegistration({ getWebId: () => 'http://pod/alice', registerApp: async () => 'http://pod/alice/reg/1' });
|
||||
expect(outcome).toEqual({ kind: 'registered', registration: 'http://pod/alice/reg/1' });
|
||||
});
|
||||
|
||||
test('registerApp throwing is a failed recovery, reported with its status', async () => {
|
||||
const err = Object.assign(new Error('Forbidden'), { status: 403 });
|
||||
const outcome = await recoverRegistration({ getWebId: () => 'http://pod/alice', registerApp: async () => { throw err; } });
|
||||
expect(outcome).toEqual({ kind: 'failed', detail: 'HTTP 403: Forbidden' });
|
||||
});
|
||||
|
||||
test('no session: registerApp is not called', async () => {
|
||||
let called = false;
|
||||
const outcome = await recoverRegistration({ getWebId: () => undefined, registerApp: async () => { called = true; return undefined; } });
|
||||
expect(outcome).toEqual({ kind: 'no-session' });
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
test('registerApp receives the session webId', async () => {
|
||||
let seen: string | undefined;
|
||||
await recoverRegistration({ getWebId: () => 'http://pod/alice', registerApp: async (w) => { seen = w; return undefined; } });
|
||||
expect(seen).toBe('http://pod/alice');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveStaleRegistration', () => {
|
||||
const failed = { kind: 'failed', detail: 'HTTP 502: Bad Gateway' } as const;
|
||||
|
||||
test('registered after all: the app stays connected, whatever was read', () => {
|
||||
expect(resolveStaleRegistration('not-installed', { kind: 'registered', registration: 'r' })).toBe('stay-connected');
|
||||
expect(resolveStaleRegistration('upgrade-needed', { kind: 'registered', registration: 'r' })).toBe('stay-connected');
|
||||
});
|
||||
|
||||
test('consent redirect under way: the connected view is taken down', () => {
|
||||
expect(resolveStaleRegistration('not-installed', { kind: 'redirecting' })).toBe('reauthorizing');
|
||||
expect(resolveStaleRegistration('upgrade-needed', { kind: 'redirecting' })).toBe('reauthorizing');
|
||||
});
|
||||
|
||||
test('session ended meanwhile: signed out, no registration-lost claim', () => {
|
||||
expect(resolveStaleRegistration('not-installed', { kind: 'no-session' })).toBe('signed-out');
|
||||
expect(resolveStaleRegistration('upgrade-needed', { kind: 'no-session' })).toBe('signed-out');
|
||||
});
|
||||
|
||||
test('a revoked registration whose recovery failed is lost — never left connected', () => {
|
||||
expect(resolveStaleRegistration('not-installed', failed)).toBe('registration-lost');
|
||||
});
|
||||
|
||||
test('an upgrade whose recovery failed fails open — current grants still work', () => {
|
||||
expect(resolveStaleRegistration('upgrade-needed', failed)).toBe('stay-connected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleStaleReading', () => {
|
||||
function spies(opts: { registerApp?: () => Promise<string | undefined>; logout?: () => Promise<void> } = {}) {
|
||||
const calls = { registerApp: 0, logout: 0 };
|
||||
return {
|
||||
calls,
|
||||
deps: {
|
||||
getWebId: () => 'http://pod/alice' as string | undefined,
|
||||
registerApp: async () => { calls.registerApp++; return opts.registerApp ? opts.registerApp() : undefined; },
|
||||
logout: async () => { calls.logout++; if (opts.logout) await opts.logout(); },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('not-installed: logs out and never calls registerApp (no consent redirect)', async () => {
|
||||
const { calls, deps } = spies();
|
||||
const out = await handleStaleReading('not-installed', deps);
|
||||
expect(out.resolution).toBe('registration-lost');
|
||||
expect(calls).toEqual({ registerApp: 0, logout: 1 });
|
||||
});
|
||||
|
||||
test('not-installed: a logout that throws still leaves the connected view, without registerApp', async () => {
|
||||
const { calls, deps } = spies({ logout: async () => { throw new Error('storage unavailable'); } });
|
||||
const out = await handleStaleReading('not-installed', deps);
|
||||
expect(out.resolution).toBe('registration-lost');
|
||||
expect(out.detail).toContain('storage unavailable');
|
||||
expect(calls).toEqual({ registerApp: 0, logout: 1 });
|
||||
});
|
||||
|
||||
test('upgrade-needed: re-runs registerApp (consent redirect) and does not log out', async () => {
|
||||
const { calls, deps } = spies();
|
||||
const out = await handleStaleReading('upgrade-needed', deps);
|
||||
expect(out.resolution).toBe('reauthorizing');
|
||||
expect(calls).toEqual({ registerApp: 1, logout: 0 });
|
||||
});
|
||||
|
||||
test('upgrade-needed: a registerApp that throws fails open — stays connected, no logout', async () => {
|
||||
const { calls, deps } = spies({ registerApp: async () => { throw new Error('boom'); } });
|
||||
const out = await handleStaleReading('upgrade-needed', deps);
|
||||
expect(out.resolution).toBe('stay-connected');
|
||||
expect(calls).toEqual({ registerApp: 1, logout: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* App-status probe — re-checks, while a session exists, that the pod still
|
||||
* holds a current registration for Festipod.
|
||||
*
|
||||
* WHY. Without it, a user who revokes the app on their pod (or whose grants go
|
||||
* stale after an app upgrade) keeps seeing a "connected" Festipod whose reads
|
||||
* come back empty and whose writes cannot land. Nothing re-checked the
|
||||
* registration after the sign-in flow.
|
||||
*
|
||||
* THE MECHANISM IS THE PROVIDER'S (pinned contract, re-baseline 2026-10-12):
|
||||
* `getAppStatus()` is a plain GET of the pod's `/.well-known/app-status` with
|
||||
* the session token, and `registerApp(webId)` is the recovery — it resolves the
|
||||
* AppRegistration URI when the registration is present and current, and
|
||||
* otherwise sends the browser to the consent screen. The provider's own drive
|
||||
* is disabled in Festipod (`appStatusCheckInterval: false`, usage declaration
|
||||
* friction 1: it leaks a timer and a listener per construction), so this module
|
||||
* is the app's own schedule for it.
|
||||
*
|
||||
* WHAT THE BODY SAYS (observed on the local pod v2.3.12, 2026-10-12, with the
|
||||
* app's own session token — `scripts/e2e-revocation.ts` records it on each run):
|
||||
* a current registration answers
|
||||
* `{ onlineBackend: true, installed: true, upgradeNeeded: false, webhookChannels: [...] }`;
|
||||
* after `POST {pod}/.auth-agent/app-registrations/remove` the same request
|
||||
* answers `{ onlineBackend: true, installed: false, upgradeNeeded: false }`.
|
||||
* A revocation therefore leaves `upgradeNeeded` FALSE — the provider's own
|
||||
* check, which acts on `upgradeNeeded` only, would miss it. The probe treats
|
||||
* `installed === false` as stale too.
|
||||
*
|
||||
* WHAT STALE LEADS TO (user decision, 2026-10-12). The two readings are not the
|
||||
* same thing. `upgrade-needed` (registered, grants out of date) re-runs
|
||||
* `registerApp`: its consent redirect is the recovery. `not-installed` (the app
|
||||
* was removed from the pod — often on purpose) does NOT go back to consent: the
|
||||
* user is logged out through the provider's `logout` and lands on the picker
|
||||
* with a notice, free to sign in again. Sending a deliberate revocation to the
|
||||
* consent screen on every load left that user unable to log out.
|
||||
*
|
||||
* SCHEDULE. Event-driven, never a polling loop (the app has none, by
|
||||
* doctrine): on app load once signed in, and when the window regains focus or
|
||||
* becomes visible. Throttled — no two runs start less than a minute apart, and
|
||||
* only one runs at a time. A trigger that lands inside the minute is NOT
|
||||
* dropped: it is deferred to the end of the minute (one pending run at most,
|
||||
* cancelled when the signed-in view goes away). Dropping it would miss exactly
|
||||
* the case this exists for — a user who revokes in the pod's tab and comes
|
||||
* straight back, then stays. A trigger that finds a run in flight is absorbed
|
||||
* by that run, which is reading the status already; the one gap is a run that
|
||||
* hangs past the minute (then fails open), after which the next focus or
|
||||
* visibility probes again.
|
||||
*
|
||||
* FAILS OPEN. A probe that throws (network error, 5xx, anything) is logged and
|
||||
* the app keeps going, mirroring the provider's own upgrade check: an
|
||||
* unreachable status endpoint is not evidence of a revocation, and turning
|
||||
* every pod hiccup into a sign-out would be worse than the hole it closes.
|
||||
*/
|
||||
|
||||
/** Minimum time between two probe runs. Never below a minute. */
|
||||
export const MIN_PROBE_INTERVAL_MS = 60_000;
|
||||
|
||||
/** What woke the probe up. */
|
||||
export type ProbeTrigger = 'load' | 'focus' | 'visibility';
|
||||
|
||||
/** Why a registration is not usable as it stands. */
|
||||
export type StaleReason = 'not-installed' | 'upgrade-needed';
|
||||
|
||||
/** The probe's reading of one status body. */
|
||||
export type AppStatusVerdict =
|
||||
| { kind: 'current' }
|
||||
| { kind: 'stale'; reason: StaleReason }
|
||||
/** The body did not say anything decidable — treated as current (fail open). */
|
||||
| { kind: 'undecided'; detail: string };
|
||||
|
||||
/** The outcome of one `run()` call. */
|
||||
export type ProbeOutcome =
|
||||
/** `deferredMs`: when a throttled trigger will run (undefined: one is already pending). */
|
||||
| { kind: 'skipped'; why: 'throttled'; deferredMs?: number }
|
||||
| { kind: 'skipped'; why: 'in-flight' | 'no-session' }
|
||||
| { kind: 'failed-open'; detail: string }
|
||||
| AppStatusVerdict;
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a status body. `installed === false` (no registration for this app —
|
||||
* what a revocation leaves) and `upgradeNeeded === true` (registered, grants
|
||||
* out of date) are stale; a body that says installed and no upgrade is current.
|
||||
* Anything else — a missing or non-boolean member, a non-object — is undecided,
|
||||
* and undecided fails open like an error does.
|
||||
*/
|
||||
export function classifyAppStatus(body: unknown): AppStatusVerdict {
|
||||
const status = asObject(body);
|
||||
if (!status) return { kind: 'undecided', detail: `status body is not an object: ${JSON.stringify(body)}` };
|
||||
if (status['installed'] === false) return { kind: 'stale', reason: 'not-installed' };
|
||||
if (status['upgradeNeeded'] === true) return { kind: 'stale', reason: 'upgrade-needed' };
|
||||
if (status['installed'] === true && status['upgradeNeeded'] === false) return { kind: 'current' };
|
||||
return { kind: 'undecided', detail: `status body not decidable: ${JSON.stringify(status)}` };
|
||||
}
|
||||
|
||||
/** Best-effort description of a thrown probe failure (the provider attaches `status`). */
|
||||
function describeFailure(err: unknown): string {
|
||||
const status = asObject(err)?.['status'];
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return typeof status === 'number' ? `HTTP ${status}: ${message}` : message;
|
||||
}
|
||||
|
||||
export interface AppStatusProbeDeps {
|
||||
/** Is there a session to probe for? No session → nothing to check. */
|
||||
hasSession: () => boolean;
|
||||
/** The provider's `getAppStatus()`. */
|
||||
getAppStatus: () => Promise<unknown>;
|
||||
/** Called once per stale reading; the probe waits for it before the next run can start. */
|
||||
onStale: (reason: StaleReason) => Promise<void> | void;
|
||||
/** Clock, injectable for tests. Defaults to `Date.now`. */
|
||||
now?: () => number;
|
||||
/** Logger, injectable for tests. Defaults to `console.info`. */
|
||||
log?: (message: string) => void;
|
||||
/** One-shot timer for a deferred trigger, injectable for tests. Defaults to `setTimeout`. */
|
||||
setTimer?: (fn: () => void, ms: number) => unknown;
|
||||
/** Cancels what `setTimer` returned. Defaults to `clearTimeout`. */
|
||||
clearTimer?: (handle: unknown) => void;
|
||||
}
|
||||
|
||||
export interface AppStatusProbe {
|
||||
run(trigger: ProbeTrigger): Promise<ProbeOutcome>;
|
||||
/** Drop a deferred trigger, if any — the signed-in view is going away. */
|
||||
cancelPending(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a throttled probe. Hold ONE per page session (the hook keeps it in a
|
||||
* ref): the throttle lives in the instance, so a remount that rebuilt it would
|
||||
* reset the minute.
|
||||
*/
|
||||
export function createAppStatusProbe(deps: AppStatusProbeDeps): AppStatusProbe {
|
||||
const now = deps.now ?? Date.now;
|
||||
const log = deps.log ?? ((message: string) => console.info(message));
|
||||
const setTimer = deps.setTimer ?? ((fn: () => void, ms: number) => setTimeout(fn, ms));
|
||||
const clearTimer = deps.clearTimer ?? ((handle: unknown) => clearTimeout(handle as ReturnType<typeof setTimeout>));
|
||||
let lastStart: number | undefined;
|
||||
let inFlight = false;
|
||||
let pending: unknown;
|
||||
|
||||
const probe: AppStatusProbe = {
|
||||
cancelPending() {
|
||||
if (pending !== undefined) clearTimer(pending);
|
||||
pending = undefined;
|
||||
},
|
||||
async run(trigger) {
|
||||
if (inFlight) return { kind: 'skipped', why: 'in-flight' };
|
||||
if (!deps.hasSession()) return { kind: 'skipped', why: 'no-session' };
|
||||
const at = now();
|
||||
if (lastStart !== undefined && at - lastStart < MIN_PROBE_INTERVAL_MS) {
|
||||
if (pending !== undefined) return { kind: 'skipped', why: 'throttled' };
|
||||
const deferredMs = lastStart + MIN_PROBE_INTERVAL_MS - at;
|
||||
pending = setTimer(() => {
|
||||
pending = undefined;
|
||||
void probe.run(trigger);
|
||||
}, deferredMs);
|
||||
return { kind: 'skipped', why: 'throttled', deferredMs };
|
||||
}
|
||||
lastStart = at;
|
||||
inFlight = true;
|
||||
try {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await deps.getAppStatus();
|
||||
} catch (err) {
|
||||
const detail = describeFailure(err);
|
||||
log(`[AppStatus] probe (${trigger}) failed, failing open: ${detail}`);
|
||||
return { kind: 'failed-open', detail };
|
||||
}
|
||||
const verdict = classifyAppStatus(body);
|
||||
log(`[AppStatus] probe (${trigger}) → ${verdict.kind}${verdict.kind === 'stale' ? ` (${verdict.reason})` : ''} ${JSON.stringify(body)}`);
|
||||
if (verdict.kind === 'stale') await deps.onStale(verdict.reason);
|
||||
return verdict;
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
return probe;
|
||||
}
|
||||
|
||||
/** What re-running the registration flow led to. */
|
||||
export type RecoveryOutcome =
|
||||
/** `registerApp` resolved `undefined`: the browser is leaving for the consent screen. */
|
||||
| { kind: 'redirecting' }
|
||||
/** `registerApp` found a current registration after all — nothing to recover. */
|
||||
| { kind: 'registered'; registration: string }
|
||||
/** No session left to register for. */
|
||||
| { kind: 'no-session' }
|
||||
/** `registerApp` threw: no recovery from here. */
|
||||
| { kind: 'failed'; detail: string };
|
||||
|
||||
export interface RecoveryDeps {
|
||||
getWebId: () => string | undefined;
|
||||
registerApp: (webId: string) => Promise<string | undefined>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-run the registration flow for a stale reading — the provider's
|
||||
* `registerApp`, whose consent redirect IS the recovery path. A resolved URI
|
||||
* means the registration is present and current (the status endpoint and the
|
||||
* registration lookup disagreed, e.g. a reading taken just before a consent
|
||||
* settled): the provider's answer wins and the app stays connected.
|
||||
*/
|
||||
export async function recoverRegistration(deps: RecoveryDeps): Promise<RecoveryOutcome> {
|
||||
const webId = deps.getWebId();
|
||||
if (!webId) return { kind: 'no-session' };
|
||||
try {
|
||||
const registration = await deps.registerApp(webId);
|
||||
return registration ? { kind: 'registered', registration } : { kind: 'redirecting' };
|
||||
} catch (err) {
|
||||
return { kind: 'failed', detail: describeFailure(err) };
|
||||
}
|
||||
}
|
||||
|
||||
/** What the app does with a stale reading. */
|
||||
export type StaleResolution =
|
||||
/** Nothing to do: the registration is current after all, or a failed upgrade recovery fails open. */
|
||||
| 'stay-connected'
|
||||
/** The browser is leaving for the consent screen: take the connected view down meanwhile. */
|
||||
| 'reauthorizing'
|
||||
/** The session ended meanwhile: back to the picker, nothing to explain. */
|
||||
| 'signed-out'
|
||||
/** Registration gone and not re-runnable: the picker, with why, and no way back into the dead view. */
|
||||
| 'registration-lost';
|
||||
|
||||
/**
|
||||
* Decide from what was found (`reason`) and what the recovery led to. The split
|
||||
* on a failed recovery is deliberate: a REVOKED registration (`not-installed`)
|
||||
* can neither read nor write, so staying connected would be the silent breakage
|
||||
* this exists to end; a registration that only needs an UPGRADE still works on
|
||||
* its current grants, so a recovery that could not run fails open like a probe
|
||||
* that could not read, and the next probe retries it.
|
||||
*/
|
||||
export function resolveStaleRegistration(reason: StaleReason, outcome: RecoveryOutcome): StaleResolution {
|
||||
switch (outcome.kind) {
|
||||
case 'registered': return 'stay-connected';
|
||||
case 'redirecting': return 'reauthorizing';
|
||||
case 'no-session': return 'signed-out';
|
||||
case 'failed': return reason === 'upgrade-needed' ? 'stay-connected' : 'registration-lost';
|
||||
}
|
||||
}
|
||||
|
||||
export interface StaleReadingDeps extends RecoveryDeps {
|
||||
/** The provider's `logout` (clears the stored token). */
|
||||
logout: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Act on one stale reading. A revoked registration (`not-installed`) is never
|
||||
* sent back to consent: it logs out (a logout that throws is logged by the
|
||||
* caller through the detail; the app still leaves the connected view) and
|
||||
* resolves `registration-lost`. An out-of-date one (`upgrade-needed`) re-runs
|
||||
* `registerApp` and resolves through `resolveStaleRegistration`.
|
||||
*/
|
||||
export async function handleStaleReading(
|
||||
reason: StaleReason,
|
||||
deps: StaleReadingDeps,
|
||||
): Promise<{ resolution: StaleResolution; detail: string }> {
|
||||
if (reason === 'not-installed') {
|
||||
try {
|
||||
await deps.logout();
|
||||
return { resolution: 'registration-lost', detail: 'logged out' };
|
||||
} catch (err) {
|
||||
return { resolution: 'registration-lost', detail: `logout failed: ${describeFailure(err)}` };
|
||||
}
|
||||
}
|
||||
const outcome = await recoverRegistration(deps);
|
||||
return {
|
||||
resolution: resolveStaleRegistration(reason, outcome),
|
||||
detail: outcome.kind === 'failed' ? `registerApp failed: ${outcome.detail}` : outcome.kind,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { AuthProvider } from '@activitypods/refine-providers';
|
||||
import { createAppStatusProbe, type AppStatusProbe, type StaleReason } from './appStatusProbe';
|
||||
|
||||
interface UseAppStatusProbeOptions {
|
||||
/** The auth provider to probe through; `undefined` disables the probe. */
|
||||
provider: AuthProvider | undefined;
|
||||
/** Probe only while the app shows a signed-in view. */
|
||||
active: boolean;
|
||||
/** What to do on a stale reading (re-run the registration flow). */
|
||||
onStale: (reason: StaleReason) => Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the app-status probe while signed in: one run when the signed-in view
|
||||
* mounts, then one per window `focus` / `visibilitychange` to visible, all
|
||||
* subject to the probe's one-minute throttle (a trigger inside the minute is
|
||||
* deferred to its end, not dropped).
|
||||
*
|
||||
* The listeners are attached in an effect and removed in its cleanup, which
|
||||
* also cancels a deferred trigger: nothing probes once the signed-in view is
|
||||
* gone, and a remount (StrictMode's double effect, a backend switch) never
|
||||
* accumulates listeners or timers — the very leak that made the provider's
|
||||
* built-in drive unusable here (usage declaration, friction 1). The probe
|
||||
* instance — and with it the throttle — outlives those remounts in a ref, for
|
||||
* the life of the page.
|
||||
*/
|
||||
export function useAppStatusProbe({ provider, active, onStale }: UseAppStatusProbeOptions): void {
|
||||
const onStaleRef = useRef(onStale);
|
||||
onStaleRef.current = onStale;
|
||||
const probeRef = useRef<{ provider: AuthProvider; probe: AppStatusProbe } | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active || !provider) return;
|
||||
if (probeRef.current?.provider !== provider) {
|
||||
probeRef.current = {
|
||||
provider,
|
||||
probe: createAppStatusProbe({
|
||||
hasSession: () => provider.getSession() !== undefined,
|
||||
getAppStatus: () => provider.getAppStatus(),
|
||||
onStale: (reason) => onStaleRef.current(reason),
|
||||
}),
|
||||
};
|
||||
}
|
||||
const { probe } = probeRef.current;
|
||||
|
||||
void probe.run('load');
|
||||
const onFocus = () => { void probe.run('focus'); };
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible') void probe.run('visibility');
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
probe.cancelPending();
|
||||
};
|
||||
}, [active, provider]);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useLayoutEffect } from 'react';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button, Avatar, EventCover, EventMeetingPoints, showToast, Text, type MeetingPointData } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
@@ -15,8 +16,19 @@ export function EventDetailScreen() {
|
||||
getEventParticipants,
|
||||
getEventMeetingPoints,
|
||||
getEventOwnership,
|
||||
openEvent,
|
||||
getEventReadState,
|
||||
} = useFestipodData();
|
||||
|
||||
// ANOTHER USER'S EVENT IS NOT IN THE OWN LIST. Découvrir links to events from
|
||||
// every pod; the data layer reads one it does not hold only when asked. Asked
|
||||
// in a LAYOUT effect so the request lands before the first paint — the
|
||||
// "not found" state must never flash for an event that is about to load.
|
||||
useLayoutEffect(() => {
|
||||
if (eventId) openEvent(eventId);
|
||||
}, [eventId, openEvent]);
|
||||
const readState = eventId ? getEventReadState(eventId) : 'unavailable';
|
||||
|
||||
const event = eventId ? getEvent(eventId) : undefined;
|
||||
const participants = eventId ? getEventParticipants(eventId) : [];
|
||||
const meetingPointsRaw = eventId ? getEventMeetingPoints(eventId) : [];
|
||||
@@ -27,6 +39,8 @@ export function EventDetailScreen() {
|
||||
when: mp.time,
|
||||
duration: '~60 min',
|
||||
lieu: mp.location,
|
||||
// Derived by the data layer (absent where it cannot be counted per point).
|
||||
participantCount: mp.participantCount,
|
||||
}));
|
||||
|
||||
// SIGNING UP IS PER MEETING POINT — the product model (on s'inscrit à un
|
||||
@@ -85,7 +99,7 @@ export function EventDetailScreen() {
|
||||
<span style={{ fontSize: 18, fontWeight: 700 }}>Événement</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text>Événement non trouvé</Text>
|
||||
<Text>{readState === 'loading' ? "Chargement de l'événement…" : 'Événement non trouvé'}</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ArrowLeft } from 'lucide-react';
|
||||
import { Header, Input, Card, Badge, BottomNav, AvatarStack, getEventPhotoUrl, Text } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { eventPath, useNavigate } from '../../../app/router';
|
||||
import { useDiscoverEvents } from '../useDiscoverEvents';
|
||||
|
||||
const PEOPLE = [
|
||||
{ name: 'Marie Leroy', color: '#E8590C' },
|
||||
@@ -15,13 +16,16 @@ const EVENT_COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E'];
|
||||
|
||||
function EventCard({
|
||||
event,
|
||||
participantCount,
|
||||
onClick,
|
||||
}: {
|
||||
event: { id: string; title: string; date: string; location: string; distance?: number; participantCount: number };
|
||||
event: { id: string; title: string; date: string; location: string; distance?: number };
|
||||
/** `undefined` when nobody counted it (a catalog entry) — then no count is shown, never a made-up 0. */
|
||||
participantCount: number | undefined;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const color = EVENT_COLORS[Number(event.id.replace(/\D/g, '') || 0) % EVENT_COLORS.length] ?? '#E8590C';
|
||||
const people = PEOPLE.slice(0, Math.min(5, Math.max(1, event.participantCount)));
|
||||
const people = PEOPLE.slice(0, Math.min(5, Math.max(1, participantCount ?? 0)));
|
||||
return (
|
||||
<Card onClick={onClick} style={{ marginBottom: 12, padding: 0, overflow: 'hidden' }} accentColor={color}>
|
||||
<div
|
||||
@@ -37,12 +41,14 @@ function EventCard({
|
||||
<div style={{ fontSize: 12.5, color: '#888', marginBottom: 10 }}>
|
||||
{event.date} · {event.location}{event.distance != null && ` · ${event.distance} km`}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<AvatarStack people={people} size={26} />
|
||||
<span style={{ fontSize: 12, color: '#666' }}>{event.participantCount} inscrits</span>
|
||||
{participantCount !== undefined && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
<AvatarStack people={people} size={26} />
|
||||
<span style={{ fontSize: 12, color: '#666' }}>{participantCount} inscrits</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -50,7 +56,8 @@ function EventCard({
|
||||
|
||||
export function EventsScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { events } = useFestipodData();
|
||||
const { events: ownEvents, getEvent } = useFestipodData();
|
||||
const events = useDiscoverEvents(ownEvents);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
@@ -76,9 +83,20 @@ export function EventsScreen() {
|
||||
Aucun événement à afficher
|
||||
</Text>
|
||||
)}
|
||||
{events.map(event => (
|
||||
<EventCard key={event.id} event={event} onClick={() => navigate(eventPath(event.id))} />
|
||||
))}
|
||||
{events.map(event => {
|
||||
// An event the data layer READ (own, or another user's opened by URI)
|
||||
// carries a derived count; a catalog entry nobody opened carries none —
|
||||
// its card shows no count rather than a misleading "0 inscrits".
|
||||
const held = getEvent(event.id);
|
||||
return (
|
||||
<EventCard
|
||||
key={event.id}
|
||||
event={held ?? event}
|
||||
participantCount={held?.participantCount}
|
||||
onClick={() => navigate(eventPath(event.id))}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<BottomNav active="discover" />
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { FpEventData } from '../../shared/data/types';
|
||||
import { fetchCatalogEvents, unionEvents } from '../../shared/data/catalog';
|
||||
import { useCurrentSession } from '../../shared/utils/currentSession';
|
||||
import { getBackendUrl } from '../../shared/utils/runtimeConfig';
|
||||
|
||||
/**
|
||||
* The Découvrir list: the user's own events plus the catalog's, de-duplicated.
|
||||
* Connected mode only — without an ActivityPods session (demo) or a configured
|
||||
* backend the catalog is never called and the list is `own` unchanged.
|
||||
*/
|
||||
export function useDiscoverEvents(own: FpEventData[]): FpEventData[] {
|
||||
const session = useCurrentSession();
|
||||
const backendUrl = getBackendUrl();
|
||||
const connected = !!session && !!backendUrl;
|
||||
const [catalog, setCatalog] = useState<FpEventData[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected || !backendUrl) {
|
||||
setCatalog([]);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
void fetchCatalogEvents(backendUrl, controller.signal).then(events => {
|
||||
if (!controller.signal.aborted) setCatalog(events);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [connected, backendUrl]);
|
||||
|
||||
return useMemo(() => unionEvents(own, catalog), [own, catalog]);
|
||||
}
|
||||
@@ -7,6 +7,8 @@ export interface MeetingPointData {
|
||||
when: string;
|
||||
duration: string;
|
||||
lieu: string;
|
||||
/** How many signed up to this point — shown when the caller knows it, omitted otherwise. */
|
||||
participantCount?: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -56,6 +58,12 @@ export function EventMeetingPoints({ points, joinedIds, onToggle, expanded = fal
|
||||
🕒 {p.when} · {p.duration}
|
||||
<br />
|
||||
📍 {p.lieu}
|
||||
{p.participantCount !== undefined && (
|
||||
<>
|
||||
<br />
|
||||
👥 {p.participantCount} inscrit{p.participantCount > 1 ? 's' : ''}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
|
||||
@@ -56,7 +56,7 @@ import {
|
||||
// Re-exported so existing importers of this module keep their surface: the
|
||||
// hook, the context object, the demo provider, and the shared types.
|
||||
export { useFestipodData, FestipodDataContext } from './festipodDataBase';
|
||||
export type { FestipodDataContextValue, EventOwnership } from './festipodDataBase';
|
||||
export type { FestipodDataContextValue, EventOwnership, EventReadState } from './festipodDataBase';
|
||||
export { resolveDataProviderMode } from './dataProviderMode';
|
||||
export type { DataProviderMode } from './dataProviderMode';
|
||||
|
||||
|
||||
@@ -41,6 +41,15 @@ import { normalizeIdentifier } from '../utils/identifier';
|
||||
*/
|
||||
export type EventOwnership = 'mine' | 'not-mine' | 'unknown';
|
||||
|
||||
/**
|
||||
* Whether `getEvent` can answer for an event: `'ready'` (it is in the read
|
||||
* set), `'loading'` (a read that would bring it in is in flight) or
|
||||
* `'unavailable'` (not held and nothing is reading it — it does not exist, or
|
||||
* this session may not read it). A screen renders "not found" on
|
||||
* `'unavailable'` only, never while `'loading'`.
|
||||
*/
|
||||
export type EventReadState = 'ready' | 'loading' | 'unavailable';
|
||||
|
||||
export interface FestipodDataContextValue {
|
||||
currentUserId: string;
|
||||
currentUser: FpUserData | undefined;
|
||||
@@ -73,6 +82,20 @@ export interface FestipodDataContextValue {
|
||||
* own case — see {@link EventOwnership}.
|
||||
*/
|
||||
getEventOwnership(eventId: string): EventOwnership;
|
||||
/**
|
||||
* Bring an event this session does not hold into its read set, by its URI —
|
||||
* another user's event, reached through discovery (the Découvrir list links
|
||||
* to it). Once read, the per-event queries (`getEvent`,
|
||||
* `getEventMeetingPoints`, `getEventParticipants`, `isParticipating`) and
|
||||
* `joinMeetingPoint` / `leaveMeetingPoint` answer for it as for an own
|
||||
* event, with its meeting points and their counts; `events` stays the
|
||||
* session's OWN list. Idempotent; a no-op for an event already held and on
|
||||
* backends whose read set is already everything they can read. A state
|
||||
* change: call it from an effect, never during render.
|
||||
*/
|
||||
openEvent(eventId: string): void;
|
||||
/** Where `getEvent(eventId)` stands — see {@link EventReadState}. */
|
||||
getEventReadState(eventId: string): EventReadState;
|
||||
|
||||
selectedEventId: string;
|
||||
setSelectedEventId(id: string): void;
|
||||
@@ -206,9 +229,25 @@ export function buildQueries(
|
||||
return meetingPoints.filter(mp => mp.eventId === eventId);
|
||||
};
|
||||
|
||||
return { getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getFriends, getEventMeetingPoints };
|
||||
// A provider whose read set is everything it can read answers from its
|
||||
// arrays: held → ready, otherwise there is nothing more to read.
|
||||
const getEventReadState = (id: string): EventReadState =>
|
||||
events.some(e => e.id === id) ? 'ready' : 'unavailable';
|
||||
|
||||
return {
|
||||
getEvent, getUser, getEventParticipants, getUserEvents, isParticipating, getFriends, getEventMeetingPoints,
|
||||
openEvent: openEventInCompleteReadSet,
|
||||
getEventReadState,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `openEvent` for a provider whose read set is already everything it can read
|
||||
* (the local fixtures, the suspended NextGraph path): nothing to bring in. One
|
||||
* module-level function, so its identity is stable across renders.
|
||||
*/
|
||||
function openEventInCompleteReadSet(_eventId: string): void {}
|
||||
|
||||
// ============================================================================
|
||||
// Hook
|
||||
// ============================================================================
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Read-your-writes of a join/leave, driven through the REAL data provider
|
||||
* (`RefineDataProvider` over `useFestipodData`) against a scripted server.
|
||||
*
|
||||
* THE RACE (rights matrix, line `leave.ui-counts`, 1 run in 5). A join or leave
|
||||
* is posted to the user's outbox and the backend updates the meeting point's
|
||||
* attendees collection LATER. When the reads that follow one act all land
|
||||
* before the backend has processed it, the act is never confirmed; if the user
|
||||
* then makes the opposite act, the read still on screen (from BEFORE the first
|
||||
* act) already shows what the second act asserts. Then the next read — the
|
||||
* first act processed, the second not yet — shows the opposite, and the view
|
||||
* must not follow it.
|
||||
*
|
||||
* THE SCRIPT IS ORDERED BY READS, NOT BY CLOCK. The server answers the
|
||||
* collection from `server.members`, and the test changes it only after counting
|
||||
* the reads served since an act — so the ordering "act → stale read → backend
|
||||
* processes → read" is the same on every run. Only the data provider's own
|
||||
* re-read timers run on the real clock.
|
||||
*
|
||||
* What is stubbed: the Refine data provider (Alice's own list: empty, or her
|
||||
* own meeting point) and `fetch` (catalog, records, collection, actor
|
||||
* document, outbox). Everything between them — the foreign read set, the
|
||||
* collection reads, the overlay, the counts — is the production code.
|
||||
*
|
||||
* THE OVERLAY MUST ALSO GO. A view that only ever shows the overlay would pass
|
||||
* every count check above; what proves the act was RELEASED (confirmed by a
|
||||
* read) is that the re-reads stop: they run only while an act is pending.
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, expect, test } from 'bun:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import { useLayoutEffect, type ReactNode } from 'react';
|
||||
import type { DataProvider } from '@refinedev/core';
|
||||
import { activitypods } from '../refine/providers/activitypods';
|
||||
import { setCurrentSession } from '../utils/currentSession';
|
||||
import { setRuntimeConfig } from '../utils/runtimeConfig';
|
||||
import { useFestipodData, type FestipodDataContextValue } from './festipodDataBase';
|
||||
|
||||
const ALICE = 'http://alice.pod.test/alice';
|
||||
const ALICE_OUTBOX = 'http://alice.pod.test/alice/outbox';
|
||||
const BOB = 'http://bob.pod.test/bob';
|
||||
const E = 'http://bob.pod.test/bob/data/events/concert';
|
||||
const M = 'http://bob.pod.test/bob/data/events/concert-bar';
|
||||
const C = 'http://bob.pod.test/bob/data/events/concert-bar/attendees';
|
||||
const BACKEND = 'http://backend.test';
|
||||
|
||||
const EVENT_RECORD = { id: E, type: 'Event', name: 'Concert', startTime: '2026-11-01T20:00:00', 'dc:creator': BOB, to: 'as:Public' };
|
||||
const MP_RECORD = { id: M, type: 'Event', location: 'Au bar', 'as:context': E, 'dc:creator': BOB, 'apods:attendees': C, to: 'as:Public' };
|
||||
/** M as Bob's pod serves it before the backend attached its collection. */
|
||||
const MP_RECORD_UNLINKED = { id: M, type: 'Event', location: 'Au bar', 'as:context': E, 'dc:creator': BOB, to: 'as:Public' };
|
||||
/** M as ALICE's own meeting point on Bob's event, as her pod lists it before the backend attached its collection. */
|
||||
const OWN_MP_UNLINKED = { id: M, type: 'Event', location: 'Au bar', 'as:context': E, 'dc:creator': ALICE, to: 'as:Public' };
|
||||
|
||||
/**
|
||||
* The scripted server: the collection's members, how many reads of it were
|
||||
* served, and Alice's own list (`own`, with M's collection link once `ownLinked`).
|
||||
*/
|
||||
const server = {
|
||||
members: [] as string[],
|
||||
reads: 0,
|
||||
posted: [] as string[],
|
||||
own: [] as Record<string, unknown>[],
|
||||
ownLinked: false,
|
||||
/** Whether Bob's pod serves M with its collection link yet. */
|
||||
mpLinked: true,
|
||||
};
|
||||
|
||||
function json(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/ld+json' } });
|
||||
}
|
||||
|
||||
async function scriptedFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
if (init?.signal?.aborted) throw new DOMException('aborted', 'AbortError');
|
||||
if ((init?.method ?? 'GET') === 'POST') {
|
||||
if (url !== ALICE_OUTBOX) return new Response(null, { status: 405 });
|
||||
server.posted.push(String((JSON.parse(String(init?.body)) as { type?: unknown }).type));
|
||||
return new Response(null, { status: 201 });
|
||||
}
|
||||
switch (url) {
|
||||
case ALICE: return json({ id: ALICE, type: 'Person', outbox: ALICE_OUTBOX });
|
||||
case `${BACKEND}/catalog`: return json({ type: 'OrderedCollection', items: [EVENT_RECORD, MP_RECORD] });
|
||||
case E: return json(EVENT_RECORD);
|
||||
case M: return json(server.mpLinked ? MP_RECORD : MP_RECORD_UNLINKED);
|
||||
case C:
|
||||
server.reads++;
|
||||
return json({ id: C, type: 'Collection', items: [...server.members] });
|
||||
default: return new Response('not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Alice's pod: her own list is `server.own` (the attendees link appears once the backend attached it). */
|
||||
const alicePod = {
|
||||
getList: async () => {
|
||||
const data = server.own.map(record => (server.ownLinked ? { ...record, 'apods:attendees': C } : record));
|
||||
return { data, total: data.length };
|
||||
},
|
||||
getOne: async () => { throw new Error('no record'); },
|
||||
getMany: async () => ({ data: [] }),
|
||||
create: async () => { throw new Error('read-only'); },
|
||||
update: async () => { throw new Error('read-only'); },
|
||||
deleteOne: async () => { throw new Error('read-only'); },
|
||||
getApiUrl: () => 'http://alice.pod.test',
|
||||
} as unknown as DataProvider;
|
||||
|
||||
let latest: FestipodDataContextValue | undefined;
|
||||
function Probe() {
|
||||
const data = useFestipodData();
|
||||
latest = data;
|
||||
// What the event detail screen does: ask for the event it shows.
|
||||
const { openEvent } = data;
|
||||
useLayoutEffect(() => { openEvent(E); }, [openEvent]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- environment ---------------------------------------------------------------
|
||||
const saved = { fetch: globalThis.fetch, build: activitypods.build, log: console.log, warn: console.warn };
|
||||
const installed: string[] = [];
|
||||
let createRoot: (typeof import('react-dom/client'))['createRoot'];
|
||||
let RefineDataProvider: (props: { children: ReactNode }) => ReactNode;
|
||||
let root: ReturnType<typeof createRoot> | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const window = new Window({ url: 'http://localhost:4000/' });
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
for (const [name, value] of Object.entries({ window, document: window.document, navigator: window.navigator })) {
|
||||
if (g[name] !== undefined) continue;
|
||||
Object.defineProperty(globalThis, name, { value, writable: true, configurable: true });
|
||||
installed.push(name);
|
||||
}
|
||||
globalThis.fetch = scriptedFetch as typeof fetch;
|
||||
activitypods.build = () => ({ dataProvider: alicePod });
|
||||
console.log = () => {};
|
||||
console.warn = () => {};
|
||||
createRoot = (await import('react-dom/client')).createRoot;
|
||||
RefineDataProvider = (await import('./refineData')).RefineDataProvider;
|
||||
setRuntimeConfig({ backendUrl: BACKEND });
|
||||
setCurrentSession({ webId: ALICE, token: 'alice-token' });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
root?.unmount();
|
||||
root = undefined;
|
||||
latest = undefined;
|
||||
// Let React run the work the unmount scheduled while the DOM globals still exist.
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
setCurrentSession(undefined);
|
||||
setRuntimeConfig({});
|
||||
globalThis.fetch = saved.fetch;
|
||||
activitypods.build = saved.build;
|
||||
console.log = saved.log;
|
||||
console.warn = saved.warn;
|
||||
for (const name of installed) delete (globalThis as Record<string, unknown>)[name];
|
||||
});
|
||||
|
||||
// --- helpers -----------------------------------------------------------------------
|
||||
async function until(condition: () => boolean, what: string, timeoutMs = 10000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (!condition()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error(`timed out waiting for ${what}`);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
}
|
||||
|
||||
/** Long enough for a served read to land in React state (in-process, no network). */
|
||||
const land = () => new Promise(resolve => setTimeout(resolve, 150));
|
||||
|
||||
/** What Alice's event detail shows: the event header count, M's count, and M's button. */
|
||||
function view(): { event?: number; mp?: number; joined: boolean } {
|
||||
const data = latest;
|
||||
if (!data) return { joined: false };
|
||||
const mp = data.getEventMeetingPoints(E).find(point => point.id === M);
|
||||
return { event: data.getEvent(E)?.participantCount, mp: mp?.participantCount, joined: data.isParticipating(M) };
|
||||
}
|
||||
|
||||
/** Poll `view()` until it matches, or give back the last one seen. */
|
||||
async function settledView(match: (v: ReturnType<typeof view>) => boolean, timeoutMs: number): Promise<ReturnType<typeof view>> {
|
||||
const start = Date.now();
|
||||
let current = view();
|
||||
while (!match(current) && Date.now() - start < timeoutMs) {
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
current = view();
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
function render(members: string[], own: Record<string, unknown>[] = [], mpLinked = true): void {
|
||||
server.members = members;
|
||||
server.reads = 0;
|
||||
server.posted = [];
|
||||
server.own = own;
|
||||
server.ownLinked = false;
|
||||
server.mpLinked = mpLinked;
|
||||
const host = document.createElement('div');
|
||||
root = createRoot(host as unknown as Element);
|
||||
root.render(<RefineDataProvider><Probe /></RefineDataProvider>);
|
||||
}
|
||||
|
||||
async function mount(members: string[]): Promise<void> {
|
||||
render(members);
|
||||
await until(() => server.reads >= 1, 'the first read of the attendees collection');
|
||||
await until(() => view().mp === members.length, `M listed with ${members.length} inscrit`);
|
||||
}
|
||||
|
||||
/**
|
||||
* How many reads followed the act by `untilMs` after it — a point later than
|
||||
* the step of the re-read schedule after the confirming read. The schedule runs
|
||||
* only while an act is pending, so an act released by its confirming read
|
||||
* leaves exactly that many; one never released (or never re-read) does not.
|
||||
* Never throws: the count goes into the compared timeline.
|
||||
*/
|
||||
async function readsBy(servedBefore: number, actAt: number, untilMs: number): Promise<number> {
|
||||
await sleep(Math.max(0, actAt + untilMs - Date.now()));
|
||||
return server.reads - servedBefore;
|
||||
}
|
||||
|
||||
const NOT_JOINED = { event: 0, mp: 0, joined: false };
|
||||
const JOINED = { event: 1, mp: 1, joined: true };
|
||||
|
||||
// --- the race, both directions -------------------------------------------------------
|
||||
|
||||
test('a leave following a still-unconfirmed join is never undone by a read that predates the leave', async () => {
|
||||
await mount([]);
|
||||
|
||||
// JOIN — the backend has not processed it when the reads that follow it land.
|
||||
let served = server.reads;
|
||||
await latest!.joinMeetingPoint(M);
|
||||
await until(() => server.reads >= served + 2, 'two reads after the join');
|
||||
await land();
|
||||
const afterJoin = view();
|
||||
|
||||
// The backend processes the join now — no read has seen it.
|
||||
server.members = [ALICE];
|
||||
|
||||
// LEAVE — the backend processes it only after two more reads.
|
||||
served = server.reads;
|
||||
await latest!.leaveMeetingPoint(M);
|
||||
const leftAt = Date.now();
|
||||
await until(() => server.reads >= served + 1, 'the first read after the leave');
|
||||
await land();
|
||||
const afterLeaveFirstRead = view();
|
||||
await until(() => server.reads >= served + 2, 'the second read after the leave');
|
||||
await land();
|
||||
const afterLeaveSecondRead = view();
|
||||
|
||||
// The backend processes the leave.
|
||||
server.members = [];
|
||||
const afterBackendProcessedLeave = await settledView(v => v.mp === 0 && v.event === 0 && !v.joined, 8000);
|
||||
// The third read (the 3 s step) confirms the leave; the 6 s step must find nothing pending.
|
||||
const readsAfterLeave = await readsBy(served, leftAt, 7000);
|
||||
|
||||
expect(server.posted).toEqual(['Join', 'Leave']);
|
||||
expect({ afterJoin, afterLeaveFirstRead, afterLeaveSecondRead, afterBackendProcessedLeave, readsAfterLeave }).toEqual({
|
||||
afterJoin: JOINED,
|
||||
afterLeaveFirstRead: NOT_JOINED,
|
||||
afterLeaveSecondRead: NOT_JOINED,
|
||||
afterBackendProcessedLeave: NOT_JOINED,
|
||||
readsAfterLeave: 3,
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
test('a join following a still-unconfirmed leave is never undone by a read that predates the join', async () => {
|
||||
await mount([ALICE]);
|
||||
expect(view()).toEqual(JOINED);
|
||||
|
||||
// LEAVE — the backend has not processed it when the reads that follow it land.
|
||||
let served = server.reads;
|
||||
await latest!.leaveMeetingPoint(M);
|
||||
await until(() => server.reads >= served + 2, 'two reads after the leave');
|
||||
await land();
|
||||
const afterLeave = view();
|
||||
|
||||
// The backend processes the leave now — no read has seen it.
|
||||
server.members = [];
|
||||
|
||||
// JOIN — the backend processes it only after two more reads.
|
||||
served = server.reads;
|
||||
await latest!.joinMeetingPoint(M);
|
||||
const joinedAt = Date.now();
|
||||
await until(() => server.reads >= served + 1, 'the first read after the join');
|
||||
await land();
|
||||
const afterJoinFirstRead = view();
|
||||
await until(() => server.reads >= served + 2, 'the second read after the join');
|
||||
await land();
|
||||
const afterJoinSecondRead = view();
|
||||
|
||||
// The backend processes the join.
|
||||
server.members = [ALICE];
|
||||
const afterBackendProcessedJoin = await settledView(v => v.mp === 1 && v.event === 1 && v.joined, 8000);
|
||||
const readsAfterJoin = await readsBy(served, joinedAt, 7000);
|
||||
|
||||
expect(server.posted).toEqual(['Leave', 'Join']);
|
||||
expect({ afterLeave, afterJoinFirstRead, afterJoinSecondRead, afterBackendProcessedJoin, readsAfterJoin }).toEqual({
|
||||
afterLeave: NOT_JOINED,
|
||||
afterJoinFirstRead: JOINED,
|
||||
afterJoinSecondRead: JOINED,
|
||||
afterBackendProcessedJoin: JOINED,
|
||||
readsAfterJoin: 3,
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
test('an own meeting point whose collection link arrives after the act is still re-read until a read confirms the act', async () => {
|
||||
// Alice's own list shows M before the backend attached its attendees collection.
|
||||
render([], [OWN_MP_UNLINKED]);
|
||||
await until(() => view().mp === 0, 'M listed (own, no collection linked yet)');
|
||||
// The backend attaches the collection now — no list read has seen it.
|
||||
server.ownLinked = true;
|
||||
|
||||
const served = server.reads;
|
||||
await latest!.joinMeetingPoint(M);
|
||||
const joinedAt = Date.now();
|
||||
// The list read the act triggers brings the link; the collection's first read predates the backend's processing.
|
||||
await until(() => server.reads >= served + 1, 'the first read of the newly linked collection');
|
||||
await land();
|
||||
const afterFirstRead = view();
|
||||
server.members = [ALICE]; // the backend processes the join
|
||||
|
||||
// The next scheduled step (1.5 s) re-reads the collection it did not know at
|
||||
// act time, that read confirms the join, and the 3 s step finds nothing pending.
|
||||
const readsAfterJoin = await readsBy(served, joinedAt, 4000);
|
||||
expect({ afterFirstRead, afterConfirmed: view(), readsAfterJoin }).toEqual({
|
||||
afterFirstRead: JOINED,
|
||||
afterConfirmed: JOINED,
|
||||
readsAfterJoin: 2,
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
test("another user's meeting point read before its collection was attached is re-read until a read confirms the act", async () => {
|
||||
// Bob's pod served M before the backend attached its attendees collection.
|
||||
render([], [], false);
|
||||
await until(() => view().mp === 0, 'M listed (no collection linked yet)');
|
||||
// The backend attaches the collection now — no read of M has seen it.
|
||||
server.mpLinked = true;
|
||||
|
||||
const served = server.reads;
|
||||
await latest!.joinMeetingPoint(M);
|
||||
const joinedAt = Date.now();
|
||||
// M is read again by URI, which brings the link; the collection's first read predates the backend's processing.
|
||||
await until(() => server.reads >= served + 1, 'the first read of the newly linked collection');
|
||||
await land();
|
||||
const afterFirstRead = view();
|
||||
server.members = [ALICE]; // the backend processes the join
|
||||
|
||||
// The 1.5 s step re-reads the collection, that read confirms the join, and the 3 s step finds nothing pending.
|
||||
const readsAfterJoin = await readsBy(served, joinedAt, 4000);
|
||||
expect({ afterFirstRead, afterConfirmed: view(), readsAfterJoin }).toEqual({
|
||||
afterFirstRead: JOINED,
|
||||
afterConfirmed: JOINED,
|
||||
readsAfterJoin: 2,
|
||||
});
|
||||
}, 30000);
|
||||
@@ -7,22 +7,44 @@
|
||||
*
|
||||
* WHAT GOES THROUGH REFINE. Everything CRUD: the event list (`useList` over
|
||||
* the `as:Event` container — the pod provider is own-pod scoped, so the list
|
||||
* is what THIS user relayed; cross-pod discovery is the catalog's job, a
|
||||
* follow-up behind this same seam), the meeting point create, the event and
|
||||
* is what THIS user relayed), the meeting point create, the event and
|
||||
* profile updates. Records are split into events vs meeting points BY THE
|
||||
* BINDING (`isInstance`) — the two share one container and `as:context`
|
||||
* presence is the only distinction.
|
||||
*
|
||||
* THE FOREIGN READ SET — another user's event, opened from Découvrir. The own
|
||||
* list never holds it, so a screen asks for it (`openEvent`) and it is read BY
|
||||
* URI. Its meeting points may live in other pods, and the backend catalog is
|
||||
* the only cross-user index of them: the catalog names their URIs
|
||||
* (`data/catalog.ts`) for every opened event — own ones included, so an owner
|
||||
* sees the meeting points others proposed on their event — and each is read by
|
||||
* URI. Those reads are direct reads (`data/publicRead.ts`), not the data
|
||||
* provider's `getOne`/`getMany`: the URIs are chosen by other users, and the
|
||||
* contract does not say a provider read can go out without the session's
|
||||
* token — so they are ANONYMOUS everywhere but on the session's own origin,
|
||||
* bounded in time, and each URI lands on its own. Foreign records join the own ones BEFORE the split, so every
|
||||
* per-event query and the join/leave flow treat them alike; `events` and
|
||||
* `meetingPoints` stay the own lists, and ownership stays the own list's answer.
|
||||
*
|
||||
* ATTENDEES COLLECTIONS ARE READ BY URI. The pod links a meeting point's
|
||||
* `apods:attendees` collection by URI and never embeds it (verified
|
||||
* 2026-10-12), while the participation binding counts an EMBEDDED collection.
|
||||
* So every meeting point's linked collection — own and foreign alike — is read
|
||||
* by URI the same way (public by design) and embedded before the split
|
||||
* (`data/attendees.ts`). A collection that cannot be read stays a bare link
|
||||
* and counts an honest zero.
|
||||
*
|
||||
* 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` activity to the user's own
|
||||
* outbox (`data/outbox.ts`), and the on-behalf backend maintains the
|
||||
* `apods:attendees` collection. The collection travels inside the event
|
||||
* record and is read by the participation binding (`membersOf`). A write made
|
||||
* beside Refine does not refresh its cache, and the backend updates the
|
||||
* collection asynchronously — so the join/leave reflects in an optimistic
|
||||
* overlay here and the lists are invalidated for the write to land when it
|
||||
* lands.
|
||||
* `apods:attendees` collection. The collection is read by URI and embedded
|
||||
* for the participation binding (`membersOf`). A write made beside Refine does
|
||||
* not refresh its cache, and the backend updates the collection
|
||||
* asynchronously — so the join/leave reflects in an optimistic overlay here,
|
||||
* held until a read issued AFTER the act confirms it (read-your-writes:
|
||||
* `settleMembershipActs` in `refineDerive.ts`), with a bounded schedule of
|
||||
* re-reads of that one collection meanwhile.
|
||||
*
|
||||
* THE COUNT AND THE ALICE/BOB RULE. The participant count of a meeting point
|
||||
* is the collection's total — never the number of RESOLVED profiles, so it
|
||||
@@ -37,8 +59,12 @@ import React, { useState, useCallback, useEffect, useMemo, useRef, type ReactNod
|
||||
import { useList, useOne, useCreate, useUpdate, useInvalidate } from '@refinedev/core';
|
||||
import type { FpEventData, FpUserData, FpMeetingPointData } from '../data/types';
|
||||
import { fetchActorDocument, fetchActorProfileRecord } from '../data/actor';
|
||||
import { attendeesCollectionLink, embedAttendeesCollection } from '../data/attendees';
|
||||
import { tokenFor } from '../data/publicRead';
|
||||
import { fetchCatalogMeetingPointIds } from '../data/catalog';
|
||||
import { postJoinActivity, postLeaveActivity } from '../data/outbox';
|
||||
import { useCurrentSession } from '../utils/currentSession';
|
||||
import { getBackendUrl } from '../utils/runtimeConfig';
|
||||
import { useCurrentPrincipal } from '../utils/currentPrincipal';
|
||||
import type { BootstrapResult } from '../utils/ngBootstrap';
|
||||
import { RefineProvider, useBackend } from '../refine/RefineProvider';
|
||||
@@ -50,12 +76,21 @@ import {
|
||||
nextId,
|
||||
type FestipodDataContextValue,
|
||||
type EventOwnership,
|
||||
type EventReadState,
|
||||
} from './festipodDataBase';
|
||||
import {
|
||||
applyMembershipOverlay,
|
||||
countsWithOverlay,
|
||||
deriveContainer,
|
||||
type MembershipOverlay,
|
||||
expireMembershipAct,
|
||||
MEMBERSHIP_REREAD_DELAYS_MS,
|
||||
overlayOfPendingActs,
|
||||
recordMembershipAct,
|
||||
settleMembershipActs,
|
||||
type MembershipOp,
|
||||
type PendingActs,
|
||||
} from './refineDerive';
|
||||
import { usePublicRecords, useReadGenerations } from './usePublicRecords';
|
||||
|
||||
export function RefineDataProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
@@ -91,6 +126,7 @@ function inertDataValue(): FestipodDataContextValue {
|
||||
getEvent: () => undefined, getUser: () => undefined,
|
||||
getEventParticipants: () => [], getUserEvents: () => [], isParticipating: () => false,
|
||||
getFriends: () => [], getEventMeetingPoints: () => [], getEventOwnership: (): EventOwnership => 'unknown',
|
||||
openEvent: () => {}, getEventReadState: (): EventReadState => 'unavailable',
|
||||
setSelectedEventId: () => {}, setSelectedUserId: () => {},
|
||||
createEvent: async (event: Omit<FpEventData, 'id'>) => ({ ...event, id: nextId('event') }),
|
||||
updateEvent: () => {}, joinMeetingPoint: () => {}, leaveMeetingPoint: () => {},
|
||||
@@ -108,6 +144,11 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
const session = useCurrentSession();
|
||||
const webId = session?.webId;
|
||||
const token = session?.token;
|
||||
// The origin that issued the session — the only one its token is sent to on
|
||||
// reads that follow other users' links.
|
||||
const trustedOrigin = useMemo(() => {
|
||||
try { return webId ? new URL(webId).origin : undefined; } catch { return undefined; }
|
||||
}, [webId]);
|
||||
|
||||
// WHO AM I — the Solid identity the session signed in as. On this backend the
|
||||
// ACTOR URI (`webId`) is the canonical user key: the attendees collection
|
||||
@@ -131,39 +172,199 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
[containerQuery.result.data],
|
||||
);
|
||||
const containerFailed = containerQuery.query.isError;
|
||||
// Settled (read, or failed) — only then is "not one of mine" an answer.
|
||||
const ownSettled = !containerQuery.query.isPending;
|
||||
|
||||
// What this session OWNS: the own container's records, split by the binding.
|
||||
// Ownership, `events` and `meetingPoints` are answered from this split only.
|
||||
const ownSplit = useMemo(() => deriveContainer(rawRecords, bindings), [rawRecords, bindings]);
|
||||
const ownEventIds = useMemo(() => new Set(ownSplit.events.map(e => e.id)), [ownSplit.events]);
|
||||
const ownMeetingPointIds = useMemo(
|
||||
() => new Set(ownSplit.meetingPoints.map(mp => mp.id)),
|
||||
[ownSplit.meetingPoints],
|
||||
);
|
||||
const ownMeetingPointIdsRef = useRef(ownMeetingPointIds);
|
||||
ownMeetingPointIdsRef.current = ownMeetingPointIds;
|
||||
|
||||
// --- OPENED EVENTS: the events screens asked to see ---------------------------
|
||||
// Grows only: an opened event stays in the read set for the page's life
|
||||
// (navigating back to it does not re-ask). Own events are opened too — the
|
||||
// catalog lookup of their meeting points serves the owner as well.
|
||||
const [openedIds, setOpenedIds] = useState<readonly string[]>([]);
|
||||
// Events this session created — owned from the moment the create returned.
|
||||
const [createdEventIds, setCreatedEventIds] = useState<readonly string[]>([]);
|
||||
// Opening an event again RETRIES what failed — a read that a pod blip or a
|
||||
// slow start past the read timeout lost, a catalog that could not be asked —
|
||||
// and re-reads nothing that already landed (`openCount` is the retry trigger).
|
||||
const [openCount, setOpenCount] = useState(0);
|
||||
// The catalog is re-asked on an open only when its last read FAILED — a ref,
|
||||
// not state: a failure must not itself trigger a re-ask (no loop against a
|
||||
// catalog that alternates between failing and answering).
|
||||
const catalogFailedRef = useRef(false);
|
||||
const [catalogAsk, setCatalogAsk] = useState(0);
|
||||
const openEvent = useCallback((eventId: string) => {
|
||||
if (!eventId) return;
|
||||
setOpenedIds(prev => (prev.includes(eventId) ? prev : [...prev, eventId]));
|
||||
setOpenCount(n => n + 1);
|
||||
if (catalogFailedRef.current) setCatalogAsk(n => n + 1);
|
||||
}, []);
|
||||
// Until the own list settles nothing is known to be foreign or not; an own
|
||||
// event is never re-read by URI. Keyed by a joined string so the array
|
||||
// identity only changes when the SET does (each own-list refetch builds a
|
||||
// new Set).
|
||||
const settledOpenedKey = ownSettled ? openedIds.join('\n') : '';
|
||||
const settledOpenedIds = useMemo(() => (settledOpenedKey ? settledOpenedKey.split('\n') : []), [settledOpenedKey]);
|
||||
const foreignEventKey = settledOpenedIds.filter(id => !ownEventIds.has(id)).join('\n');
|
||||
const foreignEventIds = useMemo(() => (foreignEventKey ? foreignEventKey.split('\n') : []), [foreignEventKey]);
|
||||
|
||||
// Another user's event, read by URI (`data/publicRead.ts`): the URI came
|
||||
// from the catalog or a link, so the read is anonymous — the token goes only
|
||||
// to the session's own origin. Unreadable → absent → "not found".
|
||||
const tokenOf = useCallback((uri: string) => tokenFor(uri, token, trustedOrigin), [token, trustedOrigin]);
|
||||
const foreignEventsRead = usePublicRecords(session ? foreignEventIds : [], { retry: openCount, tokenOf });
|
||||
|
||||
// The opened events' meeting points held in OTHER pods: the catalog names
|
||||
// them (the only cross-user index), fail-open — a catalog that cannot be read
|
||||
// leaves the event with only the meeting points the own list holds.
|
||||
const backendUrl = getBackendUrl();
|
||||
const [catalogMeetingPointIds, setCatalogMeetingPointIds] = useState<readonly string[]>([]);
|
||||
// Re-asked when the opened set changes, and — only after a catalog read
|
||||
// failed — on the next open (`catalogAsk`). A failed read keeps the meeting
|
||||
// points already listed (it is "could not ask", not "none").
|
||||
useEffect(() => {
|
||||
if (settledOpenedIds.length === 0 || !backendUrl || !session) {
|
||||
setCatalogMeetingPointIds(prev => (prev.length === 0 ? prev : []));
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
void fetchCatalogMeetingPointIds(backendUrl, settledOpenedIds, controller.signal).then(ids => {
|
||||
if (controller.signal.aborted) return;
|
||||
catalogFailedRef.current = ids === undefined;
|
||||
if (ids !== undefined) setCatalogMeetingPointIds(ids);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [settledOpenedIds, backendUrl, session, catalogAsk]);
|
||||
// An own meeting point (on an own event, or one this user proposed on
|
||||
// another's) is already in the own list.
|
||||
const foreignMeetingPointKey = catalogMeetingPointIds.filter(id => !ownMeetingPointIds.has(id)).join('\n');
|
||||
const foreignMeetingPointIds = useMemo(
|
||||
() => (foreignMeetingPointKey ? foreignMeetingPointKey.split('\n') : []),
|
||||
[foreignMeetingPointKey],
|
||||
);
|
||||
// Re-read on purpose only while an act waits on a meeting point whose record
|
||||
// carried no collection link yet (`afterMembershipAct`).
|
||||
const [meetingPointGenerations, readMeetingPointAgain] = useReadGenerations();
|
||||
const foreignMeetingPointsRead = usePublicRecords(foreignMeetingPointIds, {
|
||||
refresh: meetingPointGenerations,
|
||||
retry: openCount,
|
||||
tokenOf,
|
||||
});
|
||||
|
||||
// What was read is trusted for what it IS, not for what the catalog or the
|
||||
// route said it was: an "event" URI that reads as a meeting point (and the
|
||||
// reverse) is dropped by the binding's own test. Only the records of the
|
||||
// CURRENT id lists are taken (the read keeps earlier ones while it refreshes).
|
||||
const readRecords: unknown[] = useMemo(() => {
|
||||
const pick = (ids: readonly string[], read: ReadonlyMap<string, unknown>, keep: (record: unknown) => boolean) =>
|
||||
ids.flatMap(id => {
|
||||
const record = read.get(id);
|
||||
return record !== undefined && keep(record) ? [record] : [];
|
||||
});
|
||||
const foreignEvents = pick(foreignEventIds, foreignEventsRead.records,
|
||||
record => bindings.event.isInstance?.(record) ?? true);
|
||||
const foreignMeetingPoints = pick(foreignMeetingPointIds, foreignMeetingPointsRead.records,
|
||||
record => bindings.meetingPoint.isInstance?.(record) ?? false);
|
||||
return foreignEvents.length === 0 && foreignMeetingPoints.length === 0
|
||||
? rawRecords
|
||||
: [...rawRecords, ...foreignEvents, ...foreignMeetingPoints];
|
||||
}, [rawRecords, foreignEventIds, foreignEventsRead.records, foreignMeetingPointIds,
|
||||
foreignMeetingPointsRead.records, bindings]);
|
||||
|
||||
// --- ATTENDEES COLLECTIONS, read by URI and embedded --------------------------
|
||||
// Every meeting point's linked collection, own and foreign alike — public by
|
||||
// design, read by URI with the same token policy, keyed by the URI the record
|
||||
// links (never by what the fetched document claims). A join/leave re-reads
|
||||
// the ONE collection it changed (`readCollectionAgain` moves its generation);
|
||||
// a failed one is retried on the next open.
|
||||
const collectionOf = useMemo(() => {
|
||||
const links = new Map<string, string>(); // meeting point id → collection URI
|
||||
for (const record of readRecords) {
|
||||
if (!(bindings.meetingPoint.isInstance?.(record) ?? false)) continue;
|
||||
const link = attendeesCollectionLink(record);
|
||||
if (!link) continue;
|
||||
try {
|
||||
links.set(bindings.meetingPoint.domainId(record), link);
|
||||
} catch {
|
||||
// No identifier: the derive skips this record too.
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}, [readRecords, bindings]);
|
||||
// Read when a re-read FIRES, not when the act was made: an own meeting point
|
||||
// can be listed before the backend attached its collection, and the link
|
||||
// arrives with a later list read.
|
||||
const collectionOfRef = useRef(collectionOf);
|
||||
collectionOfRef.current = collectionOf;
|
||||
const collectionKey = useMemo(() => [...new Set(collectionOf.values())].join('\n'), [collectionOf]);
|
||||
const collectionIds = useMemo(() => (collectionKey ? collectionKey.split('\n') : []), [collectionKey]);
|
||||
// An act learns the generation of the read it triggers at once — that
|
||||
// number is what may confirm the act.
|
||||
const [collectionGenerations, readCollectionAgain] = useReadGenerations();
|
||||
const collectionsRead = usePublicRecords(session ? collectionIds : [], {
|
||||
refresh: collectionGenerations,
|
||||
retry: openCount,
|
||||
tokenOf,
|
||||
});
|
||||
const embeddedRecords = useMemo(
|
||||
() => (collectionsRead.records.size === 0
|
||||
? readRecords
|
||||
: readRecords.map(record => embedAttendeesCollection(record, collectionsRead.records))),
|
||||
[readRecords, collectionsRead.records],
|
||||
);
|
||||
|
||||
// --- SPLIT + PARTICIPATION/COUNT DERIVATION (vocabulary lives in bindings) --
|
||||
const derived = useMemo(() => deriveContainer(rawRecords, bindings), [rawRecords, bindings]);
|
||||
const derived = useMemo(() => deriveContainer(embeddedRecords, bindings), [embeddedRecords, bindings]);
|
||||
|
||||
// --- OPTIMISTIC MEMBERSHIP OVERLAY ------------------------------------------
|
||||
// `watchShape`-style latency, Refine edition: the outbox post returns before
|
||||
// the backend has maintained the collection, and the container re-fetch can
|
||||
// still precede it. The overlay reflects the join/leave immediately and
|
||||
// reconciles away once the read carries the truth — no polling.
|
||||
const [overlay, setOverlay] = useState<MembershipOverlay>(new Map());
|
||||
// --- OPTIMISTIC MEMBERSHIP OVERLAY (read-your-writes) ----------------------
|
||||
// The outbox post returns before the backend has maintained the collection,
|
||||
// and a read can land on either side of that. Each act is held as a pending
|
||||
// act and reflected at once; it goes only when a read ISSUED AFTER it
|
||||
// confirms it, or when the bounded wait runs out and a fresh re-read is
|
||||
// taken as the truth (`settleMembershipActs`). The read on screen merely
|
||||
// showing the asserted state is not a confirmation: it may predate the act.
|
||||
const [pendingActs, setPendingActs] = useState<PendingActs>(new Map());
|
||||
const pendingActsRef = useRef(pendingActs);
|
||||
pendingActsRef.current = pendingActs;
|
||||
const overlay = useMemo(() => overlayOfPendingActs(pendingActs), [pendingActs]);
|
||||
|
||||
const participations = useMemo(
|
||||
() => applyMembershipOverlay(derived.participations, overlay, webId),
|
||||
[derived.participations, overlay, webId],
|
||||
);
|
||||
// The counts move with the overlay, so a join shows in the number at once.
|
||||
const counted = useMemo(() => countsWithOverlay(derived, overlay, webId), [derived, overlay, webId]);
|
||||
const allEvents = counted.events;
|
||||
const allMeetingPoints = counted.meetingPoints;
|
||||
const ownEvents = useMemo(() => allEvents.filter(e => ownEventIds.has(e.id)), [allEvents, ownEventIds]);
|
||||
const ownMeetingPoints = useMemo(
|
||||
() => allMeetingPoints.filter(mp => ownMeetingPointIds.has(mp.id)),
|
||||
[allMeetingPoints, ownMeetingPointIds],
|
||||
);
|
||||
|
||||
// RECONCILIATION — drop each overlay entry once the container read carries
|
||||
// what it asserts (join: membership present; leave: membership absent).
|
||||
// RECONCILIATION — confront each pending act with the read: what it shows for
|
||||
// the session on that meeting point, and which read generation it comes from.
|
||||
useEffect(() => {
|
||||
if (overlay.size === 0 || !webId) return;
|
||||
setOverlay(prev => {
|
||||
let changed = false;
|
||||
const next = new Map(prev);
|
||||
for (const [mpId, op] of prev) {
|
||||
const present = derived.participations.some(p => p.eventId === mpId && p.userId === webId);
|
||||
if ((op === 'join' && present) || (op === 'leave' && !present)) {
|
||||
next.delete(mpId);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [derived.participations, overlay, webId]);
|
||||
if (pendingActs.size === 0 || !webId) return;
|
||||
setPendingActs(prev => settleMembershipActs(prev, mpId => {
|
||||
if (!derived.meetingPoints.some(mp => mp.id === mpId)) return undefined;
|
||||
const collection = collectionOf.get(mpId);
|
||||
return {
|
||||
member: derived.participations.some(p => p.eventId === mpId && p.userId === webId),
|
||||
generation: collection === undefined ? undefined : collectionsRead.readAt.get(collection),
|
||||
attempted: collection === undefined ? undefined : collectionsRead.attemptedAt.get(collection),
|
||||
};
|
||||
}));
|
||||
}, [pendingActs, derived, collectionOf, collectionsRead.readAt, collectionsRead.attemptedAt, webId]);
|
||||
|
||||
// --- MY PROFILE (the app's own entity id space) ------------------------------
|
||||
// The actor (WebID) document links the `as:Profile` record via `url`; the
|
||||
@@ -219,7 +420,9 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
const fresh: FpUserData[] = [];
|
||||
const refused: string[] = [];
|
||||
for (const id of missing) {
|
||||
const record = await fetchActorProfileRecord(id, token);
|
||||
// The token goes only to the session's own pod provider: attendee
|
||||
// URIs come from collections other users' meeting points link to.
|
||||
const record = await fetchActorProfileRecord(id, token, trustedOrigin);
|
||||
if (record === undefined) {
|
||||
refused.push(id);
|
||||
} else {
|
||||
@@ -248,7 +451,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
// resolvedProfiles is read to compute `missing` but not listed: listing it
|
||||
// would re-run the effect on every resolution it itself performs.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [attendeeIds, unresolved, token, bindings]);
|
||||
}, [attendeeIds, unresolved, token, trustedOrigin, bindings]);
|
||||
|
||||
const users = useMemo(() => {
|
||||
const all = myProfile ? [myProfile, ...resolvedProfiles] : resolvedProfiles;
|
||||
@@ -261,9 +464,9 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const first = derived.events[0];
|
||||
const first = ownEvents[0];
|
||||
if (!selectedEventId && first) setSelectedEventId(first.id);
|
||||
}, [derived.events, selectedEventId]);
|
||||
}, [ownEvents, selectedEventId]);
|
||||
|
||||
// Identity-first log prefix — same shape as the other providers.
|
||||
const logPrefix = currentUserId || currentPrincipal
|
||||
@@ -272,17 +475,17 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
|
||||
// --- Event-level derivations over meeting-point memberships -------------------
|
||||
const mpIdsOf = (eventId: string) =>
|
||||
derived.meetingPoints.filter(mp => mp.eventId === eventId).map(mp => mp.id);
|
||||
allMeetingPoints.filter(mp => mp.eventId === eventId).map(mp => mp.id);
|
||||
|
||||
// Shared queries whose participation semantics already match (memberships are
|
||||
// keyed by the joined target's id — here, the meeting point). `currentUserId`
|
||||
// passed here is the MEMBERSHIP default key, which on this backend is the
|
||||
// actor URI, not the profile URI.
|
||||
const shared = buildQueries(
|
||||
derived.events,
|
||||
allEvents,
|
||||
users,
|
||||
participations,
|
||||
derived.meetingPoints,
|
||||
allMeetingPoints,
|
||||
[],
|
||||
webId ?? '',
|
||||
);
|
||||
@@ -299,21 +502,21 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
}
|
||||
}
|
||||
return users.filter(u => ids.has(u.id));
|
||||
}, [derived.meetingPoints, participations, users]);
|
||||
}, [allMeetingPoints, participations, users]);
|
||||
|
||||
const getUserEvents = useCallback((userId: string) => {
|
||||
// Answerable for the signed-in user only: memberships are what this
|
||||
// session can read. Someone else's events-by-participation are not
|
||||
// reachable through an own-pod-scoped read.
|
||||
// session can read (the own list, plus the events opened by URI).
|
||||
// Someone else's events-by-participation are not reachable.
|
||||
if (userId !== currentUserId || !webId) return [];
|
||||
const joinedEventIds = new Set<string>();
|
||||
for (const p of participations) {
|
||||
if (p.userId !== webId) continue;
|
||||
const mp = derived.meetingPoints.find(m => m.id === p.eventId);
|
||||
const mp = allMeetingPoints.find(m => m.id === p.eventId);
|
||||
if (mp) joinedEventIds.add(mp.eventId);
|
||||
}
|
||||
return derived.events.filter(e => joinedEventIds.has(e.id));
|
||||
}, [currentUserId, webId, participations, derived.meetingPoints, derived.events]);
|
||||
return allEvents.filter(e => joinedEventIds.has(e.id));
|
||||
}, [currentUserId, webId, participations, allMeetingPoints, allEvents]);
|
||||
|
||||
// --- Ownership: editing is owning ------------------------------------------------
|
||||
// The list is own-pod scoped, so membership in it IS ownership. A failed
|
||||
@@ -326,33 +529,71 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
}, [containerFailed, logPrefix]);
|
||||
|
||||
const getEventOwnership = useCallback((eventId: string): EventOwnership => {
|
||||
if (derived.events.some(e => e.id === eventId)) return 'mine';
|
||||
// The OWN list answers — an event opened by URI is read, not owned. An
|
||||
// event THIS session just created is its own even before the refetch the
|
||||
// create triggered lands (it may be read by URI first): the create wrote
|
||||
// it into the own container.
|
||||
if (ownEventIds.has(eventId) || createdEventIds.includes(eventId)) return 'mine';
|
||||
if (containerQuery.query.isPending) return 'unknown';
|
||||
return 'not-mine';
|
||||
}, [derived.events, containerQuery.query.isPending]);
|
||||
}, [ownEventIds, createdEventIds, containerQuery.query.isPending]);
|
||||
|
||||
const getEventReadState = useCallback((eventId: string): EventReadState => {
|
||||
if (allEvents.some(e => e.id === eventId)) return 'ready';
|
||||
// The own list may still bring it — on its first read, and on a re-read
|
||||
// (an event created a moment ago lands with the refetch the create
|
||||
// triggered, possibly before its public grant lets the URI read succeed).
|
||||
if (containerQuery.query.isPending || containerQuery.query.isFetching) return 'loading';
|
||||
if (foreignEventsRead.pending.has(eventId)) return 'loading';
|
||||
return 'unavailable';
|
||||
}, [allEvents, containerQuery.query.isPending, containerQuery.query.isFetching, foreignEventsRead.pending]);
|
||||
|
||||
// --- Mutations through the data provider ------------------------------------------
|
||||
const { mutateAsync: createRecord } = useCreate();
|
||||
const { mutateAsync: updateRecord } = useUpdate();
|
||||
const invalidate = useInvalidate();
|
||||
|
||||
// A join/leave is maintained asynchronously by the backend, so the immediate
|
||||
// invalidate can re-fetch BEFORE the collection is updated — and nothing
|
||||
// re-reads afterwards (the list subscription only reports membership changes).
|
||||
// ONE delayed re-invalidation per action closes that gap: no polling, at most
|
||||
// one pending timer, cancelled on unmount.
|
||||
const revalidateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// A join/leave is maintained asynchronously by the backend, so the read that
|
||||
// follows the act can still precede the backend's update. The collection the
|
||||
// act changed is re-read at once, then on a FINITE schedule
|
||||
// (`MEMBERSHIP_REREAD_DELAYS_MS`) while the act is unconfirmed; the last
|
||||
// re-read ends the wait and its result wins. While the meeting point links no
|
||||
// collection yet (read before the backend attached it), a step re-reads the
|
||||
// meeting point's RECORD instead, where the link will arrive: the own list
|
||||
// for an own meeting point, the record by URI for another user's. A newer act
|
||||
// on the same meeting point replaces the schedule; every timer is cleared on
|
||||
// unmount.
|
||||
const actTimers = useRef(new Map<string, ReturnType<typeof setTimeout>[]>());
|
||||
useEffect(() => () => {
|
||||
if (revalidateTimer.current) clearTimeout(revalidateTimer.current);
|
||||
revalidateTimer.current = null;
|
||||
for (const timers of actTimers.current.values()) timers.forEach(clearTimeout);
|
||||
actTimers.current.clear();
|
||||
}, []);
|
||||
const scheduleListRevalidate = useCallback(() => {
|
||||
if (revalidateTimer.current) clearTimeout(revalidateTimer.current);
|
||||
revalidateTimer.current = setTimeout(() => {
|
||||
revalidateTimer.current = null;
|
||||
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
|
||||
}, 1500);
|
||||
}, [invalidate, bindings]);
|
||||
const afterMembershipAct = useCallback((mpId: string, op: MembershipOp) => {
|
||||
const invalidateOwnList = () => void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
|
||||
// Re-read what can confirm the act: its collection — the generation of that
|
||||
// read is the first one issued after the outbox accepted the act. With no
|
||||
// collection linked yet, re-read the record that will carry the link (no
|
||||
// generation: any read of a collection that appears later is after the act).
|
||||
const readAgain = (): number | undefined => {
|
||||
const collection = collectionOfRef.current.get(mpId);
|
||||
if (collection !== undefined) return readCollectionAgain(collection);
|
||||
if (ownMeetingPointIdsRef.current.has(mpId)) invalidateOwnList();
|
||||
else readMeetingPointAgain(mpId);
|
||||
return undefined;
|
||||
};
|
||||
const confirmFrom = readAgain();
|
||||
setPendingActs(prev => recordMembershipAct(prev, mpId, op, confirmFrom ?? 0));
|
||||
// The own list, as after every write (unless the line above just did).
|
||||
if (confirmFrom !== undefined || !ownMeetingPointIdsRef.current.has(mpId)) invalidateOwnList();
|
||||
for (const timer of actTimers.current.get(mpId) ?? []) clearTimeout(timer);
|
||||
const last = MEMBERSHIP_REREAD_DELAYS_MS.length - 1;
|
||||
actTimers.current.set(mpId, MEMBERSHIP_REREAD_DELAYS_MS.map((delay, step) => setTimeout(() => {
|
||||
// Confirmed meanwhile: nothing left to read for it.
|
||||
if (!pendingActsRef.current.has(mpId)) return;
|
||||
const generation = readAgain();
|
||||
if (step === last) setPendingActs(prev => expireMembershipAct(prev, mpId, generation ?? 0));
|
||||
}, delay)));
|
||||
}, [readCollectionAgain, readMeetingPointAgain, invalidate, bindings]);
|
||||
|
||||
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
|
||||
console.log(`${logPrefix} createEvent (refine):`, event.title);
|
||||
@@ -361,6 +602,7 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
values: bindings.event.toValues(event),
|
||||
});
|
||||
const created = bindings.event.toDomain(response.data);
|
||||
setCreatedEventIds(prev => (prev.includes(created.id) ? prev : [...prev, created.id]));
|
||||
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
|
||||
if (currentUserId) setSelectedEventId(created.id);
|
||||
return created;
|
||||
@@ -411,24 +653,22 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
console.warn(`${logPrefix} joinMeetingPoint for another user (${userId}) is not supported on this backend — ignored`);
|
||||
return;
|
||||
}
|
||||
const mp = derived.meetingPoints.find(m => m.id === meetingPointId);
|
||||
const mp = allMeetingPoints.find(m => m.id === meetingPointId);
|
||||
if (!mp || !session) {
|
||||
throw new Error(`joinMeetingPoint refused: ${meetingPointId} is not in the read set ` +
|
||||
'(or there is no session) — the write would target a reference this session cannot see.');
|
||||
}
|
||||
console.log(`${logPrefix} joinMeetingPoint (refine):`, meetingPointId);
|
||||
await postJoinActivity({ meetingPointUri: mp.id, hostUri: mp.hostId, session });
|
||||
setOverlay(prev => new Map(prev).set(mp.id, 'join'));
|
||||
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
|
||||
scheduleListRevalidate();
|
||||
}, [logPrefix, derived.meetingPoints, session, webId, invalidate, bindings, scheduleListRevalidate]);
|
||||
afterMembershipAct(mp.id, 'join');
|
||||
}, [logPrefix, allMeetingPoints, session, webId, afterMembershipAct]);
|
||||
|
||||
const leaveMeetingPoint = useCallback(async (meetingPointId: string, userId?: string) => {
|
||||
if (userId && userId !== webId) {
|
||||
console.warn(`${logPrefix} leaveMeetingPoint for another user (${userId}) is not supported on this backend — ignored`);
|
||||
return;
|
||||
}
|
||||
const mp = derived.meetingPoints.find(m => m.id === meetingPointId);
|
||||
const mp = allMeetingPoints.find(m => m.id === meetingPointId);
|
||||
if (!mp || !session) {
|
||||
throw new Error(`leaveMeetingPoint refused: ${meetingPointId} is not in the read set ` +
|
||||
'(or there is no session).');
|
||||
@@ -441,10 +681,8 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
}
|
||||
console.log(`${logPrefix} leaveMeetingPoint (refine):`, meetingPointId);
|
||||
await postLeaveActivity({ meetingPointUri: mp.id, hostUri: mp.hostId, session });
|
||||
setOverlay(prev => new Map(prev).set(mp.id, 'leave'));
|
||||
void invalidate({ resource: bindings.event.resource, invalidates: ['list'] });
|
||||
scheduleListRevalidate();
|
||||
}, [logPrefix, derived.meetingPoints, participations, session, webId, invalidate, bindings, scheduleListRevalidate]);
|
||||
afterMembershipAct(mp.id, 'leave');
|
||||
}, [logPrefix, allMeetingPoints, participations, session, webId, afterMembershipAct]);
|
||||
|
||||
// --- The rest of the contract -------------------------------------------------------
|
||||
const addFriend = useCallback((friendId: string) => {
|
||||
@@ -463,19 +701,20 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
return emptyBootstrap();
|
||||
}, [logPrefix]);
|
||||
|
||||
const selectedEvent = derived.events.find(e => e.id === selectedEventId);
|
||||
const selectedEvent = allEvents.find(e => e.id === selectedEventId);
|
||||
const selectedUser = users.find(u => u.id === selectedUserId);
|
||||
|
||||
console.log(`${logPrefix} Render — refine/${backend.name} | events:`, derived.events.length,
|
||||
'| meetingPoints:', derived.meetingPoints.length, '| participations:', participations.length,
|
||||
console.log(`${logPrefix} Render — refine/${backend.name} | events:`, ownEvents.length,
|
||||
'| opened:', allEvents.length - ownEvents.length,
|
||||
'| meetingPoints:', allMeetingPoints.length, '| participations:', participations.length,
|
||||
'| selectedEvent:', selectedEvent?.title ?? '(none)');
|
||||
|
||||
return {
|
||||
currentUserId, currentUser, currentPrincipal,
|
||||
events: derived.events,
|
||||
events: ownEvents,
|
||||
users,
|
||||
participations,
|
||||
meetingPoints: derived.meetingPoints,
|
||||
meetingPoints: ownMeetingPoints,
|
||||
friendships: [],
|
||||
notifications: [],
|
||||
selectedEventId, setSelectedEventId, selectedEvent,
|
||||
@@ -488,6 +727,8 @@ function useRefineData(backend: RefineBackend): FestipodDataContextValue {
|
||||
getFriends: shared.getFriends,
|
||||
getEventMeetingPoints: shared.getEventMeetingPoints,
|
||||
getEventOwnership,
|
||||
openEvent,
|
||||
getEventReadState,
|
||||
createEvent, updateEvent, joinMeetingPoint, leaveMeetingPoint,
|
||||
addMeetingPoint, addFriend, updateProfile, loadTestData,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { deriveContainer, applyMembershipOverlay } from './refineDerive';
|
||||
import { expect, spyOn, test } from 'bun:test';
|
||||
import {
|
||||
deriveContainer,
|
||||
applyMembershipOverlay,
|
||||
countsWithOverlay,
|
||||
expireMembershipAct,
|
||||
MEMBERSHIP_REREAD_DELAYS_MS,
|
||||
overlayOfPendingActs,
|
||||
recordMembershipAct,
|
||||
settleMembershipActs,
|
||||
type MembershipObservation,
|
||||
type PendingActs,
|
||||
} from './refineDerive';
|
||||
import { getBackend } from '../refine/providers';
|
||||
import type { FpParticipationData } from '../data/types';
|
||||
|
||||
@@ -126,3 +137,200 @@ test('an empty overlay or no session leaves the read untouched', () => {
|
||||
expect(applyMembershipOverlay(read, new Map(), ALICE)).toEqual(read);
|
||||
expect(applyMembershipOverlay(read, new Map([[MP, 'join']]), undefined)).toEqual(read);
|
||||
});
|
||||
|
||||
// ── Counts follow the overlay ──────────────────────────────────────────────
|
||||
// A join is posted beside Refine and lands in the collection asynchronously;
|
||||
// the number must move with the membership, not wait for the re-read.
|
||||
|
||||
const MP2 = 'http://localhost:3000/alice/data/mp-2';
|
||||
function container(mp1Members: unknown[], mp2Members: unknown[]) {
|
||||
return deriveContainer(
|
||||
[
|
||||
{ id: EVENT, type: 'Event', name: 'Concert' },
|
||||
{ id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': { id: COLLECTION, items: mp1Members } },
|
||||
{ id: MP2, type: 'Event', 'as:context': EVENT, 'apods:attendees': { id: `${COLLECTION}-2`, items: mp2Members } },
|
||||
],
|
||||
bindings,
|
||||
);
|
||||
}
|
||||
|
||||
test("a meeting point carries its own count, and the event's is their sum", () => {
|
||||
const derived = container([BOB], [BOB, ALICE]);
|
||||
expect(derived.meetingPoints.map(mp => mp.participantCount)).toEqual([1, 2]);
|
||||
expect(derived.events[0]?.participantCount).toBe(3);
|
||||
});
|
||||
|
||||
test('a pending join adds one to its meeting point and to the event', () => {
|
||||
const derived = container([BOB], []);
|
||||
const counted = countsWithOverlay(derived, new Map([[MP, 'join']]), ALICE);
|
||||
expect(counted.meetingPoints.find(mp => mp.id === MP)?.participantCount).toBe(2);
|
||||
expect(counted.meetingPoints.find(mp => mp.id === MP2)?.participantCount).toBe(0);
|
||||
expect(counted.events[0]?.participantCount).toBe(2);
|
||||
});
|
||||
|
||||
test('a join the read already carries is not counted twice', () => {
|
||||
const derived = container([BOB, ALICE], []);
|
||||
const counted = countsWithOverlay(derived, new Map([[MP, 'join']]), ALICE);
|
||||
expect(counted.meetingPoints.find(mp => mp.id === MP)?.participantCount).toBe(2);
|
||||
expect(counted.events[0]?.participantCount).toBe(2);
|
||||
});
|
||||
|
||||
test('a pending leave removes one; a leave the read no longer carries removes nothing', () => {
|
||||
const pending = countsWithOverlay(container([BOB, ALICE], []), new Map([[MP, 'leave']]), ALICE);
|
||||
expect(pending.meetingPoints.find(mp => mp.id === MP)?.participantCount).toBe(1);
|
||||
expect(pending.events[0]?.participantCount).toBe(1);
|
||||
const landed = countsWithOverlay(container([BOB], []), new Map([[MP, 'leave']]), ALICE);
|
||||
expect(landed.meetingPoints.find(mp => mp.id === MP)?.participantCount).toBe(1);
|
||||
});
|
||||
|
||||
test('no overlay, no session, or an overlay on an unknown meeting point leaves the counts as read', () => {
|
||||
const derived = container([BOB], []);
|
||||
expect(countsWithOverlay(derived, new Map(), ALICE).events).toBe(derived.events);
|
||||
expect(countsWithOverlay(derived, new Map([[MP, 'join']]), undefined).meetingPoints).toBe(derived.meetingPoints);
|
||||
const stray = countsWithOverlay(derived, new Map([['http://elsewhere/mp', 'join']]), ALICE);
|
||||
expect(stray.events[0]?.participantCount).toBe(1);
|
||||
});
|
||||
|
||||
// ── Per-item isolation: other pods' data never takes the read down ──────────
|
||||
|
||||
test('an attendee entry carrying no reference is skipped; the meeting point and the other members stay', () => {
|
||||
const warn = spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const derived = deriveContainer(
|
||||
[
|
||||
{ id: EVENT, type: 'Event', name: 'Concert' },
|
||||
{ id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': { id: COLLECTION, items: [42, BOB, {}, null] } },
|
||||
],
|
||||
bindings,
|
||||
);
|
||||
expect(derived.meetingPoints.map(mp => mp.id)).toEqual([MP]);
|
||||
expect(derived.participations.map(p => p.userId)).toEqual([BOB]);
|
||||
expect(derived.events.map(e => e.id)).toEqual([EVENT]);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test('a record the bindings reject is skipped; the others are derived', () => {
|
||||
const warn = spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const derived = deriveContainer(
|
||||
[
|
||||
{ type: 'Event', name: 'No identifier' },
|
||||
{ id: EVENT, type: 'Event', name: 'Concert' },
|
||||
{ type: 'Event', 'as:context': EVENT },
|
||||
],
|
||||
bindings,
|
||||
);
|
||||
expect(derived.events.map(e => e.id)).toEqual([EVENT]);
|
||||
expect(derived.meetingPoints).toEqual([]);
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
// ── Read-your-writes: when a pending join/leave may go ──────────────────────
|
||||
// State: the act (join | leave), how many unconfirmed acts it replaced (0, 1,
|
||||
// 2+), the read's generation (none, before the act, after it, the fresh one at
|
||||
// the end of the wait) and what the read shows (asserted state or not).
|
||||
|
||||
const NONE: PendingActs = new Map();
|
||||
/** The read says: the session is (not) a member, from a read of `generation`. */
|
||||
const reads = (member: boolean, generation: number | undefined) =>
|
||||
(): MembershipObservation => ({ member, generation });
|
||||
|
||||
test('a clean act is confirmed by a read issued after it that shows the asserted state', () => {
|
||||
const join = recordMembershipAct(NONE, MP, 'join', 3);
|
||||
expect(settleMembershipActs(join, reads(true, 3)).has(MP)).toBe(false);
|
||||
const leave = recordMembershipAct(NONE, MP, 'leave', 3);
|
||||
expect(settleMembershipActs(leave, reads(false, 4)).has(MP)).toBe(false);
|
||||
});
|
||||
|
||||
test('a read issued after a clean act that does not show it yet keeps the act (the backend has not processed it)', () => {
|
||||
const join = recordMembershipAct(NONE, MP, 'join', 3);
|
||||
expect(settleMembershipActs(join, reads(false, 3))).toBe(join);
|
||||
const leave = recordMembershipAct(NONE, MP, 'leave', 3);
|
||||
expect(settleMembershipActs(leave, reads(true, 5))).toBe(leave);
|
||||
});
|
||||
|
||||
test('a read from BEFORE the act confirms nothing, even when it shows the asserted state', () => {
|
||||
const leave = recordMembershipAct(NONE, MP, 'leave', 3);
|
||||
expect(settleMembershipActs(leave, reads(false, 2))).toBe(leave);
|
||||
expect(settleMembershipActs(leave, reads(false, undefined))).toBe(leave);
|
||||
const join = recordMembershipAct(NONE, MP, 'join', 3);
|
||||
expect(settleMembershipActs(join, reads(true, 2))).toBe(join);
|
||||
});
|
||||
|
||||
test('an act replacing ONE unconfirmed opposite act: the asserted state alone confirms nothing', () => {
|
||||
// Join (unconfirmed), then leave: "neither processed" and "both processed" both read as absent.
|
||||
const chain = recordMembershipAct(recordMembershipAct(NONE, MP, 'join', 1), MP, 'leave', 3);
|
||||
expect(chain.get(MP)?.unconfirmedBefore).toBe(1);
|
||||
expect(settleMembershipActs(chain, reads(false, 3))).toBe(chain);
|
||||
});
|
||||
|
||||
test('…until a read after it shows the opposite state: the earlier act is processed, this one now confirms normally', () => {
|
||||
const chain = recordMembershipAct(recordMembershipAct(NONE, MP, 'join', 1), MP, 'leave', 3);
|
||||
const decided = settleMembershipActs(chain, reads(true, 3)); // the join landed, the leave not yet
|
||||
expect(decided.get(MP)).toEqual({ op: 'leave', confirmFrom: 3, unconfirmedBefore: 0 });
|
||||
expect(settleMembershipActs(decided, reads(false, 4)).has(MP)).toBe(false);
|
||||
// Symmetric: leave (unconfirmed), then join.
|
||||
const joinChain = recordMembershipAct(recordMembershipAct(NONE, MP, 'leave', 1), MP, 'join', 3);
|
||||
expect(settleMembershipActs(joinChain, reads(true, 3))).toBe(joinChain);
|
||||
const joinDecided = settleMembershipActs(joinChain, reads(false, 3));
|
||||
expect(settleMembershipActs(joinDecided, reads(true, 4)).has(MP)).toBe(false);
|
||||
});
|
||||
|
||||
test('with two or more unconfirmed acts before it, no read decides: only the end of the wait does', () => {
|
||||
const chain = recordMembershipAct(
|
||||
recordMembershipAct(recordMembershipAct(NONE, MP, 'join', 1), MP, 'leave', 2), MP, 'join', 3);
|
||||
expect(chain.get(MP)?.unconfirmedBefore).toBe(2);
|
||||
expect(settleMembershipActs(chain, reads(true, 9))).toBe(chain);
|
||||
expect(settleMembershipActs(chain, reads(false, 9))).toBe(chain);
|
||||
const expired = expireMembershipAct(chain, MP, 10);
|
||||
expect(settleMembershipActs(expired, reads(false, 9))).toBe(expired);
|
||||
expect(settleMembershipActs(expired, reads(false, 10)).has(MP)).toBe(false);
|
||||
});
|
||||
|
||||
test('a repeated act asserts nothing new: it inherits the earlier act\'s standing', () => {
|
||||
const twice = recordMembershipAct(recordMembershipAct(NONE, MP, 'join', 1), MP, 'join', 3);
|
||||
expect(twice.get(MP)).toEqual({ op: 'join', confirmFrom: 3, unconfirmedBefore: 0 });
|
||||
});
|
||||
|
||||
test('when the bounded wait runs out, the fresh re-read wins whatever it shows', () => {
|
||||
const join = recordMembershipAct(NONE, MP, 'join', 3);
|
||||
const expired = expireMembershipAct(join, MP, 9);
|
||||
expect(expired.get(MP)?.settleFrom).toBe(9);
|
||||
// A read older than the fresh one still cannot drop it…
|
||||
expect(settleMembershipActs(expired, reads(false, 8))).toBe(expired);
|
||||
// …the fresh one does, even showing the act never landed.
|
||||
expect(settleMembershipActs(expired, reads(false, 9)).has(MP)).toBe(false);
|
||||
// Expiring twice keeps the first fresh generation.
|
||||
expect(expireMembershipAct(expired, MP, 12)).toBe(expired);
|
||||
// With no read issued after the act at all — none readable, or the meeting point gone from the read set —
|
||||
// nothing settles it, even once the wait is over: a read from before the act never undoes it.
|
||||
expect(settleMembershipActs(expired, () => undefined)).toBe(expired);
|
||||
expect(settleMembershipActs(expired, reads(false, undefined))).toBe(expired);
|
||||
expect(settleMembershipActs(expired, reads(false, 2))).toBe(expired);
|
||||
expect(settleMembershipActs(join, () => undefined)).toBe(join);
|
||||
});
|
||||
|
||||
test('when the fresh re-read at the end of the wait FAILS, the last read issued after the act wins — never one from before it', () => {
|
||||
const expired = expireMembershipAct(recordMembershipAct(NONE, MP, 'join', 3), MP, 9);
|
||||
// Generation 9 came back failed: the record shown is still generation 8's, issued after the act.
|
||||
expect(settleMembershipActs(expired, () => ({ member: false, generation: 8, attempted: 9 })).has(MP)).toBe(false);
|
||||
// Every read since the act failed (offline): the record shown predates the act, which stays.
|
||||
expect(settleMembershipActs(expired, () => ({ member: false, generation: 2, attempted: 9 }))).toBe(expired);
|
||||
// Before generation 9 came back at all, the act stays.
|
||||
expect(settleMembershipActs(expired, () => ({ member: false, generation: 8, attempted: 8 }))).toBe(expired);
|
||||
// An attempt alone never confirms an act whose wait is not over.
|
||||
const join = recordMembershipAct(NONE, MP, 'join', 3);
|
||||
expect(settleMembershipActs(join, () => ({ member: true, generation: 2, attempted: 5 }))).toBe(join);
|
||||
});
|
||||
|
||||
test('acts on different meeting points settle independently; the overlay is their ops', () => {
|
||||
const both = recordMembershipAct(recordMembershipAct(NONE, MP, 'join', 1), MP2, 'leave', 1);
|
||||
const settled = settleMembershipActs(both, id => (id === MP ? { member: true, generation: 1 } : { member: true, generation: 1 }));
|
||||
expect([...settled.keys()]).toEqual([MP2]);
|
||||
expect([...overlayOfPendingActs(both)]).toEqual([[MP, 'join'], [MP2, 'leave']]);
|
||||
});
|
||||
|
||||
test('the re-read schedule is finite and increasing', () => {
|
||||
expect(MEMBERSHIP_REREAD_DELAYS_MS.length).toBeGreaterThan(0);
|
||||
const previous = [0, ...MEMBERSHIP_REREAD_DELAYS_MS];
|
||||
expect(MEMBERSHIP_REREAD_DELAYS_MS.every((delay, i) => delay > (previous[i] ?? delay))).toBe(true);
|
||||
});
|
||||
|
||||
@@ -16,8 +16,16 @@
|
||||
* (the Alice/Bob rule).
|
||||
*
|
||||
* 2. `applyMembershipOverlay` reflects a join/leave made beside Refine (through
|
||||
* the outbox) before the collection read carries it. It reconciles away on
|
||||
* its own once the read shows the truth — no polling.
|
||||
* the outbox) before the collection read carries it. `countsWithOverlay`
|
||||
* applies the same overlay to the COUNTS, so a join shows in the number the
|
||||
* moment it shows in the membership. WHEN the overlay may go is decided by
|
||||
* `settleMembershipActs` (read-your-writes, below): only a read issued after
|
||||
* the act can confirm it, or the bounded wait runs out.
|
||||
*
|
||||
* THE COLLECTION MUST BE EMBEDDED TO BE COUNTED. The pod links a meeting
|
||||
* point's collection by URI and never embeds it (verified 2026-10-12); the
|
||||
* reader fetches it and embeds it before calling `deriveContainer`
|
||||
* (`data/attendees.ts`). A link left un-embedded reads as an honest zero.
|
||||
*/
|
||||
|
||||
import type { FpEventData, FpMeetingPointData, FpParticipationData } from '../data/types';
|
||||
@@ -54,33 +62,57 @@ export function deriveContainer(
|
||||
const meetingPoints: FpMeetingPointData[] = [];
|
||||
const participations: FpParticipationData[] = [];
|
||||
const totals = new Map<string, number>();
|
||||
// PER-ITEM ISOLATION. Records and collection members come from other users'
|
||||
// pods too (events opened by URI, their meeting points, the collections those
|
||||
// link to): one that the bindings reject is skipped with a warning — it never
|
||||
// takes the whole read down with it.
|
||||
for (const record of records) {
|
||||
if (bindings.meetingPoint.isInstance?.(record) ?? false) {
|
||||
const mp = bindings.meetingPoint.toDomain(record);
|
||||
meetingPoints.push(mp);
|
||||
const attendees = bindings.participation.membersOf?.(record);
|
||||
const members = attendees?.members ?? [];
|
||||
for (const member of members) {
|
||||
const mapped = bindings.participation.toDomain(member);
|
||||
participations.push({ ...mapped, id: `${mp.id}|${mapped.userId}`, eventId: mp.id });
|
||||
try {
|
||||
if (bindings.meetingPoint.isInstance?.(record) ?? false) {
|
||||
const mp = bindings.meetingPoint.toDomain(record);
|
||||
meetingPoints.push(mp);
|
||||
const attendees = bindings.participation.membersOf?.(record);
|
||||
const members = attendees?.members ?? [];
|
||||
for (const member of members) {
|
||||
try {
|
||||
const mapped = bindings.participation.toDomain(member);
|
||||
participations.push({ ...mapped, id: `${mp.id}|${mapped.userId}`, eventId: mp.id });
|
||||
} catch (error) {
|
||||
console.warn(`[refine] skipped an unreadable attendee of ${mp.id}`, error);
|
||||
}
|
||||
}
|
||||
const total = attendees?.total ?? members.length;
|
||||
totals.set(mp.id, total);
|
||||
mp.participantCount = total;
|
||||
} else if (bindings.event.isInstance?.(record) ?? true) {
|
||||
events.push(bindings.event.toDomain(record));
|
||||
}
|
||||
totals.set(mp.id, attendees?.total ?? members.length);
|
||||
} else if (bindings.event.isInstance?.(record) ?? true) {
|
||||
events.push(bindings.event.toDomain(record));
|
||||
} catch (error) {
|
||||
console.warn('[refine] skipped a record the bindings cannot read', error);
|
||||
}
|
||||
}
|
||||
// The event's count is the SUM of its meeting points' collection totals —
|
||||
// derived at read time, never a stored counter. A record whose collection
|
||||
// could not be read contributes 0, which is the honest "unknown" reading of
|
||||
// a total nobody could count for us.
|
||||
for (const event of events) {
|
||||
event.participantCount = meetingPoints
|
||||
.filter(mp => mp.eventId === event.id)
|
||||
.reduce((sum, mp) => sum + (totals.get(mp.id) ?? 0), 0);
|
||||
event.participantCount = eventParticipantCount(event.id, meetingPoints, totals);
|
||||
}
|
||||
return { events, meetingPoints, participations, totals };
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's count: the SUM of its meeting points' collection totals —
|
||||
* derived at read time, never a stored counter. A meeting point whose
|
||||
* collection could not be read contributes 0, which is the honest "unknown"
|
||||
* reading of a total nobody could count for us.
|
||||
*/
|
||||
function eventParticipantCount(
|
||||
eventId: string,
|
||||
meetingPoints: readonly FpMeetingPointData[],
|
||||
totals: ReadonlyMap<string, number>,
|
||||
): number {
|
||||
return meetingPoints
|
||||
.filter(mp => mp.eventId === eventId)
|
||||
.reduce((sum, mp) => sum + (totals.get(mp.id) ?? 0), 0);
|
||||
}
|
||||
|
||||
/** A membership overlay entry: what this session just joined or left. */
|
||||
export type MembershipOverlay = Map<string, 'join' | 'leave'>;
|
||||
|
||||
@@ -115,3 +147,165 @@ export function applyMembershipOverlay(
|
||||
}
|
||||
return [...byMp.values()].flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* The counts this session should see: the read totals, adjusted by the same
|
||||
* optimistic overlay `applyMembershipOverlay` applies to the memberships — a
|
||||
* `join` the read does not carry yet adds one to that meeting point, a `leave`
|
||||
* the read still carries removes one. Event counts are re-summed from the
|
||||
* adjusted totals, exactly as `deriveContainer` sums them. Once the read
|
||||
* carries the act, the overlay entry adjusts nothing (and is reconciled away).
|
||||
* With no overlay, or no `webId` to attribute it to, the derived lists are
|
||||
* returned as they are (same arrays).
|
||||
*/
|
||||
export function countsWithOverlay(
|
||||
derived: DerivedContainer,
|
||||
overlay: MembershipOverlay,
|
||||
webId: string | undefined,
|
||||
): { events: FpEventData[]; meetingPoints: FpMeetingPointData[] } {
|
||||
if (overlay.size === 0 || !webId) return { events: derived.events, meetingPoints: derived.meetingPoints };
|
||||
const totals = new Map(derived.totals);
|
||||
for (const [mpId, op] of overlay) {
|
||||
const base = totals.get(mpId);
|
||||
if (base === undefined) continue;
|
||||
const readCarries = derived.participations.some(p => p.eventId === mpId && p.userId === webId);
|
||||
if (op === 'join' && !readCarries) totals.set(mpId, base + 1);
|
||||
else if (op === 'leave' && readCarries) totals.set(mpId, Math.max(0, base - 1));
|
||||
}
|
||||
const meetingPoints = derived.meetingPoints.map(mp => {
|
||||
const total = totals.get(mp.id);
|
||||
return total === undefined || total === mp.participantCount ? mp : { ...mp, participantCount: total };
|
||||
});
|
||||
const events = derived.events.map(event => {
|
||||
const count = eventParticipantCount(event.id, meetingPoints, totals);
|
||||
return count === event.participantCount ? event : { ...event, participantCount: count };
|
||||
});
|
||||
return { events, meetingPoints };
|
||||
}
|
||||
|
||||
/**
|
||||
* READ-YOUR-WRITES FOR A JOIN/LEAVE — when the overlay may go.
|
||||
*
|
||||
* The outbox accepts an act before the backend has updated the attendees
|
||||
* collection, so a read can land on either side of the backend's processing.
|
||||
* The overlay entry for a meeting point is therefore dropped only when a read
|
||||
* CONFIRMS it — never because the read on screen happens to show the asserted
|
||||
* state:
|
||||
*
|
||||
* - Only a read ISSUED AFTER the act can confirm it (`confirmFrom`: the first
|
||||
* read generation of the collection made once the outbox accepted the act).
|
||||
* A read from before says nothing about it.
|
||||
* - An act made while an earlier act of this session on the same meeting point
|
||||
* is still unconfirmed is NOT CLEAN: the collection shows the same thing
|
||||
* before both acts and after both ("neither processed" and "both processed"
|
||||
* look alike), so the asserted state alone confirms nothing. One case is
|
||||
* decidable: with ONE unconfirmed act before it, a read showing the opposite
|
||||
* of the asserted state proves the earlier act processed and this one not
|
||||
* yet — from then on the act is clean. With two or more, no read decides.
|
||||
* - The wait is BOUNDED: when it runs out, the caller issues one fresh re-read
|
||||
* (`expireMembershipAct` records its generation) and that read wins, whatever
|
||||
* it shows — and if it fails, the last good read issued after the act wins.
|
||||
* With NO read issued after the act at all (offline, a collection that never
|
||||
* became readable), nothing can settle it and the act stays shown: a read
|
||||
* from before the act never undoes it, at the end of the wait included.
|
||||
* Until then the caller re-reads on a fixed, finite schedule
|
||||
* (`MEMBERSHIP_REREAD_DELAYS_MS`) — never an open-ended poll.
|
||||
*/
|
||||
|
||||
/** A join or a leave this session made on a meeting point. */
|
||||
export type MembershipOp = 'join' | 'leave';
|
||||
|
||||
/** One act of this session on a meeting point, not yet confirmed by a read. */
|
||||
export interface PendingAct {
|
||||
op: MembershipOp;
|
||||
/** Reads of the collection made for this generation or later were issued after the act. */
|
||||
confirmFrom: number;
|
||||
/** How many acts of this session on the same meeting point were still unconfirmed when this one was made. */
|
||||
unconfirmedBefore: number;
|
||||
/** Once the bounded wait ran out: the generation of the fresh re-read then issued — its result wins. */
|
||||
settleFrom?: number;
|
||||
}
|
||||
|
||||
/** The pending acts, keyed by meeting point id. */
|
||||
export type PendingActs = ReadonlyMap<string, PendingAct>;
|
||||
|
||||
/**
|
||||
* What the CURRENT read says about one meeting point: whether it carries the
|
||||
* session's membership, the generation of the collection read it comes from
|
||||
* (`undefined` when no collection read has succeeded for it), and the
|
||||
* generation of the last collection read that came back, failed or not.
|
||||
*/
|
||||
export interface MembershipObservation {
|
||||
member: boolean;
|
||||
generation: number | undefined;
|
||||
attempted?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* When the collection is re-read after an act, in ms from the act — the first
|
||||
* re-read is immediate and not listed. The LAST one is the fresh re-read whose
|
||||
* result wins (`expireMembershipAct`). Finite by construction.
|
||||
*/
|
||||
export const MEMBERSHIP_REREAD_DELAYS_MS: readonly number[] = [1500, 3000, 6000, 12000, 20000, 30000];
|
||||
|
||||
/** Record an act the outbox accepted; it replaces any earlier act on the same meeting point. */
|
||||
export function recordMembershipAct(
|
||||
pending: PendingActs,
|
||||
meetingPointId: string,
|
||||
op: MembershipOp,
|
||||
confirmFrom: number,
|
||||
): PendingActs {
|
||||
const previous = pending.get(meetingPointId);
|
||||
// A repeated act asserts nothing new: it inherits what was still unconfirmed
|
||||
// under the earlier one. An opposite act stacks on it.
|
||||
const unconfirmedBefore = previous === undefined
|
||||
? 0
|
||||
: previous.op === op ? previous.unconfirmedBefore : previous.unconfirmedBefore + 1;
|
||||
return new Map(pending).set(meetingPointId, { op, confirmFrom, unconfirmedBefore });
|
||||
}
|
||||
|
||||
/** The bounded wait ran out: the read of generation `settleFrom` (or later) will win. */
|
||||
export function expireMembershipAct(pending: PendingActs, meetingPointId: string, settleFrom: number): PendingActs {
|
||||
const act = pending.get(meetingPointId);
|
||||
if (!act || act.settleFrom !== undefined) return pending;
|
||||
return new Map(pending).set(meetingPointId, { ...act, settleFrom });
|
||||
}
|
||||
|
||||
/**
|
||||
* Confront the pending acts with what the current read says. Returns the SAME
|
||||
* map when nothing changes (a state setter can bail out on it).
|
||||
*/
|
||||
export function settleMembershipActs(
|
||||
pending: PendingActs,
|
||||
observe: (meetingPointId: string) => MembershipObservation | undefined,
|
||||
): PendingActs {
|
||||
let next: Map<string, PendingAct> | undefined;
|
||||
const set = (id: string, act: PendingAct | undefined) => {
|
||||
next ??= new Map(pending);
|
||||
if (act) next.set(id, act);
|
||||
else next.delete(id);
|
||||
};
|
||||
for (const [id, act] of pending) {
|
||||
const seen = observe(id);
|
||||
const generation = seen?.generation;
|
||||
// Only a read issued after the act says anything about it.
|
||||
if (seen === undefined || generation === undefined || generation < act.confirmFrom) continue;
|
||||
if (act.settleFrom !== undefined && (generation >= act.settleFrom || (seen.attempted ?? -1) >= act.settleFrom)) {
|
||||
// The wait is over: the fresh read wins — or, if it failed, the last
|
||||
// read issued after the act.
|
||||
set(id, undefined);
|
||||
continue;
|
||||
}
|
||||
const asserted = seen.member === (act.op === 'join');
|
||||
if (asserted && act.unconfirmedBefore === 0) set(id, undefined);
|
||||
else if (!asserted && act.unconfirmedBefore === 1) set(id, { ...act, unconfirmedBefore: 0 });
|
||||
}
|
||||
return next ?? pending;
|
||||
}
|
||||
|
||||
/** The overlay the views apply: each pending act's op. */
|
||||
export function overlayOfPendingActs(pending: PendingActs): MembershipOverlay {
|
||||
const overlay: MembershipOverlay = new Map();
|
||||
for (const [id, act] of pending) overlay.set(id, act.op);
|
||||
return overlay;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* `usePublicRecords` mounted for real (happy-dom), against a fetch whose
|
||||
* answers the test releases by hand — so "in flight" is a state the test holds,
|
||||
* not a race it hopes for.
|
||||
*
|
||||
* What is pinned here: each URI is read ON ITS OWN — a slow or failing one
|
||||
* costs that URI only; moving one URI's generation re-reads that URI and never
|
||||
* aborts or restarts another's read in flight; a URI whose own generation moves
|
||||
* on while in flight is read again and the late answer of the old read is
|
||||
* never taken; a re-open (retry) while a read is in flight does not restart it,
|
||||
* yet still gets a read if that one fails — nothing is ever left pending with
|
||||
* no read in flight; the token goes only where the caller grants it.
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, beforeAll, expect, test } from 'bun:test';
|
||||
import { Window } from 'happy-dom';
|
||||
import type { PublicRecordsOptions, PublicRecordsRead } from './usePublicRecords';
|
||||
|
||||
const A = 'http://bob.pod.test/bob/data/a';
|
||||
const B = 'http://bob.pod.test/bob/data/b';
|
||||
const OWN = 'http://alice.pod.test/alice/data/c';
|
||||
|
||||
interface Call { uri: string; auth: string | null; aborted: () => boolean; answer: (body: unknown, status?: number) => void }
|
||||
let calls: Call[] = [];
|
||||
|
||||
/** Every GET waits until the test answers it (or the read is aborted). */
|
||||
function heldFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const uri = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
const signal = init?.signal;
|
||||
signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
|
||||
calls.push({
|
||||
uri,
|
||||
auth: new Headers(init?.headers).get('Authorization'),
|
||||
aborted: () => signal?.aborted ?? false,
|
||||
answer: (body, status = 200) =>
|
||||
resolve(new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/ld+json' } })),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const callsTo = (uri: string) => calls.filter(c => c.uri === uri);
|
||||
const lastCall = (uri: string): Call => {
|
||||
const found = callsTo(uri).at(-1);
|
||||
if (!found) throw new Error(`no read of ${uri}`);
|
||||
return found;
|
||||
};
|
||||
|
||||
// --- environment -------------------------------------------------------------------
|
||||
const saved = { fetch: globalThis.fetch };
|
||||
const installed: string[] = [];
|
||||
let createRoot: (typeof import('react-dom/client'))['createRoot'];
|
||||
let usePublicRecords: (uris: readonly string[], options?: PublicRecordsOptions) => PublicRecordsRead;
|
||||
let root: ReturnType<typeof createRoot> | undefined;
|
||||
let latest: PublicRecordsRead | undefined;
|
||||
|
||||
function Probe({ uris, options }: { uris: readonly string[]; options: PublicRecordsOptions }) {
|
||||
latest = usePublicRecords(uris, options);
|
||||
return null;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const window = new Window({ url: 'http://localhost:4000/' });
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
for (const [name, value] of Object.entries({ window, document: window.document, navigator: window.navigator })) {
|
||||
if (g[name] !== undefined) continue;
|
||||
Object.defineProperty(globalThis, name, { value, writable: true, configurable: true });
|
||||
installed.push(name);
|
||||
}
|
||||
globalThis.fetch = heldFetch as typeof fetch;
|
||||
createRoot = (await import('react-dom/client')).createRoot;
|
||||
usePublicRecords = (await import('./usePublicRecords')).usePublicRecords;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
root?.unmount();
|
||||
root = undefined;
|
||||
latest = undefined;
|
||||
calls = [];
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
globalThis.fetch = saved.fetch;
|
||||
for (const name of installed) delete (globalThis as Record<string, unknown>)[name];
|
||||
});
|
||||
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 30));
|
||||
|
||||
async function show(uris: readonly string[], options: PublicRecordsOptions = {}): Promise<void> {
|
||||
if (!root) root = createRoot(document.createElement('div') as unknown as Element);
|
||||
root.render(<Probe uris={uris} options={options} />);
|
||||
await tick();
|
||||
}
|
||||
|
||||
// --- the cases ------------------------------------------------------------------------
|
||||
|
||||
test('each URI lands on its own: a slow one withholds nothing, a failing one costs only itself', async () => {
|
||||
await show([A, B]);
|
||||
lastCall(B).answer({ id: B, type: 'Event' });
|
||||
await tick();
|
||||
expect([...latest!.records.keys()]).toEqual([B]);
|
||||
expect([...latest!.pending]).toEqual([A]);
|
||||
lastCall(A).answer({}, 404);
|
||||
await tick();
|
||||
expect([...latest!.records.keys()]).toEqual([B]);
|
||||
expect(latest!.pending.size).toBe(0);
|
||||
expect(latest!.attemptedAt.get(A)).toBe(0);
|
||||
});
|
||||
|
||||
test("moving one URI's generation re-reads that URI only — another's read in flight is not restarted", async () => {
|
||||
await show([A, B]);
|
||||
lastCall(B).answer({ id: B });
|
||||
await tick();
|
||||
// A is still in flight when B is asked again.
|
||||
await show([A, B], { refresh: new Map([[B, 1]]) });
|
||||
expect(callsTo(A)).toHaveLength(1);
|
||||
expect(lastCall(A).aborted()).toBe(false);
|
||||
expect(callsTo(B)).toHaveLength(2);
|
||||
lastCall(A).answer({ id: A });
|
||||
lastCall(B).answer({ id: B, name: 'again' });
|
||||
await tick();
|
||||
expect(latest!.records.get(A)).toEqual({ id: A });
|
||||
expect(latest!.readAt.get(A)).toBe(0);
|
||||
expect(latest!.readAt.get(B)).toBe(1);
|
||||
});
|
||||
|
||||
test("a URI whose own generation moves on while in flight is read again, and the old read's answer is never taken", async () => {
|
||||
await show([A]);
|
||||
const first = lastCall(A);
|
||||
await show([A], { refresh: new Map([[A, 1]]) });
|
||||
expect(first.aborted()).toBe(true);
|
||||
expect(callsTo(A)).toHaveLength(2);
|
||||
first.answer({ id: A, name: 'before' });
|
||||
lastCall(A).answer({ id: A, name: 'after' });
|
||||
await tick();
|
||||
expect(latest!.records.get(A)).toEqual({ id: A, name: 'after' });
|
||||
expect(latest!.readAt.get(A)).toBe(1);
|
||||
});
|
||||
|
||||
test('a re-open while a read is in flight does not restart it; a success then answers it', async () => {
|
||||
await show([A]);
|
||||
await show([A], { retry: 1 });
|
||||
expect(callsTo(A)).toHaveLength(1);
|
||||
lastCall(A).answer({ id: A });
|
||||
await tick();
|
||||
expect(callsTo(A)).toHaveLength(1);
|
||||
expect(latest!.pending.size).toBe(0);
|
||||
expect(latest!.records.get(A)).toEqual({ id: A });
|
||||
});
|
||||
|
||||
test('…but if that read FAILS, the re-open still gets its read — nothing stays pending with nothing in flight', async () => {
|
||||
await show([A]);
|
||||
await show([A], { retry: 1 });
|
||||
lastCall(A).answer({}, 504); // the read issued before the re-open fails
|
||||
await tick();
|
||||
expect(callsTo(A)).toHaveLength(2);
|
||||
expect([...latest!.pending]).toEqual([A]);
|
||||
lastCall(A).answer({ id: A });
|
||||
await tick();
|
||||
expect(latest!.pending.size).toBe(0);
|
||||
expect(latest!.records.get(A)).toEqual({ id: A });
|
||||
// A failure with no re-open since it was issued waits for the next re-open.
|
||||
await show([A, B], { retry: 1 });
|
||||
lastCall(B).answer({}, 503);
|
||||
await tick();
|
||||
expect(callsTo(B)).toHaveLength(1);
|
||||
expect(latest!.pending.size).toBe(0);
|
||||
await show([A, B], { retry: 2 });
|
||||
expect(callsTo(B)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('a URI that leaves the set has its read aborted; unmounting aborts the rest', async () => {
|
||||
await show([A, B]);
|
||||
await show([B]);
|
||||
expect(lastCall(A).aborted()).toBe(true);
|
||||
expect(lastCall(B).aborted()).toBe(false);
|
||||
root?.unmount();
|
||||
root = undefined;
|
||||
expect(lastCall(B).aborted()).toBe(true);
|
||||
});
|
||||
|
||||
test('the token goes only where the caller grants it', async () => {
|
||||
await show([A, OWN], { tokenOf: uri => (uri.startsWith('http://alice.pod.test/') ? 'alice-token' : undefined) });
|
||||
expect(lastCall(OWN).auth).toBe('Bearer alice-token');
|
||||
expect(lastCall(A).auth).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { landedAfter, needsRead } from './usePublicRecords';
|
||||
|
||||
const RECORD = { id: 'http://localhost:3000/bob/data/e1', type: 'Event' };
|
||||
|
||||
describe('landedAfter — last good wins', () => {
|
||||
test('a successful read stores the record and the generation that produced it', () => {
|
||||
expect(landedAfter(undefined, 0, 0, RECORD)).toEqual({ refresh: 0, retry: 0, record: RECORD, recordRefresh: 0, failed: false });
|
||||
});
|
||||
test('a failed re-read keeps the record already shown — and its OLDER generation — and is marked failed', () => {
|
||||
const shown = landedAfter(undefined, 0, 0, RECORD);
|
||||
// The record kept is still generation 0's: it must not pass for a read issued after a write.
|
||||
expect(landedAfter(shown, 1, 0, undefined)).toEqual({ refresh: 1, retry: 0, record: RECORD, recordRefresh: 0, failed: true });
|
||||
});
|
||||
test('a retry that succeeds is a read of the current generation', () => {
|
||||
const failed = landedAfter(landedAfter(undefined, 0, 0, RECORD), 2, 0, undefined);
|
||||
expect(landedAfter(failed, 2, 1, RECORD).recordRefresh).toBe(2);
|
||||
});
|
||||
test('a first read that fails stores nothing', () => {
|
||||
const landed = landedAfter(undefined, 0, 0, undefined);
|
||||
expect(landed.record).toBeUndefined();
|
||||
expect(landed.recordRefresh).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsRead — refresh re-reads everything, retry re-reads only failures', () => {
|
||||
const ok = landedAfter(undefined, 0, 0, RECORD);
|
||||
const failed = landedAfter(undefined, 0, 0, undefined);
|
||||
test('never read → read', () => {
|
||||
expect(needsRead(undefined, 0, 0)).toBe(true);
|
||||
});
|
||||
test('landed for the current counters → no read', () => {
|
||||
expect(needsRead(ok, 0, 0)).toBe(false);
|
||||
expect(needsRead(failed, 0, 0)).toBe(false);
|
||||
});
|
||||
test('a new retry re-reads a failure, never a success', () => {
|
||||
expect(needsRead(failed, 0, 1)).toBe(true);
|
||||
expect(needsRead(ok, 0, 1)).toBe(false);
|
||||
});
|
||||
test('a new refresh re-reads both', () => {
|
||||
expect(needsRead(ok, 1, 0)).toBe(true);
|
||||
expect(needsRead(failed, 1, 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* `usePublicRecords` — the React side of `data/publicRead.ts`: read a set of
|
||||
* records by URI, each one INDEPENDENTLY.
|
||||
*
|
||||
* - `records` is keyed by the REQUESTED URI. A URI never read successfully is
|
||||
* absent. LAST GOOD WINS: a re-read that fails keeps the record already
|
||||
* shown (a blip never blanks the screen or turns a membership off).
|
||||
* - `refresh` (a generation PER URI, absent = 0) re-reads a URI each time its
|
||||
* generation moves — what a join/leave needs for the one attendees
|
||||
* collection it changed. `retry` (a counter) re-reads only the URIs whose
|
||||
* last read FAILED — what re-opening an event needs, without re-reading
|
||||
* everything that already landed.
|
||||
* - `readAt` gives, per URI, the generation of the read that produced the
|
||||
* record SHOWN — so a caller can tell a read issued after a write from one
|
||||
* issued before it. A failed re-read leaves it where the record came from:
|
||||
* the record kept is still the older read's. `attemptedAt` gives the
|
||||
* generation of the last read that LANDED, failed or not — "the read asked
|
||||
* for has come back", whatever it brought.
|
||||
* - `pending` holds the current URIs whose read for the current generation and
|
||||
* retry has not landed yet. It is computed from state, so a URI is pending
|
||||
* from the very render it enters the set — "not read yet" is never mistaken
|
||||
* for "absent".
|
||||
* - Each URI is read ON ITS OWN: one slow or dead host delays that URI only
|
||||
* (and gives up after the read timeout), never the others — and re-reading
|
||||
* one URI (its generation moved) never aborts or restarts another's read
|
||||
* still in flight. A read in flight is aborted only when its URI leaves the
|
||||
* set, when its own generation moves on, or on unmount.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { JsonRecord } from '../data/jsonRecord';
|
||||
import { fetchPublicRecord } from '../data/publicRead';
|
||||
|
||||
export interface Landed {
|
||||
/** The generation and retry this read was made for. */
|
||||
refresh: number;
|
||||
retry: number;
|
||||
/** The last record read successfully (kept across a failed re-read). */
|
||||
record: JsonRecord | undefined;
|
||||
/** The generation of the read that produced `record`. */
|
||||
recordRefresh: number | undefined;
|
||||
/** Whether THIS read failed. */
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
export interface PublicRecordsRead {
|
||||
records: ReadonlyMap<string, JsonRecord>;
|
||||
/** Per URI, the generation of the read that produced the record in `records`. */
|
||||
readAt: ReadonlyMap<string, number>;
|
||||
/** Per URI, the generation of the last read that landed, successful or failed. */
|
||||
attemptedAt: ReadonlyMap<string, number>;
|
||||
pending: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
export interface PublicRecordsOptions {
|
||||
/** Per-URI read generation (absent = 0): moving it re-reads that URI. */
|
||||
refresh?: ReadonlyMap<string, number>;
|
||||
retry?: number;
|
||||
/** The token to attach for a URI — the caller grants one only for its own origin. */
|
||||
tokenOf?: (uri: string) => string | undefined;
|
||||
}
|
||||
|
||||
const NO_GENERATIONS: ReadonlyMap<string, number> = new Map();
|
||||
|
||||
/**
|
||||
* What a read landing leaves for its URI: the generation and retry it was made
|
||||
* for, and the LAST GOOD record with the generation that produced it — a failed
|
||||
* read keeps the previous one.
|
||||
*/
|
||||
export function landedAfter(
|
||||
previous: Landed | undefined,
|
||||
refresh: number,
|
||||
retry: number,
|
||||
record: JsonRecord | undefined,
|
||||
): Landed {
|
||||
return record === undefined
|
||||
? { refresh, retry, record: previous?.record, recordRefresh: previous?.recordRefresh, failed: true }
|
||||
: { refresh, retry, record, recordRefresh: refresh, failed: false };
|
||||
}
|
||||
|
||||
/** Does this URI need a read for its current generation and retry? */
|
||||
export function needsRead(entry: Landed | undefined, refresh: number, retry: number): boolean {
|
||||
if (!entry || entry.refresh !== refresh) return true;
|
||||
return entry.failed && entry.retry !== retry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-URI read generations for `usePublicRecords`' `refresh`: `readAgain(uri)`
|
||||
* moves that URI's generation and returns the new value AT ONCE — the number a
|
||||
* caller compares `readAt` against to know a read was issued after this call.
|
||||
*/
|
||||
export function useReadGenerations(): [ReadonlyMap<string, number>, (uri: string) => number] {
|
||||
const [generations, setGenerations] = useState<ReadonlyMap<string, number>>(new Map());
|
||||
const generationsRef = useRef(generations);
|
||||
const readAgain = useCallback((uri: string): number => {
|
||||
const generation = (generationsRef.current.get(uri) ?? 0) + 1;
|
||||
generationsRef.current = new Map(generationsRef.current).set(uri, generation);
|
||||
setGenerations(generationsRef.current);
|
||||
return generation;
|
||||
}, []);
|
||||
return [generations, readAgain];
|
||||
}
|
||||
|
||||
export function usePublicRecords(uris: readonly string[], options: PublicRecordsOptions = {}): PublicRecordsRead {
|
||||
const { refresh = NO_GENERATIONS, retry = 0, tokenOf } = options;
|
||||
const key = uris.join('\n');
|
||||
// The generations of the CURRENT URIs, as a string: the effect re-runs when
|
||||
// one of them moves, not when the caller rebuilds an equal map.
|
||||
const generationKey = key ? key.split('\n').map(uri => refresh.get(uri) ?? 0).join(',') : '';
|
||||
const [landed, setLanded] = useState<ReadonlyMap<string, Landed>>(new Map());
|
||||
// Read by the effect without re-running it: what already landed is not read
|
||||
// again when the set grows, and the token policy is the caller's current one.
|
||||
const landedRef = useRef(landed);
|
||||
landedRef.current = landed;
|
||||
const tokenOfRef = useRef(tokenOf);
|
||||
tokenOfRef.current = tokenOf;
|
||||
const retryRef = useRef(retry);
|
||||
retryRef.current = retry;
|
||||
// The reads in flight, per URI, with the generation each was issued for.
|
||||
const inFlight = useRef(new Map<string, { generation: number; controller: AbortController }>());
|
||||
// Moved when a read FAILS after a re-open that came while it was in flight:
|
||||
// that failure is no answer to the re-open, so the effect reads again.
|
||||
const [relaunch, setRelaunch] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const all = key ? key.split('\n') : [];
|
||||
const generations = generationKey ? generationKey.split(',').map(Number) : [];
|
||||
const generationOf = new Map(all.map((uri, i) => [uri, generations[i] ?? 0]));
|
||||
// A read in flight for a URI that left the set, or whose generation moved
|
||||
// on, answers a question nobody asks any more. Every other read in flight
|
||||
// is left alone: one URI's re-read never restarts another's.
|
||||
for (const [uri, read] of inFlight.current) {
|
||||
if (generationOf.get(uri) === read.generation) continue;
|
||||
read.controller.abort();
|
||||
inFlight.current.delete(uri);
|
||||
}
|
||||
for (const uri of all) {
|
||||
const generation = generationOf.get(uri) ?? 0;
|
||||
if (inFlight.current.has(uri) || !needsRead(landedRef.current.get(uri), generation, retry)) continue;
|
||||
const controller = new AbortController();
|
||||
inFlight.current.set(uri, { generation, controller });
|
||||
void fetchPublicRecord(uri, { signal: controller.signal, token: tokenOfRef.current?.(uri) }).then(record => {
|
||||
if (controller.signal.aborted) return;
|
||||
inFlight.current.delete(uri);
|
||||
setLanded(prev => new Map(prev).set(uri, landedAfter(prev.get(uri), generation, retry, record)));
|
||||
// A re-open while this read was in flight did not restart it (a slow
|
||||
// read is not a failed one) — but if it fails, the re-open still wants
|
||||
// its read: issue it now.
|
||||
if (record === undefined && retryRef.current !== retry) setRelaunch(n => n + 1);
|
||||
});
|
||||
}
|
||||
}, [key, generationKey, retry, relaunch]);
|
||||
useEffect(() => () => {
|
||||
for (const read of inFlight.current.values()) read.controller.abort();
|
||||
inFlight.current.clear();
|
||||
}, []);
|
||||
|
||||
const records = useMemo(() => {
|
||||
const out = new Map<string, JsonRecord>();
|
||||
for (const [uri, entry] of landed) if (entry.record) out.set(uri, entry.record);
|
||||
return out;
|
||||
}, [landed]);
|
||||
const readAt = useMemo(() => {
|
||||
const out = new Map<string, number>();
|
||||
for (const [uri, entry] of landed) if (entry.record && entry.recordRefresh !== undefined) out.set(uri, entry.recordRefresh);
|
||||
return out;
|
||||
}, [landed]);
|
||||
const attemptedAt = useMemo(() => {
|
||||
const out = new Map<string, number>();
|
||||
for (const [uri, entry] of landed) out.set(uri, entry.refresh);
|
||||
return out;
|
||||
}, [landed]);
|
||||
const pending = useMemo(() => {
|
||||
const out = new Set<string>();
|
||||
if (key) {
|
||||
const generations = generationKey.split(',').map(Number);
|
||||
key.split('\n').forEach((uri, i) => {
|
||||
if (needsRead(landed.get(uri), generations[i] ?? 0, retry)) out.add(uri);
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}, [key, generationKey, landed, retry]);
|
||||
return { records, readAt, attemptedAt, pending };
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { asRecord, linkId } from './jsonRecord';
|
||||
import { tokenFor } from './publicRead';
|
||||
|
||||
/** The parts of the actor (WebID) document this app uses. */
|
||||
export interface ActorDocument {
|
||||
@@ -60,15 +61,24 @@ export async function fetchActorDocument(webId: string, token?: string): Promise
|
||||
* missing `url`, a non-object body — collapses to `undefined`; the record is
|
||||
* returned raw for the binding's `toDomain` to map, so this module stays free
|
||||
* of vocabulary.
|
||||
*
|
||||
* THE TOKEN STAYS HOME when `trustedOrigin` is given. Attendee URIs come out of
|
||||
* collections other users' meeting points link to, and an actor document names
|
||||
* its own profile URI: with `trustedOrigin` (the session's own pod provider),
|
||||
* each of the two fetches carries the token only when it targets that origin
|
||||
* (`tokenFor`), and goes out anonymously anywhere else.
|
||||
*/
|
||||
export async function fetchActorProfileRecord(webId: string, token?: string): Promise<unknown> {
|
||||
export async function fetchActorProfileRecord(webId: string, token?: string, trustedOrigin?: string): Promise<unknown> {
|
||||
const credentialFor = (uri: string): string | undefined =>
|
||||
trustedOrigin === undefined ? token : tokenFor(uri, token, trustedOrigin);
|
||||
try {
|
||||
const actor = await fetchActorDocument(webId, token);
|
||||
const actor = await fetchActorDocument(webId, credentialFor(webId));
|
||||
if (!actor.profileUri) return undefined;
|
||||
const profileToken = credentialFor(actor.profileUri);
|
||||
const response = await fetch(actor.profileUri, {
|
||||
headers: {
|
||||
Accept: 'application/ld+json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(profileToken ? { Authorization: `Bearer ${profileToken}` } : {}),
|
||||
},
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { attendeesCollectionLink, embedAttendeesCollection } from './attendees';
|
||||
import { deriveContainer } from '../context/refineDerive';
|
||||
import { getBackend } from '../refine/providers';
|
||||
|
||||
const bindings = getBackend('activitypods').bindings;
|
||||
|
||||
const EVENT = 'http://localhost:3000/bob/data/event-1';
|
||||
const MP = 'http://localhost:3000/bob/data/mp-1';
|
||||
const COLLECTION = 'http://localhost:3000/bob/data/att-1';
|
||||
const ALICE = 'http://localhost:3000/alice';
|
||||
const CAROL = 'http://localhost:3000/carol';
|
||||
|
||||
// The shapes the pod returns, as observed live: the record links the collection
|
||||
// by URI; the collection is its own resource with `items` and no `totalItems`.
|
||||
const meetingPoint = { id: MP, type: 'Event', 'as:context': EVENT, 'apods:attendees': COLLECTION };
|
||||
const collection = { id: COLLECTION, type: 'Collection', 'semapps:dereferenceItems': false, items: [ALICE, CAROL] };
|
||||
|
||||
describe('attendeesCollectionLink', () => {
|
||||
test('a bare URI is a link to read', () => {
|
||||
expect(attendeesCollectionLink(meetingPoint)).toBe(COLLECTION);
|
||||
expect(attendeesCollectionLink({ ...meetingPoint, 'apods:attendees': [COLLECTION] })).toBe(COLLECTION);
|
||||
});
|
||||
test('an embedded collection, no collection or no record is nothing to read', () => {
|
||||
expect(attendeesCollectionLink({ ...meetingPoint, 'apods:attendees': collection })).toBeUndefined();
|
||||
expect(attendeesCollectionLink({ id: EVENT, type: 'Event' })).toBeUndefined();
|
||||
expect(attendeesCollectionLink(null)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('embedAttendeesCollection', () => {
|
||||
test('a read collection is embedded where the participation binding reads it', () => {
|
||||
const embedded = embedAttendeesCollection(meetingPoint, new Map([[COLLECTION, collection]]));
|
||||
const derived = deriveContainer([{ id: EVENT, type: 'Event', name: 'Concert' }, embedded], bindings);
|
||||
expect(derived.participations.map(p => p.userId)).toEqual([ALICE, CAROL]);
|
||||
// No totalItems on the pod's collection: the count is the items.
|
||||
expect(derived.totals.get(MP)).toBe(2);
|
||||
expect(derived.meetingPoints[0]?.participantCount).toBe(2);
|
||||
expect(derived.events[0]?.participantCount).toBe(2);
|
||||
});
|
||||
|
||||
test('an empty read collection counts zero', () => {
|
||||
const embedded = embedAttendeesCollection(meetingPoint, new Map([[COLLECTION, { ...collection, items: [] }]]));
|
||||
const derived = deriveContainer([embedded], bindings);
|
||||
expect(derived.totals.get(MP)).toBe(0);
|
||||
});
|
||||
|
||||
test('an unread collection leaves the record as it was (same object) — it reads as an honest zero', () => {
|
||||
expect(embedAttendeesCollection(meetingPoint, new Map())).toBe(meetingPoint);
|
||||
expect(embedAttendeesCollection(meetingPoint, new Map([[COLLECTION, 'not-a-record']]))).toBe(meetingPoint);
|
||||
const event = { id: EVENT, type: 'Event' };
|
||||
expect(embedAttendeesCollection(event, new Map([[COLLECTION, collection]]))).toBe(event);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Attendees collections, joined back onto the meeting point that links them.
|
||||
*
|
||||
* WHAT THE POD ACTUALLY RETURNS (verified live 2026-10-12, container listing,
|
||||
* direct GET and catalog item alike): a meeting point record carries its
|
||||
* `apods:attendees` collection as a BARE URI, never embedded. The collection is
|
||||
* its own resource — `{ type: Collection, items: [...] }`, members as actor
|
||||
* URIs, no `totalItems` — readable by anyone (public read, granted at creation).
|
||||
*
|
||||
* The participation binding reads members from an EMBEDDED collection object
|
||||
* (`membersOf`), and an un-embedded link reads as an honest zero. So the
|
||||
* reader fetches each linked collection by its URI and puts it back where the
|
||||
* binding looks: this module is that join, pure and vocabulary-only — no fetch,
|
||||
* no React. A collection that could not be read is simply not embedded, and
|
||||
* the record keeps reading as zero.
|
||||
*/
|
||||
|
||||
import { asRecord, asString, firstEntry } from './jsonRecord';
|
||||
|
||||
const ATTENDEES = 'apods:attendees';
|
||||
|
||||
/** The attendees collection a record LINKS without embedding it — its URI — or `undefined`. */
|
||||
export function attendeesCollectionLink(record: unknown): string | undefined {
|
||||
return asString(firstEntry(asRecord(record)?.[ATTENDEES]));
|
||||
}
|
||||
|
||||
/**
|
||||
* The record with its linked attendees collection embedded, when that
|
||||
* collection was read (`collections` is keyed by collection URI). Otherwise the
|
||||
* record unchanged — the same object, so an unaffected record keeps its identity.
|
||||
*/
|
||||
export function embedAttendeesCollection(record: unknown, collections: ReadonlyMap<string, unknown>): unknown {
|
||||
const link = attendeesCollectionLink(record);
|
||||
if (link === undefined) return record;
|
||||
const collection = asRecord(collections.get(link));
|
||||
const host = asRecord(record);
|
||||
if (!collection || !host) return record;
|
||||
return { ...host, [ATTENDEES]: collection };
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import {
|
||||
catalogEventsFromBody,
|
||||
catalogMeetingPointIdsOf,
|
||||
fetchCatalogEvents,
|
||||
fetchCatalogMeetingPointIds,
|
||||
unionEvents,
|
||||
} from './catalog';
|
||||
import type { FpEventData } from './types';
|
||||
|
||||
const ev = (id: string, title = id): FpEventData => ({
|
||||
id, title, description: '', date: '', location: '', participantCount: 0,
|
||||
});
|
||||
|
||||
const body = {
|
||||
type: 'Collection',
|
||||
totalItems: 3,
|
||||
items: [
|
||||
{ id: 'http://pod/x/e1', type: 'Event', name: 'Concert', location: { name: 'Paris' }, startTime: '2026-10-20T19:00:00.000Z' },
|
||||
{ id: 'http://pod/x/m1', type: 'Event', name: 'Rdv', 'as:context': 'http://pod/x/e1' },
|
||||
{ id: 'http://pod/y/e2', type: 'Event', name: 'Expo' },
|
||||
],
|
||||
};
|
||||
|
||||
describe('catalogEventsFromBody', () => {
|
||||
test('keeps events, drops meeting points, maps id/title/location/date', () => {
|
||||
const events = catalogEventsFromBody(body);
|
||||
expect(events.map(e => e.id)).toEqual(['http://pod/x/e1', 'http://pod/y/e2']);
|
||||
expect(events[0]?.title).toBe('Concert');
|
||||
expect(events[0]?.location).toBe('Paris');
|
||||
expect(events[0]?.startDate).toBe('2026-10-20');
|
||||
});
|
||||
test('malformed bodies map to nothing', () => {
|
||||
expect(catalogEventsFromBody(null)).toEqual([]);
|
||||
expect(catalogEventsFromBody({ items: 'nope' })).toEqual([]);
|
||||
expect(catalogEventsFromBody({ items: [1, null, 'x'] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unionEvents', () => {
|
||||
test('de-duplicates by URI, own first', () => {
|
||||
const out = unionEvents([ev('a', 'own-a')], [ev('a', 'cat-a'), ev('b')]);
|
||||
expect(out.map(e => `${e.id}:${e.title}`)).toEqual(['a:own-a', 'b:b']);
|
||||
});
|
||||
test('empty catalog leaves own untouched', () => {
|
||||
expect(unionEvents([ev('a')], []).map(e => e.id)).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchCatalogEvents fails open', () => {
|
||||
const original = globalThis.fetch;
|
||||
afterEach(() => { globalThis.fetch = original; });
|
||||
const stub = (impl: () => Promise<Response>) => { globalThis.fetch = (async () => impl()) as unknown as typeof fetch; };
|
||||
|
||||
test('success', async () => {
|
||||
stub(async () => new Response(JSON.stringify(body)));
|
||||
expect((await fetchCatalogEvents('http://b/')).length).toBe(2);
|
||||
});
|
||||
test('network error', async () => {
|
||||
const warn = spyOn(console, 'warn').mockImplementation(() => {});
|
||||
stub(async () => { throw new Error('down'); });
|
||||
expect(await fetchCatalogEvents('http://b')).toEqual([]);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
test('non-2xx', async () => {
|
||||
const warn = spyOn(console, 'warn').mockImplementation(() => {});
|
||||
stub(async () => new Response('x', { status: 500 }));
|
||||
expect(await fetchCatalogEvents('http://b')).toEqual([]);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
test('malformed body', async () => {
|
||||
const warn = spyOn(console, 'warn').mockImplementation(() => {});
|
||||
stub(async () => new Response('{"items":"x"}'));
|
||||
expect(await fetchCatalogEvents('http://b')).toEqual([]);
|
||||
stub(async () => new Response('not json'));
|
||||
expect(await fetchCatalogEvents('http://b')).toEqual([]);
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// PER-ITEM ISOLATION: the catalog aggregates many pods' records, so one bad item
|
||||
// must cost that item only — never the whole list.
|
||||
describe('one malformed catalog item is skipped, the valid ones are kept', () => {
|
||||
const mixed = {
|
||||
type: 'Collection',
|
||||
items: [
|
||||
{ id: 'http://pod/x/e1', type: 'Event', name: 'Concert' },
|
||||
{ type: 'Event', name: 'No identifier' },
|
||||
'not-an-object',
|
||||
{ '@id': 'http://pod/y/e2', type: 'Event', name: 'Expo' },
|
||||
{ id: 'http://pod/x/m1', type: 'Event', name: 'Rdv', 'as:context': 'http://pod/x/e1' },
|
||||
{ type: 'Event', name: 'Rdv sans id', 'as:context': 'http://pod/x/e1' },
|
||||
],
|
||||
};
|
||||
|
||||
test('events: the item the reader rejects is skipped and warned, the others mapped', () => {
|
||||
const warn = spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const events = catalogEventsFromBody(mixed);
|
||||
expect(events.map(e => e.id)).toEqual(['http://pod/x/e1', 'http://pod/y/e2']);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test('fetchCatalogEvents keeps the valid events of a body carrying a bad item', async () => {
|
||||
const original = globalThis.fetch;
|
||||
const warn = spyOn(console, 'warn').mockImplementation(() => {});
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify(mixed))) as unknown as typeof fetch;
|
||||
try {
|
||||
expect((await fetchCatalogEvents('http://b')).map(e => e.title)).toEqual(['Concert', 'Expo']);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('meeting points: an item without identifier is skipped, the readable one kept', () => {
|
||||
const warn = spyOn(console, 'warn').mockImplementation(() => {});
|
||||
expect(catalogMeetingPointIdsOf(mixed, ['http://pod/x/e1'])).toEqual(['http://pod/x/m1']);
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalogMeetingPointIdsOf — the cross-user source of a foreign event\'s meeting points', () => {
|
||||
test('returns the meeting points anchored on the asked events only, de-duplicated, in catalog order', () => {
|
||||
const catalog = {
|
||||
items: [
|
||||
{ id: 'http://pod/x/e1', type: 'Event', name: 'Concert' },
|
||||
{ id: 'http://pod/x/m1', type: 'Event', 'as:context': 'http://pod/x/e1' },
|
||||
{ id: 'http://pod/y/m2', type: 'Event', 'as:context': { id: 'http://pod/x/e1' } },
|
||||
{ id: 'http://pod/y/m3', type: 'Event', 'as:context': 'http://pod/y/e2' },
|
||||
{ id: 'http://pod/x/m1', type: 'Event', 'as:context': 'http://pod/x/e1' },
|
||||
],
|
||||
};
|
||||
expect(catalogMeetingPointIdsOf(catalog, ['http://pod/x/e1'])).toEqual(['http://pod/x/m1', 'http://pod/y/m2']);
|
||||
expect(catalogMeetingPointIdsOf(catalog, ['http://pod/y/e2'])).toEqual(['http://pod/y/m3']);
|
||||
expect(catalogMeetingPointIdsOf(catalog, ['http://pod/z/none'])).toEqual([]);
|
||||
});
|
||||
|
||||
test('a malformed body has no meeting points', () => {
|
||||
expect(catalogMeetingPointIdsOf(undefined, ['http://pod/x/e1'])).toEqual([]);
|
||||
expect(catalogMeetingPointIdsOf({ items: 'x' }, ['http://pod/x/e1'])).toEqual([]);
|
||||
});
|
||||
|
||||
test('fetchCatalogMeetingPointIds: an unreadable catalog is "could not ask" (undefined), not "none"; nothing asked, no fetch', async () => {
|
||||
const original = globalThis.fetch;
|
||||
let calls = 0;
|
||||
const warn = spyOn(console, 'warn').mockImplementation(() => {});
|
||||
try {
|
||||
globalThis.fetch = (async () => { calls++; throw new Error('down'); }) as unknown as typeof fetch;
|
||||
expect(await fetchCatalogMeetingPointIds('http://b', ['http://pod/x/e1'])).toBeUndefined();
|
||||
expect(calls).toBe(1);
|
||||
expect(await fetchCatalogMeetingPointIds('http://b', [])).toEqual([]);
|
||||
expect(calls).toBe(1);
|
||||
globalThis.fetch = (async () => new Response(JSON.stringify(body))) as unknown as typeof fetch;
|
||||
expect(await fetchCatalogMeetingPointIds('http://b/', ['http://pod/x/e1'])).toEqual(['http://pod/x/m1']);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Catalog read — cross-user event discovery, living BESIDE Refine.
|
||||
*
|
||||
* The Festipod backend keeps a public index of every event and meeting point
|
||||
* created through the app (`GET {backend}/catalog`, an unauthenticated
|
||||
* ActivityStreams collection). The Refine data provider is CRUD over the
|
||||
* signed-in user's own pod and has no `custom` method, so this read is a plain
|
||||
* fetch, like the actor reads in `actor.ts`.
|
||||
*
|
||||
* FAILS OPEN. Discovery is an enrichment of the list, never a precondition: a
|
||||
* network error, a non-2xx answer or a malformed body all read back as "no
|
||||
* catalog records" (with one `console.warn`), so the caller still renders the
|
||||
* user's own events.
|
||||
*
|
||||
* PER-ITEM ISOLATION. The catalog aggregates records written by many pods; one
|
||||
* malformed item (not an object, no identifier, a record the event reader
|
||||
* rejects) is skipped with a warning and the valid items are kept — a bad item
|
||||
* never empties the whole catalog.
|
||||
*
|
||||
* MEETING POINTS ARE IN THE CATALOG TOO (both are `as:Event`; a meeting point
|
||||
* carries `as:context` → its anchor event). They are filtered out of the
|
||||
* Découvrir list, which shows events, and they are the ONLY cross-user source
|
||||
* of another user's meeting points: `catalogMeetingPointIdsOf` answers "which
|
||||
* meeting points hang off these events". The catalog item is an index entry,
|
||||
* not the record — the caller reads the meeting point itself by its URI.
|
||||
*/
|
||||
|
||||
import type { FpEventData } from './types';
|
||||
import { asRecord, asString, linkId, type JsonRecord } from './jsonRecord';
|
||||
import { eventFromRecord } from '../refine/providers/activitypods';
|
||||
|
||||
/** The catalog item's own identifier — `id` or `@id`, whichever is a string. */
|
||||
function itemId(record: JsonRecord): string | undefined {
|
||||
return asString(record['id']) ?? asString(record['@id']);
|
||||
}
|
||||
|
||||
/** The anchor event a catalog item points at — present on a meeting point, absent on an event. */
|
||||
function anchorOf(record: JsonRecord): string | undefined {
|
||||
return linkId(record['as:context']);
|
||||
}
|
||||
|
||||
/**
|
||||
* The object items of a catalog body. A body without an `items` array has no
|
||||
* items; a non-object item is skipped (and counted in one warning).
|
||||
*/
|
||||
export function catalogRecordsFromBody(body: unknown): JsonRecord[] {
|
||||
const items = asRecord(body)?.['items'];
|
||||
if (!Array.isArray(items)) return [];
|
||||
const records: JsonRecord[] = [];
|
||||
let skipped = 0;
|
||||
for (const item of items) {
|
||||
const record = asRecord(item);
|
||||
if (record) records.push(record);
|
||||
else skipped++;
|
||||
}
|
||||
if (skipped > 0) console.warn(`[catalog] skipped ${skipped} non-object catalog item(s)`);
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* The records of a catalog body that are events (not meeting points), mapped
|
||||
* to the app's event type. An item the event reader rejects (no identifier, for
|
||||
* one) is skipped and warned about; the others are kept.
|
||||
*/
|
||||
export function catalogEventsFromBody(body: unknown): FpEventData[] {
|
||||
const events: FpEventData[] = [];
|
||||
for (const record of catalogRecordsFromBody(body)) {
|
||||
if (anchorOf(record) !== undefined) continue;
|
||||
try {
|
||||
events.push(eventFromRecord(record));
|
||||
} catch (error) {
|
||||
console.warn('[catalog] skipped a malformed catalog event item', error);
|
||||
}
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* The URIs of the catalog's meeting points that hang off one of `eventIds`
|
||||
* (their `as:context`), in catalog order, de-duplicated. An item without an
|
||||
* identifier cannot be read by URI and is skipped.
|
||||
*/
|
||||
export function catalogMeetingPointIdsOf(body: unknown, eventIds: readonly string[]): string[] {
|
||||
const wanted = new Set(eventIds);
|
||||
const out: string[] = [];
|
||||
for (const record of catalogRecordsFromBody(body)) {
|
||||
const anchor = anchorOf(record);
|
||||
if (anchor === undefined || !wanted.has(anchor)) continue;
|
||||
const id = itemId(record);
|
||||
if (id === undefined) {
|
||||
console.warn(`[catalog] skipped a meeting point item of ${anchor} that carries no identifier`);
|
||||
continue;
|
||||
}
|
||||
if (!out.includes(id)) out.push(id);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the catalog body. Never throws: every failure is a warned `undefined`,
|
||||
* which every reader above maps to "nothing".
|
||||
*/
|
||||
async function fetchCatalogBody(backendUrl: string, signal?: AbortSignal): Promise<unknown> {
|
||||
const url = `${backendUrl.replace(/\/+$/, '')}/catalog`;
|
||||
try {
|
||||
const response = await fetch(url, { headers: { Accept: 'application/json' }, signal });
|
||||
if (!response.ok) {
|
||||
console.warn(`[catalog] ${url} answered HTTP ${response.status} — no catalog records`);
|
||||
return undefined;
|
||||
}
|
||||
const body: unknown = await response.json();
|
||||
if (!Array.isArray(asRecord(body)?.['items'])) {
|
||||
console.warn(`[catalog] ${url} returned a malformed body — no catalog records`);
|
||||
return undefined;
|
||||
}
|
||||
return body;
|
||||
} catch (error) {
|
||||
if (signal?.aborted) return undefined;
|
||||
console.warn(`[catalog] ${url} unreachable — no catalog records`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the catalog and return its events. Never throws: every failure is a
|
||||
* warned empty list.
|
||||
*/
|
||||
export async function fetchCatalogEvents(backendUrl: string, signal?: AbortSignal): Promise<FpEventData[]> {
|
||||
return catalogEventsFromBody(await fetchCatalogBody(backendUrl, signal));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the catalog and return the URIs of the meeting points hanging off
|
||||
* `eventIds`. Never throws. A catalog that could not be read answers
|
||||
* `undefined` (warned) — NOT an empty list: "no meeting point" and "could not
|
||||
* ask" are different answers, and a caller that already shows meeting points
|
||||
* keeps them on the second.
|
||||
*/
|
||||
export async function fetchCatalogMeetingPointIds(
|
||||
backendUrl: string,
|
||||
eventIds: readonly string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<string[] | undefined> {
|
||||
if (eventIds.length === 0) return [];
|
||||
const body = await fetchCatalogBody(backendUrl, signal);
|
||||
return body === undefined ? undefined : catalogMeetingPointIdsOf(body, eventIds);
|
||||
}
|
||||
|
||||
/** Own events first, then catalog events not already listed, de-duplicated by event URI. */
|
||||
export function unionEvents(own: FpEventData[], catalog: FpEventData[]): FpEventData[] {
|
||||
const seen = new Set<string>();
|
||||
const out: FpEventData[] = [];
|
||||
for (const event of [...own, ...catalog]) {
|
||||
if (seen.has(event.id)) continue;
|
||||
seen.add(event.id);
|
||||
out.push(event);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, describe, expect, spyOn, test } from 'bun:test';
|
||||
import { fetchPublicRecord, tokenFor } from './publicRead';
|
||||
import { fetchActorProfileRecord } from './actor';
|
||||
|
||||
const original = globalThis.fetch;
|
||||
afterEach(() => { globalThis.fetch = original; });
|
||||
|
||||
interface Call { url: string; init: RequestInit }
|
||||
function install(handler: (url: string) => Response): Call[] {
|
||||
const calls: Call[] = [];
|
||||
globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => {
|
||||
const u = typeof url === 'string' ? url : url instanceof URL ? url.toString() : url.url;
|
||||
calls.push({ url: u, init: init ?? {} });
|
||||
return Promise.resolve(handler(u));
|
||||
}) as typeof fetch;
|
||||
return calls;
|
||||
}
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/ld+json' } });
|
||||
const authOf = (call: Call | undefined) => new Headers(call?.init.headers).get('Authorization');
|
||||
|
||||
const E = 'http://localhost:3000/bob/data/e1';
|
||||
const M = 'http://localhost:3000/bob/data/m1';
|
||||
const EVIL = 'https://elsewhere.example/thing';
|
||||
|
||||
describe('fetchPublicRecord — anonymous, trusted for what was asked', () => {
|
||||
test('reads without credentials of any kind', async () => {
|
||||
const calls = install(() => json({ id: E, type: 'Event' }));
|
||||
expect(await fetchPublicRecord(E)).toEqual({ id: E, type: 'Event' });
|
||||
expect(authOf(calls[0])).toBeNull();
|
||||
expect(calls[0]?.init.credentials).toBe('omit');
|
||||
});
|
||||
|
||||
test('a document identifying itself as another resource is refused', async () => {
|
||||
const warn = spyOn(console, 'warn').mockImplementation(() => {});
|
||||
install(() => json({ id: M, type: 'Event', name: 'forged' }));
|
||||
expect(await fetchPublicRecord(E)).toBeUndefined();
|
||||
install(() => json({ type: 'Event' }));
|
||||
expect(await fetchPublicRecord(E)).toBeUndefined();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test('a refusal, a network error, a non-object body or a non-http URI reads as absent', async () => {
|
||||
install(() => json({ error: 'Forbidden' }, 403));
|
||||
expect(await fetchPublicRecord(E)).toBeUndefined();
|
||||
globalThis.fetch = (() => Promise.reject(new Error('down'))) as unknown as typeof fetch;
|
||||
expect(await fetchPublicRecord(E)).toBeUndefined();
|
||||
install(() => json([1, 2]));
|
||||
expect(await fetchPublicRecord(E)).toBeUndefined();
|
||||
const calls = install(() => json({ id: 'javascript:alert(1)' }));
|
||||
expect(await fetchPublicRecord('javascript:alert(1)')).toBeUndefined();
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchPublicRecord — bounded', () => {
|
||||
test('a host that never answers gives up at the timeout', async () => {
|
||||
globalThis.fetch = ((_url: string | URL | Request, init?: RequestInit) =>
|
||||
new Promise<Response>((_, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new Error('aborted')));
|
||||
})) as typeof fetch;
|
||||
const started = Date.now();
|
||||
expect(await fetchPublicRecord(EVIL, { timeoutMs: 150 })).toBeUndefined();
|
||||
expect(Date.now() - started).toBeLessThan(2000);
|
||||
});
|
||||
|
||||
test('an already-aborted read sends nothing', async () => {
|
||||
const calls = install(url => json({ id: url }));
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
expect(await fetchPublicRecord(E, { signal: controller.signal })).toBeUndefined();
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenFor — the session token stays on its own origin', () => {
|
||||
test('same origin gets the token, any other origin or a bad URI does not', () => {
|
||||
expect(tokenFor(E, 'tok', 'http://localhost:3000')).toBe('tok');
|
||||
expect(tokenFor(EVIL, 'tok', 'http://localhost:3000')).toBeUndefined();
|
||||
expect(tokenFor('http://localhost:3001/x', 'tok', 'http://localhost:3000')).toBeUndefined();
|
||||
expect(tokenFor('not a uri', 'tok', 'http://localhost:3000')).toBeUndefined();
|
||||
expect(tokenFor(E, undefined, 'http://localhost:3000')).toBeUndefined();
|
||||
expect(tokenFor(E, 'tok', undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('profile resolution with a trusted origin never sends the token elsewhere', async () => {
|
||||
const ACTOR = 'http://localhost:3000/carol';
|
||||
const calls = install(url => (url === ACTOR ? json({ id: ACTOR, url: EVIL }) : json({ id: EVIL, name: 'x' })));
|
||||
await fetchActorProfileRecord(ACTOR, 'tok', 'http://localhost:3000');
|
||||
expect(calls.map(c => [c.url, authOf(c)])).toEqual([[ACTOR, 'Bearer tok'], [EVIL, null]]);
|
||||
|
||||
const foreignActor = install(() => json({ id: EVIL, url: `${EVIL}/profile` }, 200));
|
||||
await fetchActorProfileRecord(EVIL, 'tok', 'http://localhost:3000');
|
||||
expect(foreignActor.every(c => authOf(c) === null)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Reads by URI of other users' public data — living BESIDE Refine.
|
||||
*
|
||||
* WHY NOT THE DATA PROVIDER. The pinned contract lets `getOne`/`getMany` fetch
|
||||
* any URI, but it does not say they can do so WITHOUT the session's
|
||||
* credentials — and the URIs read here are chosen by OTHER users: a discovered
|
||||
* event's URI (from the catalog, or from a link someone sent), the meeting
|
||||
* points the catalog lists for it, the attendees collection each of those
|
||||
* records links to. Sending the session token to wherever such a link points
|
||||
* would hand a replayable credential to a host nobody vetted. These resources
|
||||
* are public by design (granted world-readable at creation), so the read is
|
||||
* ANONYMOUS — no `Authorization`, no cookies — except on the origin that
|
||||
* issued the session (`tokenFor`): there the token adds only what the user may
|
||||
* already read, and covers a resource whose public grant did not land.
|
||||
*
|
||||
* BOUNDED. Each read gives up after `timeoutMs` (a host that never answers is
|
||||
* an unreadable URI, not a stalled page); the caller reads each URI on its own
|
||||
* (`context/usePublicRecords.ts`), so one slow host never withholds the others.
|
||||
*
|
||||
* TRUSTED FOR WHAT WAS ASKED, NOT FOR WHAT IT CLAIMS. A record is kept only
|
||||
* when its own identifier (`id`/`@id`) is the URI that was requested, and the
|
||||
* result is keyed by that URI — so a document cannot pass itself off as
|
||||
* another resource (another event, another meeting point's collection).
|
||||
*
|
||||
* FAILS OPEN, PER URI. A refusal, a 404, a network error, a non-object body or
|
||||
* a mismatched identifier all read back as "absent" for that URI only; the
|
||||
* others are kept. Absent is the caller's "not readable → not shown" answer.
|
||||
*/
|
||||
|
||||
import { asRecord, asString, type JsonRecord } from './jsonRecord';
|
||||
|
||||
/** Is this an absolute http(s) URI — the only kind this module fetches? */
|
||||
function isHttpUri(uri: string): boolean {
|
||||
try {
|
||||
const { protocol } = new URL(uri);
|
||||
return protocol === 'http:' || protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** How long one read may take before the URI counts as unreadable. */
|
||||
export const PUBLIC_READ_TIMEOUT_MS = 10000;
|
||||
|
||||
export interface PublicReadOptions {
|
||||
signal?: AbortSignal;
|
||||
/** Bearer token to attach — the caller passes one only for its own origin (`tokenFor`). */
|
||||
token?: string;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one record by URI. `undefined` when it cannot be read in time, or when
|
||||
* the document does not identify itself as `uri`.
|
||||
*/
|
||||
export async function fetchPublicRecord(uri: string, options: PublicReadOptions = {}): Promise<JsonRecord | undefined> {
|
||||
const { signal, token, timeoutMs = PUBLIC_READ_TIMEOUT_MS } = options;
|
||||
if (!isHttpUri(uri) || signal?.aborted) return undefined;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
const forward = () => controller.abort();
|
||||
signal?.addEventListener('abort', forward, { once: true });
|
||||
try {
|
||||
const response = await fetch(uri, {
|
||||
headers: {
|
||||
Accept: 'application/ld+json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
credentials: 'omit',
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const record = asRecord(await response.json());
|
||||
if (!record) return undefined;
|
||||
const id = asString(record['id']) ?? asString(record['@id']);
|
||||
if (id !== uri) {
|
||||
console.warn(`[public-read] ${uri} answered a document identified as ${String(id)} — ignored`);
|
||||
return undefined;
|
||||
}
|
||||
return record;
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', forward);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Authorization` the session may attach to a request for `uri`: its
|
||||
* bearer token, but ONLY on the origin that issued the session (`trustedOrigin`,
|
||||
* the session's own pod provider). Anywhere else — a URI another user's data
|
||||
* pointed to — the request goes out without credentials.
|
||||
*/
|
||||
export function tokenFor(uri: string, token: string | undefined, trustedOrigin: string | undefined): string | undefined {
|
||||
if (!token || !trustedOrigin) return undefined;
|
||||
try {
|
||||
return new URL(uri).origin === trustedOrigin ? token : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,12 @@ export interface FpMeetingPointData {
|
||||
time: string;
|
||||
/** NURI of the meeting point's inbox (wired in T02.b/c). */
|
||||
inbox?: string;
|
||||
/**
|
||||
* How many people signed up — DERIVED by the reader from the meeting point's
|
||||
* attendees, never stored or written. Absent where the backend keys its
|
||||
* participations by event rather than by meeting point (nothing to count it from).
|
||||
*/
|
||||
participantCount?: number;
|
||||
}
|
||||
|
||||
export interface FpNotificationData {
|
||||
|
||||
@@ -124,6 +124,9 @@ test('event toValues composes the datetimes and never stores the derived fields'
|
||||
// text value resolved into a garbage IRI.
|
||||
location: { name: 'La Maison du Vélo, Lyon' },
|
||||
image: 'https://example.org/atelier.jpg',
|
||||
// Public intent, written on create: the backend grants anonymous read only to a
|
||||
// created as:Event addressed to the Public collection.
|
||||
to: 'https://www.w3.org/ns/activitystreams#Public',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,14 +142,22 @@ test('event toValues keeps a date-only end and drops a time-only start', () => {
|
||||
location: '',
|
||||
participantCount: 0,
|
||||
}),
|
||||
).toEqual({ name: 'T', content: '', startTime: '2026-03-01', endTime: '2026-03-03', location: { name: '' } });
|
||||
).toEqual({
|
||||
name: 'T',
|
||||
content: '',
|
||||
startTime: '2026-03-01',
|
||||
endTime: '2026-03-03',
|
||||
location: { name: '' },
|
||||
to: 'https://www.w3.org/ns/activitystreams#Public',
|
||||
});
|
||||
expect(
|
||||
event.toValues({ title: 'T', description: '', date: '', startTime: '09:00', location: '', participantCount: 0 }),
|
||||
).toEqual({ name: 'T', content: '', location: { name: '' } });
|
||||
).toEqual({ name: 'T', content: '', location: { name: '' }, to: 'https://www.w3.org/ns/activitystreams#Public' });
|
||||
});
|
||||
|
||||
test('event toPatch sends only what changed', () => {
|
||||
const { event } = getBackend('activitypods').bindings;
|
||||
// No addressing either: it is written on create, and the merge keeps it on an edit.
|
||||
expect(event.toPatch({ title: 'Nouveau titre' })).toEqual({ name: 'Nouveau titre' });
|
||||
});
|
||||
|
||||
@@ -206,6 +217,8 @@ test('meetingPoint toValues writes the context and the named location, never org
|
||||
content: 'Sous l’horloge',
|
||||
location: { name: 'Café de la Place' },
|
||||
'as:context': 'http://localhost:3000/alice/data/event-1',
|
||||
// A meeting point is public too: created addressed to the Public collection.
|
||||
to: 'https://www.w3.org/ns/activitystreams#Public',
|
||||
});
|
||||
// The host is the Pod-stamped creator; nothing here writes an `organizer` term
|
||||
// the platform context does not define (it would not round-trip).
|
||||
|
||||
@@ -67,10 +67,9 @@
|
||||
* together, and the data/live providers take the auth provider as a parameter. Its
|
||||
* `appStatusCheckInterval` is disabled deliberately: construction installs a
|
||||
* `setInterval` and a `visibilitychange` listener with no teardown (usage
|
||||
* declaration, friction 1). Driving `getAppStatus()` on Festipod's own schedule is
|
||||
* the planned replacement, but it is NOT wired yet: the contract documents the
|
||||
* method's name only, not the shape of the `AppStatus` result, so the follow-up is
|
||||
* parked until that surface is pinned (handoff, follow-ups). The `clientId` names
|
||||
* declaration, friction 1). Festipod drives `getAppStatus()` on its own schedule
|
||||
* instead — the app-status probe beside the auth flow (`src/app/auth/appStatusProbe.ts`),
|
||||
* against the shape pinned in the contract's 2026-10-12 re-baseline. The `clientId` names
|
||||
* the app; the app description document it points to
|
||||
* is served by Festipod's own backend, and the values resolve from the runtime
|
||||
* config — the SAME source the AuthGate flow uses, so both halves agree.
|
||||
@@ -219,7 +218,7 @@ function eventValues(data: Partial<FpEventData>): Record<string, unknown> {
|
||||
return out;
|
||||
}
|
||||
|
||||
function eventFromRecord(record: unknown): FpEventData {
|
||||
export function eventFromRecord(record: unknown): FpEventData {
|
||||
const r = asRecord(record);
|
||||
const start = splitDateTime(r?.['startTime']);
|
||||
const end = splitDateTime(r?.['endTime']);
|
||||
@@ -368,11 +367,24 @@ function attendeesFromRecord(record: unknown): { members: unknown[]; total?: num
|
||||
return { members, total: total ?? members.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* PUBLIC ADDRESSING, WRITTEN ON CREATE. Events and meeting points are public
|
||||
* (`visibility: 'public'` on both bindings), and on this backend the intent travels
|
||||
* as ActivityStreams addressing: each is created addressed `to` the Public
|
||||
* collection. `create` sends every variable verbatim, and the pod stores the
|
||||
* addressing on the record (`"to": "as:Public"`). Festipod's backend reads it on the
|
||||
* created record (`backend/services/public-intent.js`) and grants anonymous read ONLY to an
|
||||
* `as:Event` that carries it — one created without it stays private to its owner,
|
||||
* whichever client created it. Create only: `update` merges onto the stored record,
|
||||
* so the addressing survives an edit without being re-sent.
|
||||
*/
|
||||
const PUBLIC_AUDIENCE = { to: 'https://www.w3.org/ns/activitystreams#Public' } as const;
|
||||
|
||||
const eventBinding: RecordBinding<FpEventData> = {
|
||||
recordKey: recordId,
|
||||
domainId: recordId,
|
||||
toDomain: eventFromRecord,
|
||||
toValues: (data) => eventValues(data),
|
||||
toValues: (data) => ({ ...eventValues(data), ...PUBLIC_AUDIENCE }),
|
||||
toPatch: (updates) => eventValues(updates),
|
||||
// Events and meeting points share the as/Event container; an `as:context` link
|
||||
// is what makes a record a MEETING point, so its absence is the event test.
|
||||
@@ -386,7 +398,7 @@ const meetingPointBinding: RecordBinding<FpMeetingPointData> = {
|
||||
recordKey: recordId,
|
||||
domainId: recordId,
|
||||
toDomain: meetingPointFromRecord,
|
||||
toValues: (data) => meetingPointValues(data),
|
||||
toValues: (data) => ({ ...meetingPointValues(data), ...PUBLIC_AUDIENCE }),
|
||||
toPatch: (updates) => meetingPointValues(updates),
|
||||
// The distinction lives in the record, not the container — see the event binding.
|
||||
isInstance: (record) => linkId(asRecord(record)?.['as:context']) !== undefined,
|
||||
|
||||
Reference in New Issue
Block a user