38 Commits

Author SHA1 Message Date
Sylvain Duchesne a28a318c24 Deploy to the Coolify test server: docker-compose.coolify.yml
The stack Coolify builds from the repository, on the test server only for
now, modelled on the sibling ActivityPods apps: the app (root Dockerfile), the
backend (backend/Dockerfile) and a per-stack Redis; Fuseki is the shared one,
reached over the external coolify network, with a pair of datasets per stack.

One base URL per environment: the app at https://festipod.dev.reconnexion.coop,
the backend's Domains https://<host>/api,https://<host>/.well-known, with
Coolify's Strip Prefixes OFF. The Pod provider offered in one click defaults
to https://mypod.store, as for the sibling dev apps.

The deployment leaf no longer claims that nothing is deployed with this shape
and that no Coolify config exists; it records the stack, the base-path layout,
both images' build traps and the first-boot ones (set the domains before the
first deployment; restart the backend once so /app carries the access-need
group).
2026-10-19 16:44:09 +02:00
Sylvain Duchesne 738d59596a Serve the backend under a base path: one URL per environment, the backend under /api
The test deployment keeps one base URL per environment, as the sibling
ActivityPods apps do, so a pull-request preview lives on a single host: the
app at the root, the backend under /api on the same host, its /.well-known
routes at the origin root, the proxy passing paths through unchanged.

SemApps already works that way — it mounts every route it adds under the base
URL's path and keeps /.well-known at the root — but Festipod's own pieces were
written for a backend at the root of its own origin: its routes (/app,
/as/actor/app, /catalog) were mounted at fixed paths, the public-surface
allow-list matched fixed paths (so under /api every request would have been
refused), and the Solid-OIDC client_id joined the raw request path to the base
URL, which would have repeated the base path (/api/api/...).

base-path.js derives the base path from SEMAPPS_HOME_URL; the routes are
mounted under it; the allow-list matches paths relative to it, the two
/.well-known endpoints against the full path; the client_id is the requested
URI rebuilt from the base URL as written, equal byte for byte to the actor
URI and to what the front end derives. At the root every gate decision is
identical to before (compared over ~8M method/path cases). The rights matrix
probes /.well-known at the backend's origin root.

Verified: bun test (424 pass); the full gate at the root; and the built images
behind a local proxy routing /api and /.well-known unstripped with the Host
header kept — login flow, rights matrix 98/98, revocation.
2026-10-19 16:44:09 +02:00
Sylvain Duchesne 8d0670a637 The backend image starts: approve the native builds, compile sharp against the system libvips
Built and run for the first time (until now the dockerized path had only been
checked with `docker compose config`): the image built, and the container
exited at start with `Cannot find module '../build/Release/sharp-linux-x64.node'`.
pnpm 10 skips dependency build scripts unless they are approved, so sharp,
bcrypt and sodium-native were installed without their binaries. A local
node_modules can carry binaries from an earlier manual build, which is how it
stayed invisible. `pnpm.onlyBuiltDependencies` now approves the three.

Approved, sharp's installer then downloaded its prebuilt libvips and binary
from GitHub, and that download timed out on every in-build attempt (3 of 3),
failing the build, while the same download from a plain container was
instantaneous. With libvips-dev installed, sharp detects a system libvips and
compiles itself instead; npm_config_nodedir points node-gyp at the headers the
image already ships. Verified with GitHub and nodejs.org blackholed: the build
passes, sharp loads against libvips 8.14.1, bcrypt falls back to compiling.

Cost: a larger image; a multi-stage build keeping only libvips42 at runtime
would drop several hundred MB.
2026-10-19 16:44:09 +02:00
Sylvain Duchesne 812844e99d Never show a raw identifier as a person's name, handle or initials
A participant's profile can resolve and still carry an empty name, a WebID,
an actor path or another identifier-shaped value. safeUserName,
safeUserUsername and safeUserInitials turn those into a neutral placeholder,
and every screen that shows a person goes through them: event participants,
participant list, friends, invitations, meeting points, connect, profile,
share profile, edit-profile avatar and someone else's profile.

An absent user renders as before, blank while loading. Your own profile never
titles itself « Participant » for an empty name; other people do. Initials
derived from an identifier become '?'.

The check is start-of-value only: a leading `word:` counts as a scheme, so a
name such as « DJ:Kool » is masked too, and an identifier embedded mid-string
is not. Both are known and accepted for now. The edit-profile form still
prefills its inputs with the raw values.

Screen-level tests render each screen with identifier-shaped and empty names,
and turn red when any sanitizer call is reverted.
2026-10-13 20:58:06 +02:00
Sylvain Duchesne 6ee95c9e68 Record the public-data and rights-matrix work in the handoff, the usage declaration and the doctrine
The handoff gets the closed state of the T05 work with its commits, the new gate commands, the user decisions (attendee lists stay world-readable for now, a revoked app logs the user out, the local network is trusted), the checks kept manual, the platform defects to report upstream, the documented limits of the backend hardening and the open follow-ups. The usage declaration records that Festipod drives the app-status probe, relies on the installed field and on sending to through create, and the subscribe Forbidden friction; the discovery leaf and the local-platform cookbook match the current design and bring-up.
2026-10-13 18:11:34 +02:00
Sylvain Duchesne 9381c206df One sign-in drive for every e2e, the public surface and visibility in the rights matrix, and the stack gates in validate
e2e-login-flow and e2e-revocation now share the matrix's driveLogin, which counts a sign-in only once the app is really reached and the app install is asserted; their own loops could report "shell reached" while still on the picker page. The rights matrix gains a public-surface group (allow-listed endpoints answer 200, non-public paths 404, status codes only) and a visibility group (an event with a meeting point and a participant switched public and private by its owner: event, meeting point, attendees collection and catalog listing follow). validate.ts probes the stack and runs test:e2e-rights and test:e2e-revocation as layers, reported SKIPPED when the stack is down and never counted as passed.
2026-10-13 18:11:34 +02:00
Sylvain Duchesne 88f23b7023 Harden the backend's trust surface: an allow-listed public surface, decisions taken on data re-read from the pod
The backend served its settings dataset (including the app actor's private key) to anonymous callers, through the SPARQL endpoint and through the semapps LDP catch-all route, since WebACL is off. An allow-list mounted on every route now exposes only what the app and the pod need (/app, /as/actor/app and its Client ID Document, /catalog, the public key, access needs, the JSON-LD context, nodeinfo, the app inbox and the webhook endpoint); everything else answers 404.

Every watcher-fed service (public read, catalog, attendees, contacts, registration) now decides on the activity and object re-read from the pod as the app, checks they belong to the outbox owner and that the stored object has the expected type, and never acts on the notification body. Decisions for one object are serialized and taken on a read made when acting, with a 30 s bound and an abort check before every write. The anonymous read follows the event's visibility both ways, attendees collection included; Festipod records the grants it makes and revokes only those, leaving any other public share untouched; an unreadable ACL changes nothing. A participant's attendance follows their latest act by the pod's timestamp, not the order notifications arrive. Known limits (no conditional write on the pod, a write in flight at the timeout) are documented in the code.
2026-10-13 16:30:52 +02:00
Sylvain Duchesne d38f8a7384 Add the end-to-end rights matrix as a runnable gate: bun run test:e2e-rights
Three fresh users plus an anonymous reader, on the live local stack: login, consent and app install are driven through the UI and asserted at setup; reads, refused writes, profile privacy, discovery, opening a foreign event, join and leave with their counts, isolation of each user's own pod, and catalog listing are each a named line with expected and actual, printed as a report. The exit code is non-zero iff a hard line fails. Known platform behaviours (attendee lists world-readable, an anonymous PATCH accepted on attendees collections) are tracked as non-fatal lines that flag a change; paths that need a crafted request are recorded as manual checks.
2026-10-13 14:49:39 +02:00
Sylvain Duchesne 6bcf40306b Keep a join or a leave on screen until a read taken after it confirms it
After leaving a meeting point, the view could stay at "1 inscrit" while the collection was already empty (caught by the rights matrix, 1 run in 5): a read issued before the act was taken as confirmation, the overlay was dropped, and nothing re-read afterwards. Now only a read issued after the act confirms it; while unconfirmed, only the collection the act changed is re-read (immediately, then at 1.5, 3, 6, 12, 20 and 30 s, the last read wins), and a pre-act read never undoes it. The join direction had the mirror race. Foreign records are read one URI at a time.
2026-10-13 10:57:21 +02:00
Sylvain Duchesne f14cc37f4c List only Festipod-public events in the catalog, and only let an owner change their entry
The catalog indexed every as:Event written to any user's outbox and served private bodies (other apps' events, friend-addressed ones); since the index is persistent, they survived restarts. It now lists only objects addressed to the Public collection in to (cc-only is unlisted: readable, never listed), using the same predicate module as the public-read grant; stored entries that do not qualify are filtered at every read and swept at startup by a conditional delete. A create, update or delete is applied only when the object lies under the pod of the actor whose outbox emitted it (same origin, path prefix with a / boundary): one user could previously remove another's entry with a Delete carrying an embedded Tombstone. Catalog handlers never throw into the webhook path, whose listener does not await them.
2026-10-12 17:08:24 +02:00
Sylvain Duchesne fbb1f6f61b Open another user's event from Découvrir: detail, meeting points, counts and join
Tapping a catalog event used to show "Événement non trouvé" because the detail only searched the user's own events. An event is now read by URI, its meeting points come from the catalog, and each meeting point's count comes from its attendees collection (event count = sum); joining a foreign meeting point goes through the existing outbox flow and the counts move at once and survive a reload. Foreign records are read anonymously by direct HTTP: the session token only goes to the session's own origin, each read is bounded and independent, and one malformed catalog item, record or collection no longer takes the whole read down. Own counts were always 0 before, because the pod never embeds apods:attendees; they are now read the same way. Catalog-only cards show no count instead of a misleading zero.
2026-10-12 17:00:42 +02:00
Sylvain Duchesne a0d2cb8f73 Make events and meeting points readable by anyone only when Festipod marks them public
Festipod's create path now addresses events and meeting points to the ActivityStreams Public collection (to: as:Public), and the backend grants acl:Read to foaf:Agent on a created as:Event only when it carries that public intent (one predicate, hasPublicIntent, in backend/services/public-intent.js). Before this, every as:Event written to a user's outbox would have been made world-readable, including other apps' events and events addressed to a single friend. The grant is idempotent (it reads the ACL first) and never throws into the webhook path.

The attendees service overrides the collections mixin so that an event posted with apods:attendees already attached no longer reaches new URL(undefined) and takes the whole backend process down. Attendee lists stay world-readable as the pod creates them; whether they should is an open product question.
2026-10-12 16:59:00 +02:00
Sylvain Duchesne d4fcb7e311 Log out when the app was removed from the pod instead of re-asking consent
A registration read as installed:false means the user removed Festipod from their pod, often on purpose: re-running registerApp sent them to consent on every load with no way to sign out. The probe now logs out through the provider and lands on the picker with a notice that the authorization was removed and they can sign in again; Back and reload stay there. upgradeNeeded:true keeps the automatic consent redirect. The revocation e2e asserts the new path and fails on the previous one.
2026-10-12 16:27:02 +02:00
Sylvain Duchesne f32e166601 Persist the catalog index across backend restarts and de-index deleted events
Each indexed event is one entry in the backend's settings dataset, default graph (an undeclared named graph is kept in memory by fuseki and lost on restart): the item JSON verbatim plus its first-index time, written by one atomic upsert, so concurrent re-adds stay one entry. The catalog now has its own delete matcher, because the platform one compares formerType as:Event to the compacted Event and never fired: an owner's deletion removes the entry, also after a restart. GET /catalog keeps its shape.
2026-10-12 16:15:58 +02:00
Sylvain Duchesne 4fc856fa7e Detect a revoked or stale app registration and leave the broken connected view
While a session exists, the app probes getAppStatus() on load and on focus/visibility, at most once a minute (a trigger inside the minute is deferred, not dropped), failing open on errors. installed:false or upgradeNeeded:true re-runs registerApp, whose consent redirect is the recovery; meanwhile the app view is replaced by a renewal screen, and an unrecoverable registration lands on the picker with a notice. bun run test:e2e-revocation proves both sides on the live stack: a current registration stays usable, a revoked one leaves the connected state and its writes no longer land.
2026-10-12 16:07:14 +02:00
Sylvain Duchesne e21c408dc1 List other users' events in Découvrir: own pod events joined with the backend catalog
Découvrir (/events) now shows the union of the signed-in user's own-pod events and the backend catalog's events, de-duplicated by URI, own first. The catalog read lives beside Refine (CRUD-only per the contract) as an unauthenticated, fail-open GET on the backend's /catalog, in connected mode only; meeting points carried by the catalog (as:context) are filtered out. Opening a foreign event is not wired yet.
2026-10-12 15:22:30 +02:00
Sylvain Duchesne 706ee132da Bank the full login flow as an e2e gate and un-suspend join-to-collection: 5/5 smoke, programmatic app install for the test user 2026-10-12 14:02:36 +02:00
Sylvain Duchesne a61c8704c9 Close the login loop end-to-end: client_id is the app actor uri, actor path serves the OIDC client document via a boot-safe service, startup heals the access-need-group link, stack aligned on pod provider v2.3.12 2026-10-12 13:12:38 +02:00
Sylvain Duchesne 18fc71ff36 Record the verify drift shim and the reinstall step in the handoff 2026-10-12 11:24:50 +02:00
Sylvain Duchesne 6b48b24350 Shim the app-registrations verify for the pod provider drift: satisfiesAccessNeed lives on DataGrants 2026-10-12 11:23:45 +02:00
Sylvain Duchesne 77d567b445 Cap the sign-in column on desktop — full-width buttons stay right on mobile 2026-10-12 11:04:18 +02:00
Sylvain Duchesne 799de590aa Record the cleared client_id half of the app-install blocker in the handoff 2026-10-12 11:02:21 +02:00
Sylvain Duchesne 3aa9131a01 Serve a Solid-OIDC Client ID Document at /app for the OIDC adapter fetch, keeping the app actor for SAI 2026-10-12 11:01:34 +02:00
Sylvain Duchesne 84a0882d32 Document the root .env copy for manual app serving — without it the app falls back to the local demo 2026-10-12 10:37:35 +02:00
Sylvain Duchesne 659d7652c4 Move the Festipod app to :4000 — :5000 belongs to the pod provider frontend in the local stack 2026-10-12 10:33:07 +02:00
Sylvain Duchesne 8dfc574346 Close the adversarial review round: auth hardening, data fidelity against the platform context, backend grants and docker coherence 2026-10-10 09:54:53 +02:00
Sylvain Duchesne 13d9f179ef Record the executed state of the ActivityPods integration at the T04 tree close 2026-10-09 21:21:27 +02:00
Sylvain Duchesne f8c2e3b809 Suspend the NextGraph suites, and smoke the ActivityPods stack 2026-10-09 21:18:51 +02:00
Sylvain Duchesne c195211629 Rewrite FestipodDataContext over the Refine seam, meeting-point centric 2026-10-09 21:04:35 +02:00
Sylvain Duchesne b09884d586 ActivityPods Solid-OIDC auth flow in AuthGate with pod provider picker and /login callback 2026-10-09 14:07:47 +02:00
Sylvain Duchesne e17398d86d T04.f: backend Festipod ActivityPods + dockerisation
- Ajoute la configuration et les services backend (app, actors, registration,
  attendees, contacts, catalog, core auth/nodeinfo/proxy/notification).
- Corrige le démarrage : désactive resourcesWithContainerPath pour éviter le
  conflit entre l'URI de l'acteur applicatif (/as/actor/app) et la route /app.
- Corrige l'URL du dépôt de shapes : https://shapes.activitypods.org/.
- Ajoute backend/Dockerfile et docker-compose.yml racine (frontend + backend).
- Met à jour .env.example racine avec les variables SEMAPPS_* et VITE_BACKEND_*.
- Gates verts : bunx tsc --noEmit (0 erreur) et bun test (50 pass).
2026-10-09 13:51:02 +02:00
Sylvain Duchesne e27cfa4e0a The ActivityPods backend registers as the default behind the seam 2026-10-09 13:37:28 +02:00
Sylvain Duchesne 9823a45bab Decide the participation arbitration route, and document the local platform setup 2026-10-09 11:28:46 +02:00
Sylvain Duchesne 21b1223b84 Add ActivityPods refine-providers dependency and backend package 2026-10-09 11:12:55 +02:00
Sylvain Duchesne b1add6d8c8 Record the registration precedent, and the event-in-event feasibility without new shapes 2026-10-09 10:26:25 +02:00
Sylvain Duchesne 184998a343 Record the ActivityPods-first directives, and what the reference reconnaissance established 2026-10-09 09:48:52 +02:00
Sylvain Duchesne df1db790ad The URL says which provider set runs, and the seam stops claiming otherwise
The seam arrived carrying a switching design that no longer holds. It presented
runtime hot-switching as what shaped it — selection in state, a key on <Refine>
to drop the previous backend's cache — and it read the backend from a build
global whose comment claimed build.ts defined it. Nothing ever did, and nothing
could have: this project serves from src/ in production too, so that define
block never reaches what runs.

What replaces it is a URL. The default provider set lives at the root, another
behind a path prefix naming it, and moving between them is a link. The reload is
the mechanism rather than a cost to avoid: it discards the cache and the session
with no code to undo them. So the switch helpers are gone and the file is
smaller than what it replaced.

The resolution is a pure function with its own tests, including the case that is
true today — an empty registry, where every URL is the default. The router does
NOT yet strip the prefix, deliberately: it is a shared primitive every screen
depends on, the suites that would prove a change to it are not run here, and no
URL carries a prefix while one backend is registered. That half lands with the
second backend, which is what makes it testable.

Three claims the move to this base falsified are corrected too. The inbox the
count comment points at is local here, not on another branch. The worked
provider implementation is not in this branch's ancestry. And the MeetingPoint
shape exists here, so its binding stops being optional — a backend that cannot
carry one of the four entities cannot serve the app, and nothing optional should
let a half-backend register quietly.

Also: both usage declarations are named usage_festipod.md, so the stem is now
ambiguous as a link. They are referred to by folder path, and both are listed
under publish so each provider can re-find its own if it moves.
2026-10-08 10:08:40 +02:00
Sylvain Duchesne e282384e00 The Refine seam travels to this base, deliberately unwired
The seam that puts Festipod's persistence behind interchangeable Refine backends
was built on `festipod-refine`, over a base this one has since overtaken
everywhere else. Carrying the seam rather than the branch keeps the evolved data
layer intact and costs nothing: the backend contract, the registry and the
provider that mounts Refine depend only on the domain types, so they compile
here untouched once `@refinedev/core` is present — the one dependency this adds,
installed through pnpm as this project requires.

Nothing imports them and the registry is empty, which is the intended state.
Wiring the seam means rewriting `FestipodDataContext` against this base's
version of it — its own decision, with its own failure modes, and not one to
smuggle into a transfer. Landing the files first makes that decision reviewable
against real code instead of a diff between two branches.

The doctrine travels with the code, because the seam is unreadable without it:
the reconstructed observation of `@activitypods/refine-providers` — a package
that publishes no engagement at all, hence `type: reconstruct` and a pin on the
commit it was read at — our usage declaration beside it, and the brief that
arbitrates one data shape for two rights models. So the manifest grows a
`publish:` section: this repo still offers no interface, but it does now publish
what it consumes, which is the only channel a provider has for learning that.

The brief was written where three of the leaves it cites did not exist, and
named them in prose to say so. Here they exist, so it links them; and its open
deduplication point now reads against this repo's own `materializeAttendance`
instead of against a sibling branch. It stays open, and the finding is
unchanged: the active set is keyed per deposit, which buys replay idempotence
and not person identity, so the count counts deposits rather than people.

Two comment zones came over knowingly superseded — runtime hot-switching as what
shapes the provider, and a sign-in barrier waiting on something `AuthGate` no
longer does. They are recorded as data-layer doc-debt instead of being quietly
rewritten, so the rewrite lands with the wiring that settles what they should
say.
2026-10-05 18:46:32 +02:00
155 changed files with 36127 additions and 2318 deletions
+48 -2
View File
@@ -37,12 +37,58 @@ FESTIPOD_SHARED_WALLET_FILE_BASE64=
FESTIPOD_AUTO_SEED=
# ── Serveur ────────────────────────────────────────────────────────────────
# Port HTTP du serveur (défaut 3000).
PORT=3000
# Port HTTP du serveur (4000 : l'app est servie sur l'origine :4000 de la pile locale).
PORT=4000
# NODE_ENV=production bascule `bun run start` (pas de HMR). En dev, laisser vide.
NODE_ENV=
# ── Backend ActivityPods ───────────────────────────────────────────────────
# URL publique du backend Festipod (écoute sur le port 3003).
SEMAPPS_HOME_URL=http://localhost:3003
# URL publique du frontend (utilisée par le backend pour les OIDC redirects, etc.)
SEMAPPS_FRONT_URL=http://localhost:4000
# Endpoint SPARQL du Fuseki auquel se connecte le backend.
SEMAPPS_SPARQL_ENDPOINT=http://localhost:3030/
# Dataset Jena/Fuseki principal utilisé par le backend.
SEMAPPS_MAIN_DATASET=settings
# Identifiants Jena/Fuseki.
SEMAPPS_JENA_USER=admin
SEMAPPS_JENA_PASSWORD=admin
# Redis : cache (db 3) et file des jobs (db 4).
SEMAPPS_REDIS_CACHE_URL=redis://localhost:6390/3
SEMAPPS_QUEUE_SERVICE_URL=redis://localhost:6390/4
# Dataset utilisé par le service auth.account.
SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME=settings
# Dépôt de shapes/shapetrees utilisé pour déclarer les besoins d'accès.
SEMAPPS_SHAPE_REPOSITORY_URL=https://shapes.activitypods.org/
# Nom et description de l'application (backend).
SEMAPPS_APP_NAME=Festipod
SEMAPPS_APP_DESCRIPTION=Points de rencontre sur les événements publics
SEMAPPS_APP_LANG=en,fr
SEMAPPS_PORT=3003
# ── Frontend / Backend coupling ────────────────────────────────────────────
# URL du backend telle que vue par le navigateur.
VITE_BACKEND_URL=http://localhost:3003
# Client ID OAuth/Solid du backend (laisser vide pour dériver `${VITE_BACKEND_URL}/as/actor/app`).
# Le client_id DOIT être l'URI de l'acteur app : le pod enregistre l'app sous cette URI et
# ne sert l'en-tête `Link: rel="interop:registeredAgent"` (découverte de l'enregistrement
# par le auth provider) que si le client_id égalise l'acteur.
VITE_BACKEND_CLIENT_ID=
# Pod provider ActivityPods par défaut (optionnel). Vide → l'écran de login demande une URL.
VITE_POD_PROVIDER_URL=http://localhost:3000
# ── Outillage dev (facultatif) ─────────────────────────────────────────────
# Override du chemin local du polyfill @ng-eventually/sdk pour `pnpm run
# overlay:polyfill` (overlay local réactif). Défaut = ../nextgraph/ng-eventually-js/packages/sdk.
+6 -3
View File
@@ -2,8 +2,8 @@
type: _overview
summary: How Festipod persists its data through the @ng-eventually/polyfill SDK — entities stored as documents placed by scope, direct SPARQL writes + reactive shape reads, SHEX stack, connected/demo modes, seed
triggers:
keywords: [nextgraph, "@ng-eventually", polyfill, watchShape, useShape, useShapeQuery, SHEX, shape, scope, "@graph", NURI, inbox, share, sparql, seed, wallet, FestipodData, ngSession, ngGraph, storeRegistry, bootstrap, document, entité, déconnexion, reconnexion]
paths: ["src/shared/shapes/**", "src/shared/data/**", "src/shared/context/NextGraphContext.tsx", "src/shared/context/FestipodDataContext.tsx", "src/shared/utils/*", "src/shared/data/seedData.ts"]
keywords: [nextgraph, "@ng-eventually", polyfill, watchShape, useShape, useShapeQuery, SHEX, shape, scope, "@graph", NURI, inbox, share, sparql, seed, wallet, FestipodData, ngSession, ngGraph, storeRegistry, bootstrap, document, entité, déconnexion, reconnexion, refine, provider, activitypods, participation, WAC]
paths: ["src/shared/shapes/**", "src/shared/data/**", "src/shared/context/NextGraphContext.tsx", "src/shared/context/FestipodDataContext.tsx", "src/shared/utils/*", "src/shared/data/seedData.ts", "src/shared/refine/**"]
---
# Data layer
@@ -16,8 +16,11 @@ How Festipod **persists its data** through NextGraph (P2P, local-first, end-to-e
Each external interface this concept consumes lives in **its own folder**, holding the provider's engagement (pulled, version-pinned) and — once Festipod actually consumes it — our own declaration beside it.
- `polyfill-surface/` — [[contract_polyfill-surface]], **the data contract, PULLED from the provider and version-pinned**: the `@ng-eventually/polyfill` surface the app codes against, what it guarantees and what it refuses to promise. The ONLY reference — never open the provider's own sources. Beside it, [[usage_festipod]] — what the app *actually* calls, the conditions it needs, and the frictions measured against the engagement. **Frictions are how a need reaches the provider**: put it there, then signal it out of band.
- `polyfill-surface/` — [[contract_polyfill-surface]], **the data contract, PULLED from the provider and version-pinned**: the `@ng-eventually/polyfill` surface the app codes against, what it guarantees and what it refuses to promise. The ONLY reference — never open the provider's own sources. Beside it, `polyfill-surface/usage_festipod.md` — what the app *actually* calls, the conditions it needs, and the frictions measured against the engagement. **Frictions are how a need reaches the provider**: put it there, then signal it out of band.
- `indexing-layer/` — [[contract_indexing-layer]], the `@ng-helpers/indexing` engagement, PULLED and pinned on `v1.0.0`: creating an index, depositing references into it, curating it, reading it back. **Nothing consumes it yet** — no declaration is authored beside it, deliberately, since an empty one would say nothing.
- `activitypods-providers/` — [[contract_activitypods-providers]], a RECONSTRUCTION rather than a pull: that package publishes no engagement, so the leaf is **our own pinned observation** of it, to be re-verified at every version bump. Beside it, `activitypods-providers/usage_festipod.md`, our declaration toward it. Nothing is wired yet — the Refine seam that would use it (`src/shared/refine/`) carries no backend.
Two declarations now share the name `usage_festipod.md`, so refer to either by its folder path rather than by wikilink — the stem alone no longer says which.
## Model & data
- [[knowledge_nextgraph-stack]] — the SHEX shapes, the reactive ORM bindings, `build:orm`, injection through `ngSession`
@@ -0,0 +1,100 @@
---
type: contract
summary: The Refine data, auth and live providers @activitypods/refine-providers exposes to an application over a Solid Pod — what each operation does, what a record looks like, and what the package does not offer
built_from: https://github.com/activitypods/refine-providers.git/src
built_version: 66616484b96fb2227cd8cdf02321aacfb4811365
built_at: 2026-10-02
---
# contract_activitypods-providers — `@activitypods/refine-providers`
**This is a RECONSTRUCTION, not the provider's promise.** The package publishes no engagement and no change policy, so this document is an observation made once, at adoption time, and pinned to the commit named in `built_version`. Nothing here is committed to by its authors: any release may move any of it. Re-observe at each upgrade — the pin is what makes a difference visible.
## Scope
Three Refine providers over a Solid Pod running ActivityPods: a `dataProvider` (CRUD over LDP containers), an `authProvider` (Solid-OIDC sign-in plus the app-registration dance ActivityPods requires), and a `liveProvider` (push over the Solid Notifications Protocol). Resources are not addressed by a path the caller chooses: each configured resource is mapped at runtime to an LDP container discovered in a WebID's Solid TypeIndex.
Two Ant Design components also ship (`antd-auth-page`, `antd-background-checks`) and are outside this scope — `antd` and `@ant-design/icons` are optional peer dependencies, so an app with its own interface consumes the three providers without them. `react-router` is likewise an optional peer dependency.
Package `@activitypods/refine-providers`, version 0.1.5 at the pinned commit. Peer dependency `@refinedev/core ^5.0.12`. Full typed shape: the package's shipped declarations, `./types` (`dist/types.d.ts`) and `./index` (`dist/index.d.ts`).
## Surface
```ts
dataProvider({ resources, authProvider, jsonContext? }): DataProvider
authProvider({ clientId, redirectUri?, allowAnonymous?, appStatusCheckInterval? }): AuthProvider
liveProvider({ resources, authProvider, jsonContext? }): LiveProvider
type ResourceConfig = { shapeTreeUri?: string; types?: string | string[] }
type ResourcesConfig = Record<string, ResourceConfig> // keyed by Refine resource name
type JsonContext = string | string[] | Record<string, any>
type AuthSession = { token: string; webId: string }
```
`AuthProvider` is Refine's `AuthProvider` plus four members: `getSession(): AuthSession | undefined` (synchronous), `handleCallback(): Promise<{ webId, redirect }>`, `registerApp(webId?): Promise<string | undefined>`, `getAppStatus(): Promise<AppStatus>`. Its `login`/`register` take `{ issuer, redirect?, isSignup? }` — the Pod provider's base URL is a caller-supplied parameter, so the app must let the user pick one.
`DataProvider` implements `getList`, `getOne`, `getMany`, `create`, `update`, `deleteOne` and `getApiUrl`. Nothing else.
Utility functions are exported from the package root as well (notably the TypeIndex discovery and container resolution used internally); they are not part of what this document engages.
## Guarantees
Observed at the pinned commit.
**A record is a JSON-LD resource with an `id`.** `id` is the resource's own URI — `id` or `@id` of the fetched document, whichever is present; a resource carrying neither raises. The record also carries `@context` (the container's, on a listed record) and every other predicate verbatim. There is exactly ONE identifier: `getOne`, `update` and `deleteOne` take that same `id` and use it directly as the URL they fetch.
**`getList`** resolves the resource's container, fetches it whole, and maps `ldp:contains`. Refine's filters and sorters are then applied **in memory**, and pagination slices the result; `total` is the count before slicing. The filter set is broad (equality, `in`, substring, prefix/suffix, ordering, `between`, null checks, and `and`/`or` nesting), string comparison being case-insensitive unless the case-sensitive variant is used, and a filter field may be a dotted path.
**`create`** POSTs `{ "@context": jsonContext, "@type": <resolved types>, ...variables }` to the container, requires a `Location` response header, and returns the record re-fetched from it. The `@type` is the resource's configured `types` expanded through `jsonContext`, or, when only `shapeTreeUri` is configured, the `sh:targetClass` of the shape the shape tree points at.
**`update` merges — a partial write is safe.** It fetches the current resource, drops its `id` and `@context`, and PUTs `{ "@context": jsonContext, ...current, ...variables }`. So values the caller omits survive. The merge is **shallow**: a nested object in `variables` replaces its counterpart whole. It returns the record re-fetched after the write.
**`deleteOne`** DELETEs the resource and returns `{ id }` only.
**A configured resource's container is discovered, not configured.** Resolution reads the WebID document, then its `solid:publicTypeIndex`, then best-effort its `pim:preferencesFile` → `solid:privateTypeIndex`, and takes the first type registration whose `solid:forClass` matches the resource's resolved types and which carries a `solid:instanceContainer`. No match raises, naming the WebID and the types. A `shapeTreeUri` is fetched over plain HTTP, as is the shape it points at — so a shape tree the caller hosts itself works, provided it is reachable and carries `st:shape` → `sh:targetClass`.
**`jsonContext`** is sent as `@context` on every create and update, and is what expands configured CURIE types. It defaults to the ActivityStreams 2 context; an application with its own vocabulary passes its own.
**`liveProvider` pushes — the caller never polls.** It opens a `notify:WebSocketChannel2023` channel per topic, discovering the subscription endpoint from the Pod origin's `.well-known/solid`. Watching specific records subscribes to each record and reports `Update` → `updated`, `Delete`/`Remove` → `deleted`. Watching a list subscribes to the **container** and reports `Add` → `created`, `Remove` → `deleted`. Unsubscribing closes the socket and deletes the channel best-effort. A resource absent from `resources` yields a no-op unsubscribe rather than an error.
**Auth is Solid-OIDC with PKCE.** `login` redirects to the issuer's authorization endpoint with scope `openid webid offline_access`; `handleCallback`, called from the redirect route, completes the code grant and stores the ID token. The session's `webId` is the token's `webid` claim, and an expired token reads as no session. `logout` clears local state. `check` reports unauthenticated with `redirectTo: "/login"` unless `allowAnonymous` is set. `getIdentity` returns `{ id: webId, name, avatar }`, name and avatar read from the WebID document and the profile it links — ActivityPods' own profile vocabulary, unrelated to any vocabulary the app defines for its own user records. `registerApp` returns the AppRegistration URI when the app is registered and its grants current, and otherwise redirects the browser to the user's authorization agent and resolves `undefined`.
**Failures throw, and carry the HTTP status.** Every Pod call that returns non-2xx raises an `Error` with `status` and `body` attached, its message taken from the response body's error message when there is one. `onError` maps a 401 to logout.
## Non-guarantees
**Every read and write is scoped to the logged-in user's own Pod.** Container resolution always runs against the session's own WebID — in `getList`, in `create`, and in a list subscription alike. There is no parameter, filter or option through which an operation reaches another user's resources: `getList` returns what the current user holds, and nothing else. (`getOne`/`getMany` fetch a URI directly and so are not bound this way, but they require the caller to already hold that URI.)
**No server-side querying.** Filtering, sorting and pagination are applied after the whole container has been fetched, so cost grows with the container, not with the page.
**`getManyReference` is not implemented**, despite the data provider's own documentation mentioning it. Neither are `createMany`, `updateMany` and `deleteMany`.
**`getMany` drops what it cannot fetch.** Failed fetches are filtered out silently, so the returned array may be shorter than `ids` with no indication of which are missing.
**Caches are module-level and never invalidated** — shape trees by URI, type registrations by WebID, subscription endpoints by origin. A container that becomes available after a first lookup (the user granting access, for instance) is not seen until the page reloads, which is what the registration flow expects.
**`authProvider` installs process-global timers it never removes.** Unless `appStatusCheckInterval: false` is passed, construction registers a `setInterval` and a `visibilitychange` listener, with no teardown; constructing the provider more than once per page accumulates them.
**One session per page.** The token lives at a fixed `localStorage` key, so two concurrent sessions cannot coexist.
**A list subscription does not report field changes.** A container notifies about membership only, so a member's own predicates changing is invisible to a list; the record has to be watched directly.
**`types` wins over `shapeTreeUri` when both are set** — the opposite of what the configuration type's own documentation states. Configure exactly one. Likewise, when several type registrations match a resource's types, the first encountered wins, with no stated ordering.
**Unregistered apps read and write nothing.** An ActivityPods app must hold an AppRegistration before any data operation succeeds, and keeping it current is the app's business, through `registerApp` and `getAppStatus`.
## Change policy
None is published. The package carries no engagement document, no stated versioning commitment and, at the pinned commit, no git tags — its released versions are visible only as `package.json` version bumps. Treat every upgrade as potentially breaking: re-read the surface against the new commit and re-stamp this document. Divergences found between this reconstruction and observed behaviour are defects in this document, to be fixed here.
## Re-baseline 2026-10-12 (authorized one-time re-read of the installed 0.2.0)
The user authorized a single re-read of the installed package to resolve the login-loop investigation; these paragraphs pin what the first reconstruction had not observed. They stand until the next re-baseline.
**`registerApp` discovers the registration through a Link header, and the discovery key is the requester's identity.** `registerApp(webId)` fetches the user's WebID document, reads `interop:hasAuthorizationAgent`, then fetches the auth-agent document and parses its `Link` response header for `rel="http://www.w3.org/ns/solid/interop#registeredAgent"`; the first link's anchor is the AppRegistration URI. The pod provider only emits that header when the requesting app's identity — the access token's `azp`, i.e. the Solid-OIDC `client_id` — equals the registration's `registeredAgent`. The pod stores `registeredAgent` under the app actor URI (its authorize flow resolves the client_id document to the actor and passes the actor as `appUri`). CONSEQUENCE, verified end-to-end 2026-10-12: the `client_id` configured in `authProvider({clientId})` must be the app actor URI — not a separate document URI — or the lookup never matches and `registerApp` re-redirects to the consent screen forever.
**`registerApp`'s fallback is a browser redirect, not an error.** When no registration is found (or the granted needs are stale), it stores the current path in `localStorage` under a return-to key, then sets `window.location` to the auth-agent's `interop:hasAuthorizationRedirectEndpoint` with a `client_id` query parameter, and resolves `undefined`. The app resumes at the stored path after the consent dance; that path is what the app's callback stage must parse.
**`getAppStatus()` is a plain HTTP GET, and its shape is small.** It calls `GET /.well-known/app-status` on the pod provider's origin with the session token and returns the JSON body; the member the provider itself acts on is `upgradeNeeded` (boolean). A status probe that throws is treated by the provider's own upgrade check as fail-open (no upgrade). This resolves the previously documented gap on the `AppStatus` shape: `{ upgradeNeeded: boolean }` plus whatever the pod adds.
**The built-in status drive exists and is on by default.** Unless `appStatusCheckInterval: false` is passed, the auth provider installs a periodic + `visibilitychange` check that silently re-runs `registerApp` (→ consent redirect) when `upgradeNeeded` turns true. Festipod currently opts out (`appStatusCheckInterval: false`, friction 1 in the usage declaration) and drives the check on its own schedule instead.
@@ -0,0 +1,78 @@
---
type: usage
summary: What Festipod consumes from @activitypods/refine-providers as one of its interchangeable Refine backends, and the conditions its own vocabulary and confidentiality model put on that backend
against: 66616484b96fb2227cd8cdf02321aacfb4811365
---
# usage_festipod — Festipod on `@activitypods/refine-providers`
Festipod is a mobile-first web application: users create **meeting points** grafted onto public events, and sign up to them. Its entire persistence goes through Refine, behind a **backend seam** that exists so the app can support several Refine backends and let the user switch between them at runtime. This package is one of the two backends targeted; the other is a Refine provider over NextGraph plus its polyfill. The seam's own contract — what a backend must answer before it can be added — lives in code, in `src/shared/refine/providers/types.ts`.
The seam is wired since 2026-10-09 (T04.e): this backend is the registered default, and Festipod reads and writes through it — `useList` over the shared `as:Event` container (split into events and meeting points by the bindings' `isInstance`), creates and updates through the data provider, and the embedded `apods:attendees` collection through the participation binding's `membersOf`. Joins/leaves are the one write beside Refine (the package implements no `custom`): an `as:Join`/`as:Leave` posted to the user's own outbox by `src/shared/data/outbox.ts`, per the platform flow recorded in the 2026-10-09 brief. What the list below declares is therefore measured where a runtime check exists and declared elsewhere; `against:` names the observation's commit, and the package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped.
## Consumed surface
### Provider construction
- `authProvider({ clientId, redirectUri, appStatusCheckInterval: false })` — one call site, per backend selection. `appStatusCheckInterval` is disabled deliberately, see friction 1; Festipod drives the check itself.
- `dataProvider({ authProvider, resources, jsonContext })` — one call site, handed the `authProvider` above.
- `liveProvider({ authProvider, resources, jsonContext })` — same, so Refine can subscribe behind every query.
All three are built together, **per backend instance**, inside the seam's `build()`.
### Auth members
`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.
### Not consumed
- `shapeTreeUri` — exactly one of the two resource identifiers must be configured, and `types` names Festipod's classes directly, without a shape tree to host and keep reachable.
- `getIdentity` — the signed-in identity and a Festipod user profile are two unrelated things in this app; the profile is read as an ordinary record, and only `getSession`'s `webId` is needed.
- `getMany`, `getApiUrl`, `allowAnonymous`, and both Ant Design entry points — Festipod has its own interface.
## Constraints
What must keep holding for this backend to serve the app.
1. **Festipod's own vocabulary, not ActivityStreams.** The four entities are Festipod domain types defined by SHEX shapes this repo holds; none is an ActivityStreams type. A caller-supplied `jsonContext` and caller-supplied `types` are what make that possible, and both must stay caller-supplied.
2. **The identifier a record is addressed by must stay the identifier other records reference.** The seam carries the two separately because they differ on the other targeted backend; here they coincide, and the app relies on that. Splitting them later would break every cross-entity reference silently.
3. **A partial update must stay partial.** The app sends only changed fields. A move to replacing the resource would drop every field omitted, with no error.
4. **Every participation must be readable by every user.** The participant count is the number of participations of an event, counted with no filtering. What is confidential is the **link between a participation and a person** — only a connection can read the profile a participation points at and recognise who it is — never the participation record itself. The provider alone cannot satisfy it (need 1).
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 — 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 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.
@@ -0,0 +1,100 @@
---
type: brief
summary: One persisted shape holding on BOTH backends — NextGraph (per-document encryption) and ActivityPods (WAC) — references rather than embedded content, no stored counter, with the acl:Append behaviour and the deduplication gap left open
---
# Brief (2026-10-03) — One data shape for two rights models
Festipod puts all its persistence behind interchangeable Refine backends (the seam's own contract is in code, `src/shared/refine/providers/types.ts`). Two are targeted: NextGraph plus its polyfill, and `@activitypods/refine-providers`, whose reconstructed observation is pinned beside this leaf ([[contract_activitypods-providers]]). The question this brief answers: what structure survives both rights models unchanged.
## The primitives, side by side
| What is needed | ActivityPods | NextGraph |
|---|---|---|
| Unit of rights | LDP resource + WAC | **document** |
| List attached to a resource | `as:Collection` attached by a predicate, `semapps:dereferenceItems` | a Set inside the document |
| Let a third party contribute | `acl:Append`, or `apods:PostOutbox` to the recipient's inbox | `inbox.postToDocument` |
| Grant "my connections" | `acl:agentGroup` + a WAC group | per-document sharing, once per connection |
| Name without granting access | reference + WAC refuses the GET | reference carrying no key |
| Revoke | `pod-permissions.remove` exists | not published |
| Write offline | no (client-server) | yes (local-first) |
## The design rule that follows
**The common denominator is NextGraph**, whose model is the strict subset: no groups, no revocation, sharing wholesale per document. So structure for NextGraph and ActivityPods will serve that structure — more cheaply even, with groups. The converse does not hold: any structure that needs a server-side projection or a revocation is untenable on NextGraph.
## The structure
```
Event own document, public, written by its creator
└─ participations an attached list of REFERENCES (never embedded content)
Participation own document, owned by THE PARTICIPANT
├─ event → ref
├─ active → boolean
└─ participant → ref to the Profile
Profile own document, protected
```
## Why references and not embedded content — three facts
1. **Ownership.** The participation must be writable by its participant and by nobody else (withdrawal must be authoritative — [[caveat_participation-deletion]]). On NextGraph write access is granted per document, so the participation **must be** a document; embedded in the event it would be writable only by the event's creator.
2. **Per-reader variation is impossible inside a single document on NextGraph**, because encryption and sharing are per document. It is obtained by **indirection**: the reference is always present, and what varies is the ability to **follow** it.
3. [[rule_document-per-entity]] already says this, and says it for exactly this reason.
## The non-visibility mechanism differs — PO precision, 2026-10-03
On NextGraph the target is **undecipherable** for want of a key: nothing is absent, the reference itself is readable. On ActivityPods it is either inaccessible (WAC refuses the GET) or the reference is omitted by server-side projection. Both land on the same application state: **a reference one may or may not resolve.**
## Consequence on the domain types
The type models the NextGraph shape, where everything is present:
```ts
interface FpParticipationData {
id: string;
event: string;
active: boolean; // NOT optional — the participation is readable by everyone
participant: string; // NOT optional — a reference, which names without granting access
}
```
Resolution alone may fail: `resolveParticipant(ref)` yields `FpUserData | undefined`, where `undefined` means "not a connection". ActivityPods' omission is a **degradation the binding absorbs**: it reports "a participation whose participant does not resolve" — the same state as "I do not hold the key".
An error made in session and corrected here: `active?` and `participant?` were proposed first, which lifted the ActivityPods adaptation into the common model.
## The count
**No stored counter, ever.** The number of participants is `participations.length`. `knownParticipants` is the participations whose participant resolves — a presence test, not an access check. Withdrawal is `active: false` on one's own document.
## The three invariants that make the app platform-indifferent
1. One document per entity; never embedded content.
2. A reference may be unresolvable, and that is a **normal state** — neither an error nor an absence (the app shows "someone").
3. The list is an **upper bound that converges**; never assume it is complete at the instant (already documented by [[caveat_participant-count-one-connection-lag]]).
## Open points
### NOT VERIFIED — does `acl:Append` on a collection let a third party insert an item without being able to remove one?
WAC mode enforcement lives in `@semapps/webacl`, absent from the ActivityPods checkout consulted and not installed. The mode is real and genuinely used (`tests/apps/example.app.js` declares `accessMode: ['acl:Read', 'acl:Append']`), but its behaviour on a SparqlPatch `INSERT DATA` / `DELETE DATA` was not observed.
**The structure does not depend on the answer.** If Append works that way, the participant appends directly and the count is immediate. Otherwise the route is `apods:PostOutbox` to the creator's inbox, whose app adds the reference — which is exactly the NextGraph shape. Only the latency changes.
### OPEN — deduplication: neither platform counts people, and the implementation here does not either
[[brief_2026-07-20_attendance-set-model]] requires that the creator tell two references from the *same* person apart from two references from different people, **without knowing who** — otherwise the count is not a count of people and a participant can inflate it. **The code here does not do that** (verified 2026-10-05). A different problem is solved, and solved well: `materializeAttendance` (`src/shared/data/registration.ts`) keys its active set by a **per-deposit** `uid` minted from time plus randomness, so a broker re-sync replaying the same deposits converges instead of double-counting — that is **replay idempotence**, not person identity. The count is therefore a count of **deposits**: the same person joining twice counts twice. Three further findings about the current implementation:
- **The plaintext `userId` travels in the deposit**, and the cancellation fallback depends on it — a leave without a known `regUid` cancels the earliest still-active join carrying that `userId`, so N leaves cancel N joins. The attendance-set brief's own change *"drop the `userId` from inbox deposits"* was never made, so the creator **does** know who and the anonymity property does not hold today. The privacy-preserving discriminator was never needed because the privacy was never implemented.
- **Participations live in the protected scope**, not public, and withdrawal is still a deletion ([[caveat_participation-deletion]]) — so the target model is not in place either. The attendance-set brief's own status line, *model settled / implementation gated*, still holds.
- **The gap was never raised through the declared channel.** The five frictions in this app's polyfill declaration (`polyfill-surface/usage_festipod.md`) cover inbox idempotence, the absence of a push on one's own deposit, the reactive read not seeing one's own write, the impossibility of resetting the test wallet, and a wrong published signature. None asks for a per-person discriminator.
So the divergence is not "NextGraph has an upstream gap while ActivityPods does not": **both count deposits rather than people.** ActivityPods could arbitrate server-side because the server sees everything; NextGraph awaits a primitive. And the gap sits in **neither Refine provider** — it is in the polyfill on one side and the server on the other, which is what matters when deciding what to contribute where. The upper bound remains the common fallback position, already accepted by the PO.
## Two ActivityPods constraints not to rediscover later
- **The Refine providers cover CRUD only.** Creating the collection, setting WAC, creating a group, posting to the outbox are not in the data provider, which does not implement Refine's `custom` either — so there is no escape hatch through Refine and those calls happen beside it.
- **A custom ontology prefix must be registered at prefix.cc**, the Pod provider requiring every ontology to have a known prefix; this concerns our `fp:`. Shape trees must also sit at a public URL, one per entity, since they cannot reference other shapes — no sharing cascade.
## Reservation to keep alive
The reference to the profile is readable by everyone and therefore collectable: it is a **correlatable pseudonym, not anonymity**, and must never be presented as such. The reservation comes from the attendance-set-model brief, and the 2026-10-03 precision reactivates it.
@@ -0,0 +1,43 @@
---
type: brief
summary: ActivityPods is the sole priority provider — everything that can goes through the Refine providers, a backend for actions on behalf of a user is to be added, the NextGraph+polyfill backend is suspended (temporary); reference material and open questions recorded 2026-10-09
---
# Brief (2026-10-09) — ActivityPods first, and a backend for on-behalf actions
Directives recorded at the resumption of the hand-off (`.project/handoff/refine-provider-abstraction.md`), to anchor the ActivityPods integration workstream.
## Directives
1. **ActivityPods is the priority.** The Refine backend over NextGraph plus its polyfill is **suspended, temporarily** — it is not abandoned (the two-rights-models structure still targets both), but no work goes into it while ActivityPods is being wired.
2. **Everything that can must go through `@activitypods/refine-providers`.** The Refine boundary rule from the hand-off applies: what Refine can express is a Refine resource; what it cannot (collection creation, WAC, groups, outbox posting) happens beside Refine, in the seam or in the new backend. Capability differences stay inside the provider layer, never in the app.
3. **A backend must be added for actions "on behalf of a user".** Festipod needs server-side actions executed for the account of a user (the pattern used by the reference apps below). Its mechanism, packages and deployment shape are the first design question of this workstream.
4. **Minimise new shapes — interoperability first (directive 2026-10-09).** A new shape is a last resort, not a default: anything an existing vocabulary already carries must reuse it. A MeetingPoint is therefore modelled as **an event within an event** — an `as/Event` distinguished by an **optional `parentEvent` reference** to its anchor event, not a new `fp:MeetingPoint` type. Whether an existing ActivityStreams predicate (e.g. `as:context`) can carry that link, or the `as/Event` shape must gain an optional property, is part of the survey. For Participation, the existing fediverse vocabulary must be surveyed before anything is created.
## Reference material (user-designated 2026-10-09)
Reading these at this stage is adoption-time reading, explicitly directed — the standing constraint against reading provider sources at work time is unaffected (`@activitypods/refine-providers` remains specified by its pinned reconstruction, `activitypods-providers/contract_activitypods-providers.md`).
- **Example apps** — `/home/sylvain/projects/reconnexion/welcometomyplace` and `/home/sylvain/projects/reconnexion/mutual-aid.app`: working integrations to mine for provider configuration, resources, and the on-behalf backend pattern.
- **ActivityPods platform source** — `/home/sylvain/projects/activitypods/activitypods`; Semapps lives in a sibling folder.
- **Shapes** — `/home/sylvain/projects/activitypods/shapes`: readily usable, and **evolvable by us** if Festipod's entities require it (that repo is ours to adapt).
## Constraints already on record (see the 2026-10-03 brief and the reconstruction)
- `fp:` must be registered at prefix.cc; shape trees must sit at public URLs, one per entity, no sharing cascade.
- The Refine providers cover CRUD only; `custom` is not implemented — no escape hatch through Refine.
- `getMany` silently drops failed fetches — it erases the "unreadable reference" state the model needs (a candidate contribution).
- `acl:Append` third-party insert-without-remove behaviour is NOT VERIFIED; the structure does not depend on the answer.
## Reconnaissance answers (2026-10-09)
- **On-behalf mechanism — ANSWERED.** An app backend is an ActivityPub actor (`@activitypods/app` `AppService`, Moleculer); it signs pod requests as the app actor and the pod impersonates the user after checking registration + grants. Frontend calls use the Solid-OIDC dual-identity token instead. No unattended per-user backend token exists.
- **Registration/Participation — production precedent found.** welcometomyplace persists NO registration resource: a join is an `as:Join` activity posted to the user's outbox, the backend service handles it and adds the actor to the event's `apods:attendees` collection (attached via `attachPredicate`, `PodCollectionsHandlerMixin`); a leave is `as:Leave`, same path in reverse. Membership is read via `pod-collections.getItems`. mutual-aid uses none of this (comments, likes, announces only).
- **Event-in-event — feasible without touching any shape.** The `as/Event` and `as/Profile` SHACL shapes are open (no `sh:closed`), and the generic ActivityStreams vocabulary already defines `as:context`, `as:partOf`, `as:inReplyTo`. A meeting point is a plain `as/Event` carrying an `as:context` link to its parent event — the presence of the link is what distinguishes it, nothing else. Shapes are consumed for access needs and container setup, not server-side resource validation, so nothing breaks.
## Open questions
- Participation topology — the one real divergence from the 2026-10-03 structure: welcometomyplace keeps the canonical list creator-side (an `apods:attendees` collection on the event, writes backend-mediated), where the two-rights-models structure makes each participation a participant-owned document. Both land on the same application state (a reference that may or may not resolve); the choice is who owns the write path. **Arbitration route decided 2026-10-09: the implementation plan evaluates both against the concrete screens (count, withdrawal, discovery, reference non-resolution) and submits an argued recommendation before anything is frozen.**
- `as:Join` has a shape but no shape tree: if participations must be addressable as resources in access needs, adding `shapetrees/as/Join.ttl` for an already-existing AS type is the minimal move — or the collection pattern makes it unnecessary.
- The backend's deployment shape (service alongside the app? a platform app?).
- Whether the four pointed-to provider contributions (from the hand-off) are still the right set once the backend exists — some may move from "contribution to the providers" to "our backend's job".
@@ -0,0 +1,38 @@
---
type: cookbook
summary: Bring up a local ActivityPods pod provider for Festipod development — ports, the reproducible signup → login → create-as/Event flow, the fresh-pod container behaviour, teardown
---
# Cookbook — Local ActivityPods platform
Platform checkout: `/home/sylvain/projects/activitypods/activitypods` (never modify it; verified pristine 2026-10-09). Everything runs from `pod-provider/`.
## Ports
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 && 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)
- Signup: `POST http://localhost:3000/auth/signup`, `Content-Type: application/json` (NOT JSON-LD), body `{"username":"alice","email":"alice@test.com","password":"Test1test","name":"Alice","schema:knowsLanguage":"en"}` → 200 `{"token":"...","webId":"http://localhost:3000/alice","newUser":true}` (~10 s, pod-creation hooks).
- Login: `POST http://localhost:3000/auth/login`, same content type, `{"username":"alice","password":"Test1test"}` → 200 with token; wrong password → 401.
- Read the actor: `GET http://localhost:3000/alice`, `Authorization: Bearer <token>`, `Accept: application/ld+json` → 200; gives `pim:storage` (`http://localhost:3000/alice/data`).
- Create an event: `POST <storage>`, Bearer + `Content-Type: application/ld+json`, body `{"@context":"https://activitypods.org/context.json","type":"Event","name":"...","startTime":"...","endTime":"..."}` → 201 + `Location`.
- Re-read `Location` with Bearer → 200 full JSON-LD; without token → 403 (WAC: private by default).
## Fresh-pod behaviour
On a fresh pod, an `as:Event` POST lands at the pod root (`/alice/data/<uuid>`): typed containers such as `/alice/data/as/event` exist only after a user installs an app whose access needs declare that shape tree. Do not expect typed containers before app registration.
## Teardown
`pkill -f moleculer-runner; pkill -f react-scripts`, then `docker compose down` in `pod-provider/`. Data persists in `pod-provider/data/` (gitignored); delete that directory to fully reset.
Current stack test account: `alice` / `Test1test` on localhost:3000.
@@ -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.
+2 -2
View File
@@ -3,7 +3,7 @@ type: _overview
summary: Stack and tooling — Bun-first (runtime, bundler, native APIs), build pipeline, and the project's commands
triggers:
keywords: [bun, bunx, build, bundler, vite, webpack, jest, npm, storybook, "bun.serve", hmr, tailwind, package.json]
paths: ["build.ts", "package.json", "pnpm-lock.yaml", "Dockerfile", ".env.example", "bunfig.toml", "tsconfig.json", "src/index.ts", "src/index.html", ".storybook/**", "scripts/**"]
paths: ["build.ts", "package.json", "pnpm-lock.yaml", "Dockerfile", "backend/Dockerfile", "docker-compose*.yml", "backend/services/base-path.js", ".env.example", "bunfig.toml", "tsconfig.json", "src/index.ts", "src/index.html", ".storybook/**", "scripts/**"]
---
# Tech stack
@@ -18,4 +18,4 @@ The project's stack and tooling. Guiding principle: **Bun-first** — Bun replac
- [[knowledge_bun-apis]] — native Bun APIs (serve, sqlite, redis, sql, file, shell)
- [[knowledge_build-pipeline]] — build.ts, bundler, server, harness built separately, Storybook
- [[knowledge_stack-and-commands]] — stack components + the real scripts (plus quirks)
- [[knowledge_deployment]] — Dockerfile, prod served from src/, no CI, `portless` in dev
- [[knowledge_deployment]] — the Coolify test stack (one base URL, backend under /api), both images, first-boot traps, prod served from src/, `portless` in dev
@@ -1,14 +1,32 @@
---
type: knowledge
summary: Deployment — multi-stage Bun Alpine Dockerfile; install through pnpm (git+node inside the image) but bun at runtime; runs `bun run start` from src/ (not dist/), EXPOSE 3000; the data-layer git dependency must be pinned to a tag/commit and match `contracts.yaml`'s ref; the shared wallet reaches the container through env vars, not a mount, because it isn't a secret; no CI/CD committed; dev goes through the portless wrapper
last_checked: 2026-08-17
summary: Deployment — the Coolify test stack (app + backend + Redis, shared Fuseki), one base URL with the backend under /api (Strip Prefixes OFF), both images' build traps, first-boot traps, the data-layer git pin, the shared wallet as env config
last_checked: 2026-10-19
---
# Deployment & infra
Nothing has actually been deployed with this shape yet — this leaf states what a deployment needs to line up, verified against the code and manifests, not a procedure that has been run end to end.
## The Coolify test stack
## Dockerfile
`docker-compose.coolify.yml` is what Coolify (build pack *Docker Compose*) builds and runs from the git repository, on the test server only for now: `frontend` (the root `Dockerfile`, below), `backend` (`backend/Dockerfile`, Moleculer + `@activitypods/app`) and a per-stack Redis. Fuseki is the one shared with the other ActivityPods apps of the Coolify instance (`shared-infra`): only the backend joins the external `coolify` network to reach it, with a pair of datasets of its own named from `DATASET_PREFIX` and the backend's service name (a second non-preview stack on the same Fuseki needs its own prefix). The variables Coolify must carry are listed at the top of the compose file; `SERVICE_FQDN_*` / `SERVICE_NAME_*` are generated by Coolify.
**One base URL per environment** (user decision 2026-10-19, so a pull-request preview lives on a single host, like the sibling apps): the app at `https://festipod.dev.reconnexion.coop/`, the backend on the same host under `/api` — its Domains field is `https://<host>/api,https://<host>/.well-known`. Coolify's **Strip Prefixes must be OFF** (it is on the sibling apps): the backend mounts its routes under `/api` itself, read from `SEMAPPS_HOME_URL=https://<host>/api/`, and the front end is pointed at `VITE_BACKEND_URL=https://<host>/api`. With the prefix stripped every backend request is refused (404).
**The backend is base-path aware** (`backend/services/base-path.js`), because SemApps is: it mounts every route it adds under the base URL's path (LDP resources, the app actor and its inbox, public keys, access needs, webhooks, `/nodeinfo/2.1`) but keeps the `/.well-known` ones (`context.jsonld`, `nodeinfo`, `solid`, `void`) at the origin root. Festipod's own routes (`/app`, `/as/actor/app`, `/catalog`) are mounted under the base path the same way, the public-surface allow-list matches paths relative to it (the two `/.well-known` endpoints against the full path), and the Solid-OIDC `client_id` is the requested URI rebuilt from the base URL as written — so it equals, byte for byte, the actor URI the backend mints and the `${backendUrl}/as/actor/app` the front end derives. At the root (`http://localhost:3003`, local dev) every decision is identical to the pre-base-path code. To verify: `bun test backend/services` (with and without `SEMAPPS_HOME_URL=https://<host>/api/`).
**First-boot traps.**
- The backend registers its app actor in its dataset with the domain it has on the FIRST boot: set the domains in Coolify before the first deployment; changing them later means deleting the two datasets and redeploying.
- On a cold first boot, `GET <base>app` answers from an in-memory copy of the actor taken before the access-need group got linked, so it lacks `interop:hasAccessNeedGroup` until the backend restarts (the actor itself, `<base>as/actor/app`, is right). A real sign-in is unaffected — the pod reads the actor — but the rights matrix's programmatic install reads `/app`: restart the backend once after a first deployment.
**Verified 2026-10-19, locally, not yet on Coolify**: both images built from a clean export of the tree, run against the local pod provider behind a same-origin proxy that routes `/api` and `/.well-known` to the backend unstripped with the Host header kept (as Traefik does) — the login flow, the rights matrix (98/98 hard lines) and the revocation probe all pass; the same images at the origin root pass them too.
## Backend image
`node:22-slim`, `pnpm install --frozen-lockfile --prod`, `pnpm start` (tsx over the Moleculer runner). Two things it needs that a local install hides:
- **pnpm 10 skips dependency build scripts unless approved**: `backend/package.json` approves `bcrypt`, `sharp` and `sodium-native` (`pnpm.onlyBuiltDependencies`). Without it the image builds and the container exits at start (`sharp-linux-x64.node` missing; bcrypt would fail next). A local `node_modules` may carry the binaries from an earlier manual build, which is how this stayed invisible.
- **sharp must not depend on GitHub at build time**: its installer downloads prebuilt libvips and binaries from GitHub, and that download timed out on every in-build attempt (2026-10-19) and failed the build. The image installs `libvips-dev` (so sharp detects a system libvips and compiles itself) and sets `npm_config_nodedir=/usr/local` (node-gyp uses the image's own headers). bcrypt still tries GitHub first and falls back to compiling, slower but working offline. Cost: a larger image (a multi-stage build keeping only `libvips42` at runtime would drop several hundred MB — not done).
## App image (root Dockerfile)
A `Dockerfile` exists (multi-stage Bun Alpine). **Installation goes through pnpm, but runtime/build/test stay on bun** (see [[knowledge_stack-and-commands]]):
- `FROM oven/bun:1-alpine`, `install` stage: `apk add --no-cache git nodejs npm` then `npm install -g pnpm@10.26.0` (the bun image has neither Node nor pnpm; Alpine's `apk nodejs` does not ship corepack), `COPY package.json pnpm-lock.yaml`, then `pnpm install --frozen-lockfile`. `git` is required because `@ng-eventually/polyfill` is a public **git+https** dependency (Gitea, no auth). `release` stage: copies `node_modules` plus the source.
@@ -31,9 +49,7 @@ A `Dockerfile` exists (multi-stage Bun Alpine). **Installation goes through pnpm
**`pnpm install --frozen-lockfile` (the Dockerfile's install step) never regenerates — it only verifies.** `pnpm-lock.yaml` must already reproduce `package.json` exactly, so any change to the git specifier (ref, path, or package name) needs `pnpm install` run and the regenerated lockfile committed *before* the image can build; skipping that step fails the build outright, not silently. This has bitten once: the lockfile still named the old package and path after the dependency was renamed, so `--frozen-lockfile` refused and the image could not build until it was regenerated.
## CI/CD
**No** pipeline is committed (`.github/workflows/` absent, no Coolify config in the repo). A knowingly accepted blind spot. To host the Bun app, the `coolify-hosting` skill applies.
## The NextGraph-era deployment
**A deployed origin IS embeddable in the hosted broker's iframe — VERIFIED 2026-08-17 in production**, on the first deployment carrying the injected wallet and the external data layer: a user signed in and saw their own data, which is only reachable through that iframe. The question had been open because nothing in this repo exercises it; it is settled for this origin, and it is settled by the deployment rather than by a test — **no scenario covers it**, so a change of origin, of proxy, or of the broker's embedding policy would be found by a person, not by the suite. [[caveat_firefox-lna-blocks-broker-iframe]] remains the one recorded failure mode, and it is a local-dev-origin one (`127.0.0.1` blocked by Firefox LNA).
+34 -5
View File
@@ -1,7 +1,10 @@
# Inter-repo contracts. Festipod is a CONSUMER only: it publishes no interface of its own,
# and it consumes two — the SDK surface `@ng-eventually/polyfill` engages toward the
# applications built on it, and the indexing layer `ng-helpers` engages toward the
# applications that need to make things findable.
# Inter-repo contracts. Festipod publishes no interface of its own — what it lists under
# `publish:` is its own consumer-side DECLARATION, the channel a provider pulls to learn
# what this app calls, what it needs and what blocks it. It consumes three — the SDK
# surface `@ng-eventually/polyfill` engages toward the applications built on it, the
# indexing layer `ng-helpers` engages toward the applications that need to make things
# findable, and the Refine data providers `@activitypods/refine-providers`, which engage
# nothing at all.
#
# The pulled copy under `into:` IS the specification Festipod codes against. An agent
# working here reads that copy and never opens the provider's own source: a gap is raised
@@ -9,7 +12,7 @@
#
# Each interface gets its own FOLDER inside the concept that owns it, holding the pulled
# engagement and — once Festipod actually consumes the interface — the `usage_festipod.md`
# declaration beside it. Both interfaces land in `data-layer`: it is the concept that owns
# declaration beside it. All three land in `data-layer`: it is the concept that owns
# how Festipod uses an external data surface, including the machinery behind discovery
# (`functional-domain` owns the product intent of discovery and explicitly delegates its
# technical how to the data SDK).
@@ -19,6 +22,14 @@
# access to a local checkout lives in `.project/contracts.local.yaml`, which is never
# committed.
publish:
# Both declarations are named `usage_festipod.md`, so the keys are qualified by their
# interface — publish keys must stay unique, and the bare subject no longer says which.
# Listing a declaration here is what lets its provider re-find it with `list --from`
# after it moves; it sends nothing on its own.
polyfill-surface--festipod: concepts/data-layer/polyfill-surface/usage_festipod.md
activitypods-providers--festipod: concepts/data-layer/activitypods-providers/usage_festipod.md
consume:
- contract: polyfill-surface
into: concepts/data-layer/polyfill-surface/
@@ -43,3 +54,21 @@ consume:
# Pinned on the TAG, never on a branch: a branch moves under us and the pin would stop
# naming a state anyone can go back to. Re-pin to the next tag at each upgrade.
ref: v1.0.1
- contract: activitypods-providers
into: concepts/data-layer/activitypods-providers/
# `@activitypods/refine-providers` publishes NO engagement: no contract document, no
# stated change policy, not even a git tag. So this entry is `type: reconstruct` — the
# one bounded crossing this protocol allows, made once at adoption time: the leaf under
# `into:` is OUR observation of that package, authored here and pinned to the commit it
# was read at. It is not a promise anyone made us, and every upgrade has to re-observe
# it. Once reconstructed, that leaf IS the specification this app codes against.
type: reconstruct
# Canonical identity: the package's git remote plus the repo-relative folder the
# observation was made from. Per-developer access to a checkout of it lives in
# `.project/contracts.local.yaml`, which is never committed.
reconstructFrom: https://github.com/activitypods/refine-providers.git/src
# The branch the observation was read from. A tag would be the better pin, but this
# repository publishes none — the resolver turns this into the commit actually read,
# which is what `built_version` records.
ref: master
@@ -0,0 +1,200 @@
---
subject: Refine as Festipod's provider-swap abstraction layer
created: 2026-10-08
status: open
---
# Refine as the provider-swap abstraction layer
## Where the work lives
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.
The main checkout `/home/sylvain/projects/festipod/festipod` is itself on `ng-eventually`. Do not modify it. Note that the concept hook reads it, so it announces leaves that may not exist in whichever worktree you are in.
## The goal
Put all of Festipod's persistence behind Refine so that several **provider sets** can serve the same app. **ActivityPods is the default and the sole priority** — directive of 2026-10-09 (recorded in `data-layer/brief_2026-10-09_activitypods-first-backend`): everything that can goes through `@activitypods/refine-providers`, and a **backend for actions on behalf of a user** is to be added. A Refine provider over NextGraph plus its polyfill remains the declared second target, but its development is **suspended, temporarily** — no work goes into it while ActivityPods is wired.
## Directives of 2026-10-09 (resumption) — and first reconnaissance
Recorded in `data-layer/brief_2026-10-09_activitypods-first-backend`; the essentials and what the reconnaissance (three read-only passes over the user-designated reference repos) established:
- **The on-behalf mechanism is mapped.** An app backend IS an ActivityPub actor (`@activitypods/app` `AppService`, Moleculer). It signs pod requests with HTTP signatures as the app actor; the pod provider checks the app's registration and grants, then impersonates the user (`pod-provider/backend/middlewares/app-control.js`). Services call `pod-outbox.post`, `pod-resources.*`, `pod-collections.*`, `pod-wac-groups.*`, `pod-permissions.*` with an `actorUri`. Frontend calls use the Solid-OIDC dual-identity token instead (`webId` = app client_id, `impersonatedUser` = user). Reference implementations: `welcometomyplace/backend/services/registration.service.js`, `attendees-matcher.service.js`; both example apps run the same stack (`@activitypods/app` 2.3 + `@semapps/*` 1.2, Moleculer backend + Vite/React/Refine frontend, Docker-deployed).
- **The refine-providers package is NOT in the platform monorepo** — it lives at `/home/sylvain/projects/activitypods/refine-providers` (a `/home/sylvain/projects/nextgraph/activitypodes-refine-providers` checkout also exists; the pinned reconstruction remains the work-time specification).
- **Shapes**: 15 shape trees published at `https://shapes.activitypods.org` from `/home/sylvain/projects/activitypods/shapes` (repo evolvable by us). The 2026-10-09 directives **amended the first mapping proposal — minimise new shapes, interoperability first**: a MeetingPoint is a plain `as/Event` carrying an `as:context` link to its parent event (event within an event; the `as/Event` shape is open — no `sh:closed` — so nothing breaks and no new shape is needed). For Participation the existing vocabulary was surveyed: see the registration precedent in the next bullet.
- **Registration/Participation — production precedent found.** welcometomyplace persists no registration resource: join = `as:Join` activity in the user's outbox, handled by the backend service which adds the actor to the event's `apods:attendees` collection (`PodCollectionsHandlerMixin`, `attachPredicate`); leave = `as:Leave`; reads via `pod-collections.getItems`. The one open design question this raises — collection creator-side (this pattern) vs participant-owned participation documents (the 2026-10-03 structure) — is recorded in the brief, to arbitrate before implementation.
- **No unattended per-user backend token exists** on the platform: server-side per-user actions ride the app-actor signature + grants; the platform-idiomatic channel for user-facing reactions is the app outbox → user inbox (notifications style).
## What exists, and what deliberately does not
In `src/shared/refine/`:
- `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/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.
**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/`:
- `concepts/data-layer/activitypods-providers/contract_activitypods-providers.md` — a **reconstruction**, not a pull: that package publishes no engagement, so this is our own observation, pinned (`built_version: 66616484b9…`). It is the specification to code against.
- `concepts/data-layer/activitypods-providers/usage_festipod.md` — our declaration toward them. Its Frictions/Needs are the channel to the provider.
- `concepts/data-layer/brief_2026-10-03_structure-for-two-rights-models.md` — the data-shape design, below.
- `concepts/data-layer/brief_2026-10-09_activitypods-first-backend.md` — the 2026-10-09 directives (ActivityPods priority, on-behalf backend, NextGraph suspended) and the reference material; the brief this hand-off's resumption added.
- `handoff/refine-provider-abstraction.md` — this document.
- `contracts.yaml` — `publish:` (both declarations, keys qualified by interface) and three `consume:` entries (polyfill-surface, indexing-layer, activitypods-providers).
- `contracts.local.yaml` — gitignored, per-worktree. Must exist for `contracts.py check` to resolve the ActivityPods entry.
## Decisions, and why
**One app variant per provider set, selected by a URL path prefix.** The default at the root, another behind a prefix naming it; switching is a plain link and costs a full page reload. The reload is the mechanism, not a cost: it discards the previous backend's Refine cache and session with no code to undo them, and losing access to the left backend's data is accepted. An earlier plan for in-session hot switching was dropped. The deep reason: **the sign-in experience is not abstractable** — the NextGraph SDK mounts its own full-screen barrier and owns the whole wallet experience (`src/app/AuthGate.tsx` renders no access screen), while a Solid backend hands the app an OIDC flow it must render itself (Pod-provider picker, callback route). One confiscates the screen, the other delegates a flow.
The prefix **is** the backend's name, so a backend must never be named after a top-level route (`events`, `profile`, `home`, `settings`, `users`).
**The router does not yet strip the prefix, deliberately.** `parsePath`/`navigate` in `src/app/router.tsx` are a shared primitive every screen depends on; the suites that would prove a change there are not run in this worktree; and while one backend is registered no URL carries a prefix, so nothing is broken. That half lands with the second backend, when it becomes testable.
**The data shape: references, not embedded content.** Three levels — Event (own document, public, written by its creator, carrying an attached list of *references*); Participation (own document, owned by **the participant**, carrying `event`, `active`, `participant`); Profile (own document, protected). Three facts force it: the participation must be writable by its participant and nobody else, and NextGraph grants write per document; per-reader variation is impossible inside one document where encryption is per document, so it is obtained by **indirection** (the reference is always present, the ability to *follow* it varies); and `rule_document-per-entity` already says this for that reason.
The non-visibility mechanism differs and both land on the same application state — *a reference one may or may not resolve*. On NextGraph the target is **undecipherable** for want of a key (nothing is absent). On ActivityPods it is refused by WAC, or omitted by server-side projection. So the domain type models the NextGraph shape, where everything is present: `active` and `participant` are **not** optional; only resolution fails. (Proposing them optional was an error — it lifted an ActivityPods adaptation into the common model.)
**No stored counter, ever.** The participant count is `participations.length`. `knownParticipants` is the participations whose participant resolves — a presence test, not an access check. Withdrawal is `active: false` on one's own document.
**NextGraph is the common denominator.** Its model is the strict subset: no WAC groups, no revocation, sharing wholesale per document, but writes work offline. Structure for it and ActivityPods serves the structure, more cheaply even. The converse fails: anything needing a server projection or a revocation is untenable on NextGraph.
**The Refine boundary rule.** Refine's `DataProvider` is flat CRUD (`getList`, `getMany?`, `getOne`, `create`, `createMany?`, `update`, `updateMany?`, `deleteOne`, `deleteMany?`, `getApiUrl`, `custom?`) — **no relation or join concept at all**, and no `getManyReference`. Everything that makes Festipod social (append to someone else's collection, grant/revoke read, discovery, offline queueing) is outside its vocabulary. Since cache, live subscription and optimistic update are bought **per resource**, the rule is: *anything the app displays and that changes must be a Refine resource; anything one-shot and invisible may live in the seam.* A write passed beside Refine does not invalidate its cache, so the list would not refresh.
Refine does support **several data providers at once** (`Resource.dataProviderName`, `useList({dataProviderName})`). Deliberately unused for switching, but it is the tool for reading one entity from another backend (discovery from a shared index) or for migration.
**The seam should shrink.** `types.ts` was written from measurements against one raw provider that could not be changed. Since we can contribute to the providers, several of its fields are symptoms rather than requirements: `updateReplaces` + `toPatch` should vanish (a provider exposes one semantic), and `recordKey` vs `domainId` should collapse to `id` (ActivityPods already does). `toDomain`/`toValues` shrink to vocabulary mapping; `visibility` and `resource` stay — visibility is a product declaration, not a transport fact. **The measure of success is how small `RecordBinding` gets.** Capability differences belong inside the provider, never in the app.
## What is known about `@activitypods/refine-providers`
The reconstruction is the specification. Its source was read **once, deliberately, at adoption time** (that is what a reconstruction is); it is not to be read again at work time — a gap goes into the declaration's Frictions/Needs. Checkouts, if ever needed again: the package at `/home/sylvain/projects/nextgraph/activitypodes-refine-providers`, the platform at `/home/sylvain/projects/activitypods/activitypods`.
- A record's `id` is the LDP resource URI, and it is the **only** identifier — `getOne`/`update`/`deleteOne` use it directly as the URL. The seam's two identifiers collapse into one here.
- `update` fetches the current resource, strips `id`/`@context`, and PUTs `{...current, ...variables}` — so **a partial write is safe**, the merge being **shallow**.
- `getList` resolves the resource's container from the **session's own WebID** (public Solid TypeIndex, best-effort private), so it returns only what the current user holds. `getOne`/`getMany` fetch a URI directly and so **do** cross Pods — they just need the URI.
- No server-side filtering, sorting or pagination: the whole container is fetched, then filtered in memory.
- **`getMany` silently drops failed fetches** (`allSettled` then filter) — it erases exactly the "unreadable reference" state the model needs.
- Module-level caches (shape trees, type registrations per WebID, subscription endpoints) are **never invalidated**.
- `authProvider` installs a `setInterval` and a `visibilitychange` listener at construction with **no teardown**; the only opt-out is `appStatusCheckInterval: false`.
- Live updates are real push (`notify:WebSocketChannel2023`), but a **list** subscription reports membership only (`Add`/`Remove`), never a member's field change.
- `types` takes precedence over `shapeTreeUri` when both are set — the **opposite** of what the configuration type documents.
- `custom` is not implemented, so there is **no escape hatch through Refine** for non-CRUD operations.
- Collections are POSTable LDP resources (`/as/collection`) with `semapps:dereferenceItems` — *"all items will be dereferenced (if permissions allow)"*. That is how `event.participations` can come back as full objects for a reader who is allowed, and as URIs otherwise, in one read. Items are added/removed by SparqlPatch on `as:items`.
- `acl:Append` exists among the WAC modes and is really used in app access needs. **Whether it lets a third party insert an item without being able to remove one is NOT VERIFIED** — enforcement lives in `@semapps/webacl`, absent from the checkout. The structure does not depend on the answer: if it works that way the participant appends directly; otherwise the route is `apods:PostOutbox` to the owner's inbox, which is exactly the NextGraph shape, and only latency changes.
- An app declares `accessNeeds` (`shapeTreeUri` + `accessMode`) plus special needs: `apods:CreateCollection`, `apods:CreateWacGroup`, `apods:PostOutbox`, `apods:QuerySparqlEndpoint`, `apods:UpdateWebId`, inbox/outbox reads.
- **Backend-less apps are supported**: `onlineBackend` is false only when the app description document at the `clientId` URI is not fetchable. A static file suffices.
- A custom ontology prefix **must be registered at prefix.cc** (the Pod provider requires every ontology to have a known prefix) — this concerns Festipod's `fp:`. Shape trees must sit at a public URL, one per entity, and **cannot reference other shapes** (no sharing cascade).
**Four contributions this points to** (we may contribute to these providers): `getMany` must signal the unreadable instead of swallowing it; an append path into a collection one does not own; collection support as a resource with `dereferenceItems`; and a teardown for the auth provider's timer.
## The deduplication question — answered, and still open
The attendance model (`brief_2026-07-20_attendance-set-model`, PO 2026-07-27) requires the creator to tell two references from the *same* person apart from two from different people **without knowing who**, or the count is not a count of people and a participant can inflate it.
**Checked on this base: not solved.** A different problem is, and well. `materializeAttendance` (`src/shared/data/registration.ts`) keys its active set by a **per-deposit** `uid` minted from time plus randomness, so a broker re-sync replaying the same deposits converges — that is **replay idempotence**, not person identity. The count counts **deposits**: the same person joining twice counts twice. Further: the **plaintext `userId` travels in the deposit** and the cancellation fallback depends on it (a leave without a known `regUid` cancels the earliest still-active join for that `userId`), so the creator *does* know who and the anonymity the model rests on does not hold. Participations are in the **protected** scope rather than public, and withdrawal is still a deletion. And the gap was **never raised** through the channel meant for it.
So both platforms count deposits rather than people, and the gap sits in **neither Refine provider** — polyfill on one side, server on the other. ActivityPods could arbitrate server-side because the server sees everything.
## Problems hit, and how they turned out
- **Two false claims were written into durable records and corrected.** First, that the participant count needs an inbox and that a CRUD-only backend therefore could not carry the model — wrong: every participation is readable by everyone, only the link to a person is protected, so counting is ordinary CRUD. Second, a declared "need" for a WebID filter on `getList` — Festipod does not need it: `getOne`/`getMany` already cross Pods, the participations arrive with the event, and discovery is an **index** problem (already decided on this base: `decision_2026-08-17_discovery-through-a-shared-index`), not a provider one. Both corrections are in the files.
- **`__FESTIPOD_BACKEND__` was a lie in a comment** claiming `build.ts` defined it. Nothing did, and nothing could: this project serves from `src/` in production too (`bun run start`), so `build.ts`'s `define` block never reaches what runs (`tech-stack`, `knowledge_build-pipeline`). The runtime mechanism this base already uses instead is a config route — `/festipod-config.json` served by `src/index.ts`, read by `src/app/frontend.tsx` *before* it imports `App`. Replaced by the URL read.
- **Three separate times the answer was already on `ng-eventually`** (the runtime config route, the delegation of wallet access to the SDK, the dedup implementation). That is what triggered rebasing onto it.
- **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-13, T05 closed, tip = the commit recording this state)
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. A revoked app is now detected client-side (`4fc856f`, `d4fcb7e` — see `## T05`). The join→collection scenarios are re-enabled and green (5/5 smoke), and the full authenticated UI flow is banked as a durable gate: `bun run test:e2e-flow` (scripts/e2e-login-flow.ts — picker → login → consent → authenticated → event → meeting point, fresh user, benign-4xx classification, non-zero exit on any fatal ≥400). Platform fragility observed while un-suspending: hand-crafting an `apods:attendees` collection (instead of letting the backend attach it) crashed the festipod backend with an uncaught `Invalid URL` in `@activitypods/app` `pod-collections-handler.getGroupSlugFromCollectionUri(undefined)` — the scenario now reads the attached collection rather than building it; the crash path itself is platform-side.
- Both usage declarations are named `usage_festipod.md`, so the bare stem is ambiguous as a wikilink — refer to either by folder path.
- Driving `getAppStatus()`: DONE (`4fc856f`, `d4fcb7e`). Festipod relies on its `installed` member, which the reconstruction does not pin — recorded in the activitypods-providers usage declaration (constraint 9, need 3), together with its reliance on `create` sending `to` unchanged (constraint 10).
- The `activitypods-providers` reconstruction is stamped at the 0.1.5-era pin while the installed package is 0.2.0 — re-constructing the contract at the installed version is a pending decision (the source is read once at adoption time; a re-read is its own decision).
- 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.
- 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.
## Standing constraints
- A pulled or reconstructed contract **is** the specification. Never read a provider's source at work time; a gap goes into the declaration's Frictions/Needs and is signalled out of band (content lives in the document). Never describe in this repo how the data layer is implemented.
- **No attribution to Claude** anywhere in produced artifacts — commits, PRs, docs, comments. This overrides harness defaults.
- Project documentation in **English**, never hard-wrapped (one line per paragraph or list item). Conversation with the user in **French**.
- **No effort or timeline estimates**, anywhere.
- Package installation goes through **pnpm**, never `bun install`; Bun-first otherwise.
- Project facts are documented through `/concept document`, not written freehand into `.project/`.
- Commit freely on a branch; push at validated states without being asked (Gitea SSH needs the sandbox disabled).
- Never bare `git stash` — the stack is shared across worktrees and sessions.
- `pre-push` blocks while any `_debt.md` exists.
+1 -1
View File
@@ -12,7 +12,7 @@ Web app mobile-first où les utilisateurs créent des **points de rencontre** qu
src/screens/index.ts # Registre d'écrans (utilisé par Storybook)
```
- **Un module n'importe QUE depuis `shared/` — jamais d'un autre module.** C'est l'invariant qui rend l'archi réelle.
- **Bun-first** : `bun` / `bun install` / `bun test` / `bun build`, jamais node/npm/vite/jest. `bun run dev` (port 3000).
- **Bun-first** : `bun` / `bun install` / `bun test` / `bun build`, jamais node/npm/vite/jest. `bun run dev` (portless, port dynamique ; défaut servi par `bun run start` : 4000).
## Frontière SDK
+14
View File
@@ -0,0 +1,14 @@
SEMAPPS_APP_NAME=Festipod
SEMAPPS_APP_DESCRIPTION=Points de rencontre sur les événements publics
SEMAPPS_APP_LANG=en,fr
SEMAPPS_HOME_URL=http://localhost:3003
SEMAPPS_FRONT_URL=http://localhost:4000
SEMAPPS_SHAPE_REPOSITORY_URL=https://shapes.activitypods.org/
SEMAPPS_PORT=3003
SEMAPPS_SPARQL_ENDPOINT=http://localhost:3030/
SEMAPPS_MAIN_DATASET=settings
SEMAPPS_JENA_USER=admin
SEMAPPS_JENA_PASSWORD=admin
SEMAPPS_REDIS_CACHE_URL=redis://localhost:6390/3
SEMAPPS_QUEUE_SERVICE_URL=redis://localhost:6390/4
SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME=settings
+34
View File
@@ -0,0 +1,34 @@
FROM node:22-slim
# Install pnpm and build tools required for native modules (sharp, bcrypt).
# libvips-dev: with a system libvips present, sharp compiles itself against it instead of
# downloading its prebuilt binaries from GitHub, a download that timed out on every in-build
# attempt (2026-10-19) and failed the build.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
python3 \
make \
g++ \
pkg-config \
libvips-dev \
&& rm -rf /var/lib/apt/lists/* \
&& corepack enable && corepack prepare pnpm@10.26.0 --activate
# node-gyp compiles against the headers this image already ships rather than downloading them.
ENV npm_config_nodedir=/usr/local
WORKDIR /app
# Install dependencies first to leverage Docker layer caching. The native modules' build
# scripts only run because package.json approves them (pnpm.onlyBuiltDependencies).
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
# Copy application code.
COPY config ./config
COPY moleculer.config.js ./
COPY services ./services
EXPOSE 3003
CMD ["pnpm", "start"]
+18
View File
@@ -0,0 +1,18 @@
require('dotenv-flow').config();
module.exports = {
APP_NAME: process.env.SEMAPPS_APP_NAME,
APP_DESCRIPTION: process.env.SEMAPPS_APP_DESCRIPTION,
APP_LANG: process.env.SEMAPPS_APP_LANG,
HOME_URL: process.env.SEMAPPS_HOME_URL,
FRONT_URL: process.env.SEMAPPS_FRONT_URL,
SHAPE_REPOSITORY_URL: process.env.SEMAPPS_SHAPE_REPOSITORY_URL,
PORT: process.env.SEMAPPS_PORT,
SPARQL_ENDPOINT: process.env.SEMAPPS_SPARQL_ENDPOINT,
MAIN_DATASET: process.env.SEMAPPS_MAIN_DATASET,
JENA_USER: process.env.SEMAPPS_JENA_USER,
JENA_PASSWORD: process.env.SEMAPPS_JENA_PASSWORD,
REDIS_CACHE_URL: process.env.SEMAPPS_REDIS_CACHE_URL,
QUEUE_SERVICE_URL: process.env.SEMAPPS_QUEUE_SERVICE_URL,
AUTH_ACCOUNTS_DATASET_NAME: process.env.SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME
};
+62
View File
@@ -0,0 +1,62 @@
const { CacherMiddleware } = require('@semapps/webacl');
const CONFIG = require('./config/config');
Error.stackTraceLimit = Infinity;
const cacherConfig = CONFIG.REDIS_CACHE_URL
? {
type: 'Redis',
options: {
prefix: 'action',
ttl: 2592000,
redis: CONFIG.REDIS_CACHE_URL
}
}
: undefined;
const SkipOrphanBlankNodesCleanupMiddleware = () => ({
name: 'SkipOrphanBlankNodesCleanupMiddleware',
localAction: (next, action) => {
if (action.name === 'triplestore.deleteOrphanBlankNodes') {
return async () => {};
}
return next;
}
});
// ActivityPods app services mark internal calls with isSystemCall, but the SDK
// triplestore layer needs an explicit webId='system' to bypass pod-side WAC.
const SystemCallWebIdMiddleware = () => ({
name: 'SystemCallWebIdMiddleware',
localAction: (next, action) => {
return async ctx => {
if (ctx.meta?.isSystemCall && !ctx.params?.webId) {
ctx.params.webId = 'system';
}
return next(ctx);
};
}
});
module.exports = {
middlewares: [
SkipOrphanBlankNodesCleanupMiddleware(),
SystemCallWebIdMiddleware(),
CacherMiddleware(cacherConfig)
],
logger: {
type: 'Console',
options: {
formatter: 'short',
level: 'info'
}
},
retryPolicy: {
enabled: true,
retries: 0,
delay: 100,
maxDelay: 30000,
factor: 2,
check: err => err && !!err.retryable
}
};
+45
View File
@@ -0,0 +1,45 @@
{
"name": "festipod-backend",
"private": true,
"scripts": {
"dev": "tsx node_modules/moleculer/bin/moleculer-runner.js --hot --repl services/*.service.js services/**/*.service.js",
"start": "tsx node_modules/moleculer/bin/moleculer-runner.js services/*.service.js services/**/*.service.js"
},
"dependencies": {
"@activitypods/app": "2.3.2",
"@rdfjs/data-model": "2.1.1",
"@semapps/activitypub": "1.2.7",
"@semapps/auth": "1.2.7",
"@semapps/core": "1.2.7",
"@semapps/crypto": "1.2.7",
"@semapps/importer": "1.2.7",
"@semapps/ldp": "1.2.7",
"@semapps/mime-types": "1.2.7",
"@semapps/nodeinfo": "1.2.7",
"@semapps/ontologies": "1.2.7",
"@semapps/solid": "1.2.7",
"@semapps/sparql-endpoint": "1.2.7",
"@semapps/triplestore": "1.2.7",
"@semapps/webacl": "1.2.7",
"dotenv-flow": "^3.2.0",
"ioredis": "^4.17.3",
"moleculer": "^0.14.19",
"moleculer-bull": "0.2.8",
"moleculer-cron": "0.0.4",
"moleculer-repl": "^0.6.6",
"sharp": "0.31.3",
"tsx": "^4.20.3",
"url-join": "^4.0.1"
},
"engines": {
"node": ">=22.12"
},
"pnpm": {
"onlyBuiltDependencies": [
"bcrypt",
"sharp",
"sodium-native"
]
},
"packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402"
}
+7629
View File
File diff suppressed because it is too large Load Diff
+55
View File
@@ -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 };
+56
View File
@@ -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);
});
+152
View File
@@ -0,0 +1,152 @@
const urlJoin = require('url-join');
const { AppService } = require('@activitypods/app');
const CONFIG = require('../config/config');
const { requestUri } = require('./base-path');
// `GET /app` has two personalities, discriminated by the request's `JsonLdContext` header.
//
// 1. Solid-OIDC Client ID Document. The pod provider's OIDC adapter (RedisAdapter.find)
// resolves an unknown `client_id` that starts with `http` by fetching it, sending
// `Accept: application/ld+json` and `JsonLdContext: https://www.w3.org/ns/solid/oidc-context.jsonld`.
// The pod provider runs the login with `client_id = <HOME_URL>/app`, so this same URL must,
// for that fetch, return a document whose `@context` literally includes the Solid-OIDC
// context, whose `client_id` equals the fetched URI exactly, and which carries `redirect_uris`.
// Anything else fails `invalid_client_metadata / client_id is mandatory property`.
//
// 2. SAI app description (the actor). The pod provider's auth-agent fetches this URL
// WITHOUT the `JsonLdContext` header (`ldp.remote.getNetwork`) to install/describe the app,
// and expects the app actor document served today (`type: [Application, interop:Application]`).
//
// The `JsonLdContext` header is the only discriminator: same path, two representations.
module.exports = {
mixins: [AppService],
settings: {
baseUrl: CONFIG.HOME_URL,
app: {
name: CONFIG.APP_NAME,
description: CONFIG.APP_DESCRIPTION,
thumbnail: urlJoin(CONFIG.FRONT_URL, 'logo.svg'),
frontUrl: CONFIG.FRONT_URL,
supportedLocales: CONFIG.APP_LANG
},
oidc: {
clientUri: CONFIG.FRONT_URL,
redirectUris: urlJoin(CONFIG.FRONT_URL, 'login'),
postLogoutRedirectUris: urlJoin(CONFIG.FRONT_URL, 'login?logout=true'),
tosUri: null
},
accessNeeds: {
required: [
{
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event'),
accessMode: ['acl:Read', 'acl:Write', 'acl:Control'],
preferredScope: 'interop:AllFromRegistry'
},
{
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Profile'),
accessMode: ['acl:Read', 'acl:Write'],
preferredScope: 'interop:AllFromRegistry'
},
{
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/vcard/Group'),
accessMode: 'acl:Read',
preferredScope: 'interop:AllFromRegistry'
},
'apods:ReadInbox',
'apods:ReadOutbox',
'apods:PostOutbox',
'apods:QuerySparqlEndpoint',
'apods:CreateWacGroup',
'apods:CreateCollection',
'apods:UpdateWebId'
],
optional: []
},
queueServiceUrl: CONFIG.QUEUE_SERVICE_URL
},
actions: {
// Overrides the mixin's `get` (which returns `this.appActor` for every request) to
// answer the Solid-OIDC Client ID Document fetch as well. `this.appActor` is set by the
// mixin's `started()`, i.e. long before any request is served.
get: {
async handler(ctx) {
// moleculer-web exposes the raw request only when the route alias sets
// passReqResToParams (see services/core/core.service.js). Internal calls
// (ctx.call('app.get')) have no `$req`, so they always get the actor.
const req = ctx.params && ctx.params.$req;
const headers = (req && req.headers) || {};
const jsonLdContextKey = Object.keys(headers).find(key => key.toLowerCase() === 'jsonldcontext');
const isOidcClientIdDocument =
jsonLdContextKey !== undefined && String(headers[jsonLdContextKey]).includes('solid/oidc-context.jsonld');
// `this.appActor` is set at the very END of the mixin's started(). During boot,
// this action is already routable (the api gateway is up before the services
// finish starting) and `@activitypods/app`'s own `awaitCreateComplete` polls the
// actor over HTTP through this very route — waiting on an action that waits on the
// boot it blocks. Read the actor straight from the triplestore in that window.
let actor = this.appActor;
if (!actor) {
try {
actor = await ctx.call('ldp.resource.get', {
resourceUri: urlJoin(CONFIG.HOME_URL, 'as/actor/app'),
accept: 'application/ld+json',
webId: 'system'
});
} catch (e) {
actor = undefined;
}
}
if (isOidcClientIdDocument) {
// The client_id must equal the fetched URI exactly. Both `/app` and `/as/actor/app`
// serve this document (the configured client_id is the actor uri; `/app` remains
// a valid alias), so echo the request path rather than hardcoding one.
// `req.url` is rewritten to the alias remainder by moleculer-web; the raw path, base
// path included, survives in `originalUrl`.
const requestPath = String((req && (req.originalUrl || req.url)) || '').split('?')[0];
return {
'@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'],
client_id: requestPath ? requestUri(CONFIG.HOME_URL, requestPath) : urlJoin(CONFIG.HOME_URL, 'as/actor/app'),
redirect_uris: [urlJoin(CONFIG.FRONT_URL, 'login')],
client_name: CONFIG.APP_NAME,
client_uri: CONFIG.FRONT_URL
};
}
return actor;
}
}
},
async started() {
// Heal the actor ↔ access-need-group link. `@activitypods/app` attaches the group to the
// actor in an after.post hook of the group's creation, but the hook races the actor's own
// creation on a cold start (both run inside the broker's parallel started() phase): the
// group then exists while the actor never gets `interop:hasAccessNeedGroup`, and both the
// pod frontend (consent screen) and the pod backend (requirements discovery) read the
// needs from that link — an empty consent registers the app with zero grants and the
// register flow loops. Idempotent: only patches when the link is missing.
const actorUri = urlJoin(CONFIG.HOME_URL, 'as/actor/app');
const actor = await this.broker.call('ldp.resource.get', {
resourceUri: actorUri,
accept: 'application/ld+json',
webId: 'system'
});
const link = actor && actor['interop:hasAccessNeedGroup'];
const hasLink = Array.isArray(link) ? link.length > 0 : Boolean(link);
if (!hasLink) {
const groups = await this.broker.call('access-needs-groups.list', {
filters: {
'http://www.w3.org/ns/solid/interop#accessNecessity': 'http://www.w3.org/ns/solid/interop#AccessRequired'
},
accept: 'application/ld+json'
});
const group = groups && groups['ldp:contains'] && groups['ldp:contains'][0];
if (group) {
await this.broker.call('actors.attachAccessNeedGroup', {
accessNeedGroupUri: group.id || group['@id']
});
this.logger.info(`Healed the actor's interop:hasAccessNeedGroup link to ${group.id || group['@id']}`);
}
}
}
};
+88
View File
@@ -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
};
+83
View File
@@ -0,0 +1,83 @@
const urlJoin = require('url-join');
const { PodCollectionsHandlerMixin, PodResourcesHandlerMixin } = require('@activitypods/app');
const CONFIG = require('../config/config');
const { neverThrowingMatcher, verifiedEmit, uriOf } = require('./emitted-activity');
const { guardedCreateAndAttach } = require('./collection-attach');
// Attaches an `apods:attendees` collection (and a WAC group of the same members) to every
// `as:Event` created in a user's pod that this backend learns of through the outbox watcher —
// plain events as well as meeting points (observed 2026-10-12). A join adds the actor to it.
//
// 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],
settings: {
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Event'),
attachPredicate: 'http://activitypods.org/ns/core#attendees',
collectionOptions: {
ordered: false,
summary: 'Meeting point attendees'
},
createWacGroup: true
},
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' }
)
}
},
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}`);
}
}
}
};
+35
View File
@@ -0,0 +1,35 @@
// The path the backend is served under, read from its base URL (SEMAPPS_HOME_URL): `/` when the
// backend owns its origin (http://localhost:3003 locally), `/api/` when it shares the front end's
// origin (https://<host>/api/, the Coolify layout, where one base URL per environment keeps pull
// request previews on a single host). The proxy in front passes the path through unchanged; SemApps
// mounts every route it adds under the base path, except the `/.well-known` ones, which stay at the
// origin root. Festipod's own routes and its public-surface allow-list follow the same split.
const urlJoin = require('url-join');
/** basePathOf(baseUrl) → the base URL's path, always ending with `/` (`/` when it has none or is unset). */
function basePathOf(baseUrl) {
if (!baseUrl) return '/';
const { pathname } = new URL(baseUrl);
return pathname.endsWith('/') ? pathname : `${pathname}/`;
}
/** underBasePath(basePath, path) → the absolute `path` (e.g. `/catalog`) as mounted under `basePath`. */
function underBasePath(basePath, path) {
return basePath + path.replace(/^\/+/, '');
}
/**
* requestUri(baseUrl, requestPath) → the absolute URI a request was made to. `requestPath` is the raw
* path the gateway received (moleculer-web's `originalUrl`), base path included: the base path is
* taken off before joining it to the base URL, which would otherwise repeat it. Joining (rather than
* resolving with `new URL`) keeps the base URL exactly as written — host case, explicit port — so the
* result matches the URIs the backend mints from the same base URL, its actor's first.
*/
function requestUri(baseUrl, requestPath) {
const basePath = basePathOf(baseUrl);
const relative = requestPath.startsWith(basePath) ? requestPath.slice(basePath.length - 1) : requestPath;
return urlJoin(baseUrl, relative);
}
module.exports = { basePathOf, underBasePath, requestUri };
+55
View File
@@ -0,0 +1,55 @@
const { test, expect, describe } = require('bun:test');
const { basePathOf, underBasePath, requestUri } = require('./base-path');
describe('basePathOf — the path the backend is served under', () => {
test('a backend owning its origin is served at the root, with or without a trailing slash', () => {
expect(basePathOf('http://localhost:3003')).toBe('/');
expect(basePathOf('http://localhost:3003/')).toBe('/');
});
test('a backend sharing the front end origin is served under its path, always ending with a slash', () => {
expect(basePathOf('https://festipod.dev.reconnexion.coop/api/')).toBe('/api/');
expect(basePathOf('https://festipod.dev.reconnexion.coop/api')).toBe('/api/');
});
test('an unset base URL reads as the root', () => {
expect(basePathOf(undefined)).toBe('/');
expect(basePathOf('')).toBe('/');
});
});
describe('underBasePath — where a route is mounted', () => {
test('at the root, a route keeps its path', () => {
expect(underBasePath('/', '/catalog')).toBe('/catalog');
expect(underBasePath('/', '/as/actor/app')).toBe('/as/actor/app');
});
test('under a base path, a route is prefixed by it, without a doubled slash', () => {
expect(underBasePath('/api/', '/catalog')).toBe('/api/catalog');
expect(underBasePath('/api/', 'as/actor/app')).toBe('/api/as/actor/app');
});
});
describe('requestUri — the URI a request was made to', () => {
test('at the root, the request path is appended to the origin', () => {
expect(requestUri('http://localhost:3003', '/as/actor/app')).toBe('http://localhost:3003/as/actor/app');
expect(requestUri('http://localhost:3003/', '/app')).toBe('http://localhost:3003/app');
});
test('under a base path, the raw request path already carries it and is not prefixed twice', () => {
expect(requestUri('https://festipod.dev.reconnexion.coop/api/', '/api/as/actor/app')).toBe(
'https://festipod.dev.reconnexion.coop/api/as/actor/app'
);
expect(requestUri('https://festipod.dev.reconnexion.coop/api', '/api/app')).toBe(
'https://festipod.dev.reconnexion.coop/api/app'
);
});
test('the base URL is kept exactly as written — the same URI the backend mints its actor under', () => {
const urlJoin = require('url-join');
for (const baseUrl of ['http://localhost:80', 'https://Festipod.Example/api/', 'https://host:443/api']) {
const path = `${basePathOf(baseUrl)}as/actor/app`;
expect(requestUri(baseUrl, path)).toBe(urlJoin(baseUrl, 'as/actor/app'));
}
});
});
+172
View File
@@ -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
};
+173
View File
@@ -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']);
});
});
+222
View File
@@ -0,0 +1,222 @@
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',
mixins: [PodResourcesHandlerMixin],
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(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: 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: {
/**
* 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 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 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}`);
}
}
}
};
+429
View File
@@ -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');
});
});
+52
View File
@@ -0,0 +1,52 @@
const urlJoin = require('url-join');
const CONFIG = require('../config/config');
const { requestUri } = require('./base-path');
// Serves the app actor at its canonical uri (`/as/actor/app`) with a Solid-OIDC dual
// personality, discriminated by the request's `JsonLdContext` header — same contract as
// the `/app` route (see app.service.js for the full story), but implemented as a
// standalone service with no dependency on the `app` service.
//
// WHY A SEPARATE SERVICE: the Solid-OIDC client_id IS the actor uri (the pod provider's
// auth-agent only returns the `Link: rel="interop:registeredAgent"` header when the
// requesting app's identity — the token's azp, i.e. this client_id — matches the
// registration's `registeredAgent`, which the pod stores under the actor uri). The
// adapter therefore fetches THIS path as the client metadata document. Routing the path
// to `app.get` deadlocked boot: `@activitypods/app`'s `awaitCreateComplete` polls the
// actor over HTTP while the `app` service is still starting, and an unregistered action
// 503s the poll it waits on. This service has no `started()` work and no dependencies,
// so it is routable long before the `app` service exists.
module.exports = {
name: 'client-id-doc',
actions: {
get: {
async handler(ctx) {
const req = ctx.params && ctx.params.$req;
const headers = (req && req.headers) || {};
const jsonLdContextKey = Object.keys(headers).find(key => key.toLowerCase() === 'jsonldcontext');
const isOidcClientIdDocument =
jsonLdContextKey !== undefined && String(headers[jsonLdContextKey]).includes('solid/oidc-context.jsonld');
if (isOidcClientIdDocument) {
// The client_id must equal the fetched URI exactly. `req.url` is rewritten to
// the alias remainder by moleculer-web; the raw path, base path included, survives
// in `originalUrl`.
const requestPath = String((req && (req.originalUrl || req.url)) || '').split('?')[0];
return {
'@context': ['https://www.w3.org/ns/solid/oidc-context.jsonld'],
client_id: requestPath ? requestUri(CONFIG.HOME_URL, requestPath) : urlJoin(CONFIG.HOME_URL, 'as/actor/app'),
redirect_uris: [urlJoin(CONFIG.FRONT_URL, 'login')],
client_name: CONFIG.APP_NAME,
client_uri: CONFIG.FRONT_URL
};
}
return await ctx.call('ldp.resource.get', {
resourceUri: urlJoin(CONFIG.HOME_URL, 'as/actor/app'),
accept: 'application/ld+json',
webId: 'system'
});
}
}
}
};
+45
View File
@@ -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([]);
});
});
+45
View File
@@ -0,0 +1,45 @@
const urlJoin = require('url-join');
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],
settings: {
shapeTreeUri: urlJoin(CONFIG.SHAPE_REPOSITORY_URL, 'shapetrees/as/Profile'),
attachPredicate: 'http://activitypods.org/ns/core#contacts',
collectionOptions: {
ordered: false,
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
}
};
@@ -0,0 +1,8 @@
const { AuthAccountService } = require('@semapps/auth');
const { TripleStoreAdapter } = require('@semapps/triplestore');
const CONFIG = require('../../config/config');
module.exports = {
mixins: [AuthAccountService],
adapter: new TripleStoreAdapter({ type: 'AuthAccount', dataset: CONFIG.AUTH_ACCOUNTS_DATASET_NAME })
};
+92
View File
@@ -0,0 +1,92 @@
const path = require('path');
const { CoreService } = require('@semapps/core');
const { apods, notify, interop, oidc, skos } = require('@semapps/ontologies');
const CONFIG = require('../../config/config');
const { createPublicSurfaceGate } = require('../public-surface');
const { basePathOf, underBasePath } = require('../base-path');
// Festipod's own routes live under the base path, as SemApps' do (see base-path.js).
const BASE_PATH = basePathOf(CONFIG.HOME_URL);
module.exports = {
mixins: [CoreService],
settings: {
baseUrl: CONFIG.HOME_URL,
baseDir: path.resolve(__dirname, '../..'),
triplestore: {
url: CONFIG.SPARQL_ENDPOINT,
user: CONFIG.JENA_USER,
password: CONFIG.JENA_PASSWORD,
mainDataset: CONFIG.MAIN_DATASET
},
ontologies: [apods, notify, interop, oidc, skos],
activitypub: {
queueServiceUrl: CONFIG.QUEUE_SERVICE_URL
},
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: [createPublicSurfaceGate(BASE_PATH)],
routes: [
{
path: underBasePath(BASE_PATH, '/app'),
aliases: {
'GET /': {
action: 'app.get',
// `app.get` must see the JsonLdContext request header to answer the Solid-OIDC
// Client ID Document fetch; moleculer-web only exposes the raw request via
// ctx.params.$req when the alias opts in.
passReqResToParams: true
}
},
mappingPolicy: 'restrict',
authentication: false,
authorization: false,
bodyParsers: {
json: true
}
},
{
path: underBasePath(BASE_PATH, '/as/actor/app'),
aliases: {
'GET /': {
// Served by a standalone service (not `app.get`): the Solid-OIDC client_id
// IS the actor uri, and routing this to `app.get` deadlocked boot (the app
// service's own startup polls this path over HTTP). See client-id-doc.service.js.
action: 'client-id-doc.get',
passReqResToParams: true
}
},
mappingPolicy: 'restrict',
authentication: false,
authorization: false,
bodyParsers: {
json: true
}
},
{
path: underBasePath(BASE_PATH, '/catalog'),
aliases: {
'GET /': 'catalog.discover'
},
mappingPolicy: 'restrict',
authentication: false,
authorization: false,
bodyParsers: {
json: true
}
}
]
},
webacl: false,
void: false,
// No public SPARQL endpoint. CoreService mounts one at `/sparql` unless this is `false`, and
// with `webacl: false` it answers anonymous queries over the whole `settings` dataset — the
// app actor's key, the webhook listener URLs, the catalog store and, locally, the pod
// provider's accounts (observed 2026-10-12). Nothing in Festipod queries it.
sparqlEndpoint: false
}
};
+30
View File
@@ -0,0 +1,30 @@
const urlJoin = require('url-join');
const { NodeinfoService } = require('@semapps/nodeinfo');
const CONFIG = require('../../config/config');
module.exports = {
mixins: [NodeinfoService],
settings: {
baseUrl: CONFIG.HOME_URL,
software: {
name: 'festipod'
},
protocols: ['activitypub'],
metadata: {
frontend_url: CONFIG.FRONT_URL,
login_url: CONFIG.FRONT_URL && urlJoin(CONFIG.FRONT_URL, 'login'),
logout_url: CONFIG.FRONT_URL && urlJoin(CONFIG.FRONT_URL, 'login?logout=true')
}
},
actions: {
async getUsersCount(ctx) {
const containerUri = await ctx.call('app-registrations.getContainerUri');
const registrationsUris = await ctx.call('ldp.container.getUris', { containerUri });
return {
total: registrationsUris.length,
activeHalfYear: registrationsUris.length,
activeMonth: registrationsUris.length
};
}
}
};
@@ -0,0 +1,11 @@
const { TripleStoreAdapter } = require('@semapps/triplestore');
const { NotificationsListenerService } = require('@semapps/solid');
const CONFIG = require('../../config/config');
module.exports = {
mixins: [NotificationsListenerService],
adapter: new TripleStoreAdapter({ type: 'WebhookChannelListener', dataset: CONFIG.AUTH_ACCOUNTS_DATASET_NAME }),
settings: {
baseUrl: CONFIG.HOME_URL
}
};
+3
View File
@@ -0,0 +1,3 @@
const { ProxyService } = require('@semapps/crypto');
module.exports = ProxyService;
+236
View File
@@ -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
};
+266
View File
@@ -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}`);
});
});
+108
View File
@@ -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 };
+156
View File
@@ -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]]);
});
});
+97
View File
@@ -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 };
+128
View File
@@ -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));
}
});
+146
View File
@@ -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
};
+96
View File
@@ -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);
});
});
+60
View File
@@ -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 };
+349
View File
@@ -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
});
});
+99
View File
@@ -0,0 +1,99 @@
// 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.
//
// WHERE. The paths above are relative to the backend's base path (`/` locally, `/api/` on Coolify —
// see base-path.js), except the two `/.well-known` ones, which SemApps mounts at the origin root
// whatever the base path. A path outside the base path is refused like any other.
//
// Everything else is refused, notably: `/key` (private keys), `/` and every other container listing,
// `/interop/*-registration|*-grant`, `/sparql` (also unmounted, `core.service.js`), `/proxy` (the
// signing proxy — the app uses it internally only), `/.well-known/webfinger` (unused), the app
// actor's other collections, and every write through the LDP routes.
// moleculer-web turns a thrown error's `code` into the HTTP status (it is not a direct dependency).
const { MoleculerClientError } = require('moleculer').Errors;
// One path segment as the backend mints them (UUIDs): no dot, so no `.`/`..` segment; no `%`, so no
// encoded separator or traversal; no empty segment.
const ID = '[A-Za-z0-9][A-Za-z0-9_-]*';
const READ = ['GET', 'HEAD'];
const PUBLIC_ENDPOINTS = [
{ methods: READ, path: /^\/app$/ },
{ methods: READ, path: /^\/as\/actor\/app$/ },
{ methods: READ, path: /^\/catalog$/ },
{ methods: READ, path: new RegExp(`^/public-key/${ID}$`) },
{ methods: READ, path: new RegExp(`^/interop/access-need-group/${ID}$`) },
{ methods: READ, path: new RegExp(`^/interop/access-need/${ID}$`) },
{ methods: READ, path: /^\/nodeinfo\/2\.1$/ },
{ methods: ['POST'], path: /^\/as\/actor\/app\/inbox$/ },
{ methods: ['POST'], path: new RegExp(`^/\\.webhooks/${ID}$`) }
];
/** The endpoints at the origin root, whatever the base path. */
const ROOT_ENDPOINTS = [
{ methods: READ, path: /^\/\.well-known\/context\.jsonld$/ },
{ methods: READ, path: /^\/\.well-known\/nodeinfo$/ }
];
/**
* isPublicRequest(method, path, basePath = '/') → boolean. True only when `method` (upper case) on
* `path` (the request path without its query string, as the gateway routes it) is in `ROOT_ENDPOINTS`,
* or lies under `basePath` (as `basePathOf` returns it) and, relative to it, is in `PUBLIC_ENDPOINTS`.
*/
function isPublicRequest(method, path, basePath = '/') {
if (typeof method !== 'string' || typeof path !== 'string') return false;
const verb = method.toUpperCase();
const listed = (endpoints, p) => endpoints.some(endpoint => endpoint.methods.includes(verb) && endpoint.path.test(p));
if (listed(ROOT_ENDPOINTS, path)) return true;
if (!path.startsWith(basePath)) return false;
return listed(PUBLIC_ENDPOINTS, path.slice(basePath.length - 1));
}
/** The path the gateway routes on: moleculer-web's `parsedUrl` (query stripped, trailing `/` trimmed). */
function routedPathOf(req) {
if (typeof req.parsedUrl === 'string') return req.parsedUrl;
const raw = String(req.originalUrl || req.url || '').split('?')[0];
return raw.length > 1 && raw.endsWith('/') ? raw.slice(0, -1) : raw;
}
/**
* createPublicSurfaceGate(basePath = '/') → publicSurfaceGate(req, res, next), a moleculer-web global
* middleware (`api.use`, which the gateway puts in front of every route, including the ones services
* add at runtime). Passes a public request (`isPublicRequest` under `basePath`) through; answers any
* other with 404 before authentication or any action runs.
*/
function createPublicSurfaceGate(basePath = '/') {
return function publicSurfaceGate(req, res, next) {
const method = req.method === 'OPTIONS' ? req.headers && req.headers['access-control-request-method'] : req.method;
if (isPublicRequest(method, routedPathOf(req), basePath)) return next();
return next(new MoleculerClientError('Not found', 404, 'NOT_FOUND'));
};
}
module.exports = { isPublicRequest, createPublicSurfaceGate, PUBLIC_ENDPOINTS, ROOT_ENDPOINTS };
+242
View File
@@ -0,0 +1,242 @@
const path = require('path');
const { test, expect, describe, afterEach } = require('bun:test');
const { ServiceBroker } = require('moleculer');
const { isPublicRequest, createPublicSurfaceGate } = require('./public-surface');
const { basePathOf } = require('./base-path');
const UUID = 'd9b5f8ca-b2e2-41eb-af83-64e5ef1d4b20';
describe('the core service mounts the gate in front of every route', () => {
test('core.service.js wires the gate, for its own base path, as a global gateway middleware, and no SPARQL endpoint', () => {
const core = require('./core/core.service.js');
const CONFIG = require('../config/config');
const base = basePathOf(CONFIG.HOME_URL);
expect(core.settings.api.use).toHaveLength(1);
const [gate] = core.settings.api.use;
expect(gate.name).toBe('publicSurfaceGate');
let passed;
gate({ method: 'GET', parsedUrl: `${base}catalog`, headers: {} }, {}, err => (passed = err === undefined));
expect(passed).toBe(true);
gate({ method: 'GET', parsedUrl: `${base}key`, headers: {} }, {}, err => (passed = err === undefined));
expect(passed).toBe(false);
expect(core.settings.sparqlEndpoint).toBe(false);
});
test("core.service.js mounts Festipod's own routes under its base path", () => {
const core = require('./core/core.service.js');
const CONFIG = require('../config/config');
const base = basePathOf(CONFIG.HOME_URL);
expect(core.settings.api.routes.map(route => route.path)).toEqual([`${base}app`, `${base}as/actor/app`, `${base}catalog`]);
});
});
describe('isPublicRequest — the allow-list', () => {
test('every endpoint a pod or the Festipod front end calls is public', () => {
for (const [method, p] of [
['GET', '/app'],
['GET', '/as/actor/app'],
['GET', '/catalog'],
['GET', `/public-key/${UUID}`],
['HEAD', `/public-key/${UUID}`],
['GET', `/interop/access-need-group/${UUID}`],
['GET', `/interop/access-need/${UUID}`],
['GET', '/.well-known/context.jsonld'],
['GET', '/.well-known/nodeinfo'],
['GET', '/nodeinfo/2.1'],
['POST', '/as/actor/app/inbox'],
['POST', `/.webhooks/${UUID}`]
]) {
expect(`${method} ${p} ${isPublicRequest(method, p)}`).toBe(`${method} ${p} true`);
}
});
test("the app's private keys, its registrations and grants, every listing and the SPARQL endpoint are refused", () => {
for (const p of [
'/key',
`/key/${UUID}`,
'/', // the root container: lists every container of the settings dataset
'/public-key', // container listing
'/interop',
`/interop/application-registration/${UUID}`,
`/interop/access-grant/${UUID}`,
`/interop/data-grant/${UUID}`,
'/interop/access-need',
'/sparql',
'/as/actor/app/outbox',
'/as/actor/app/inbox', // GET: nobody reads the app's inbox
'/as/actor/app/followers',
'/as/actor',
'/.well-known/webfinger',
`/.webhooks/${UUID}`, // GET
'/proxy'
]) {
expect(`GET ${p} ${isPublicRequest('GET', p)}`).toBe(`GET ${p} false`);
}
});
test('every write outside the two delivery endpoints is refused', () => {
for (const method of ['POST', 'PUT', 'PATCH', 'DELETE']) {
for (const p of ['/app', '/catalog', '/as/actor/app', `/public-key/${UUID}`, `/interop/access-need/${UUID}`, '/key', '/', '/sparql', '/proxy', '/as/actor/app/outbox']) {
expect(`${method} ${p} ${isPublicRequest(method, p)}`).toBe(`${method} ${p} false`);
}
}
expect(isPublicRequest('PUT', `/.webhooks/${UUID}`)).toBe(false);
expect(isPublicRequest('DELETE', '/as/actor/app/inbox')).toBe(false);
});
test('a path that only resembles a public one is refused (traversal, encoding, extra segments, case)', () => {
for (const p of [
`/public-key/../key/${UUID}`,
'/public-key/..',
`/public-key/%2e%2e/key/${UUID}`,
`/public-key/${UUID}%2F..%2Fkey`,
`/public-key/${UUID}/extra`,
`//public-key/${UUID}`,
`/Public-Key/${UUID}`,
'/app/',
'/apps',
'/catalog/x',
`/interop/access-need/${UUID}/../../key`,
'/.well-known/context.jsonldx'
]) {
expect(`${p} ${isPublicRequest('GET', p)}`).toBe(`${p} false`);
}
expect(isPublicRequest(undefined, '/app')).toBe(false);
expect(isPublicRequest('GET', undefined)).toBe(false);
});
});
describe('isPublicRequest — under a base path (the Coolify layout: the backend at /api/ on the front end origin)', () => {
const BASE = '/api/';
test('every public endpoint is public under the base path, the /.well-known ones at the origin root', () => {
for (const [method, p] of [
['GET', '/api/app'],
['GET', '/api/as/actor/app'],
['GET', '/api/catalog'],
['GET', `/api/public-key/${UUID}`],
['GET', `/api/interop/access-need-group/${UUID}`],
['GET', `/api/interop/access-need/${UUID}`],
['GET', '/api/nodeinfo/2.1'],
['POST', '/api/as/actor/app/inbox'],
['POST', `/api/.webhooks/${UUID}`],
['GET', '/.well-known/context.jsonld'],
['GET', '/.well-known/nodeinfo']
]) {
expect(`${method} ${p} ${isPublicRequest(method, p, BASE)}`).toBe(`${method} ${p} true`);
}
});
test('the same endpoints outside the base path, and the /.well-known ones under it, are refused', () => {
for (const [method, p] of [
['GET', '/app'],
['GET', '/as/actor/app'],
['GET', '/catalog'],
['GET', `/public-key/${UUID}`],
['POST', '/as/actor/app/inbox'],
['POST', `/.webhooks/${UUID}`],
['GET', '/api/.well-known/context.jsonld'],
['GET', '/api/.well-known/nodeinfo'],
['GET', '/api'],
['GET', '/apiapp'],
['GET', `/apipublic-key/${UUID}`]
]) {
expect(`${method} ${p} ${isPublicRequest(method, p, BASE)}`).toBe(`${method} ${p} false`);
}
});
test('what is refused at the root is refused under the base path', () => {
for (const p of ['/', '/key', `/key/${UUID}`, '/sparql', '/proxy', `/interop/access-grant/${UUID}`, '/as/actor/app/outbox', `/public-key/../key/${UUID}`, `//public-key/${UUID}`]) {
expect(`GET /api${p} ${isPublicRequest('GET', `/api${p}`, BASE)}`).toBe(`GET /api${p} false`);
}
expect(isPublicRequest('PUT', '/api/catalog', BASE)).toBe(false);
expect(isPublicRequest('POST', '/api/app', BASE)).toBe(false);
});
});
describe('publicSurfaceGate — the middleware', () => {
const publicSurfaceGate = createPublicSurfaceGate('/');
const run = req => {
let passed;
publicSurfaceGate(req, {}, err => {
passed = err === undefined ? true : err;
});
return passed;
};
test('passes a public request, answers 404 to any other (before authentication runs)', () => {
expect(run({ method: 'GET', parsedUrl: '/catalog', headers: {} })).toBe(true);
const refusal = run({ method: 'GET', parsedUrl: '/key', headers: {} });
expect(refusal).toBeInstanceOf(Error);
expect(refusal.code).toBe(404);
});
test('a CORS preflight is judged on the method it announces', () => {
expect(run({ method: 'OPTIONS', parsedUrl: '/catalog', headers: { 'access-control-request-method': 'GET' } })).toBe(true);
expect(run({ method: 'OPTIONS', parsedUrl: '/catalog', headers: { 'access-control-request-method': 'DELETE' } }).code).toBe(404);
expect(run({ method: 'OPTIONS', parsedUrl: '/catalog', headers: {} }).code).toBe(404);
});
test('without the gateway-parsed path, the raw URL is used without its query string', () => {
expect(run({ method: 'GET', originalUrl: '/catalog?x=1', headers: {} })).toBe(true);
expect(run({ method: 'GET', originalUrl: '/key?x=/catalog', headers: {} }).code).toBe(404);
});
});
// The mechanism, end to end: a real moleculer-web gateway (the version CoreService uses), with the
// REAL @semapps/ldp catch-all route added at runtime as the LDP service adds it, in front of a stand-in
// `ldp.api` whose every action answers as if it served the settings dataset. Status codes only.
describe('the gate covers routes added at runtime — the LDP catch-all that served the private key', () => {
const semappsCore = path.dirname(require.resolve('@semapps/core'));
const ApiGateway = require(require.resolve('moleculer-web', { paths: [semappsCore] }));
const ldpDir = path.dirname(require.resolve('@semapps/ldp/package.json', { paths: [semappsCore] }));
const { default: getCatchAllRoute } = require(path.join(ldpDir, 'dist/routes/getCatchAllRoute.js'));
let broker;
afterEach(async () => {
if (broker) await broker.stop();
broker = undefined;
});
async function startGateway(use) {
broker = new ServiceBroker({ logger: false });
const answer = () => 'settings dataset content';
broker.createService({
name: 'ldp.api',
actions: { get: answer, head: answer, post: answer, put: answer, patch: answer, delete: answer }
});
const gateway = broker.createService({
mixins: [ApiGateway],
settings: { port: 0, ip: '127.0.0.1', routes: [], ...(use ? { use } : {}) },
methods: {
// CoreService's: no credentials → anonymous.
authenticate(ctx) {
ctx.meta.webId = 'anon';
return Promise.resolve(null);
}
}
});
await broker.start();
await broker.call('api.addRoute', { route: getCatchAllRoute('/', false) });
return `http://127.0.0.1:${gateway.server.address().port}`;
}
const status = async (base, method, p) => (await fetch(base + p, { method })).status;
test('BASELINE (no gate): the catch-all serves the key container to an anonymous caller', async () => {
const base = await startGateway(undefined);
expect(await status(base, 'GET', '/key')).toBe(200);
});
test('with the gate: the key container, the root and any write are 404; a public key is served', async () => {
const base = await startGateway([createPublicSurfaceGate('/')]);
expect(await status(base, 'GET', '/key')).toBe(404);
expect(await status(base, 'GET', `/key/${UUID}`)).toBe(404);
expect(await status(base, 'GET', '/')).toBe(404);
expect(await status(base, 'GET', `/interop/data-grant/${UUID}`)).toBe(404);
expect(await status(base, 'PUT', `/public-key/${UUID}`)).toBe(404);
expect(await status(base, 'DELETE', `/interop/access-need/${UUID}`)).toBe(404);
expect(await status(base, 'GET', `/public-key/${UUID}`)).toBe(200);
expect(await status(base, 'GET', `/interop/access-need/${UUID}`)).toBe(200);
});
});
+179
View File
@@ -0,0 +1,179 @@
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 attendees collection of a meeting point of `actorUri`'s pod, from the STORED meeting point.
* It is attached asynchronously when the meeting point is created (attendees service), so a
* Join/Leave processed in that window sees none: the meeting point is read again a bounded number
* of times until it names one; only then give up. The collection must lie in the same pod.
*/
async attendeesCollectionOf(ctx, meetingPoint, meetingPointUri, actorUri) {
let collectionUri = linkOf(meetingPoint, 'apods:attendees', APODS_ATTENDEES);
for (let attempt = 0; !collectionUri && attempt < 3; attempt++) {
await new Promise(resolve => setTimeout(resolve, 500));
try {
const { 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-read ${meetingPointUri} while waiting for its attendees collection: ${e.message}`);
}
}
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: 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 });
const contactsUri = actor && linkOf(actor, 'apods:contacts', APODS_CONTACTS);
if (!contactsUri) return;
if (!isInActorPod(contactsUri, actorUri)) {
this.logger.warn(`${actorUri} names a contacts collection outside its pod: no contact added`);
return;
}
const contactsGroupUri = await ctx.call('pod-wac-groups.getUriFromCollectionUri', { collectionUri: contactsUri });
await ctx.call('pod-wac-groups.addMember', { groupUri: contactsGroupUri, memberUri: organizerUri, actorUri });
},
{ objectType: 'Event', objectIn: 'any' }
),
// The organizer's inbox: `actorUri` is the organizer; the meeting point must lie in her pod.
onReceive: verifiedReceive(
'Join',
async function (ctx, { activity, emitterUri, object, objectUri }, actorUri) {
await this.updateAttendance(ctx, { change: 'join', memberUri: emitterUri, meetingPointUri: objectUri, meetingPoint: object, activity, actorUri });
},
{ objectType: 'Event' }
)
},
leave: {
match: neverThrowingMatcher(function (activity, fetcher) {
return matchActivity(LEAVE_PATTERN, activity, fetcher);
}),
onReceive: verifiedReceive(
'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();
});
});
+2 -2
View File
@@ -6,7 +6,7 @@
"src/modules/*/steps/**/*.ts"
],
"paths": ["src/modules/*/features/**/*.feature"],
"tags": "not @wip and not @humain",
"tags": "not @wip and not @humain and not @suspendu and not @e2e-activitypods",
"format": [
"progress-bar",
"json:reports/cucumber-report.json",
@@ -16,6 +16,6 @@
"formatOptions": {
"snippetInterface": "async-await"
},
"strict": false
"strict": true
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"default": {
"import": [
"src/shared/support/**/*.ts",
"src/shared/steps/**/*.ts",
"src/modules/*/steps/**/*.ts"
],
"paths": ["src/modules/event/features/e2e-activitypods.feature"],
"tags": "@e2e-activitypods and not @suspendu",
"format": ["progress-bar"],
"language": "fr",
"formatOptions": {
"snippetInterface": "async-await"
},
"strict": true
}
}
+96
View File
@@ -0,0 +1,96 @@
# Docker Compose definition used by Coolify to build and run Festipod straight from the
# git repository: one stack for the branch it follows (the test environment). The images
# are built on the Coolify server from the two Dockerfiles of this repository (the app at
# the root, the backend in /backend); nothing is pulled from a registry.
#
# Before `docker compose build` and `up`, Coolify writes a .env file next to this file
# with the variables defined in its UI plus generated ones. The generated ones used here:
# SERVICE_FQDN_BACKEND / SERVICE_FQDN_FRONTEND host of each service, as set in its
# Domains field
# SERVICE_NAME_BACKEND / SERVICE_NAME_FESTIPOD_REDIS service names (Coolify suffixes
# them with "-pr-<n>" in a preview)
# A value starting with `${` becomes an editable variable in Coolify (with its default);
# the other `${...}` references are only interpolated by docker compose.
#
# One base URL per environment, as for the other ActivityPods apps on this Coolify (it is
# what lets a pull request preview live on a single host): the frontend's domain serves the
# app, and the backend is served on the same host under `/api` and `/.well-known` — its
# Domains field is `https://<host>/api,https://<host>/.well-known`. The proxy must pass those
# paths through unchanged — Coolify's "Strip Prefixes" OFF, as on the other apps: the backend
# mounts its routes under `/api` itself, from its base URL, and keeps its `/.well-known` ones
# at the origin root (backend/services/base-path.js). With the prefixes stripped, every
# backend request would be refused (404).
#
# The backend registers its app actor in its main dataset on the first boot, with the
# domain it had at that time: set the domains in Coolify BEFORE the first deployment. If
# they change later, delete the two datasets in Fuseki and redeploy.
#
# Fuseki is the one shared with the other apps (shared-infra, in the "infra" project): the
# backend joins the "coolify" network to reach it, with its own pair of datasets. Redis
# runs inside the stack (on the stack's private network only, under a name that cannot be
# confused with the shared one).
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
restart: always
depends_on:
- festipod-redis
expose:
- '3003'
# Coolify adds the stack's own network to every service; only the backend also needs
# the shared one, where Fuseki lives.
networks:
- coolify
environment:
SEMAPPS_APP_NAME: ${APP_NAME:-Festipod}
SEMAPPS_APP_DESCRIPTION: ${APP_DESCRIPTION:-Points de rencontre sur les événements publics}
SEMAPPS_APP_LANG: ${APP_LANG:-en,fr}
SEMAPPS_HOME_URL: https://${SERVICE_FQDN_BACKEND}/api/
SEMAPPS_FRONT_URL: https://${SERVICE_FQDN_FRONTEND}
SEMAPPS_PORT: '3003'
SEMAPPS_SHAPE_REPOSITORY_URL: ${SHAPE_REPOSITORY_URL:-https://shapes.activitypods.org/}
SEMAPPS_SPARQL_ENDPOINT: ${SPARQL_ENDPOINT}
SEMAPPS_JENA_USER: ${JENA_USER:-admin}
SEMAPPS_JENA_PASSWORD: ${JENA_PASSWORD}
# A pair of datasets named after DATASET_PREFIX and the backend's service name, e.g.
# festipod-backend / settings-festipod-backend (…-backend-pr-<n> in a preview). Two
# non-preview stacks on the same Fuseki would share them: give the second its own
# DATASET_PREFIX (or MAIN_DATASET / SETTINGS_DATASET).
SEMAPPS_MAIN_DATASET: ${MAIN_DATASET:-${DATASET_PREFIX:-festipod}-${SERVICE_NAME_BACKEND}}
SEMAPPS_AUTH_ACCOUNTS_DATASET_NAME: ${SETTINGS_DATASET:-settings-${DATASET_PREFIX:-festipod}-${SERVICE_NAME_BACKEND}}
SEMAPPS_REDIS_CACHE_URL: redis://${SERVICE_NAME_FESTIPOD_REDIS}:6379/0
SEMAPPS_QUEUE_SERVICE_URL: redis://${SERVICE_NAME_FESTIPOD_REDIS}:6379/1
frontend:
build:
context: .
dockerfile: Dockerfile
restart: always
depends_on:
- backend
expose:
- '3000'
# Read by the server on every request to /festipod-config.json, not inlined at build
# time: changing one needs a restart, not a rebuild.
environment:
PORT: '3000'
VITE_BACKEND_URL: https://${SERVICE_FQDN_BACKEND}/api
# The Pod provider the login screen offers in one click; any other can be typed in.
VITE_POD_PROVIDER_URL: ${POD_PROVIDER_URL:-https://mypod.store}
festipod-redis:
image: redis:7-alpine
restart: always
command: ['redis-server', '--appendonly', 'yes']
volumes:
- redis-data:/data
volumes:
redis-data:
networks:
coolify:
external: true
+29
View File
@@ -0,0 +1,29 @@
services:
frontend:
build:
context: .
dockerfile: Dockerfile
ports:
- "4000:3000"
environment:
- PORT=3000
- NODE_ENV=production
- VITE_BACKEND_URL=${VITE_BACKEND_URL:-http://localhost:3003}
- VITE_BACKEND_CLIENT_ID=${VITE_BACKEND_CLIENT_ID:-}
- FESTIPOD_SHARED_WALLET_PASSWORD=${FESTIPOD_SHARED_WALLET_PASSWORD:-}
- FESTIPOD_SHARED_WALLET_FILE=${FESTIPOD_SHARED_WALLET_FILE:-}
- FESTIPOD_SHARED_WALLET_FILE_BASE64=${FESTIPOD_SHARED_WALLET_FILE_BASE64:-}
- FESTIPOD_AUTO_SEED=${FESTIPOD_AUTO_SEED:-}
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "3003:3003"
env_file:
- path: ./backend/.env
required: false
environment:
- SEMAPPS_HOME_URL=${SEMAPPS_HOME_URL:-http://localhost:3003}
- SEMAPPS_FRONT_URL=${SEMAPPS_FRONT_URL:-http://localhost:4000}
+5 -1
View File
@@ -9,8 +9,10 @@
"build": "bun run build.ts",
"test:cucumber": "bun run cucumber:run && bun run cucumber:report && bun run features:parse && bun run steps:extract",
"cucumber:run": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json",
"test:data": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json --tags @data",
"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",
@@ -23,6 +25,7 @@
"build-storybook": "storybook build"
},
"dependencies": {
"@activitypods/refine-providers": "^0.2.0",
"@ng-eventually/polyfill": "git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill",
"@ng-helpers/indexing": "git+https://gitea.reconnexion.apps.gueraud.net/Sylvain/ng-helpers.git#v1.0.1",
"@ng-org/alien-deepsignals": "0.1.2-alpha.11",
@@ -33,6 +36,7 @@
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@refinedev/core": "^5.0.12",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+299
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@activitypods/refine-providers':
specifier: ^0.2.0
version: 0.2.0(@refinedev/core@5.0.12(@tanstack/react-query@5.104.1(react@19.2.7))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(web-streams-polyfill@3.3.3)
'@ng-eventually/polyfill':
specifier: git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill
version: git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#a8d53010c227462cc9317e9be499c2100ca8d533&path:/packages/polyfill(@ng-org/alien-deepsignals@0.1.2-alpha.11(react@19.2.7))(@ng-org/orm@0.1.2-alpha.18(react@19.2.7))(@ng-org/shex-orm@0.1.2-alpha.8(typescript@6.0.3))(@ng-org/web@0.1.2-alpha.13)
@@ -38,6 +41,9 @@ importers:
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@refinedev/core':
specifier: ^5.0.12
version: 5.0.12(@tanstack/react-query@5.104.1(react@19.2.7))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
bun-plugin-tailwind:
specifier: ^0.1.2
version: 0.1.2(bun@1.3.14)
@@ -120,6 +126,24 @@ importers:
packages:
'@activitypods/refine-providers@0.2.0':
resolution: {integrity: sha512-iuhN19ogAUM9rxv+hQAKz8MSNAhYJe/rKIfm+Ykp1kiOBJxZ9B8tfS25AQx73QW+1N1z3mxW6sp+pdM1GxxR4A==}
peerDependencies:
'@ant-design/icons': ^5.5.1
'@refinedev/core': ^5.0.12
antd: ^5.23.0
react: ^18.0.0 || ^19.0.0
react-router: ^7.0.2
peerDependenciesMeta:
'@ant-design/icons':
optional: true
antd:
optional: true
react:
optional: true
react-router:
optional: true
'@adobe/css-tools@4.5.0':
resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
@@ -265,6 +289,10 @@ packages:
'@cucumber/tag-expressions@9.1.0':
resolution: {integrity: sha512-bvHjcRFZ+J1TqIa9eFNO1wGHqwx4V9ZKV3hYgkuK/VahHx73uiP4rKV3JVrvWSMrwrFvJG6C8aEwnCWSvbyFdQ==}
'@digitalbazaar/http-client@3.4.1':
resolution: {integrity: sha512-Ahk1N+s7urkgj7WvvUND5f8GiWEPfUw0D41hdElaqLgu8wZScI8gdI0q+qWw5N1d35x7GCRH2uk9mi+Uzo9M3g==}
engines: {node: '>=14.0'}
'@emnapi/core@1.11.2':
resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==}
@@ -439,6 +467,10 @@ packages:
cpu: [x64]
os: [win32]
'@fastify/busboy@2.1.1':
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
engines: {node: '>=14'}
'@floating-ui/core@1.8.0':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
@@ -1134,6 +1166,34 @@ packages:
'@rdfjs/types@1.1.2':
resolution: {integrity: sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg==}
'@refinedev/core@5.0.12':
resolution: {integrity: sha512-9y5Bi9Lb7XyJmM55b8rCeBTDRCBU41p47OymJldasLfrtpUm2EwI+27DjjNpHTOugymiZsIbLlPtHCPQIXBHcg==}
engines: {node: '>=20'}
peerDependencies:
'@tanstack/react-query': ^5.81.5
'@types/react': ^18.0.0 || ^19.0.0
'@types/react-dom': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@refinedev/devtools-internal@2.0.2':
resolution: {integrity: sha512-1YYizOW1lyy9ep8eQ7TcUPBooKXIlvzTLjLdDArsQwx7P33cn2uXdqM7So5VhlNFXhjOjAKFgrH5c1jleRF8Jg==}
engines: {node: '>=20'}
peerDependencies:
'@types/react': ^18.0.0 || ^19.0.0
'@types/react-dom': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@refinedev/devtools-shared@2.0.2':
resolution: {integrity: sha512-3cTjR1mEWn0tHFZBfPD5aVpBGLUhpAkfjqYCwKrijIicr1Utp/j0BqiPRnNqTf+W71HTng3znBpUhnR83u+tuA==}
engines: {node: '>=20'}
peerDependencies:
'@types/react': ^18.0.0 || ^19.0.0
'@types/react-dom': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@shexjs/parser@1.0.0-alpha.28':
resolution: {integrity: sha512-eeVeHq/2JG9X+3h7y+7EmuBSWWl2EMj/EQBLk5CTRx4W4hWDdjWczsY8RWwKjkIzLwUS1+G0aiAI1u5LHCZ2Rw==}
engines: {node: '>=0.10.0'}
@@ -1361,6 +1421,14 @@ packages:
'@swc/types@0.1.27':
resolution: {integrity: sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==}
'@tanstack/query-core@5.104.1':
resolution: {integrity: sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==}
'@tanstack/react-query@5.104.1':
resolution: {integrity: sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==}
peerDependencies:
react: ^18 || ^19
'@teppeis/multimaps@3.0.0':
resolution: {integrity: sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==}
engines: {node: '>=14'}
@@ -1549,6 +1617,10 @@ packages:
'@xtuc/long@4.2.2':
resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==}
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
acorn-import-phases@1.0.4:
resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==}
engines: {node: '>=10.13.0'}
@@ -1725,6 +1797,9 @@ packages:
caniuse-lite@1.0.30001805:
resolution: {integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==}
canonicalize@1.0.8:
resolution: {integrity: sha512-0CNTVCLZggSh7bc5VkX5WWPWO+cyZbNd07IHIsSXLia/eAq+r836hgk+8BKoEh7949Mda87VUOitx5OddVj64A==}
capital-case@1.0.4:
resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==}
@@ -1875,6 +1950,10 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
data-uri-to-buffer@4.0.1:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -2045,6 +2124,10 @@ packages:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
events@3.3.0:
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
engines: {node: '>=0.8.x'}
@@ -2061,6 +2144,10 @@ packages:
fast-uri@3.1.3:
resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==}
fetch-blob@3.2.0:
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
engines: {node: ^12.20 || >= 14.13}
figures@3.2.0:
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
engines: {node: '>=8'}
@@ -2102,6 +2189,10 @@ packages:
resolution: {integrity: sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA==}
engines: {node: '>= 0.12'}
formdata-polyfill@4.0.10:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'}
fs-extra@10.1.0:
resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==}
engines: {node: '>=12'}
@@ -2357,12 +2448,33 @@ packages:
jsonld2graphobject@0.0.5:
resolution: {integrity: sha512-5BqfXOq96+OBjjiJNG8gQH66pYt6hW88z2SJxdvFJo4XNoVMvqAcUz+JSm/KEWS5NLRnebApEzFrYP3HUiUmYw==}
jsonld@8.3.3:
resolution: {integrity: sha512-9YcilrF+dLfg9NTEof/mJLMtbdX1RJ8dbWtJgE00cMOIohb1lIyJl710vFiTaiHTl6ZYODJuBd32xFvUhmv3kg==}
engines: {node: '>=14'}
jwt-decode@3.1.2:
resolution: {integrity: sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
knuth-shuffle-seeded@1.0.6:
resolution: {integrity: sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==}
ky-universal@0.11.0:
resolution: {integrity: sha512-65KyweaWvk+uKKkCrfAf+xqN2/epw1IJDtlyCPxYffFCMR8u1sp2U65NtWpnozYfZxQ6IUzIlvUcw+hQ82U2Xw==}
engines: {node: '>=14.16'}
peerDependencies:
ky: '>=0.31.4'
web-streams-polyfill: '>=3.2.1'
peerDependenciesMeta:
web-streams-polyfill:
optional: true
ky@0.33.3:
resolution: {integrity: sha512-CasD9OCEQSFIam2U8efFK81Yeg8vNMTBUqtMOHlrcWQHqUX3HeCl9Dr31u4toV7emlH8Mymk5+9p0lL6mKb/Xw==}
engines: {node: '>=14.16'}
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
@@ -2377,6 +2489,9 @@ packages:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
lodash-es@4.18.1:
resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==}
lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -2402,6 +2517,10 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lru-cache@6.0.0:
resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
engines: {node: '>=10'}
lucide-react@0.545.0:
resolution: {integrity: sha512-7r1/yUuflQDSt4f1bpn5ZAocyIxcTyVyBBChSVtBKn5M+392cPmI5YJMWOJKk/HUWGm5wg83chlAZtCcGbEZtw==}
peerDependencies:
@@ -2544,6 +2663,11 @@ packages:
node-abort-controller@3.1.1:
resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==}
node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
@@ -2553,6 +2677,10 @@ packages:
encoding:
optional: true
node-fetch@3.3.2:
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
node-releases@2.0.51:
resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==}
engines: {node: '>=18'}
@@ -2564,6 +2692,9 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
oauth4webapi@2.17.0:
resolution: {integrity: sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -2608,6 +2739,9 @@ packages:
resolution: {integrity: sha512-4cy8M95ioIGolCoMmm2cMntGR1lPLEbOMzOKu8bzjuJP6JpzEMQcDHmh7hHLYGgob+nKe1YHFMaG4V59HQa89g==}
engines: {node: '>=0.10.0'}
papaparse@5.7.0:
resolution: {integrity: sha512-qBGxg/7Q3Kl9Wfhrz2Z74UnvnHTXLNG6jmKJFeBvP2+y4lV7So+7SR62+Zd47JvdrCkX+nDcnr0ObPzek/+6RA==}
param-case@3.0.4:
resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==}
@@ -2677,6 +2811,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
pluralize@8.0.0:
resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==}
engines: {node: '>=4'}
postcss-modules-extract-imports@3.1.0:
resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==}
engines: {node: ^10 || ^12 || >= 14}
@@ -2749,6 +2887,10 @@ packages:
resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
engines: {node: '>= 0.6'}
rdf-canonize@3.4.0:
resolution: {integrity: sha512-fUeWjrkOO0t1rg7B2fdyDTvngj+9RlUyL92vOdiB7c0FPguWVsniIMjEtHH+meLBO9rzkUlUzBVXgWrjI8P9LA==}
engines: {node: '>=12'}
rdf-data-factory@1.1.3:
resolution: {integrity: sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ==}
@@ -2909,6 +3051,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
setimmediate@1.0.5:
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
side-channel-list@1.0.1:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
@@ -3166,6 +3311,10 @@ packages:
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
undici@5.29.0:
resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==}
engines: {node: '>=14.0'}
unicorn-magic@0.4.0:
resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==}
engines: {node: '>=20'}
@@ -3242,10 +3391,17 @@ packages:
validate-npm-package-license@3.0.4:
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
warn-once@0.1.1:
resolution: {integrity: sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==}
watchpack@2.5.2:
resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==}
engines: {node: '>=10.13.0'}
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
@@ -3315,6 +3471,9 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yallist@4.0.0:
resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'}
@@ -3325,6 +3484,18 @@ packages:
snapshots:
'@activitypods/refine-providers@0.2.0(@refinedev/core@5.0.12(@tanstack/react-query@5.104.1(react@19.2.7))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7))(react@19.2.7)(web-streams-polyfill@3.3.3)':
dependencies:
'@refinedev/core': 5.0.12(@tanstack/react-query@5.104.1(react@19.2.7))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
http-link-header: 1.1.4
jsonld: 8.3.3(web-streams-polyfill@3.3.3)
jwt-decode: 3.1.2
oauth4webapi: 2.17.0
optionalDependencies:
react: 19.2.7
transitivePeerDependencies:
- web-streams-polyfill
'@adobe/css-tools@4.5.0': {}
'@babel/code-frame@7.29.7':
@@ -3557,6 +3728,14 @@ snapshots:
'@cucumber/tag-expressions@9.1.0': {}
'@digitalbazaar/http-client@3.4.1(web-streams-polyfill@3.3.3)':
dependencies:
ky: 0.33.3
ky-universal: 0.11.0(ky@0.33.3)(web-streams-polyfill@3.3.3)
undici: 5.29.0
transitivePeerDependencies:
- web-streams-polyfill
'@emnapi/core@1.11.2':
dependencies:
'@emnapi/wasi-threads': 1.2.2
@@ -3667,6 +3846,8 @@ snapshots:
'@esbuild/win32-x64@0.28.1':
optional: true
'@fastify/busboy@2.1.1': {}
'@floating-ui/core@1.8.0':
dependencies:
'@floating-ui/utils': 0.2.12
@@ -4208,6 +4389,41 @@ snapshots:
dependencies:
'@types/node': 26.1.1
'@refinedev/core@5.0.12(@tanstack/react-query@5.104.1(react@19.2.7))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@refinedev/devtools-internal': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@tanstack/react-query': 5.104.1(react@19.2.7)
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
lodash: 4.18.1
lodash-es: 4.18.1
papaparse: 5.7.0
pluralize: 8.0.0
qs: 6.15.3
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
tslib: 2.8.1
warn-once: 0.1.1
'@refinedev/devtools-internal@2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@refinedev/devtools-shared': 2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@tanstack/react-query': 5.104.1(react@19.2.7)
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
error-stack-parser: 2.1.4
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@refinedev/devtools-shared@2.0.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@tanstack/react-query': 5.104.1(react@19.2.7)
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
error-stack-parser: 2.1.4
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@shexjs/parser@1.0.0-alpha.28':
dependencies:
'@shexjs/util': 1.0.0-alpha.28
@@ -4483,6 +4699,13 @@ snapshots:
dependencies:
'@swc/counter': 0.1.3
'@tanstack/query-core@5.104.1': {}
'@tanstack/react-query@5.104.1(react@19.2.7)':
dependencies:
'@tanstack/query-core': 5.104.1
react: 19.2.7
'@teppeis/multimaps@3.0.0': {}
'@testing-library/dom@10.4.1':
@@ -4721,6 +4944,10 @@ snapshots:
'@xtuc/long@4.2.2': {}
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
acorn-import-phases@1.0.4(acorn@8.17.0):
dependencies:
acorn: 8.17.0
@@ -4891,6 +5118,8 @@ snapshots:
caniuse-lite@1.0.30001805: {}
canonicalize@1.0.8: {}
capital-case@1.0.4:
dependencies:
no-case: 3.0.4
@@ -5027,6 +5256,8 @@ snapshots:
csstype@3.2.3: {}
data-uri-to-buffer@4.0.1: {}
debug@4.4.3(supports-color@8.1.1):
dependencies:
ms: 2.1.3
@@ -5197,6 +5428,8 @@ snapshots:
esutils@2.0.3: {}
event-target-shim@5.0.1: {}
events@3.3.0: {}
fast-deep-equal@3.1.3: {}
@@ -5207,6 +5440,11 @@ snapshots:
fast-uri@3.1.3: {}
fetch-blob@3.2.0:
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 3.3.3
figures@3.2.0:
dependencies:
escape-string-regexp: 1.0.5
@@ -5266,6 +5504,10 @@ snapshots:
mime-types: 2.1.35
safe-buffer: 5.2.1
formdata-polyfill@4.0.10:
dependencies:
fetch-blob: 3.2.0
fs-extra@10.1.0:
dependencies:
graceful-fs: 4.2.11
@@ -5512,6 +5754,17 @@ snapshots:
transitivePeerDependencies:
- encoding
jsonld@8.3.3(web-streams-polyfill@3.3.3):
dependencies:
'@digitalbazaar/http-client': 3.4.1(web-streams-polyfill@3.3.3)
canonicalize: 1.0.8
lru-cache: 6.0.0
rdf-canonize: 3.4.0
transitivePeerDependencies:
- web-streams-polyfill
jwt-decode@3.1.2: {}
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -5520,6 +5773,16 @@ snapshots:
dependencies:
seed-random: 2.2.0
ky-universal@0.11.0(ky@0.33.3)(web-streams-polyfill@3.3.3):
dependencies:
abort-controller: 3.0.0
ky: 0.33.3
node-fetch: 3.3.2
optionalDependencies:
web-streams-polyfill: 3.3.3
ky@0.33.3: {}
lines-and-columns@1.2.4: {}
loader-runner@4.3.2: {}
@@ -5532,6 +5795,8 @@ snapshots:
dependencies:
p-locate: 4.1.0
lodash-es@4.18.1: {}
lodash.merge@4.6.2: {}
lodash.mergewith@4.6.2: {}
@@ -5552,6 +5817,10 @@ snapshots:
dependencies:
yallist: 3.1.1
lru-cache@6.0.0:
dependencies:
yallist: 4.0.0
lucide-react@0.545.0(react@19.2.7):
dependencies:
react: 19.2.7
@@ -5642,10 +5911,18 @@ snapshots:
node-abort-controller@3.1.1: {}
node-domexception@1.0.0: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
node-fetch@3.3.2:
dependencies:
data-uri-to-buffer: 4.0.1
fetch-blob: 3.2.0
formdata-polyfill: 4.0.10
node-releases@2.0.51: {}
normalize-package-data@8.0.0:
@@ -5658,6 +5935,8 @@ snapshots:
dependencies:
boolbase: 1.0.0
oauth4webapi@2.17.0: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -5738,6 +6017,8 @@ snapshots:
dependencies:
repeat-string: 1.6.1
papaparse@5.7.0: {}
param-case@3.0.4:
dependencies:
dot-case: 3.0.4
@@ -5800,6 +6081,8 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
pluralize@8.0.0: {}
postcss-modules-extract-imports@3.1.0(postcss@8.5.19):
dependencies:
postcss: 8.5.19
@@ -5866,6 +6149,10 @@ snapshots:
range-parser@1.3.0: {}
rdf-canonize@3.4.0:
dependencies:
setimmediate: 1.0.5
rdf-data-factory@1.1.3:
dependencies:
'@rdfjs/types': 1.1.2
@@ -6032,6 +6319,8 @@ snapshots:
semver@7.8.5: {}
setimmediate@1.0.5: {}
side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0
@@ -6265,6 +6554,10 @@ snapshots:
undici-types@8.3.0: {}
undici@5.29.0:
dependencies:
'@fastify/busboy': 2.1.1
unicorn-magic@0.4.0: {}
universalify@2.0.1: {}
@@ -6326,10 +6619,14 @@ snapshots:
spdx-correct: 3.2.0
spdx-expression-parse: 3.0.1
warn-once@0.1.1: {}
watchpack@2.5.2:
dependencies:
graceful-fs: 4.2.11
web-streams-polyfill@3.3.3: {}
webidl-conversions@3.0.1: {}
webidl-conversions@7.0.0: {}
@@ -6411,6 +6708,8 @@ snapshots:
yallist@3.1.1: {}
yallist@4.0.0: {}
yaml@2.9.0: {}
yup@1.7.1:
File diff suppressed because one or more lines are too long
+10212 -75
View File
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
/**
* Banked login-flow probe: drives the REAL local ActivityPods stack through the
* browser and asserts the whole app-install path — picker → pod login → consent →
* authenticated app → create event → create meeting point.
*
* WHY A SCRIPT, NOT A CUCUMBER STEP. The `@browser` smoke only checks the picker
* renders; the consent hop (an OIDC interaction on the pod frontend) and the
* app-registration round-trip are what actually proved the stack aligned. This
* runs it end-to-end and fails on any UNEXPECTED ≥400, so the regression is
* caught without a human driving the browser.
*
* ORIGIN. The OIDC redirect_uri is registered for `http://localhost:4000/login`
* 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 type { ApodsStackConfig } from '../src/shared/support/activitypodsStack';
import { driveLogin, installedContainer, loginWithRetry, type DriveContext } from './e2e-rights-matrix-support';
const strip = (u: string): string => u.replace(/\/+$/, '');
const BACKEND: string = strip(process.env.BACKEND_URL || 'http://localhost:3003');
const APP: string = strip(process.env.APP_URL || 'http://localhost:4000');
const 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';
const DEADLINE = Date.now() + 8 * 60 * 1000;
interface HttpError { url: string; status: number; method: string; body: string }
const redirects: string[] = [];
const errors4xx: HttpError[] = [];
const consoleErrors: 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 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);
function wire(page: Page): void {
let last = '';
page.on('framenavigated', f => {
if (f === page.mainFrame() && f.url() !== last) {
redirects.push(`${last || '(start)'} -> ${f.url()}`);
last = f.url();
}
});
page.on('response', r => {
if (r.status() >= 400) {
const method = r.request()?.method?.() ?? '?';
r.text()
.then(b => errors4xx.push({ url: r.url(), status: r.status(), method, body: b.slice(0, 400) }))
.catch(() => errors4xx.push({ url: r.url(), status: r.status(), method, body: '(unreadable)' }));
}
});
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 300)); });
page.on('pageerror', e => consoleErrors.push('pageerror: ' + String(e.message).slice(0, 300)));
}
async function createEvent(page: Page): Promise<boolean> {
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('Concert flow').catch(() => {});
const dates = page.locator('input[type="date"]');
if (await dates.count()) await dates.first().fill('2026-12-20').catch(() => {});
await page.getByRole('button', { name: /Suivant/ }).click().catch(() => {});
await page.waitForTimeout(1200);
const next = page.getByRole('button', { name: /Suivant/ });
if (await next.count()) await next.click().catch(() => {});
await page.waitForTimeout(1200);
await page.getByRole('button', { name: /Relayer l'événement/ }).click().catch(() => {});
await page.waitForTimeout(5000);
const u = page.url();
return /\/events\/.+/.test(u) && !u.includes('/events/new');
}
async function createMeetingPoint(page: Page): Promise<boolean> {
const propose = page.getByRole('button', { name: /Proposer un point de rencontre/i });
if (!(await propose.count())) return false;
await propose.first().click().catch(() => {});
await page.waitForTimeout(1500);
await page.locator('input[placeholder="Ex : Gouvernance coopérative — retours SCIC"]').fill('Rdv flow').catch(() => {});
await page.getByRole('button', { name: /Créer le point de rencontre/ }).click().catch(() => {});
await page.waitForTimeout(5000);
const txt = await bodyText(page);
return /POINTS DE RENCONTRE|Rdv flow/i.test(txt) && !/pas pu/i.test(txt);
}
/** The two benign ≥400 classes; anything else is a real failure. */
function classify(e: HttpError): string | null {
if (e.url.includes('/.notifications/WebSocketChannel2023')) return 'websocket-channel 403 (benign)';
if (e.status === 403 && e.method === 'GET' && /\/data\//.test(e.url)) return 'WAC profile-read 403 (benign)';
return null;
}
(async () => {
// 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);
// 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;
await snap(page, 'login-flow-after-meeting-point');
await ctx.close();
await browser.close();
await new Promise(r => setTimeout(r, 1200));
const fatal = errors4xx.filter(e => classify(e) === null);
const benign = errors4xx.map(e => ({ e, label: classify(e) })).filter(x => x.label).map(x => ({ status: x.e.status, url: x.e.url, label: x.label }));
const report = {
user: USER,
authenticated,
login: login.detail,
installed: install.actual,
eventCreated,
meetingPointCreated,
redirects,
hops: drive.hops,
benign4xx: benign,
fatal4xx: fatal,
consoleErrors,
ok: authenticated && eventCreated && meetingPointCreated && 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), redirects, errors4xx, consoleErrors }, null, 2));
process.exit(1);
});
+395
View File
@@ -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);
});
+522
View File
@@ -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);
}
+994
View File
@@ -0,0 +1,994 @@
/**
* 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)';
// A path is under the backend's base URL; a full URL is taken as is.
const status = async (target: string): Promise<number> => (await http('GET', /^https?:\/\//.test(target) ? target : `${BACKEND}${target}`)).status;
// The /.well-known endpoints sit at the backend ORIGIN's root whatever its base path (`/api` on Coolify, backend/services/base-path.js).
const atOriginRoot = (path: string): string => `${new URL(BACKEND).origin}${path}`;
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)'),
atOriginRoot('/.well-known/context.jsonld'), atOriginRoot('/.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 = atOriginRoot(`/.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);
});
+1 -1
View File
@@ -106,7 +106,7 @@ async function main() {
fs.mkdirSync(path.dirname(AUTH_STATE_PATH), { recursive: true });
fs.writeFileSync(AUTH_STATE_PATH, JSON.stringify(state, null, 2));
console.log(`\nAuth state saved to: ${AUTH_STATE_PATH}`);
console.log('You can now run: bun run test:data');
console.log('Auth state saved. The @data/@e2e suites that consume it are suspended (@suspendu) until the NextGraph workstream resumes.');
} catch {
console.log('\nCould not save auth state — browser may have closed too quickly.');
}
+206 -405
View File
@@ -1,190 +1,149 @@
#!/usr/bin/env bun
/**
* validate.ts — Validation matrix (broker-level tests only, no @ui/@e2e).
* validate.ts — the Festipod validation matrix.
*
* Default run (no flags) — key subset only, fast:
* (a) Polyfill unit tests (@ng-eventually/client — bun test)
* (b) Polyfill e2e real-broker (@ng-eventually/client — bun run e2e/run.ts)
* (c) Festipod @data KEY SUBSET (cucumber --name regex covering terrain bugs)
* (d) Festipod @multibrowser (cucumber --tags @multibrowser)
* (e) Festipod @smoke (cucumber --tags @smoke — boot connecté rend)
* (f) Festipod @wip [informational only, non-blocking]
* SCOPE (2026-10-09, after the ActivityPods-first switch; e2e probes added
* 2026-10-13). Four layers are in the gate:
*
* With --full flag:
* (c) becomes full @data suite (cucumber --tags @data)
* (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
* command is, verbatim:
* node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js \
* --config cucumber.json \
* --tags 'not @wip and not @humain and not @data and not @e2e and not @multibrowser'
* `cucumber.json` additionally excludes `@suspendu` and `@e2e-activitypods`
* (Cucumber ANDs the config tags with the CLI ones), so this stays the
* stack-free UI layer exactly as verified at 125 scenarios / 0 failed.
*
* Each step runs even if the previous one failed (--bail mode is OFF).
* Exit code is non-zero if any non-informational step has failures.
* (b) Festipod @e2e-activitypods — the smoke over the LOCAL ActivityPods stack
* (pod provider + Festipod backend + the app served for real). Distinct
* from the suspended `@e2e`: no wallet, no broker, so `cucumber.json`
* excludes it. Its config is `cucumber.smoke.json`; its reproducible setup
* is the concept note `data-layer`, `cookbook_local-activitypods-platform.md`.
* Requires the stack to be UP (the app server and Chromium are started
* here); an unreachable stack fails the layer rather than skipping it.
*
* Profile rotation: both Playwright profiles are rotated before @data and
* @multibrowser when their size exceeds BLOAT_THRESHOLD_MB (default 50 MB),
* to avoid the sparql_query hang described in caveat_wallet-bloat-hang.
* (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
* harness (`src/shared/test-harness/harness-ng.tsx`). They are tagged
* `@suspendu` in the feature files and excluded there, awaiting the NextGraph
* workstream.
* - the SDK's own tests (nextgraph/ng-eventually-js) are NOT Festipod's and are
* no longer run from here (concept `bdd-testing`,
* `rule_tests-validate-festipod-not-the-sdk`).
*
* A smoke layer that cannot reach its stack FAILS, loudly — it never silently
* 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";
import * as fs from "fs";
import * as path from "path";
// ─── Config ────────────────────────────────────────────────────────────────
const FESTIPOD_DIR = "/home/sylvain/projects/festipod/festipod";
const POLYFILL_DIR =
"/home/sylvain/projects/nextgraph/ng-eventually-js/packages/client";
const REPO_DIR = process.cwd();
const FESTIPOD_PROFILE = path.join(FESTIPOD_DIR, ".playwright-profile");
const POLYFILL_PROFILE = path.join(
POLYFILL_DIR,
"e2e",
".playwright-profile-lib",
);
/** The UI layer, verbatim — see the header. */
const UI_TAGS = "not @wip and not @humain and not @data and not @e2e and not @multibrowser";
/** Rotate profile when it exceeds this many MB (caveat_wallet-bloat-hang). */
const BLOAT_THRESHOLD_MB = 50;
/** The ActivityPods smoke, minus its documented suspends. */
const APODS_TAGS = "@e2e-activitypods and not @suspendu";
/**
* Per-step timeouts:
* - polyfill unit/e2e: short steps, keep 10 min
* - @data key subset: generous — BeforeAll + 8 scenarios, ~15 min margin
* - @data full: full suite, ~35 min margin
* - @multibrowser: 7 scenarios, ~10 min margin
* - @wip: informational, 10 min
*/
const TIMEOUT_POLYFILL_UNIT_MS = 10 * 60 * 1000; // 10 min
const TIMEOUT_POLYFILL_E2E_MS = 10 * 60 * 1000; // 10 min
const TIMEOUT_DATA_KEY_MS = 15 * 60 * 1000; // 15 min (key subset)
const TIMEOUT_DATA_FULL_MS = 35 * 60 * 1000; // 35 min (--full)
const TIMEOUT_MULTIBROWSER_MS = 10 * 60 * 1000; // 10 min
const TIMEOUT_SMOKE_MS = 10 * 60 * 1000; // 10 min (1 @e2e boot scenario)
const TIMEOUT_WIP_MS = 10 * 60 * 1000; // 10 min
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)
/**
* Key-subset --name regex: matches exactly the 8 scenarios that cover the
* known terrain bugs (inscription, désinscription, isolation, reconnexion,
* compteur dérivé, créateur ne participe pas, auth vide, auth distinctes).
*
* Uses a single cucumber invocation so BeforeAll (broker login) runs once.
*
* French accent chars must be URL-safe in the regex — cucumber uses JS
* RegExp, which handles unicode natively; we pass the literal string.
*/
const DATA_KEY_NAME_REGEX = [
"S'inscrire à un événement",
"Se désinscrire d'un événement$",
"Une identité fraîche ne voit pas la participation d'une autre",
"Une page fraîche pour la même identité relit ses propres données",
"Le créateur ne participe pas automatiquement à son événement",
"L'inscription fait converger le compteur dérivé du propriétaire",
"Un portefeuille connecté est vide par défaut",
"Les données du portefeuille sont distinctes des données par défaut",
].join("|");
/** 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 ───────────────────────────────────────────────────────────────
function dirSizeMB(dir: string): number {
if (!fs.existsSync(dir)) return 0;
try {
const result = spawnSync("du", ["-sm", dir], { encoding: "utf-8" });
const line = result.stdout.trim().split("\n")[0] ?? "";
return parseInt(line.split("\t")[0] ?? "0", 10);
} catch {
return 0;
}
}
/**
* Remove any stale Chromium singleton files from `profilePath`. Chromium refuses
* to launch (ProcessSingleton error) if SingletonLock, SingletonCookie, or
* SingletonSocket are left over from a previous crashed run. Idempotent — safe to
* call even when the profile does not exist yet.
*/
function cleanSingletons(profilePath: string, label: string): void {
if (!fs.existsSync(profilePath)) return;
const singletons = ["SingletonLock", "SingletonCookie", "SingletonSocket"];
for (const name of singletons) {
const p = path.join(profilePath, name);
if (fs.existsSync(p)) {
try {
fs.rmSync(p, { force: true });
console.log(`[rotate] ${label}: removed stale ${name}.`);
} catch {
// Non-fatal: if we can't remove it, launch will fail with a clear error
}
}
}
}
function rotateProfile(profilePath: string, label: string): void {
const sizeMB = dirSizeMB(profilePath);
if (sizeMB > BLOAT_THRESHOLD_MB) {
console.log(
`[rotate] ${label}: ${sizeMB}MB > ${BLOAT_THRESHOLD_MB}MB — rotating profile...`,
);
try {
fs.rmSync(profilePath, { recursive: true, force: true });
console.log(`[rotate] ${label}: profile removed. Will be recreated.`);
} catch (e) {
console.warn(`[rotate] ${label}: failed to remove profile: ${e}`);
}
} else {
// Even if we keep the profile, remove any stale Chromium singleton files left
// by a previous crashed run — Chromium refuses to launch if they exist.
cleanSingletons(profilePath, label);
console.log(
`[rotate] ${label}: ${sizeMB}MB — below threshold, keeping profile.`,
);
}
}
interface StepResult {
label: string;
status: "passed" | "failed" | "error";
/** Lines to show in the summary (failed scenario names, FAIL lines, etc.) */
status: "passed" | "failed" | "error" | "skipped";
/** Failure lines; for a skipped layer, why it was skipped. */
failures: string[];
/** Raw exit code */
exitCode: number;
durationMs: number;
}
/**
* Run a command and capture its output. Returns the result with parsed
* pass/fail summary. Never throws — all errors are captured in StepResult.
*/
/** The Cucumber invocation every layer shares (same form as the UI gate command). */
function cucumberArgs(config: string, tags: string): string[] {
return [
"--import",
"tsx/esm",
"node_modules/@cucumber/cucumber/bin/cucumber.js",
"--config",
config,
"--tags",
tags,
];
}
function runStep(
label: string,
cmd: string,
args: string[],
cwd: string,
timeoutMs: number,
extraEnv: Record<string, string> = {},
command = "node",
extract: (output: string) => string[] = extractFailures,
): StepResult {
const t0 = Date.now();
console.log(`\n${"═".repeat(60)}`);
console.log(`\n${"═".repeat(64)}`);
console.log(`▶ ${label}`);
console.log(` ${cmd} ${args.join(" ")} (cwd: ${cwd})`);
console.log(` timeout: ${Math.round(timeoutMs / 60000)}min`);
console.log(`${"═".repeat(60)}`);
console.log(` ${command} ${args.join(" ")}`);
console.log(`${"═".repeat(64)}`);
const env = { ...process.env, ...extraEnv };
const result = spawnSync(cmd, args, {
cwd,
env,
const result = spawnSync(command, args, {
cwd: REPO_DIR,
env: process.env,
encoding: "utf-8",
timeout: timeoutMs,
maxBuffer: 20 * 1024 * 1024, // 20MB
maxBuffer: 20 * 1024 * 1024,
});
const durationMs = Date.now() - t0;
const stdout = result.stdout ?? "";
const stderr = result.stderr ?? "";
const combined = stdout + "\n" + stderr;
// Print output in real-time equivalent (post-hoc since spawnSync)
if (stdout) process.stdout.write(stdout);
if (stderr) process.stderr.write(stderr);
const combined = (result.stdout ?? "") + "\n" + (result.stderr ?? "");
process.stdout.write(result.stdout ?? "");
process.stderr.write(result.stderr ?? "");
if (result.error) {
console.error(`[${label}] process error:`, result.error.message);
return {
label,
status: "error",
@@ -193,298 +152,140 @@ function runStep(
durationMs,
};
}
return {
label,
status: (result.status ?? 1) === 0 ? "passed" : "failed",
failures: extract(combined),
exitCode: result.status ?? 1,
durationMs,
};
}
const exitCode = result.status ?? 1;
const failures = extractFailures(combined, label);
const status = exitCode === 0 ? "passed" : "failed";
/** 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 };
}
return { label, status, failures, exitCode, durationMs };
/** 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"));
}
/**
* Extract meaningful failure lines from combined stdout+stderr.
* Heuristics per step type (cucumber scenario names, FAIL lines, etc.).
* 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 extractFailures(output: string, label: string): string[] {
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");
const failures: string[] = [];
if (label.includes("polyfill:unit")) {
// bun test output: lines starting with "✗" or "FAIL" or "fail"
for (const line of lines) {
const l = line.trim();
if (/^(✗|✕|FAIL|fail)\s/.test(l) || l.includes("tests failed")) {
failures.push(l);
}
}
// Also capture summary line "N passed, M failed"
const summary = lines.find(
(l) => l.includes("passed") && l.includes("failed"),
);
if (summary) failures.push(summary.trim());
} else if (label.includes("polyfill:e2e")) {
// e2e/run.ts output: lines starting with " [FAIL]"
for (const line of lines) {
const l = line.trim();
if (l.startsWith("[FAIL]")) failures.push(l);
}
// Summary: "N passed / M failed" style
const summary = lines.find(
(l) => l.includes("passed") || l.includes("failed"),
);
if (summary && !failures.includes(summary.trim()))
failures.push(summary.trim());
} else {
// Cucumber steps: look for "✗" scenario lines, "FAILED" scenario names,
// or lines beginning with "✖" / "×" / "Scenario:" after a failure tag
for (const line of lines) {
const l = line.trim();
if (
/^(✗|✕|×|✖)\s/.test(l) ||
l.startsWith("✘") ||
l.includes("# Scénario:") ||
l.includes("# Scenario:") ||
(l.startsWith("F") && l.length === 1) // progress-bar failure tick
) {
if (l.length > 1) failures.push(l);
}
}
// Cucumber "N scenarios (M failed)" summary
const summary = lines.find((l) =>
/\d+ sc[eé]narios?.*(failed|undefined)/.test(l),
);
if (summary) failures.push(summary.trim());
// Individual scenario fail lines: "✗ Scenario name (features/...)"
for (const line of lines) {
const l = line.trim();
if (l.startsWith("✗") || l.startsWith("✕")) {
if (!failures.includes(l)) failures.push(l);
}
for (const line of lines) {
const l = line.trim();
if (/^(✗|✕|×|✖|✘)\s/.test(l)) failures.push(l);
if (/\d+ sc[eé]narios?.*(failed|undefined)/.test(l) && !failures.includes(l)) {
failures.push(l);
}
}
return failures.filter(Boolean);
}
function fmtDuration(ms: number): string {
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const m = Math.floor(ms / 60_000);
const s = ((ms % 60_000) / 1000).toFixed(0);
return `${m}m${s}s`;
return `${Math.floor(ms / 60_000)}m${((ms % 60_000) / 1000).toFixed(0)}s`;
}
function printMatrix(
steps: StepResult[],
wipResult: StepResult | null,
fullMode: boolean,
): void {
function printMatrix(steps: StepResult[]): void {
console.log("\n");
console.log("╔══════════════════════════════════════════════════════════╗");
console.log("║ VALIDATION MATRIX ║");
if (fullMode) {
console.log("║ (mode: --full, @data complet) ║");
} else {
console.log("║ (mode: défaut, sous-ensemble clé) ║");
}
console.log("╚══════════════════════════════════════════════════════════╝");
console.log("");
console.log("║ VALIDATION MATRIX ║");
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" ? "❌" : "⚠️ ";
const pad = step.label.padEnd(maxLabel + 2);
console.log(` ${icon} ${pad} [${fmtDuration(step.durationMs)}]`);
for (const f of step.failures) {
console.log(` ↳ ${f}`);
}
}
if (wipResult) {
console.log("");
console.log(" ── @wip (informational, non-blocking) ──────────────────");
const icon =
wipResult.status === "passed"
? "✅"
: wipResult.status === "failed"
? "❌"
: "⚠️ ";
const pad = wipResult.label.padEnd(maxLabel + 2);
console.log(` ${icon} ${pad} [${fmtDuration(wipResult.durationMs)}]`);
for (const f of wipResult.failures) {
console.log(` ↳ ${f}`);
}
}
console.log("");
const allPassed = steps.every((s) => s.status === "passed");
const totalMs = steps.reduce((sum, s) => sum + s.durationMs, 0) +
(wipResult?.durationMs ?? 0);
if (allPassed) {
console.log(" 🟢 ALL STEPS PASSED");
} else {
const failed = steps.filter((s) => s.status !== "passed");
console.log(` 🔴 ${failed.length} STEP(S) FAILED: ${failed.map((s) => s.label).join(", ")}`);
}
console.log(` ⏱ Total: ${fmtDuration(totalMs)}`);
if (!fullMode) {
console.log(" ℹ️ Pour @data complet : bun run validate -- --full");
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("");
}
// ─── Cucumber command builder ───────────────────────────────────────────────
function cucumberArgsByTags(tags: string): string[] {
return [
"--import",
"tsx/esm",
"node_modules/.bin/cucumber-js",
"--config",
"cucumber.json",
"--tags",
tags,
];
}
function cucumberArgsByName(nameRegex: string): string[] {
return [
"--import",
"tsx/esm",
"node_modules/.bin/cucumber-js",
"--config",
"cucumber.json",
"--tags",
"@data",
"--name",
nameRegex,
];
console.log(" ℹ️ @data/@e2e/@multibrowser/@shared-wallet/@smoke : suspendus (workstream NextGraph).");
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 ──────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
const args = process.argv.slice(2);
const fullMode = args.includes("--full");
if (fullMode) {
console.log("🔍 Festipod — Full Validation Run (--full : @data complet)");
} else {
console.log("🔍 Festipod — Validation Run (sous-ensemble clé)");
console.log(" Pour @data complet : bun run validate -- --full");
}
console.log(` Festipod: ${FESTIPOD_DIR}`);
console.log(` Polyfill: ${POLYFILL_DIR}`);
console.log("");
// ── Profile rotation AVANT les étapes broker ──────────────────────────────
console.log("── Profile rotation check (avant @data et @multibrowser) ────");
rotateProfile(FESTIPOD_PROFILE, "festipod");
rotateProfile(POLYFILL_PROFILE, "polyfill-lib");
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));
// ── (a) Polyfill unit tests ───────────────────────────────────────────────
steps.push(
runStep(
"polyfill:unit",
"bun",
["test"],
POLYFILL_DIR,
TIMEOUT_POLYFILL_UNIT_MS,
),
);
// ── (b) Polyfill e2e real broker ──────────────────────────────────────────
// Clean singleton files immediately before launching Chromium — guards against
// any file left by polyfill:unit (unlikely but defensive) or by a previous
// interrupted run that the initial rotateProfile call ran before.
cleanSingletons(POLYFILL_PROFILE, "polyfill-lib (pre-e2e)");
steps.push(
runStep(
"polyfill:e2e",
"bun",
["run", "e2e/run.ts"],
POLYFILL_DIR,
TIMEOUT_POLYFILL_E2E_MS,
),
);
// ── (c) Festipod @data ────────────────────────────────────────────────────
if (fullMode) {
// --full : lance tout @data
steps.push(
runStep(
"festipod:@data (complet)",
"node",
cucumberArgsByTags("@data"),
FESTIPOD_DIR,
TIMEOUT_DATA_FULL_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 {
// défaut : sous-ensemble clé en UNE invocation (BeforeAll partagé)
steps.push(
runStep(
"festipod:@data (clé)",
"node",
cucumberArgsByName(DATA_KEY_NAME_REGEX),
FESTIPOD_DIR,
TIMEOUT_DATA_KEY_MS,
),
);
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));
}
// ── (d) Festipod @multibrowser ────────────────────────────────────────────
// Exclude @wip: a scenario tagged @wip @multibrowser (e.g. the reactive
// cross-session scenario, blocked by the shared-wallet structural limit) must
// not gate the baseline — it flows into the informational @wip pass below.
steps.push(
runStep(
"festipod:@multibrowser",
"node",
cucumberArgsByTags("@multibrowser and not @wip"),
FESTIPOD_DIR,
TIMEOUT_MULTIBROWSER_MS,
),
);
// ── (e) Festipod @smoke — boot connecté rend / page blanche ───────────────
// Un seul scénario @e2e : boote le VRAI App, se connecte au broker, et vérifie
// que l'accueil connecté rend du contenu d'app réel SANS erreur runtime. Garde
// la CLASSE « crash de rendu une fois connecté » (page blanche). On ne lance
// QUE @smoke (pas tout @e2e) pour garder le run par défaut rapide.
// Nettoie les singletons Chromium juste avant, comme les autres étapes broker.
cleanSingletons(FESTIPOD_PROFILE, "festipod (pre-@smoke)");
steps.push(
runStep(
"festipod:@smoke",
"node",
cucumberArgsByTags("@smoke and not @wip"),
FESTIPOD_DIR,
TIMEOUT_SMOKE_MS,
),
);
// ── (f) Festipod @wip [informational] ────────────────────────────────────
console.log("\n── @wip informational pass (non-blocking) ──────────────────");
const wipResult = runStep(
"festipod:@wip",
"node",
cucumberArgsByTags("@wip"),
FESTIPOD_DIR,
TIMEOUT_WIP_MS,
);
// ── Matrix ────────────────────────────────────────────────────────────────
printMatrix(steps, wipResult, fullMode);
// ── Exit code ─────────────────────────────────────────────────────────────
const anyFailed = steps.some((s) => s.status !== "passed");
process.exit(anyFailed ? 1 : 0);
printMatrix(steps);
process.exit(steps.some((s) => s.status === "failed" || s.status === "error") ? 1 : 0);
}
main().catch((e) => {
console.error("validate.ts: unhandled error:", e);
process.exit(1);
});
void main();
+39 -13
View File
@@ -1,9 +1,11 @@
import { RouterProvider, useRouter } from './router';
import { useEffect } from 'react';
import { RouterProvider, useNavigate, useRouter } from './router';
import { ThemeProvider } from '../shared/context/ThemeContext';
import { NextGraphProvider } from '../shared/context/NextGraphContext';
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
import { AuthGate } from './AuthGate';
import { ToastContainer } from '../shared/components/sketchy';
import { initialBackendName } from '../shared/refine/providers';
// Auth
import { WelcomeScreen } from '../modules/auth/screens/WelcomeScreen';
@@ -37,6 +39,7 @@ function AppContent() {
case 'home': return <HomeScreen />;
case 'events': return <EventsScreen />;
case 'create-event': return <CreateEventScreen />;
case 'login': return <AuthenticatedLoginLanding />;
case 'event-detail': return <EventDetailScreen />;
case 'update-event': return <UpdateEventScreen />;
case 'invite': return <InviteScreen />;
@@ -52,21 +55,44 @@ function AppContent() {
}
}
/**
* What `/login` shows once the gate has let the user through: home. The route
* exists for the Solid-OIDC callback (`AuthGate` intercepts `?code` and
* `?register_app` before children render); a plain `/login` with nothing left
* to do is not a destination — send the user to the app.
*/
function AuthenticatedLoginLanding() {
const navigate = useNavigate();
useEffect(() => {
navigate('/home');
}, [navigate]);
return null;
}
export function App() {
const backendName = initialBackendName();
const isNg = backendName === 'nextgraph';
// THE AUTH GATE ABOVE THE DATA PROVIDER — signing in must have settled before
// any data path mounts. The gate renders children only once there is a
// session to read through (or an explicit offline-demo choice), so the data
// provider's dispatch decision never races a sign-in that has not finished.
const appTree = (
<AuthGate>
<FestipodDataProvider>
<div className="app-container">
<AppContent />
<ToastContainer />
</div>
</FestipodDataProvider>
</AuthGate>
);
return (
<ThemeProvider>
<NextGraphProvider>
<FestipodDataProvider>
<RouterProvider>
<div className="app-container">
<AuthGate>
<AppContent />
</AuthGate>
<ToastContainer />
</div>
</RouterProvider>
</FestipodDataProvider>
</NextGraphProvider>
<RouterProvider>
{isNg ? <NextGraphProvider>{appTree}</NextGraphProvider> : appTree}
</RouterProvider>
</ThemeProvider>
);
}
+366 -46
View File
@@ -1,83 +1,326 @@
/**
* AuthGate — the ONE await the application makes before it renders.
*
* Signing in is `ensureIdentity()` and nothing else: it takes no identifier,
* resolves who we are and does the connection work (restoring what others shared
* with us, draining our inboxes). Whatever a user has to see or do while that
* resolves — opening the wallet, loading it onto a first-time device — belongs to
* the SDK: it mounts a full-screen barrier of its own on every top-level load and
* takes it down itself, and it owns the return from the broker round-trip (the
* barrier comes back prefilled, and confirming it hands the page over a second
* time; our page is never reloaded and nothing outside the barrier is touched).
* Festipod renders no access screen of its own and re-drives nothing.
* Signing in is backend-specific. There are three paths in this gate:
*
* TWO CALLS, IN ORDER, AND THE ORDER IS CONTRACTUAL: start the session (`init`),
* then await `ensureIdentity()`. A session arrives only through `init`, and
* `ensureIdentity()` awaited before it has been called THROWS. The order is a fact
* of the statement sequence below, not of React's effect ordering — which would
* get it wrong: this gate's effect runs BEFORE its parent provider's.
* 1. ACTIVITYPODS (default variant). The app renders the Solid-OIDC flow itself:
* a Pod-provider picker when no session exists, and the `/login` route handling
* the OAuth callback plus the ActivityPods app-registration stage. The auth
* provider is built with the app description document served by our backend as
* `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.
*
* It hands back WHO WE ARE, and this is the app's only upstream answer to that
* question — everything else it knows about the user it has to read first. The
* value is published for display (`shared/utils/currentPrincipal`) and goes
* nowhere near a data call: no call takes an identity, because the session
* already belongs to one user.
* 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
* the broker round-trip.
*
* Nothing of the app renders before that await settles: a screen mounted earlier
* would read as an identity that is not yet settled.
* 3. LOCAL DEMO. When no ActivityPods backend is configured, or when the user
* explicitly chooses to continue offline, children render without an
* authenticated session and `FestipodDataProvider` serves the seeded demo data.
*
* AND NOTHING RENDERS IF IT FAILS. A rejected `ensureIdentity()` is not a mode the
* app degrades through: an app that could not sign in but still shows its screens
* is indistinguishable from an app whose user simply owns nothing — a total
* failure wearing the face of an empty account. So the rejection is SHOWN, and the
* children stay unmounted, which is also what keeps the data layer from settling
* on its empty stand-in provider for the rest of the session.
* Whatever a user has to see or do while signing in belongs on screen: the
* ActivityPods picker replaces the old wallet barrier, and the local demo fallback
* remains reachable.
*/
import { useEffect, 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';
import { setCurrentSession } from '../shared/utils/currentSession';
import type { AuthProvider } from '@activitypods/refine-providers';
import { useRouter, useNavigate } from './router';
import { initialBackendName } from '../shared/refine/providers';
import { getBackendUrl } from '../shared/utils/runtimeConfig';
import {
buildActivityPodsAuthProvider,
parseAuthFlowStage,
safeRedirectPath,
} from './auth/activityPodsAuth';
import { PodProviderPicker } from './auth/PodProviderPicker';
import { handleStaleReading, type StaleReason } from './auth/appStatusProbe';
import { useAppStatusProbe } from './auth/useAppStatusProbe';
/**
* `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']>>;
/**
* The in-flight (or settled) `handleCallback()` exchange, keyed by the `?code=`
* it belongs to. React's StrictMode double-invokes effects in dev and the
* authorization code is single-use: a second exchange would race the first, throw
* on the loser, and show the fatal screen after a successful login. Both effect
* runs share this one promise, so the exchange happens exactly once; only the
* live run publishes its result. Scoped to the code value so a genuinely new
* login (a new code) is not mistaken for a re-entry.
*/
let callbackExchange: { code: string; promise: Promise<CallbackResult> } | undefined;
/**
* Drop the OIDC callback parameters once the exchange has run. The code is
* consumed; leaving it in the address bar lets a later effect re-run — or a
* reload — treat the page as a fresh callback.
*/
function stripCallbackParams(): void {
const url = new URL(window.location.href);
url.searchParams.delete('code');
url.searchParams.delete('state');
url.searchParams.delete('session_state');
window.history.replaceState(null, '', `${url.pathname}${url.search}${url.hash}`);
}
export function AuthGate({ children }: { children: ReactNode }) {
const { route } = useRouter();
const navigate = useNavigate();
// Whether the ONE identity await has resolved.
const backendName = initialBackendName();
const isActivityPods = backendName === 'activitypods';
const isNg = backendName === 'nextgraph';
const activityPodsEnabled = isActivityPods && !!getBackendUrl();
const [demoMode, setDemoMode] = useState(false);
// Whether the ONE identity await has resolved (NextGraph path).
const [identityReady, setIdentityReady] = useState(false);
// Why it did NOT resolve. Set once, never cleared: signing in is attempted once.
const [signInError, setSignInError] = useState<string | null>(null);
// 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;
return buildActivityPodsAuthProvider(window.location.origin);
}, [activityPodsEnabled]);
// NextGraph identity flow — unchanged.
useEffect(() => {
if (!isNg) return;
let cancelled = false;
// FIRST — a session arrives only through the SDK's `init`. Idempotent, so the
// provider above may have started it already; what matters is that it has been
// called before the await below, or the await throws.
void startNgSession();
void ensureIdentity()
.then(principal => {
// Publish who we are BEFORE anything renders — the identity is a fact of
// the session, not state of this component, so it is recorded even if the
// effect was torn down in between.
.then((principal) => {
setCurrentPrincipal(principal);
if (!cancelled) setIdentityReady(true);
})
.catch(err => {
.catch((err) => {
console.error('[Auth] ensureIdentity failed:', err);
if (!cancelled) setSignInError(err instanceof Error ? err.message : String(err));
});
return () => { cancelled = true; };
}, []);
}, [isNg]);
// ActivityPods Solid-OIDC + app-registration flow.
useEffect(() => {
if (!activityPodsEnabled || !apAuthProvider || demoMode) return;
const provider = apAuthProvider;
let cancelled = false;
async function run() {
const params = new URLSearchParams(window.location.search);
const stage = parseAuthFlowStage(window.location.search);
// Captured before the exchange so stripping the params cannot erase what
// the failure decision below needs.
const code = params.get('code');
const hadFlowState = params.has('state');
try {
if (stage.stage === 'callback') {
// One exchange per code: the StrictMode double-run (both mounts see the
// same `?code=`) shares the in-flight promise instead of racing.
if (!callbackExchange || callbackExchange.code !== code) {
callbackExchange = { code: code ?? '', promise: provider.handleCallback() };
}
const exchange = callbackExchange;
try {
const result = await exchange.promise;
stripCallbackParams();
if (cancelled) return;
const session = provider.getSession();
setCurrentPrincipal(result.webId);
setCurrentSession(session ? { webId: session.webId, token: session.token } : undefined);
// The contract's callback result carries where to return to; honour
// it (validated) rather than trusting only the parsed URL.
const redirect = safeRedirectPath(result.redirect ?? stage.redirectPath);
navigate(`/login?register_app=1&redirect=${encodeURIComponent(redirect)}`);
return;
} catch (err) {
stripCallbackParams();
if (cancelled) return;
// A `?code=` with no flow state — a stale bookmark, any link that
// happens to carry the param — is not a callback the provider can
// complete: it had nothing to exchange. Degrade to the picker rather
// than the fatal screen. A callback that DID carry flow state (a
// `state` param) stays a visible error: it is a real attempt the
// provider refused.
if (!hadFlowState) {
setCurrentSession(undefined);
setApState('unauthenticated');
return;
}
throw err;
} finally {
if (callbackExchange === exchange) callbackExchange = undefined;
}
}
if (stage.stage === 'register_app') {
const session = provider.getSession();
if (!session) {
// No live session at this stage — a stale `?register_app=1` link, or
// a token that has expired. There is nothing to register; show the
// picker instead of stalling on a permanent blank `checking` screen.
setCurrentSession(undefined);
if (!cancelled) setApState('unauthenticated');
return;
}
const registration = await provider.registerApp(session.webId);
if (cancelled) return;
if (registration) {
setCurrentPrincipal(session.webId);
setCurrentSession({ webId: session.webId, token: session.token });
navigate(stage.redirectPath);
}
// If `registerApp` returned `undefined`, the browser left for the Pod's
// consent screen. The effect is torn down; it will resume when the user
// comes back.
return;
}
const session = provider.getSession();
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');
}
} catch (err) {
console.error('[Auth] ActivityPods flow failed:', err);
if (!cancelled) {
setApError(err instanceof Error ? err.message : String(err));
setApState('error');
}
}
}
void run();
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') {
navigate('/home');
}
}, [apState, route.page, navigate]);
// Once identified, leave the disconnected welcome screen for the app home.
useEffect(() => {
if (identityReady && route.page === 'welcome') {
navigate('/home');
}
}, [identityReady, route.page, navigate]);
// Signing in FAILED — say so. The app has nothing legitimate to show, and
// showing it anyway would pass a broken session off as an empty one.
// Signing in FAILED — say so. The app has nothing legitimate to show.
if (signInError) {
return (
<div id="auth-error" role="alert" className="app-card" style={{ margin: '2rem 1rem' }}>
@@ -91,13 +334,90 @@ export function AuthGate({ children }: { children: ReactNode }) {
);
}
// Signing in is not settled yet. Render NOTHING — the SDK's own full-screen
// barrier is what is on screen, it put it there and it takes it down. Anything
// of ours here would be a second thing competing with it.
if (!identityReady) {
if (apError) {
return (
<div id="auth-error" role="alert" className="app-card" style={{ margin: '2rem 1rem' }}>
<h1 className="app-title">Connexion impossible</h1>
<p className="app-text">
Festipod n’a pas réussi à se connecter à votre Pod. Vérifiez l’URL du
provider et réessayez.
</p>
<p className="app-text" data-testid="auth-error-detail">{apError}</p>
<button
type="button"
className="app-btn"
onClick={() => {
setApError(null);
setApState('unauthenticated');
}}
style={{ marginTop: 16 }}
>
Retour
</button>
</div>
);
}
// ActivityPods variant: show the picker until the user has a session.
if (isActivityPods && activityPodsEnabled && !demoMode) {
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
// once signed in, instead of dropping everyone on `/home`. Login and the
// bare welcome route are not deep links.
const hasExplicitRedirect = new URLSearchParams(window.location.search).has('redirect');
const deepLink =
route.page !== 'login' && route.page !== 'welcome' ? window.location.pathname : undefined;
const loginRedirect = hasExplicitRedirect
? parseAuthFlowStage(window.location.search).redirectPath
: deepLink ?? '/home';
return (
<PodProviderPicker
authProvider={apAuthProvider!}
redirect={loginRedirect}
notice={apNotice ?? undefined}
onDemo={() => {
setDemoMode(true);
navigate('/home');
}}
/>
);
}
// authenticated → fall through to children
}
// NextGraph variant: the SDK's own full-screen barrier is what is on screen.
if (isNg && !identityReady) {
return null;
}
// The app.
// The app (local demo or authenticated ActivityPods / NextGraph).
return <>{children}</>;
}
+132
View File
@@ -0,0 +1,132 @@
import { useState } from 'react';
import type { AuthProvider } from '@activitypods/refine-providers';
import { Button, Input, Title, Text } from '../../shared/components/sketchy';
import { resolveActivityPodsAuthConfig } from './activityPodsAuth';
interface PodProviderPickerProps {
authProvider: AuthProvider;
/**
* Where to send the user once the flow completes. The gate computes it —
* a validated `?redirect=` value, or the route the user was trying to reach —
* 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;
}
/**
* Pod-provider picker — the ActivityPods sign-in screen.
*
* When a default Pod provider is configured via `VITE_POD_PROVIDER_URL`, it is
* 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, notice, onDemo }: PodProviderPickerProps) {
const { podProviderUrl } = resolveActivityPodsAuthConfig(window.location.origin);
const [customUrl, setCustomUrl] = useState('');
const startLogin = (issuer: string) => {
void authProvider.login({ issuer, redirect });
};
const defaultProviderHost = podProviderUrl ? safeHost(podProviderUrl) : undefined;
return (
<div
style={{
padding: 24,
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: '100dvh',
// Full-width buttons are right on mobile; on desktop cap the column so
// the sign-in screen reads as a centered card instead of a full-bleed row.
maxWidth: 480,
margin: '0 auto',
}}
>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
<Title style={{ textAlign: 'center', fontSize: 32, marginBottom: 16 }}>
Festipod
</Title>
<Text
style={{
textAlign: 'center',
fontSize: 16,
marginBottom: 32,
lineHeight: 1.5,
color: '#555',
}}
>
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"
onClick={() => startLogin(podProviderUrl!)}
style={{ marginBottom: 16 }}
>
Se connecter avec {defaultProviderHost}
</Button>
)}
<div style={{ marginBottom: 24 }}>
<Input
type="url"
placeholder="URL de votre Pod provider"
value={customUrl}
onChange={(e) => setCustomUrl(e.target.value)}
style={{ marginBottom: 8, width: '100%' }}
/>
<Button
variant="default"
onClick={() => customUrl && startLogin(customUrl)}
disabled={!customUrl}
style={{ width: '100%' }}
>
Continuer
</Button>
</div>
</div>
{onDemo && (
<Button variant="accent-outline" onClick={onDemo} style={{ marginBottom: 12 }}>
Continuer hors ligne
</Button>
)}
<Text style={{ textAlign: 'center', fontSize: 12, color: '#bbb' }}>
Version beta
</Text>
</div>
);
}
function safeHost(url: string): string {
try {
return new URL(url).host;
} catch {
return url;
}
}
+93
View File
@@ -0,0 +1,93 @@
import { expect, test } from 'bun:test';
import { setRuntimeConfig } from '../../shared/utils/runtimeConfig';
import {
parseAuthFlowStage,
resolveActivityPodsAuthConfig,
type AuthFlowStage,
} from './activityPodsAuth';
test('clientId uses explicit backendClientId when set', () => {
setRuntimeConfig({ backendClientId: 'https://app.example/id.json' });
const cfg = resolveActivityPodsAuthConfig('http://localhost:3000');
expect(cfg.clientId).toBe('https://app.example/id.json');
expect(cfg.redirectUri).toBe('http://localhost:3000/login');
});
test('clientId derives the actor uri from backendUrl when backendClientId is empty', () => {
setRuntimeConfig({ backendUrl: 'http://localhost:3003', backendClientId: '' });
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
expect(cfg.clientId).toBe('http://localhost:3003/as/actor/app');
expect(cfg.redirectUri).toBe('http://localhost:5000/login');
});
test('clientId falls back to the origin actor uri when nothing is configured', () => {
setRuntimeConfig({});
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
expect(cfg.clientId).toBe('http://localhost:5000/as/actor/app');
});
test('clientId does not double the slash on a trailing-slash backendUrl', () => {
setRuntimeConfig({ backendUrl: 'http://localhost:3003/' });
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
expect(cfg.clientId).toBe('http://localhost:3003/as/actor/app');
});
test('podProviderUrl is passed through when set', () => {
setRuntimeConfig({ podProviderUrl: 'http://localhost:3000' });
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
expect(cfg.podProviderUrl).toBe('http://localhost:3000');
});
test('whitespace-only runtime values are treated as empty', () => {
setRuntimeConfig({
backendUrl: ' ',
backendClientId: ' ',
podProviderUrl: ' ',
});
const cfg = resolveActivityPodsAuthConfig('http://localhost:5000');
expect(cfg.clientId).toBe('http://localhost:5000/as/actor/app');
expect(cfg.podProviderUrl).toBeUndefined();
});
test('parseAuthFlowStage detects OIDC callback', () => {
expect(parseAuthFlowStage('?code=abc&state=xyz')).toEqual({
stage: 'callback',
redirectPath: '/home',
});
expect(parseAuthFlowStage('?code=abc&redirect=/events')).toEqual({
stage: 'callback',
redirectPath: '/events',
});
});
test('parseAuthFlowStage detects app registration stage', () => {
expect(parseAuthFlowStage('?register_app=1')).toEqual({
stage: 'register_app',
redirectPath: '/home',
});
expect(parseAuthFlowStage('?register_app=1&redirect=/profile')).toEqual({
stage: 'register_app',
redirectPath: '/profile',
});
});
test('parseAuthFlowStage defaults to picker', () => {
expect(parseAuthFlowStage('')).toEqual({ stage: 'picker', redirectPath: '/home' });
// A bare `?redirect` does not change the stage; it is carried (validated) so
// every stage exposes the same post-login destination.
expect(parseAuthFlowStage('?redirect=/events')).toEqual({
stage: 'picker',
redirectPath: '/events',
});
});
test('parseAuthFlowStage rejects non-same-origin redirects', () => {
// Absolute, protocol-relative, and backslash spellings all resolve off-origin
// — they must fall back to /home, never reach history.pushState.
expect(parseAuthFlowStage('?code=abc&redirect=https://evil.example').redirectPath).toBe('/home');
expect(parseAuthFlowStage('?register_app=1&redirect=//evil.example').redirectPath).toBe('/home');
expect(parseAuthFlowStage('?code=abc&redirect=/\\evil.example').redirectPath).toBe('/home');
expect(parseAuthFlowStage('?code=abc&redirect=relative/path').redirectPath).toBe('/home');
// A same-origin deep link survives untouched.
expect(parseAuthFlowStage('?code=abc&redirect=/events/42').redirectPath).toBe('/events/42');
});
+78
View File
@@ -0,0 +1,78 @@
import { authProvider as createActivityPodsAuthProvider } from '@activitypods/refine-providers';
import type { AuthProvider } from '@activitypods/refine-providers';
import { resolveActivityPodsAuthConfig } from '../../shared/utils/runtimeConfig';
// Re-exported so existing callers (AuthGate, the flow-stage tests) keep their
// import surface; the implementation lives in the runtime-config module, the
// shared source of truth both the auth gate and the data provider resolve
// against — one clientId/redirect for the whole app.
export { resolveActivityPodsAuthConfig };
export type { ResolvedAuthConfig } from '../../shared/utils/runtimeConfig';
/** Build an ActivityPods auth provider with the Festipod-specific configuration. */
export function buildActivityPodsAuthProvider(origin: string): AuthProvider {
const cfg = resolveActivityPodsAuthConfig(origin);
return createActivityPodsAuthProvider({
clientId: cfg.clientId,
redirectUri: cfg.redirectUri,
appStatusCheckInterval: false,
});
}
/**
* End the ActivityPods session on this device.
*
* Per the contract, `logout` clears local state — the stored OIDC token — and
* the token lives at ONE fixed storage key per page (`One session per page`,
* non-guarantees), so clearing it here ends the session for the whole app. The
* provider is built from the same resolved config as the gate and the data
* provider; the app's own session store (`utils/currentSession`) is cleared by
* the caller, before the reload.
*/
export async function logoutActivityPods(): Promise<void> {
await buildActivityPodsAuthProvider(window.location.origin).logout({});
}
/** Which stage of the Solid-OIDC / app-registration flow the current URL represents. */
export type AuthFlowStage =
| { stage: 'callback'; redirectPath: string }
| { stage: 'register_app'; redirectPath: string }
| { stage: 'picker'; redirectPath: string };
/**
* A same-origin post-login destination, or `/home`.
*
* Only a leading-slash path is accepted. An absolute URL, a protocol-relative
* `//host` value, its backslash spelling `/\host` (which the URL parser resolves
* the same way), or anything not starting with `/` falls back to `/home`.
* Validated here, in the one place the URL is parsed, so every consumer — the
* callback navigation and the picker's login call — inherits it. Without this an
* attacker-chosen `?redirect=` could leave the origin, and `history.pushState`
* would throw a SecurityError into the fatal screen.
*/
export function safeRedirectPath(raw: string | null | undefined): string {
if (!raw || !raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\')) {
return '/home';
}
return raw;
}
/**
* Parse the current URL search params to decide what AuthGate should do.
*
* - `?code=...` → the Pod provider redirected back after login; complete the
* OAuth exchange and move to app registration.
* - `?register_app=1` → back from the Pod's consent screen; ensure the app is
* registered before entering the app.
* - otherwise → show the Pod-provider picker.
*
* `redirect` is validated (`safeRedirectPath`) and carried on every stage,
* defaulting to `/home`.
*/
export function parseAuthFlowStage(search: string): AuthFlowStage {
const params = new URLSearchParams(search);
const redirectPath = safeRedirectPath(params.get('redirect'));
if (params.has('code')) return { stage: 'callback', redirectPath };
if (params.has('register_app')) return { stage: 'register_app', redirectPath };
return { stage: 'picker', redirectPath };
}
+363
View File
@@ -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 });
});
});
+277
View File
@@ -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,
};
}
+60
View File
@@ -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]);
}
+27 -5
View File
@@ -24,16 +24,38 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { setRuntimeConfig } from "../shared/utils/runtimeConfig";
/** Fetch the runtime shared-wallet config and set the global, unless it is already there. */
/** Runtime config shape served by `/festipod-config.json`. */
interface FestipodConfig {
sharedWalletPassword?: string;
autoSeed?: string;
backendUrl?: string;
backendClientId?: string;
podProviderUrl?: string;
}
/**
* Fetch the runtime config and make it available to the app.
*
* The shared-wallet globals may already be inlined by `build.ts`'s `define`; the
* runtime endpoint is still fetched for the ActivityPods backend values, which
* are env-driven and not baked into the bundle.
*/
async function loadRuntimeConfig(): Promise<void> {
// Already inlined by `build.ts`'s `define` → nothing to fetch. Bracket access,
// so that same `define` (which rewrites the dotted global) leaves this read alone.
if ((globalThis as Record<string, unknown>)["__FESTIPOD_SHARED_WALLET_PASSWORD__"] != null) return;
try {
const res = await fetch("/festipod-config.json");
if (!res.ok) return;
const cfg = (await res.json()) as { sharedWalletPassword?: string; autoSeed?: string };
const cfg = (await res.json()) as FestipodConfig;
// Backend coupling: the browser cannot read process.env, so the server exposes
// the values the ActivityPods auth flow needs.
setRuntimeConfig({
backendUrl: cfg.backendUrl,
backendClientId: cfg.backendClientId,
podProviderUrl: cfg.podProviderUrl,
});
// Bracket access so build.ts's `define` (which matches the dotted global)
// never rewrites this assignment. Only set when the env actually carries one.
const g = globalThis as Record<string, unknown>;
+75
View File
@@ -0,0 +1,75 @@
import { expect, test } from 'bun:test';
import { parsePath, routeToPath, eventPath, userPath, type Route } from './router';
// Ids on the ActivityPods backend are absolute IRIs. The router must carry them
// through the URL intact: encode on the way out, decode on the way in. A raw IRI
// in a path would be read as route structure (the `/` and `:` split it, and the
// `http:` segment falls through to the welcome page).
const IRI = 'http://localhost:3000/alice/data/abc';
test('an absolute-IRI event id survives routeToPath → parsePath intact', () => {
const route: Route = { page: 'event-detail', eventId: IRI };
const path = routeToPath(route);
expect(path).toBe(`/events/${encodeURIComponent(IRI)}`);
expect(parsePath(path)).toEqual({ page: 'event-detail', eventId: IRI });
});
test('an absolute-IRI event id survives every sub-route intact', () => {
const cases: Array<[Route, Route]> = [
[{ page: 'update-event', eventId: IRI }, { page: 'update-event', eventId: IRI }],
[{ page: 'invite', eventId: IRI }, { page: 'invite', eventId: IRI }],
[{ page: 'participants', eventId: IRI }, { page: 'participants', eventId: IRI }],
[{ page: 'meeting-points', eventId: IRI }, { page: 'meeting-points', eventId: IRI }],
];
for (const [route, expected] of cases) {
expect(parsePath(routeToPath(route))).toEqual(expected);
}
});
test('an absolute-IRI user id survives routeToPath → parsePath intact', () => {
const route: Route = { page: 'user-profile', userId: IRI };
expect(parsePath(routeToPath(route))).toEqual({ page: 'user-profile', userId: IRI });
});
// The id ITSELF may end in a segment that names a sub-route; encoding is what
// keeps it from being read as one.
test('an encoded id whose tail looks like a sub-route is not mistaken for one', () => {
const tricky = 'http://localhost:3000/alice/data/edit';
expect(parsePath(routeToPath({ page: 'event-detail', eventId: tricky }))).toEqual({
page: 'event-detail',
eventId: tricky,
});
expect(parsePath(eventPath(tricky, 'participants'))).toEqual({
page: 'participants',
eventId: tricky,
});
});
test('eventPath and userPath are the encoded path builders', () => {
expect(eventPath(IRI)).toBe(`/events/${encodeURIComponent(IRI)}`);
expect(eventPath(IRI, 'edit')).toBe(`/events/${encodeURIComponent(IRI)}/edit`);
expect(userPath(IRI)).toBe(`/users/${encodeURIComponent(IRI)}`);
});
// Defensive: a raw (unencoded) IRI still parses rather than falling through —
// the id is taken as the whole remainder and the sub-route from the tail.
test('a raw absolute-IRI path still parses as the whole remainder', () => {
expect(parsePath('/events/http://localhost:3000/alice/data/abc')).toEqual({
page: 'event-detail',
eventId: IRI,
});
expect(parsePath('/events/http://localhost:3000/alice/data/abc/participants')).toEqual({
page: 'participants',
eventId: IRI,
});
});
test('the fixed routes are unchanged', () => {
expect(parsePath('/')).toEqual({ page: 'welcome' });
expect(parsePath('/home')).toEqual({ page: 'home' });
expect(parsePath('/events')).toEqual({ page: 'events' });
expect(parsePath('/events/new')).toEqual({ page: 'create-event' });
expect(parsePath('/profile/edit')).toEqual({ page: 'edit-profile' });
expect(parsePath('/unknown')).toEqual({ page: 'welcome' });
});
+53 -14
View File
@@ -20,7 +20,8 @@ type Route =
| { page: 'share-profile' }
| { page: 'connect' }
| { page: 'user-profile'; userId: string }
| { page: 'settings' };
| { page: 'settings' }
| { page: 'login' };
export type { Route };
@@ -33,13 +34,28 @@ export interface RouteParams {
// Path parsing & generation
// ============================================================================
function parsePath(pathname: string): Route {
/**
* Decode one path segment. Ids on the ActivityPods backend are absolute IRIs, so
* they are percent-encoded by `routeToPath`; a malformed escape (a raw IRI that
* was never encoded) is returned verbatim rather than throwing — the captured
* text IS the id in that case.
*/
function decodeSegment(segment: string): string {
try {
return decodeURIComponent(segment);
} catch {
return segment;
}
}
export function parsePath(pathname: string): Route {
const path = pathname.replace(/\/+$/, '') || '/';
if (path === '/' || path === '') return { page: 'welcome' };
if (path === '/home') return { page: 'home' };
if (path === '/events') return { page: 'events' };
if (path === '/events/new') return { page: 'create-event' };
if (path === '/login') return { page: 'login' };
if (path === '/settings') return { page: 'settings' };
if (path === '/profile') return { page: 'profile' };
if (path === '/profile/edit') return { page: 'edit-profile' };
@@ -47,10 +63,14 @@ function parsePath(pathname: string): Route {
if (path === '/profile/share') return { page: 'share-profile' };
if (path === '/profile/connect') return { page: 'connect' };
// /events/:id/...
const eventMatch = path.match(/^\/events\/([^/]+)(?:\/(.+))?$/);
// /events/:id/... — the id is an absolute IRI, encoded by `routeToPath` so it
// holds no `/`; the sub-route is matched from the tail, and a raw (unencoded)
// IRI still parses as the whole remainder. An ENCODED id may itself contain a
// `/edit` segment without being read as a sub-route, which is why encoding is
// mandatory on every navigation.
const eventMatch = path.match(/^\/events\/(.+?)(?:\/(edit|invite|participants|meeting-points))?$/);
if (eventMatch) {
const eventId = eventMatch[1]!;
const eventId = decodeSegment(eventMatch[1]!);
const sub = eventMatch[2];
if (!sub) return { page: 'event-detail', eventId };
if (sub === 'edit') return { page: 'update-event', eventId };
@@ -59,10 +79,10 @@ function parsePath(pathname: string): Route {
if (sub === 'meeting-points') return { page: 'meeting-points', eventId };
}
// /users/:id
const userMatch = path.match(/^\/users\/([^/]+)$/);
// /users/:id — an absolute actor URI, encoded for the same reason.
const userMatch = path.match(/^\/users\/(.+)$/);
if (userMatch) {
return { page: 'user-profile', userId: userMatch[1]! };
return { page: 'user-profile', userId: decodeSegment(userMatch[1]!) };
}
return { page: 'welcome' };
@@ -74,21 +94,40 @@ export function routeToPath(route: Route): string {
case 'home': return '/home';
case 'events': return '/events';
case 'create-event': return '/events/new';
case 'event-detail': return `/events/${route.eventId}`;
case 'update-event': return `/events/${route.eventId}/edit`;
case 'invite': return `/events/${route.eventId}/invite`;
case 'participants': return `/events/${route.eventId}/participants`;
case 'meeting-points': return `/events/${route.eventId}/meeting-points`;
case 'login': return '/login';
// Ids are absolute IRIs: they MUST be percent-encoded into a single path
// segment, or the `/` and `:` inside them are read as route structure and
// navigation falls through to the welcome page. `parsePath` decodes them.
case 'event-detail': return `/events/${encodeURIComponent(route.eventId)}`;
case 'update-event': return `/events/${encodeURIComponent(route.eventId)}/edit`;
case 'invite': return `/events/${encodeURIComponent(route.eventId)}/invite`;
case 'participants': return `/events/${encodeURIComponent(route.eventId)}/participants`;
case 'meeting-points': return `/events/${encodeURIComponent(route.eventId)}/meeting-points`;
case 'profile': return '/profile';
case 'edit-profile': return '/profile/edit';
case 'friends': return '/profile/friends';
case 'share-profile': return '/profile/share';
case 'connect': return '/profile/connect';
case 'user-profile': return `/users/${route.userId}`;
case 'user-profile': return `/users/${encodeURIComponent(route.userId)}`;
case 'settings': return '/settings';
}
}
/**
* Build a path to an event sub-route, encoding the id. Screens navigate by path
* rather than by `Route`, so this is the one place that keeps a raw IRI out of
* the URL structure.
*/
export function eventPath(eventId: string, sub?: 'edit' | 'invite' | 'participants' | 'meeting-points'): string {
const base = `/events/${encodeURIComponent(eventId)}`;
return sub ? `${base}/${sub}` : base;
}
/** Build a path to a user profile, encoding the actor URI. */
export function userPath(userId: string): string {
return `/users/${encodeURIComponent(userId)}`;
}
// ============================================================================
// Router context
// ============================================================================
+13 -4
View File
@@ -1,7 +1,7 @@
import { serve } from "bun";
import index from "./index.html";
const port = process.env.PORT ? parseInt(process.env.PORT) : 3000;
const port = process.env.PORT ? parseInt(process.env.PORT) : 4000;
// Strict base64 check (not a mere `Buffer.from` attempt, which silently drops invalid
// characters instead of failing): reject anything that is not a well-formed base64 body
@@ -63,13 +63,22 @@ const server = serve({
// (both serve from src/, so they miss build.ts's compile-time `define`). The app
// entry (frontend.tsx) fetches this before it loads the app tree, so
// `sharedWallet.ts` sees the password. Empty env → '' → no shared wallet.
"/festipod-config.json": () =>
Response.json({
"/festipod-config.json": () => {
const backendUrl = process.env.VITE_BACKEND_URL?.trim() ?? "";
const backendClientId = process.env.VITE_BACKEND_CLIENT_ID?.trim() ?? "";
return Response.json({
sharedWalletPassword: process.env.FESTIPOD_SHARED_WALLET_PASSWORD ?? "",
// Auto-seed gate (OFF by default): only set when the env var is present, so
// the front seeds an empty wallet only on explicit opt-in (see autoSeed.ts).
autoSeed: process.env.FESTIPOD_AUTO_SEED ?? "",
}),
// ActivityPods backend coupling: the frontend needs the backend URL and the
// Solid-OIDC client_id (defaults to the app description document served by
// the backend). The Pod provider URL is optional; empty → user picks one.
backendUrl,
backendClientId,
podProviderUrl: process.env.VITE_POD_PROVIDER_URL?.trim() ?? "",
});
},
// The shared wallet file — the `fileUrl` the app hands the SDK, when configured.
//
@@ -1,5 +1,5 @@
# language: fr
@AUTH @priority-1
@AUTH @priority-1 @suspendu
Fonctionnalité: Connexion NextGraph et chargement des données
En tant qu'utilisateur
Je peux me connecter à mon portefeuille NextGraph
@@ -1,5 +1,5 @@
# language: fr
@EVENT @priority-1
@EVENT @priority-1 @suspendu
Fonctionnalité: Cycle de vie d'un événement
En tant qu'utilisateur connecté
Je peux créer, consulter, modifier et participer à des événements
@@ -6,7 +6,7 @@
# ANNUAIRE Festipod — un document public dont l'app connaît le lien, alimenté par
# dépôt et matérialisé par son curateur. À ce moment-là ce scénario est réécrit
# sur l'annuaire et redevient actif.
@EVENT @priority-1 @wip
@EVENT @priority-1 @wip @suspendu
Fonctionnalité: Découverte publique via l'index global
En tant qu'utilisateur
Je veux découvrir les événements publics des autres comptes sans être connecté
@@ -0,0 +1,57 @@
# language: fr
@EVENT @priority-1 @e2e-activitypods
Fonctionnalité: Smoke e2e de la pile ActivityPods locale
En tant que développeur validant le branchement ActivityPods
Je pilote la pile locale réelle (pod provider + backend Festipod + app servie)
Afin de prouver que les flux du modèle passent réellement par le Pod.
# PÉRIMÈTRE. Cette couche est DISTINCTE des suites `@data`/`@e2e`/`@multibrowser`
# suspendues avec le workstream NextGraph : elle ne signe aucun wallet et ne monte
# aucun broker. Elle n'existe que si la pile locale tourne — son mode d'emploi
# reproductible est le concept `data-layer`,
# `cookbook_local-activitypods-platform.md` (pod provider :3000, backend Festipod
# :3003, redis remappé :6390). Le chargement de l'app reprend le motif `@e2e` du
# repo (serveur réel + Playwright), pas le wallet partagé.
Scénario: La pile locale est joignable et le backend sert son application
Alors le pod provider local répond sur son endpoint OpenID
Et le backend Festipod sert la description de son application
@browser
Scénario: L'application démarre en variante ActivityPods et propose la connexion au Pod
Quand l'application Festipod est chargée depuis la pile locale
Alors l'entrée de connexion au Pod est proposée
Et aucune erreur runtime n'a été émise au chargement
Scénario: Un événement et un point de rencontre rattaché par as:context sont créés sur le Pod
Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test"
Quand je crée un événement "Concert smoke" et un point de rencontre "Rdv entrée" rattaché par as:context
Alors la lecture du point de rencontre expose son lien as:context vers l'événement
Et l'événement et le point de rencontre partagent le conteneur as:Event
# L'app s'installe sur le Pod (consentement de l'agent d'autorisation, ou pour un
# scénario sans navigateur l'endpoint d'enregistrement — voir l'étape
# « installée sur le Pod »). Une fois installée, l'activité `as:Join` postée sur
# l'outbox de l'utilisateur EST délivrée au backend, qui tient la collection
# `apods:attendees` du point de rencontre. Le point de rencontre est créé dans le
# conteneur enregistré (`as/event`) ; sa collection `apods:attendees` est attachée
# par le backend lui-même, à la création — le scénario la lit, il ne la crée pas.
Scénario: Rejoindre puis quitter un point de rencontre met à jour la collection apods:attendees
Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test"
Et l'application Festipod est installée sur le Pod de la session
Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees
Quand la session rejoint le point de rencontre
Alors la collection apods:attendees contient la session
Quand la session quitte le point de rencontre
Alors la collection apods:attendees ne contient plus la session
# Dépend du scénario précédent pour la mise en place, mais lit la collection
# elle-même : le compteur est le total de la collection (jamais les profils
# résolus) et une référence non résolue reste comptée et rendue anonyme.
Scénario: Le compteur et la liste des participants tiennent une référence non résolue
Étant donné une session Pod locale pour "alice" avec le mot de passe "Test1test"
Et l'application Festipod est installée sur le Pod de la session
Et un point de rencontre "Rdv entrée" rattaché à un événement, avec sa collection apods:attendees
Quand la session rejoint le point de rencontre
Alors le compteur du point de rencontre compte cette inscription
Et la liste des participants rend ce participant comme anonyme
@@ -1,5 +1,5 @@
# language: fr
@EVENT @priority-1 @multibrowser @shared-wallet
@EVENT @priority-1 @multibrowser @shared-wallet @suspendu
Fonctionnalité: Validation e2e multi-navigateurs des nouvelles features (T02.f)
En tant que développeur validant les fonctionnalités T02
Je pilote DEUX navigateurs isolés (contextes Playwright distincts, sessions
@@ -1,5 +1,5 @@
# language: fr
@EVENT @priority-1
@EVENT @priority-1 @suspendu
Fonctionnalité: Inscription réelle au point de rencontre via inbox (T02.c)
En tant qu'utilisateur qui s'inscrit à un point de rencontre
Mon inscription est persistée, l'hôte reçoit une notification par son inbox,
@@ -1,6 +1,6 @@
# language: fr
# SUSPENDU (@wip) — comportement Festipod bien réel, mais inexprimable aujourd'hui : il faut DEUX contextes navigateur réellement séparés, chacun se connectant pour lui-même via `ensureIdentity()`, ce que le support multi-navigateur n'offre pas encore.
@EVENT @priority-1 @data @wip
@EVENT @priority-1 @data @wip @suspendu
Fonctionnalité: Isolation entre deux identités sur le wallet partagé
En tant qu'utilisateur qui se connecte pour lui-même, sur le MÊME wallet
physique partagé qu'un autre utilisateur,
@@ -1,5 +1,5 @@
# language: fr
@EVENT @priority-1 @data
@EVENT @priority-1 @data @suspendu
Fonctionnalité: Reconnexion à froid SANS état local NextGraph (durabilité broker réelle)
En tant qu'utilisateur qui crée un événement puis se reconnecte sous la MÊME
identité depuis un appareil/onglet SANS aucune donnée NextGraph locale,
@@ -1,5 +1,5 @@
# language: fr
@EVENT @priority-1 @data
@EVENT @priority-1 @data @suspendu
Fonctionnalité: Reconnexion d'une même identité sur le wallet persistant
En tant qu'utilisateur qui, sur le MÊME wallet physique, ouvre une PAGE FRAÎCHE
(nouveau login broker, session fraîche) sous le MÊME identifiant qu'avant,
@@ -1,5 +1,5 @@
# language: fr
@EVENT @priority-1
@EVENT @priority-1 @suspendu
Fonctionnalité: Persistance d'un événement à la reconnexion (app réelle, @e2e)
En tant qu'utilisateur qui, dans la VRAIE app, crée un événement puis FERME
et ROUVRE l'app sous la MÊME identité (même wallet, session verifier fraîche),
@@ -34,7 +34,7 @@ Fonctionnalité: US-13 Relayer/Modifier/Supprimer un événement
# le créateur, qui n'est PAS obligé de participer. À la création, aucune
# participation n'est écrite et le compteur démarre à 0. Le créateur voit « J'y
# serai » et peut rejoindre/quitter son propre événement comme tout le monde.
@data
@data @suspendu
Scénario: Le créateur ne participe pas automatiquement à son événement
Étant donné que le créateur relaie l'événement "Signalé par le créateur"
Alors le créateur n'est pas participant de l'événement "Signalé par le créateur"
@@ -37,7 +37,7 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
# scénario @multibrowser réactif (e2e-multibrowser.feature « Un participant apparaît
# réactivement… »), avec un vrai propriétaire (A) et un vrai inscrit (B).
@data
@data @suspendu
Scénario: S'inscrire à un événement
Étant donné un événement "Formation CNV" existe
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
@@ -45,7 +45,7 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
Alors l'utilisateur est participant de l'événement "Formation CNV"
Et l'utilisateur apparaît dans la liste des participants de l'événement "Formation CNV"
@data
@data @suspendu
Scénario: Se désinscrire d'un événement
Étant donné un événement "Résidence Reconnexion" existe
Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion"
@@ -53,14 +53,14 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
Alors l'utilisateur n'est plus participant de l'événement "Résidence Reconnexion"
Et l'utilisateur n'apparaît plus dans la liste des participants de l'événement "Résidence Reconnexion"
@data
@data @suspendu
Scénario: L'inscription est idempotente
Étant donné un événement "Résidence Reconnexion" existe
Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion"
Quand l'utilisateur essaie de s'inscrire une seconde fois à l'événement "Résidence Reconnexion"
Alors l'inscription est idempotente pour l'événement "Résidence Reconnexion"
@data
@data @suspendu
Scénario: Se désinscrire d'un événement auquel on n'est pas inscrit
Étant donné un événement "Formation CNV" existe
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
@@ -80,7 +80,7 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
# non-borné au scénario). La convergence 1→2 réactive absolue reste prouvée par le
# scénario @multibrowser réactif (propriétaire A + inscrit B, e2e-multibrowser).
# Voir data-layer/knowledge_context-internals § participantCount + le caveat id-form.
@data
@data @suspendu
Scénario: L'inscription fait converger le compteur dérivé du propriétaire
Étant donné un événement "Formation CNV" existe
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
@@ -2,7 +2,7 @@ import React, { useState, useMemo } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Text, Input, Button, Placeholder, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { useNavigate } from '../../../app/router';
import { eventPath, useNavigate } from '../../../app/router';
const importableEvents = [
{
@@ -108,7 +108,7 @@ export function CreateEventScreen() {
return;
}
showToast('Événement relayé', 'success');
navigate(`/events/${newEvent.id}`);
navigate(eventPath(newEvent.id));
};
return (
@@ -239,7 +239,7 @@ export function CreateEventScreen() {
<Button
variant="primary"
style={{ width: '100%', padding: 10, fontSize: 13 }}
onClick={() => navigate(`/events/${ev.id}`)}
onClick={() => navigate(eventPath(ev.id))}
>
Voir cet événement
</Button>
+89 -58
View File
@@ -1,7 +1,9 @@
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';
import { useNavigate, useParams } from '../../../app/router';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { eventPath, useNavigate, useParams, userPath } from '../../../app/router';
export function EventDetailScreen() {
const navigate = useNavigate();
@@ -10,15 +12,25 @@ export function EventDetailScreen() {
getEvent,
currentUser,
isParticipating,
joinEvent,
leaveEvent,
joinMeetingPoint,
leaveMeetingPoint,
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 joined = eventId ? isParticipating(eventId) : false;
const participants = eventId ? getEventParticipants(eventId) : [];
const meetingPointsRaw = eventId ? getEventMeetingPoints(eventId) : [];
@@ -28,8 +40,34 @@ 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
// point de rencontre, pas à un événement). There is no event-level join on
// this screen anymore: "joined" means a member of at least one of this
// event's meeting points, and it gates the Invite affordance the way the
// old event-level membership did.
const joinedMeetingPointIds = new Set(
meetingPointsRaw.filter(mp => isParticipating(mp.id)).map(mp => String(mp.id)),
);
const joined = joinedMeetingPointIds.size > 0;
const handleToggleMeetingPoint = (id: string, _title: string, willJoin: boolean) => {
const failed = (message: string) => (err: unknown) => {
console.error('[EventDetail] meeting point participation failed:', err);
showToast(message, 'error');
};
if (willJoin) {
void Promise.resolve(joinMeetingPoint(id))
.catch(failed("L'inscription au point de rencontre n'a pas pu être enregistrée"));
} else {
void Promise.resolve(leaveMeetingPoint(id))
.catch(failed("La désinscription du point de rencontre n'a pas pu être enregistrée"));
}
};
// EDITING IS OWNING. Only a document's owner writes to it, and nothing delegates
// a write, so "may I edit this event" is answered entirely by whether this
// session owns the event's document — no permission call, no extra read.
@@ -45,33 +83,14 @@ export function EventDetailScreen() {
const canEdit = ownership === 'mine';
// Preview list shows the OTHER participants (deliberate — the total is in the
// header count; the full list at "Voir tous les participants" shows everyone).
// Compare on the PROFILE id: `currentUser.id` is the resolved profile NURI, the
// same space as `p.id` — whereas `currentUserId` is the `urn:festipod:user:`
// principal, which never equals a profile id in connected mode (so the old
// `p.id !== currentUserId` failed to drop self, leaking it in as "1 unknown").
// On this backend `currentUser.id` and `p.id` are BOTH actor URIs (the id the
// attendees collection keys memberships by), so comparing them drops self.
const knownParticipants = participants.filter(p => p.id !== currentUser?.id);
const handleToggleJoin = () => {
if (!eventId) return;
// THE CONFIRMATION FOLLOWS THE WRITE. It used to be shown on the spot, before
// the call had settled, so a sign-up that wrote nothing still read as
// « Tu participes ». The screen's own list flips immediately anyway (the data
// layer's optimistic overlay), so nothing is lost by waiting for the truth.
const confirmed = (message: string, tone: 'success' | 'info') => () => showToast(message, tone);
const failed = (message: string) => (err: unknown) => {
console.error('[EventDetail] participation write failed:', err);
showToast(message, 'error');
};
if (joined) {
void Promise.resolve(leaveEvent(eventId))
.then(confirmed('Participation annulée', 'info'))
.catch(failed("La désinscription n'a pas pu être enregistrée"));
} else {
void Promise.resolve(joinEvent(eventId))
.then(confirmed('Tu participes à cet événement', 'success'))
.catch(failed("L'inscription n'a pas pu être enregistrée"));
}
};
// The rest of the total could not be resolved to a profile — a non-contact
// sees only anonymous attendees. The total still counts them, so the
// difference is rendered as anonymous rows (mirrors ParticipantsListScreen),
// never dropped: the count and the rows must agree.
const unknownCount = Math.max(0, (event?.participantCount ?? 0) - participants.length);
if (!event) {
return (
@@ -81,7 +100,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>
);
@@ -100,7 +119,7 @@ export function EventDetailScreen() {
</div>
{canEdit && (
<span
onClick={() => navigate(`/events/${eventId}/edit`)}
onClick={() => navigate(eventPath(event.id, 'edit'))}
title="Modifier l'événement"
style={{ cursor: 'pointer', fontSize: 18, color: '#888' }}
>✎</span>
@@ -150,30 +169,26 @@ export function EventDetailScreen() {
)}
</div>
<div style={{ margin: '4px 16px 16px', display: 'flex', gap: 8 }}>
<Button
variant={joined ? 'green' : 'primary'}
onClick={handleToggleJoin}
style={{ flex: 1, padding: '12px 0' }}
>
{joined ? '✓ Je participe' : "J'y serai"}
</Button>
{joined && (
{/* SIGNING UP IS PER MEETING POINT: no event-level join button here. The
join affordances live on the meeting points below; Invite follows
membership, the way the old event-level "Je participe" did. */}
{joined && (
<div style={{ margin: '4px 16px 16px', display: 'flex', gap: 8 }}>
<Button
onClick={() => navigate(`/events/${eventId}/invite`)}
onClick={() => navigate(eventPath(event.id, 'invite'))}
style={{ flex: 1, padding: '12px 0' }}
>
Inviter
</Button>
)}
</div>
</div>
)}
{meetingPoints.length > 0 && (
<div style={{ padding: '0 16px 8px' }}>
<EventMeetingPoints
points={meetingPoints}
joinedIds={new Set()}
onToggle={() => {}}
joinedIds={joinedMeetingPointIds}
onToggle={handleToggleMeetingPoint}
expanded
/>
</div>
@@ -181,7 +196,7 @@ export function EventDetailScreen() {
<div style={{ padding: '0 16px 8px' }}>
<button
onClick={() => navigate(`/events/${eventId}/meeting-points`)}
onClick={() => navigate(eventPath(event.id, 'meeting-points'))}
style={{ width: '100%', padding: 12, border: '2px dashed #ddd', borderRadius: 12, background: 'none', fontSize: 13, fontWeight: 600, color: '#999', cursor: 'pointer', marginTop: 4, fontFamily: 'var(--font-app)' }}
>
+ Proposer un point de rencontre
@@ -192,25 +207,41 @@ export function EventDetailScreen() {
<div style={{ fontSize: 12, fontWeight: 700, color: '#999', textTransform: 'uppercase', letterSpacing: 1, marginBottom: 10 }}>
Participants ({event.participantCount})
</div>
{knownParticipants.map((p) => (
{knownParticipants.map((p) => {
const displayName = safeUserName(p);
const displayUsername = safeUserUsername(p);
const displayInitials = safeUserInitials(p);
return (
<div
key={p.id}
onClick={() => navigate(userPath(p.id))}
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', borderBottom: '1px solid #f5f5f5', cursor: 'pointer' }}
>
<Avatar initials={displayInitials} size={38} color="#2B6CB0" />
<div style={{ flex: 1 }}>
<div className="user-content" style={{ fontSize: 14, fontWeight: 600 }}>{displayName}</div>
{displayUsername && (
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>{displayUsername}</div>
)}
</div>
</div>
);
})}
{Array.from({ length: unknownCount }, (_, i) => (
<div
key={p.id}
onClick={() => navigate(`/users/${p.id}`)}
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', borderBottom: '1px solid #f5f5f5', cursor: 'pointer' }}
key={`unknown-${i}`}
style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 0', borderBottom: '1px solid #f5f5f5' }}
>
<Avatar initials={p.initials} size={38} color="#2B6CB0" />
<Avatar initials="?" size={38} color="#ccc" />
<div style={{ flex: 1 }}>
<div className="user-content" style={{ fontSize: 14, fontWeight: 600 }}>{p.name}</div>
{p.username && (
<div style={{ fontSize: 12, color: '#888', marginTop: 2 }}>{p.username}</div>
)}
<div style={{ fontSize: 14, color: '#999' }}>Participant inconnu</div>
</div>
</div>
))}
{knownParticipants.length < event.participantCount && (
{unknownCount > 0 && (
<div
style={{ marginTop: 12, padding: 12, background: '#f9f9f9', borderRadius: 12, textAlign: 'center', cursor: 'pointer' }}
onClick={() => navigate(`/events/${eventId}/participants`)}
onClick={() => navigate(eventPath(event.id, 'participants'))}
>
<span style={{ fontSize: 12, color: '#999' }}>Voir tous les participants →</span>
</div>
+30 -12
View File
@@ -1,7 +1,8 @@
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 { useNavigate } from '../../../app/router';
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(`/events/${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" />
+10 -9
View File
@@ -2,7 +2,8 @@ import { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Header, Input, Text, Avatar, Checkbox, Button, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { useNavigate, useParams } from '../../../app/router';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { eventPath, useNavigate, useParams } from '../../../app/router';
export function InviteScreen() {
const navigate = useNavigate();
@@ -24,7 +25,7 @@ export function InviteScreen() {
const send = () => {
showToast(`${selected.size} invitation${selected.size > 1 ? 's' : ''} envoyée${selected.size > 1 ? 's' : ''}`, 'success');
navigate(`/events/${eventId}`);
navigate(eventPath(eventId ?? ''));
};
if (step === 'message') {
@@ -54,9 +55,9 @@ export function InviteScreen() {
padding: '4px 10px',
}}
>
<Avatar initials={friend.initials} color="#E8590C" size={22} />
<Avatar initials={safeUserInitials(friend)} color="#E8590C" size={22} />
<Text style={{ margin: 0, fontSize: 13, color: '#C05621', fontWeight: 600 }}>
{friend.name.split(' ')[0]}
{safeUserName(friend).split(' ')[0]}
</Text>
</div>
))}
@@ -122,7 +123,7 @@ export function InviteScreen() {
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Inviter des amis"
left={<ArrowLeft size={20} onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer' }} />}
left={<ArrowLeft size={20} onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer' }} />}
/>
<div style={{ padding: '12px 16px', borderBottom: '1px solid #f0f0f0' }}>
@@ -155,11 +156,11 @@ export function InviteScreen() {
background: selected.has(friend.id) ? '#FFF7ED' : 'transparent',
}}
>
<Avatar initials={friend.initials} color="#2B6CB0" size="sm" />
<Avatar initials={safeUserInitials(friend)} color="#2B6CB0" size="sm" />
<div style={{ flex: 1, marginLeft: 12 }}>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{friend.name}</Text>
{friend.username && (
<Text style={{ margin: 0, fontSize: 13, color: '#888' }}>{friend.username}</Text>
<Text className="user-content" style={{ margin: 0, fontWeight: 'bold' }}>{safeUserName(friend)}</Text>
{safeUserUsername(friend) && (
<Text style={{ margin: 0, fontSize: 13, color: '#888' }}>{safeUserUsername(friend)}</Text>
)}
</div>
<Checkbox checked={selected.has(friend.id)} />
@@ -2,7 +2,8 @@ import React, { useState } from 'react';
import { ArrowLeft } from 'lucide-react';
import { Button, Avatar, Input, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { useNavigate, useParams } from '../../../app/router';
import { safeUserName, safeUserInitials } from '../../../shared/context/festipodDataBase';
import { eventPath, useNavigate, useParams } from '../../../app/router';
export function MeetingPointsScreen() {
const navigate = useNavigate();
@@ -28,13 +29,13 @@ export function MeetingPointsScreen() {
time: when || duration,
});
showToast(title ? `Point de rencontre créé : ${title}` : 'Point de rencontre créé', 'success');
navigate(`/events/${eventId}`);
navigate(eventPath(eventId ?? ''));
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ padding: '8px 16px', display: 'flex', alignItems: 'center', gap: 12 }}>
<button onClick={() => navigate(`/events/${eventId}`)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#333', padding: 0, display: 'flex', alignItems: 'center' }}><ArrowLeft size={20} /></button>
<button onClick={() => navigate(eventPath(eventId ?? ''))} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#333', padding: 0, display: 'flex', alignItems: 'center' }}><ArrowLeft size={20} /></button>
<span style={{ fontSize: 17, fontWeight: 700 }}>Point de rencontre</span>
</div>
@@ -85,8 +86,8 @@ export function MeetingPointsScreen() {
if (!friend) return null;
return (
<div key={id} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '6px 12px 6px 6px', borderRadius: 20, background: '#f5f5f5' }}>
<Avatar initials={friend.initials} color="#2B6CB0" size={22} />
<span style={{ fontSize: 12, fontWeight: 500 }}>{friend.name.split(' ')[0]}</span>
<Avatar initials={safeUserInitials(friend)} color="#2B6CB0" size={22} />
<span style={{ fontSize: 12, fontWeight: 500 }}>{safeUserName(friend).split(' ')[0]}</span>
<span onClick={() => removeInvited(id)} style={{ fontSize: 14, color: '#bbb', cursor: 'pointer' }}>×</span>
</div>
);
@@ -1,7 +1,8 @@
import { ArrowLeft } from 'lucide-react';
import { Header, Avatar, Text, Input } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { useNavigate, useParams } from '../../../app/router';
import { safeUserName, safeUserInitials, safeUserUsername } from '../../../shared/context/festipodDataBase';
import { eventPath, useNavigate, useParams, userPath } from '../../../app/router';
const COLORS = ['#E8590C', '#2B6CB0', '#9C4DC7', '#38A169', '#D69E2E', '#E53E3E'];
@@ -23,9 +24,9 @@ export function ParticipantsListScreen() {
const rows = [
...participants.map((p, i) => ({
key: p.id,
initials: p.initials,
name: p.name,
username: p.username,
initials: safeUserInitials(p),
name: safeUserName(p),
username: safeUserUsername(p),
color: COLORS[i % COLORS.length] ?? '#888',
known: true,
})),
@@ -43,7 +44,7 @@ export function ParticipantsListScreen() {
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title={`Participants (${totalCount})`}
left={<ArrowLeft size={20} onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer' }} />}
left={<ArrowLeft size={20} onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer' }} />}
/>
<div style={{ padding: 16, borderBottom: '1px solid #f0f0f0' }}>
@@ -54,7 +55,7 @@ export function ParticipantsListScreen() {
{rows.map((p) => (
<div
key={p.key}
onClick={p.known ? () => navigate(`/users/${p.key}`) : undefined}
onClick={p.known ? () => navigate(userPath(p.key)) : undefined}
style={{
display: 'flex',
alignItems: 'center',
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Header, Text, Input, Button, Placeholder, showToast } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { useNavigate, useParams } from '../../../app/router';
import { eventPath, useNavigate, useParams } from '../../../app/router';
export function UpdateEventScreen() {
const navigate = useNavigate();
@@ -46,7 +46,7 @@ export function UpdateEventScreen() {
}))
.then(() => {
showToast('Événement mis à jour', 'success');
navigate(`/events/${eventId}`);
navigate(eventPath(eventId ?? ''));
})
.catch((err: unknown) => {
console.error('[UpdateEvent] event update failed:', err);
@@ -61,7 +61,7 @@ export function UpdateEventScreen() {
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Modifier l'événement"
left={<span onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
left={<span onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
/>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<Text style={{ textAlign: 'center', color: '#888' }}>
@@ -80,7 +80,7 @@ export function UpdateEventScreen() {
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Modifier l'événement"
left={<span onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
left={<span onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
/>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<Text aria-busy="true" style={{ textAlign: 'center', color: '#888' }}>
@@ -95,7 +95,7 @@ export function UpdateEventScreen() {
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Modifier l'événement"
left={<span onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
left={<span onClick={() => navigate(eventPath(eventId ?? ''))} style={{ cursor: 'pointer', fontSize: 18 }}>✕</span>}
/>
<div style={{ flex: 1, padding: 16, overflow: 'auto' }}>
@@ -0,0 +1,330 @@
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
import type { ApodsSession } from '../../../../shared/support/activitypodsStack';
import {
apodsStackConfig,
attendeesCollectionUri,
contextLink,
createResource,
linkId,
podLogin,
postOutboxActivity,
readAttendees,
readResource,
recordUri,
urlStatus,
} from '../../../../shared/support/activitypodsStack';
/**
* Steps for the `@e2e-activitypods` smoke layer: real HTTP against the local
* ActivityPods stack (pod provider + Festipod backend) and, for `@browser`
* scenarios, the real app in Playwright. Nothing here mocks the platform — the
* point of the layer is that the flows actually round-trip through a Pod.
*/
function session(world: FestipodWorld): ApodsSession {
if (!world.apods.session) {
throw new Error('No Pod session — a "session Pod locale" step must run first');
}
return world.apods.session;
}
function requires(world: FestipodWorld, value: string | undefined, what: string): string {
if (!value) throw new Error(`${what} is unset — a prior step must have created it`);
return value;
}
/** The ActivityPods context the Pod round-trips the `as:`/`apods:` CURIEs against. */
const JSON_CONTEXT = 'https://activitypods.org/context.json';
function arrayOf(value: unknown): unknown[] {
return value === undefined || value === null ? [] : Array.isArray(value) ? value : [value];
}
/**
* Create a resource in ONE of the Pod's registered containers (the Event or
* Collection DataRegistration the app is granted on). The app can only WRITE
* where its access needs were mapped to WAC rights at install time — a resource
* created in the user's raw storage root is outside the grant, so the backend's
* join/leave collection update is refused (403). Placement IS part of the
* scenario, not an incidental detail of the helper.
*/
async function createInContainer(session: ApodsSession, containerUri: string, body: Record<string, unknown>): Promise<string> {
const response = await fetch(containerUri, {
method: 'POST',
headers: {
'Content-Type': 'application/ld+json',
Accept: 'application/ld+json',
Authorization: `Bearer ${session.token}`,
},
body: JSON.stringify({ '@context': JSON_CONTEXT, ...body }),
});
const location = response.headers.get('Location') ?? response.headers.get('location');
if (!response.ok || !location) {
throw new Error(`Create in ${containerUri} failed: HTTP ${response.status} ${(await response.text()).slice(0, 200)}`);
}
return location;
}
/**
* Read a collection until `predicate` holds, or the timeout expires. The
* backend's collection update lands ASYNCHRONOUSLY (the platform's own model —
* see src/shared/data/outbox.ts), so a single read right after the Join POST
* races the update; polling is what makes the assertion about reality, not luck.
* Returns the last read on timeout so the caller's `expect` reports the truth.
*/
async function waitForMembers(
session: ApodsSession,
collectionUri: string,
predicate: (members: unknown[]) => boolean,
timeoutMs = 20000,
): Promise<unknown[]> {
const end = Date.now() + timeoutMs;
let members: unknown[] = [];
while (Date.now() < end) {
members = await readAttendees(session, collectionUri);
if (predicate(members)) return members;
await new Promise(resolve => setTimeout(resolve, 500));
}
return members;
}
// ── The stack is up ────────────────────────────────────────────────────────
Then("le pod provider local répond sur son endpoint OpenID", async function (this: FestipodWorld) {
const { podProviderUrl } = apodsStackConfig();
const status = await urlStatus(`${podProviderUrl}/.well-known/openid-configuration`);
expect(status, `GET ${podProviderUrl}/.well-known/openid-configuration`).to.equal(200);
});
Then("le backend Festipod sert la description de son application", async function (this: FestipodWorld) {
const { backendUrl } = apodsStackConfig();
const description = await readResource(undefined, `${backendUrl}/app`);
// The description IS the OIDC client the app registers with: it must carry
// the interop access-need group the Pod grants, not merely answer 200.
expect(JSON.stringify(description.type)).to.contain('Application');
expect(linkId(description['interop:hasAccessNeedGroup']), 'access-need group').to.be.a('string');
});
// ── The app boots in the ActivityPods variant ──────────────────────────────
When("l'application Festipod est chargée depuis la pile locale", async function (this: FestipodWorld) {
const appUrl = requires(this, this.apods.appUrl, 'app URL (set by the @browser Before hook)');
expect(this.page, 'a Playwright page (set by the @browser Before hook)').to.not.be.null;
await this.page!.goto(appUrl, { waitUntil: 'domcontentloaded' });
// No session yet → AuthGate renders the Pod-provider picker.
await this.page!.waitForSelector('text=Connectez-vous avec votre Pod', { timeout: 30000 });
});
Then("l'entrée de connexion au Pod est proposée", async function (this: FestipodWorld) {
const { podProviderUrl } = apodsStackConfig();
const host = new URL(podProviderUrl).host;
await this.page!.waitForSelector(`text=Se connecter avec ${host}`, { timeout: 30000 });
});
Then("aucune erreur runtime n'a été émise au chargement", function (this: FestipodWorld) {
expect(
this.pageErrors,
`Des erreurs runtime ont été émises au chargement de l'app :\n` +
this.pageErrors.map((e, i) => ` [${i + 1}] ${e}`).join('\n'),
).to.be.empty;
});
// ── Event + meeting point, the as:context graft ────────────────────────────
Given(
'une session Pod locale pour {string} avec le mot de passe {string}',
async function (this: FestipodWorld, username: string, password: string) {
this.apods.session = await podLogin(apodsStackConfig(), username, password);
},
);
Given("l'application Festipod est installée sur le Pod de la session", async function (this: FestipodWorld) {
const pod = session(this);
const config = apodsStackConfig();
// The registered agent IS the Solid-OIDC client_id: the app actor URI the
// backend serves as both the actor and its Client ID Document. Registering
// against `/app` (the alias) is refused — the agent must be the actor URI.
const description = await readResource(undefined, `${config.backendUrl}/app`);
const appUri = requires(this, recordUri(description), 'app actor URI (the id of GET /app)');
const groupUri = requires(this, linkId(description['interop:hasAccessNeedGroup']), 'access-need group URI');
const group = await readResource(undefined, groupUri);
const acceptedAccessNeeds = arrayOf(group['interop:hasAccessNeed']);
const acceptedSpecialRights = arrayOf(group['apods:hasSpecialRights']);
const response = await fetch(`${config.podProviderUrl}/.auth-agent/app-registrations/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${pod.token}` },
body: JSON.stringify({ appUri, acceptedAccessNeeds, acceptedSpecialRights }),
});
if (!response.ok) {
const text = await response.text();
// Idempotent: a re-run finds the registration already in place. Any other
// refusal is a real failure, reported verbatim.
if (response.status === 400 && text.includes('already has an application registration')) return;
throw new Error(`App installation failed: HTTP ${response.status} ${text.slice(0, 300)}`);
}
// Registration is confirmed asynchronously by the app backend (the Pod posts a
// CREATE to the user's outbox), and the registered shape-tree containers come
// with it. Wait for the Event DataRegistration before a scenario writes there.
const eventContainer = `${pod.storage}/as/event`;
const end = Date.now() + 20000;
while (Date.now() < end) {
const status = await fetch(eventContainer, { headers: { Authorization: `Bearer ${pod.token}` } })
.then(r => r.status)
.catch(() => 0);
if (status === 200) return;
await new Promise(resolve => setTimeout(resolve, 500));
}
throw new Error(`Registered container ${eventContainer} never appeared after install`);
});
When(
'je crée un événement {string} et un point de rencontre {string} rattaché par as:context',
async function (this: FestipodWorld, title: string, meetingPointTitle: string) {
const pod = session(this);
const anchor = await createResource(pod, {
type: 'Event',
name: title,
startTime: '2026-11-01T18:00:00Z',
endTime: '2026-11-01T22:00:00Z',
});
this.apods.anchorUri = anchor;
this.apods.meetingPointUri = await createResource(pod, {
type: 'Event',
name: meetingPointTitle,
'as:context': anchor,
organizer: pod.webId,
});
},
);
Then(
"la lecture du point de rencontre expose son lien as:context vers l'événement",
async function (this: FestipodWorld) {
const pod = session(this);
const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');
const record = await readResource(pod, mpUri);
expect(contextLink(record)).to.equal(this.apods.anchorUri);
},
);
Then(
"l'événement et le point de rencontre partagent le conteneur as:Event",
async function (this: FestipodWorld) {
const pod = session(this);
const anchor = await readResource(pod, requires(this, this.apods.anchorUri, 'anchor URI'));
const mp = await readResource(pod, requires(this, this.apods.meetingPointUri, 'meeting point URI'));
// Both are as/Event, both created under the session's storage — so a single
// container holds the two, and the ONLY distinction is the as:context link
// the reader's bindings test.
expect(JSON.stringify(anchor.type)).to.contain('Event');
expect(JSON.stringify(mp.type)).to.contain('Event');
expect(recordUri(anchor), 'anchor URI').to.match(new RegExp(`^${pod.storage}/`));
expect(recordUri(mp), 'meeting point URI').to.match(new RegExp(`^${pod.storage}/`));
expect(contextLink(anchor), 'an anchor event carries no as:context').to.be.undefined;
expect(contextLink(mp), 'the meeting point carries as:context').to.be.a('string');
},
);
// ── Join / leave through the outbox (suspended in the feature) ─────────────
Given(
'un point de rencontre {string} rattaché à un événement, avec sa collection apods:attendees',
async function (this: FestipodWorld, title: string) {
const pod = session(this);
// Created in the Event DataRegistration the app was granted on at install.
const anchor = await createInContainer(pod, `${pod.storage}/as/event`, { type: 'Event', name: `${title} — ancre` });
// The `apods:attendees` collection is the APP BACKEND's to attach: when it
// processes the meeting point's creation, `pod-collections-handler.onCreate`
// creates and links it. Hand-attaching one (or creating the collection
// ourselves) is NOT the app's path and trips an uncaught TypeError in
// @activitypods/app — so we create the bare meeting point and let it attach.
const mp = await createInContainer(pod, `${pod.storage}/as/event`, {
type: 'Event',
name: title,
'as:context': anchor,
organizer: pod.webId,
});
this.apods.anchorUri = anchor;
this.apods.meetingPointUri = mp;
// The attach is ASYNCHRONOUS (the backend reacts to the Pod activity), so the
// collection URI is read back once the record carries it.
const end = Date.now() + 20000;
let collection: string | undefined;
while (Date.now() < end) {
collection = attendeesCollectionUri(await readResource(pod, mp));
if (collection) break;
await new Promise(resolve => setTimeout(resolve, 500));
}
if (!collection) throw new Error(`The meeting point ${mp} never gained an apods:attendees collection`);
this.apods.collectionUri = collection;
},
);
When('la session rejoint le point de rencontre', async function (this: FestipodWorld) {
const pod = session(this);
const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');
// The app's own path (src/shared/data/outbox.ts): as:Join to the user's own
// outbox, `object` = the meeting point URI and `to` = the HOST (the record's
// dc:creator). Without the addressee the backend silently drops the activity;
// and an activity carrying no routing target never reaches the host.
const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId;
await postOutboxActivity(pod, 'Join', mpUri, host);
});
When('la session quitte le point de rencontre', async function (this: FestipodWorld) {
const pod = session(this);
const mpUri = requires(this, this.apods.meetingPointUri, 'meeting point URI');
const host = linkId((await readResource(pod, mpUri))['dc:creator']) ?? pod.webId;
await postOutboxActivity(pod, 'Leave', mpUri, host);
});
Then('la collection apods:attendees contient la session', async function (this: FestipodWorld) {
const pod = session(this);
const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');
const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));
expect(members.map(linkId)).to.include(pod.webId);
});
Then(
'la collection apods:attendees ne contient plus la session',
async function (this: FestipodWorld) {
const pod = session(this);
const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');
const members = await waitForMembers(pod, collectionUri, found => !found.map(linkId).includes(pod.webId));
expect(members.map(linkId)).to.not.include(pod.webId);
},
);
// ── Read-side derivations over the live collection (the Alice/Bob rule) ─────
Then('le compteur du point de rencontre compte cette inscription', async function (this: FestipodWorld) {
const pod = session(this);
const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');
const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));
const collection = await readResource(pod, collectionUri);
// The count is the collection total — kept as-is, never the number of
// RESOLVED profiles, so it stays total for a reader who resolves nobody.
const raw = collection.totalItems ?? collection['as:totalItems'];
const total = raw === undefined ? members.length : Number(raw);
expect(total, 'the count reflects the joined session').to.be.greaterThan(0);
expect(members.map(linkId), 'the counted session is the one listed').to.include(pod.webId);
});
Then('la liste des participants rend ce participant comme anonyme', async function (this: FestipodWorld) {
const pod = session(this);
const collectionUri = requires(this, this.apods.collectionUri, 'attendees collection URI');
const members = await waitForMembers(pod, collectionUri, found => found.map(linkId).includes(pod.webId));
const joined = members.find(member => linkId(member) === pod.webId);
expect(joined, 'the joined session is listed').to.not.be.undefined;
// "Anonymous" is the read-side fact: the member travels as a REFERENCE with no
// profile the reader can resolve — the list has nothing but the URI to render,
// which is exactly what the app shows for a participant it cannot dereference.
const profileFields = ['name', 'preferredUsername', 'as:name', 'nickname'];
const hasProfile =
typeof joined === 'object' && joined !== null && profileFields.some(field => (joined as Record<string, unknown>)[field] !== undefined);
expect(hasProfile, 'a listed participant carries no resolved profile (rendered anonymous)').to.be.false;
});

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